diff --git a/packages/astro/src/@types/astro.ts b/packages/astro/src/@types/astro.ts index 5cd7abe24..fc42f2b44 100644 --- a/packages/astro/src/@types/astro.ts +++ b/packages/astro/src/@types/astro.ts @@ -881,6 +881,7 @@ export interface AstroConfig extends z.output { adapter: AstroAdapter | undefined; renderers: AstroRenderer[]; scripts: { stage: InjectedScriptStage; content: string }[]; + clientDirectives: ClientDirectiveMap; }; } @@ -1200,6 +1201,7 @@ export interface SSRElement { export interface SSRMetadata { renderers: SSRLoadedRenderer[]; + clientDirectives: ClientDirectiveMap; pathname: string; hasHydrationScript: boolean; hasDirectives: Set; @@ -1220,3 +1222,18 @@ export interface SSRResult { } export type MarkdownAstroData = { frontmatter: object }; + +/* Client Directives */ +export type ClientDirectiveMap = Map; +type Hydrate = () => Promise; +type Load = () => Promise; + +type DirectiveOptions = { + // The component displayName + name: string; + // The attribute value provided, + // for ex `client:interactive="click" + value: string; +} + +export type ClientDirective = (load: Load, opts: DirectiveOptions, element: HTMLElement) => void; diff --git a/packages/astro/src/core/config.ts b/packages/astro/src/core/config.ts index aa8d701e7..e2b37b269 100644 --- a/packages/astro/src/core/config.ts +++ b/packages/astro/src/core/config.ts @@ -353,6 +353,12 @@ export async function validateConfig( renderers: [jsxRenderer], injectedRoutes: [], adapter: undefined, + clientDirectives: new Map([ + ['visible', { + type: 'external', + src: 'astro/runtime/client/visible.js' + }] + ]), }, }; diff --git a/packages/astro/src/core/create-vite.ts b/packages/astro/src/core/create-vite.ts index 81ac43721..81f4bf45b 100644 --- a/packages/astro/src/core/create-vite.ts +++ b/packages/astro/src/core/create-vite.ts @@ -16,6 +16,7 @@ import legacyMarkdownVitePlugin from '../vite-plugin-markdown-legacy/index.js'; import markdownVitePlugin from '../vite-plugin-markdown/index.js'; import astroScriptsPlugin from '../vite-plugin-scripts/index.js'; import astroScriptsPageSSRPlugin from '../vite-plugin-scripts/page-ssr.js'; +import astroClientDirective from '../vite-plugin-client-directive/index.js'; import { createCustomViteLogger } from './errors.js'; import { resolveDependency } from './util.js'; @@ -86,6 +87,7 @@ export async function createVite( astroPostprocessVitePlugin({ config: astroConfig }), astroIntegrationsContainerPlugin({ config: astroConfig, logging }), astroScriptsPageSSRPlugin({ config: astroConfig }), + astroClientDirective({ config: astroConfig, logging }), ], publicDir: fileURLToPath(astroConfig.publicDir), root: fileURLToPath(astroConfig.root), diff --git a/packages/astro/src/core/render/core.ts b/packages/astro/src/core/render/core.ts index e5b604cba..cefb5ac20 100644 --- a/packages/astro/src/core/render/core.ts +++ b/packages/astro/src/core/render/core.ts @@ -1,6 +1,7 @@ import type { MarkdownRenderingOptions } from '@astrojs/markdown-remark'; import type { ComponentInstance, + ClientDirectiveMap, Params, Props, RouteData, @@ -79,6 +80,7 @@ export interface RenderOptions { scripts: Set; resolve: (s: string) => Promise; renderers: SSRLoadedRenderer[]; + clientDirectives: ClientDirectiveMap; route?: RouteData; routeCache: RouteCache; site?: string; @@ -91,6 +93,7 @@ export interface RenderOptions { export async function render(opts: RenderOptions): Promise { const { adapterName, + clientDirectives, links, styles, logging, @@ -145,6 +148,7 @@ export async function render(opts: RenderOptions): Promise { pathname, resolve, renderers, + clientDirectives, request, site, scripts, diff --git a/packages/astro/src/core/render/dev/index.ts b/packages/astro/src/core/render/dev/index.ts index 672af88c2..fb8b6500c 100644 --- a/packages/astro/src/core/render/dev/index.ts +++ b/packages/astro/src/core/render/dev/index.ts @@ -168,6 +168,7 @@ export async function render( let response = await coreRender({ adapterName: astroConfig.adapter?.name, + clientDirectives: astroConfig._ctx.clientDirectives, links, styles, logging, diff --git a/packages/astro/src/core/render/result.ts b/packages/astro/src/core/render/result.ts index dc5be4a07..6a1e38aa0 100644 --- a/packages/astro/src/core/render/result.ts +++ b/packages/astro/src/core/render/result.ts @@ -3,6 +3,7 @@ import { bold } from 'kleur/colors'; import type { AstroGlobal, AstroGlobalPartial, + ClientDirectiveMap, Params, Props, RuntimeMode, @@ -36,6 +37,7 @@ export interface CreateResultArgs { pathname: string; props: Props; renderers: SSRLoadedRenderer[]; + clientDirectives: ClientDirectiveMap; resolve: (s: string) => Promise; site: string | undefined; links?: Set; @@ -122,7 +124,7 @@ class Slots { let renderMarkdown: any = null; export function createResult(args: CreateResultArgs): SSRResult { - const { markdown, params, pathname, props: pageProps, renderers, request, resolve } = args; + const { clientDirectives, markdown, params, pathname, props: pageProps, renderers, request, resolve } = args; const url = new URL(request.url); const headers = new Headers(); @@ -274,6 +276,7 @@ const canonicalURL = new URL(Astro.url.pathname, Astro.site); resolve, _metadata: { renderers, + clientDirectives, pathname, hasHydrationScript: false, hasDirectives: new Set(), diff --git a/packages/astro/src/runtime/client/visible.ts b/packages/astro/src/runtime/client/visible.ts index 28975040c..e7ba3a48f 100644 --- a/packages/astro/src/runtime/client/visible.ts +++ b/packages/astro/src/runtime/client/visible.ts @@ -1,11 +1,13 @@ +import { ClientDirective } from '../../@types/astro'; + /** * Hydrate this component when one of it's children becomes visible * We target the children because `astro-island` is set to `display: contents` * which doesn't work with IntersectionObserver */ -(self.Astro = self.Astro || {}).visible = (getHydrateCallback, _opts, root) => { +const visible: ClientDirective = (load, _opts, root) => { const cb = async () => { - let hydrate = await getHydrateCallback(); + let hydrate = await load(); await hydrate(); }; @@ -24,4 +26,5 @@ io.observe(child); } }; -window.dispatchEvent(new Event('astro:visible')); + +export default visible; diff --git a/packages/astro/src/runtime/server/astro-island.ts b/packages/astro/src/runtime/server/astro-island.ts index 706ccdd88..54a2b4b62 100644 --- a/packages/astro/src/runtime/server/astro-island.ts +++ b/packages/astro/src/runtime/server/astro-island.ts @@ -62,7 +62,7 @@ declare const Astro: { start() { const opts = JSON.parse(this.getAttribute('opts')!) as Record; const directive = this.getAttribute('client') as directiveAstroKeys; - if (Astro[directive] === undefined) { + if (typeof Astro === 'undefined' || Astro[directive] === undefined) { window.addEventListener(`astro:${directive}`, () => this.start(), { once: true }); return; } diff --git a/packages/astro/src/runtime/server/render/common.ts b/packages/astro/src/runtime/server/render/common.ts index 27b012aa4..1e7e3e00f 100644 --- a/packages/astro/src/runtime/server/render/common.ts +++ b/packages/astro/src/runtime/server/render/common.ts @@ -15,7 +15,7 @@ export const Renderer = Symbol.for('astro:renderer'); // Rendering produces either marked strings of HTML or instructions for hydration. // These directive instructions bubble all the way up to renderPage so that we // can ensure they are added only once, and as soon as possible. -export function stringifyChunk(result: SSRResult, chunk: string | RenderInstruction) { +export function stringifyChunk(result: SSRResult, chunk: string | RenderInstruction): string | Promise { switch ((chunk as any).type) { case 'directive': { const { hydration } = chunk as RenderInstruction; @@ -29,7 +29,7 @@ export function stringifyChunk(result: SSRResult, chunk: string | RenderInstruct ? 'directive' : null; if (prescriptType) { - let prescripts = getPrescripts(prescriptType, hydration.directive); + let prescripts = getPrescripts(result, prescriptType, hydration.directive); return markHTMLString(prescripts); } else { return ''; diff --git a/packages/astro/src/runtime/server/render/page.ts b/packages/astro/src/runtime/server/render/page.ts index 166eb01cd..e06b6785f 100644 --- a/packages/astro/src/runtime/server/render/page.ts +++ b/packages/astro/src/runtime/server/render/page.ts @@ -6,6 +6,7 @@ import { isAstroComponent, isAstroComponentFactory, renderAstroComponent } from import { stringifyChunk } from './common.js'; import { renderComponent } from './component.js'; import { maybeRenderHead } from './head.js'; +import { string } from 'zod'; const encoder = new TextEncoder(); const needsHeadRenderingSymbol = Symbol.for('astro.needsHeadRendering'); @@ -72,7 +73,13 @@ export async function renderPage( let i = 0; try { for await (const chunk of iterable) { - let html = stringifyChunk(result, chunk); + let html: string; + let stringChunk = stringifyChunk(result, chunk); + if((stringChunk as any).then !== undefined) { + html = await stringChunk; + } else { + html = (stringChunk as string); + } if (i === 0) { if (!/\n'; diff --git a/packages/astro/src/runtime/server/scripts.ts b/packages/astro/src/runtime/server/scripts.ts index 1d57c07e9..f126943c6 100644 --- a/packages/astro/src/runtime/server/scripts.ts +++ b/packages/astro/src/runtime/server/scripts.ts @@ -14,6 +14,8 @@ export function determineIfNeedsHydrationScript(result: SSRResult): boolean { return (result._metadata.hasHydrationScript = true); } +const ISLAND_STYLES = ``; + export const hydrationScripts: Record = { idle: idlePrebuilt, load: loadPrebuilt, @@ -40,18 +42,47 @@ function getDirectiveScriptText(directive: string): string { return directiveScriptText; } -export function getPrescripts(type: PrescriptType, directive: string): string { +function getDirectiveScript(result: SSRResult, directive: string): string | Promise { + if(!result._metadata.clientDirectives.has(directive)) { + // TODO better error message + throw new Error(`Unable to find directive ${directive}`); + } + let { type, src } = result._metadata.clientDirectives.get(directive)!; + switch(type) { + case 'external': { + return result.resolve(`${src}?astro-client-directive=${directive}`).then(value => { + return ``; + }); + } + case 'inline': { + throw new Error(`Inline not yet supported`); + } + } +} + +function isPromise(value: any): value is Promise { + if(typeof value.then === 'function') { + return true; + } + return false; +} + +export function getPrescripts(result: SSRResult, type: PrescriptType, directive: string): string | Promise { // Note that this is a classic script, not a module script. // This is so that it executes immediate, and when the browser encounters // an astro-island element the callbacks will fire immediately, causing the JS // deps to be loaded immediately. switch (type) { case 'both': - return ``; + let directiveScript = getDirectiveScript(result, directive); + if(isPromise(directiveScript)) { + return directiveScript.then(scriptText => { + return `${ISLAND_STYLES}${scriptText}`; + }); + } + return `${ISLAND_STYLES}${directiveScript}` case 'directive': - return ``; + return getDirectiveScript(result, directive); } return ''; } diff --git a/packages/astro/src/vite-plugin-client-directive/index.ts b/packages/astro/src/vite-plugin-client-directive/index.ts new file mode 100644 index 000000000..cc20ce1d7 --- /dev/null +++ b/packages/astro/src/vite-plugin-client-directive/index.ts @@ -0,0 +1,34 @@ +import type * as vite from 'vite'; +import type { AstroConfig, ManifestData } from '../@types/astro'; +import { error, info, LogOptions, warn } from '../core/logger/core.js'; + +interface AstroPluginOptions { + config: AstroConfig; + logging: LogOptions; +} + +export default function createPlugin({ config, logging }: AstroPluginOptions): vite.Plugin { + return { + name: 'astro:client-directive', + transform(code, id, opts = {}) { + let idx = id.indexOf('?astro-client-directive'); + if(idx !== -1) { + let entrypoint = id.slice(0, idx); + let params = new URLSearchParams(id.slice(idx)); + let directive = params.get('astro-client-directive'); + return ` +import directive from '${entrypoint}'; + +(self.Astro = self.Astro || {}).${directive} = directive; +window.dispatchEvent(new Event('astro:${directive}')); +`.trim() + } + + /*if (opts.ssr) return; + if (!id.includes('vite/dist/client/client.mjs')) return; + return code + .replace(/\.tip \{[^}]*\}/gm, '.tip {\n display: none;\n}') + .replace(/\[vite\]/g, '[astro]');*/ + }, + }; +}