From 05915fec01a51f27ab5051644f01e6112ecf06bc Mon Sep 17 00:00:00 2001 From: Matthew Phillips Date: Tue, 6 Dec 2022 16:26:15 -0500 Subject: [PATCH] Head propagation (#5511) * Head propagation * Adding a changeset * Fix broken build * Self review stuff * Use compiler prerelease exact version * new compiler version * Update packages/astro/src/vite-plugin-head-propagation/index.ts Co-authored-by: Bjorn Lu * Use getAstroMetadata * add .js * make relative lookup work on win * Use compiler@0.30.0 * PR review comments * Make renderHead an alias for a better named function Co-authored-by: Bjorn Lu --- .changeset/cool-jobs-draw.md | 7 + packages/astro/package.json | 6 +- packages/astro/src/@types/astro.ts | 17 +- packages/astro/src/core/compile/compile.ts | 3 + packages/astro/src/core/create-vite.ts | 2 + packages/astro/src/core/render/context.ts | 3 +- packages/astro/src/core/render/core.ts | 1 + packages/astro/src/core/render/dev/head.ts | 34 ++++ packages/astro/src/core/render/dev/index.ts | 34 +--- packages/astro/src/core/render/result.ts | 4 + packages/astro/src/jsx/babel.ts | 1 + .../src/runtime/server/astro-component.ts | 29 ++++ .../astro/src/runtime/server/astro-global.ts | 1 + packages/astro/src/runtime/server/index.ts | 18 +- packages/astro/src/runtime/server/jsx.ts | 6 +- .../astro/src/runtime/server/render/any.ts | 14 +- .../astro/src/runtime/server/render/astro.ts | 146 ---------------- .../runtime/server/render/astro/factory.ts | 53 ++++++ .../server/render/astro/head-and-content.ts | 24 +++ .../src/runtime/server/render/astro/index.ts | 25 +++ .../runtime/server/render/astro/instance.ts | 82 +++++++++ .../server/render/astro/render-template.ts | 83 +++++++++ .../src/runtime/server/render/component.ts | 134 +++++++++------ .../astro/src/runtime/server/render/head.ts | 31 +++- .../astro/src/runtime/server/render/index.ts | 15 +- .../astro/src/runtime/server/render/page.ts | 41 ++++- .../src/runtime/server/render/stylesheet.ts | 25 +++ packages/astro/src/vite-plugin-astro/index.ts | 6 + .../astro/src/vite-plugin-astro/metadata.ts | 9 + packages/astro/src/vite-plugin-astro/types.ts | 2 + .../src/vite-plugin-head-propagation/index.ts | 54 ++++++ .../src/vite-plugin-load-fallback/index.ts | 2 +- .../src/vite-plugin-markdown-legacy/index.ts | 2 + .../astro/src/vite-plugin-markdown/index.ts | 1 + .../test/units/dev/head-injection.test.js | 160 ++++++++++++++++++ pnpm-lock.yaml | 8 +- 36 files changed, 804 insertions(+), 279 deletions(-) create mode 100644 .changeset/cool-jobs-draw.md create mode 100644 packages/astro/src/core/render/dev/head.ts create mode 100644 packages/astro/src/runtime/server/astro-component.ts delete mode 100644 packages/astro/src/runtime/server/render/astro.ts create mode 100644 packages/astro/src/runtime/server/render/astro/factory.ts create mode 100644 packages/astro/src/runtime/server/render/astro/head-and-content.ts create mode 100644 packages/astro/src/runtime/server/render/astro/index.ts create mode 100644 packages/astro/src/runtime/server/render/astro/instance.ts create mode 100644 packages/astro/src/runtime/server/render/astro/render-template.ts create mode 100644 packages/astro/src/runtime/server/render/stylesheet.ts create mode 100644 packages/astro/src/vite-plugin-astro/metadata.ts create mode 100644 packages/astro/src/vite-plugin-head-propagation/index.ts create mode 100644 packages/astro/test/units/dev/head-injection.test.js diff --git a/.changeset/cool-jobs-draw.md b/.changeset/cool-jobs-draw.md new file mode 100644 index 000000000000..1fee55e54439 --- /dev/null +++ b/.changeset/cool-jobs-draw.md @@ -0,0 +1,7 @@ +--- +'astro': patch +--- + +Low-level head propagation + +This adds low-level head propagation ability within the Astro runtime. This is not really useable within an Astro app at the moment, but provides the APIs necessary for `renderEntry` to do head propagation. diff --git a/packages/astro/package.json b/packages/astro/package.json index 5fbc5bd34c3e..f75ca1131cd7 100644 --- a/packages/astro/package.json +++ b/packages/astro/package.json @@ -100,7 +100,7 @@ "test:e2e:match": "playwright test -g" }, "dependencies": { - "@astrojs/compiler": "^0.29.15", + "@astrojs/compiler": "^0.30.0", "@astrojs/language-server": "^0.28.3", "@astrojs/markdown-remark": "^1.1.3", "@astrojs/telemetry": "^1.0.1", @@ -111,11 +111,11 @@ "@babel/plugin-transform-react-jsx": "^7.17.12", "@babel/traverse": "^7.18.2", "@babel/types": "^7.18.4", + "@proload/core": "^0.3.3", + "@proload/plugin-tsm": "^0.2.1", "@types/babel__core": "^7.1.19", "@types/html-escaper": "^3.0.0", "@types/yargs-parser": "^21.0.0", - "@proload/core": "^0.3.3", - "@proload/plugin-tsm": "^0.2.1", "boxen": "^6.2.1", "ci-info": "^3.3.1", "common-ancestor-path": "^1.0.1", diff --git a/packages/astro/src/@types/astro.ts b/packages/astro/src/@types/astro.ts index a6bafdad7b3e..ce117f3ac310 100644 --- a/packages/astro/src/@types/astro.ts +++ b/packages/astro/src/@types/astro.ts @@ -16,7 +16,7 @@ import type { SerializedSSRManifest } from '../core/app/types'; import type { PageBuildData } from '../core/build/types'; import type { AstroConfigSchema } from '../core/config'; import type { AstroCookies } from '../core/cookies'; -import type { AstroComponentFactory } from '../runtime/server'; +import type { AstroComponentFactory, AstroComponentInstance } from '../runtime/server'; import { SUPPORTED_MARKDOWN_FILE_EXTENSIONS } from './../core/constants.js'; export type { MarkdownHeading, @@ -1398,10 +1398,25 @@ export interface SSRMetadata { hasRenderedHead: boolean; } +/** + * A hint on whether the Astro runtime needs to wait on a component to render head + * content. The meanings: + * + * - __none__ (default) The component does not propagation head content. + * - __self__ The component appends head content. + * - __in-tree__ Another component within this component's dependency tree appends head content. + * + * These are used within the runtime to know whether or not a component should be waited on. + */ +export type PropagationHint = 'none' | 'self' | 'in-tree'; + export interface SSRResult { styles: Set; scripts: Set; links: Set; + propagation: Map; + propagators: Map; + extraHead: Array; cookies: AstroCookies | undefined; createAstro( Astro: AstroGlobalPartial, diff --git a/packages/astro/src/core/compile/compile.ts b/packages/astro/src/core/compile/compile.ts index 9936fc5e36e9..e431f1bb36d4 100644 --- a/packages/astro/src/core/compile/compile.ts +++ b/packages/astro/src/core/compile/compile.ts @@ -12,6 +12,7 @@ export interface CompileProps { astroConfig: AstroConfig; viteConfig: ResolvedConfig; filename: string; + id: string | undefined; source: string; } @@ -24,6 +25,7 @@ export async function compile({ astroConfig, viteConfig, filename, + id: moduleId, source, }: CompileProps): Promise { const cssDeps = new Set(); @@ -35,6 +37,7 @@ export async function compile({ // use `sourcemap: "both"` so that sourcemap is included in the code // result passed to esbuild, but also available in the catch handler. transformResult = await transform(source, { + moduleId, pathname: filename, projectRoot: astroConfig.root.toString(), site: astroConfig.site?.toString(), diff --git a/packages/astro/src/core/create-vite.ts b/packages/astro/src/core/create-vite.ts index da774e9ace51..576ef0469795 100644 --- a/packages/astro/src/core/create-vite.ts +++ b/packages/astro/src/core/create-vite.ts @@ -18,6 +18,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 astroHeadPropagationPlugin from '../vite-plugin-head-propagation/index.js'; import { createCustomViteLogger } from './errors/dev/index.js'; import { resolveDependency } from './util.js'; @@ -112,6 +113,7 @@ export async function createVite( astroPostprocessVitePlugin({ settings }), astroIntegrationsContainerPlugin({ settings, logging }), astroScriptsPageSSRPlugin({ settings }), + astroHeadPropagationPlugin({ settings }), ], publicDir: fileURLToPath(settings.config.publicDir), root: fileURLToPath(settings.config.root), diff --git a/packages/astro/src/core/render/context.ts b/packages/astro/src/core/render/context.ts index ab75638628ed..6e453fea0c3f 100644 --- a/packages/astro/src/core/render/context.ts +++ b/packages/astro/src/core/render/context.ts @@ -1,4 +1,4 @@ -import type { RouteData, SSRElement } from '../../@types/astro'; +import type { RouteData, SSRElement, SSRResult } from '../../@types/astro'; /** * The RenderContext represents the parts of rendering that are specific to one request. @@ -11,6 +11,7 @@ export interface RenderContext { scripts?: Set; links?: Set; styles?: Set; + propagation?: SSRResult['propagation']; route?: RouteData; status?: number; } diff --git a/packages/astro/src/core/render/core.ts b/packages/astro/src/core/render/core.ts index 86aa7fb3f778..0516a4d8ff97 100644 --- a/packages/astro/src/core/render/core.ts +++ b/packages/astro/src/core/render/core.ts @@ -98,6 +98,7 @@ export async function renderPage(mod: ComponentInstance, ctx: RenderContext, env params, props: pageProps, pathname: ctx.pathname, + propagation: ctx.propagation, resolve: env.resolve, renderers: env.renderers, request: ctx.request, diff --git a/packages/astro/src/core/render/dev/head.ts b/packages/astro/src/core/render/dev/head.ts new file mode 100644 index 000000000000..9294192b3149 --- /dev/null +++ b/packages/astro/src/core/render/dev/head.ts @@ -0,0 +1,34 @@ +import type { SSRResult } from '../../../@types/astro'; + +import type { ModuleInfo, ModuleLoader } from '../../module-loader/index'; + +import { viteID } from '../../util.js'; +import { getAstroMetadata } from '../../../vite-plugin-astro/index.js'; +import { crawlGraph } from './vite.js'; + +export async function getPropagationMap( + filePath: URL, + loader: ModuleLoader +): Promise { + const map: SSRResult['propagation'] = new Map(); + + const rootID = viteID(filePath); + addInjection(map, loader.getModuleInfo(rootID)) + for await (const moduleNode of crawlGraph(loader, rootID, true)) { + const id = moduleNode.id; + if (id) { + addInjection(map, loader.getModuleInfo(id)); + } + } + + return map; +} + +function addInjection(map: SSRResult['propagation'], modInfo: ModuleInfo | null) { + if(modInfo) { + const astro = getAstroMetadata(modInfo); + if(astro && astro.propagation) { + map.set(modInfo.id, astro.propagation) + } + } +} diff --git a/packages/astro/src/core/render/dev/index.ts b/packages/astro/src/core/render/dev/index.ts index 805be1123972..e35e152e87a9 100644 --- a/packages/astro/src/core/render/dev/index.ts +++ b/packages/astro/src/core/render/dev/index.ts @@ -3,48 +3,23 @@ import type { AstroSettings, ComponentInstance, RouteData, - RuntimeMode, SSRElement, SSRLoadedRenderer, } from '../../../@types/astro'; import { PAGE_SCRIPT_ID } from '../../../vite-plugin-scripts/index.js'; import { enhanceViteSSRError } from '../../errors/dev/index.js'; import { AggregateError, CSSError, MarkdownError } from '../../errors/index.js'; -import { LogOptions } from '../../logger/core.js'; import type { ModuleLoader } from '../../module-loader/index'; import { isPage, resolveIdToUrl } from '../../util.js'; import { createRenderContext, renderPage as coreRenderPage } from '../index.js'; import { filterFoundRenderers, loadRenderer } from '../renderer.js'; -import { RouteCache } from '../route-cache.js'; import { getStylesForURL } from './css.js'; import type { DevelopmentEnvironment } from './environment'; import { getScriptsForURL } from './scripts.js'; +import { getPropagationMap } from './head.js'; export { createDevelopmentEnvironment } from './environment.js'; export type { DevelopmentEnvironment }; -export interface SSROptionsOld { - /** an instance of the AstroSettings */ - settings: AstroSettings; - /** location of file on disk */ - filePath: URL; - /** logging options */ - logging: LogOptions; - /** "development" or "production" */ - mode: RuntimeMode; - /** production website */ - origin: string; - /** the web request (needed for dynamic routes) */ - pathname: string; - /** optional, in case we need to render something outside of a dev server */ - route?: RouteData; - /** pass in route cache because SSR can’t manage cache-busting */ - routeCache: RouteCache; - /** Module loader (Vite) */ - loader: ModuleLoader; - /** Request */ - request: Request; -} - export interface SSROptions { /** The environment instance */ env: DevelopmentEnvironment; @@ -163,7 +138,9 @@ async function getScriptsAndStyles({ env, filePath }: GetScriptsAndStylesParams) }); }); - return { scripts, styles, links }; + const propagationMap = await getPropagationMap(filePath, env.loader); + + return { scripts, styles, links, propagationMap }; } export async function renderPage(options: SSROptions): Promise { @@ -173,7 +150,7 @@ export async function renderPage(options: SSROptions): Promise { // The new instances are passed through. options.env.renderers = renderers; - const { scripts, links, styles } = await getScriptsAndStyles({ + const { scripts, links, styles, propagationMap } = await getScriptsAndStyles({ env: options.env, filePath: options.filePath, }); @@ -185,6 +162,7 @@ export async function renderPage(options: SSROptions): Promise { scripts, links, styles, + propagation: propagationMap, route: options.route, }); diff --git a/packages/astro/src/core/render/result.ts b/packages/astro/src/core/render/result.ts index 342c7fbd8a39..20fb5d5a7172 100644 --- a/packages/astro/src/core/render/result.ts +++ b/packages/astro/src/core/render/result.ts @@ -45,6 +45,7 @@ export interface CreateResultArgs { links?: Set; scripts?: Set; styles?: Set; + propagation?: SSRResult['propagation']; request: Request; status: number; } @@ -154,6 +155,9 @@ export function createResult(args: CreateResultArgs): SSRResult { styles: args.styles ?? new Set(), scripts: args.scripts ?? new Set(), links: args.links ?? new Set(), + propagation: args.propagation ?? new Map(), + propagators: new Map(), + extraHead: [], cookies, /** This function returns the `Astro` faux-global */ createAstro( diff --git a/packages/astro/src/jsx/babel.ts b/packages/astro/src/jsx/babel.ts index 541cad71cb62..8e8df454aca5 100644 --- a/packages/astro/src/jsx/babel.ts +++ b/packages/astro/src/jsx/babel.ts @@ -145,6 +145,7 @@ export default function astroJSX(): PluginObj { clientOnlyComponents: [], hydratedComponents: [], scripts: [], + propagation: 'none', }; } path.node.body.splice( diff --git a/packages/astro/src/runtime/server/astro-component.ts b/packages/astro/src/runtime/server/astro-component.ts new file mode 100644 index 000000000000..52b993f62fad --- /dev/null +++ b/packages/astro/src/runtime/server/astro-component.ts @@ -0,0 +1,29 @@ +import type { PropagationHint } from '../../@types/astro'; +import type { AstroComponentFactory } from './render/index.js'; + +function baseCreateComponent(cb: AstroComponentFactory, moduleId?: string) { + // Add a flag to this callback to mark it as an Astro component + cb.isAstroComponentFactory = true; + cb.moduleId = moduleId; + return cb; +} + +interface CreateComponentOptions { + factory: AstroComponentFactory; + moduleId?: string; + propagation?: PropagationHint; +} + +function createComponentWithOptions(opts: CreateComponentOptions) { + const cb = baseCreateComponent(opts.factory, opts.moduleId); + cb.propagation = opts.propagation; + return cb; +} +// Used in creating the component. aka the main export. +export function createComponent(arg1: AstroComponentFactory, moduleId: string) { + if(typeof arg1 === 'function') { + return baseCreateComponent(arg1, moduleId); + } else { + return createComponentWithOptions(arg1); + } +} diff --git a/packages/astro/src/runtime/server/astro-global.ts b/packages/astro/src/runtime/server/astro-global.ts index 101ec53ac598..5dd17afcef8e 100644 --- a/packages/astro/src/runtime/server/astro-global.ts +++ b/packages/astro/src/runtime/server/astro-global.ts @@ -39,6 +39,7 @@ export function createAstro( fetchContent: createDeprecatedFetchContentFn(), glob: createAstroGlobFn(), // INVESTIGATE is there a use-case for multi args? + // TODO remove in 2.0 resolve(...segments: string[]) { let resolved = segments.reduce((u, segment) => new URL(segment, u), referenceURL).pathname; // When inside of project root, remove the leading path so you are diff --git a/packages/astro/src/runtime/server/index.ts b/packages/astro/src/runtime/server/index.ts index 6ae149917ee8..519703b95de6 100644 --- a/packages/astro/src/runtime/server/index.ts +++ b/packages/astro/src/runtime/server/index.ts @@ -4,11 +4,13 @@ export { escapeHTML, HTMLBytes, HTMLString, markHTMLString, unescapeHTML } from export { renderJSX } from './jsx.js'; export { addAttribute, + createHeadAndContent, defineScriptVars, Fragment, maybeRenderHead, - renderAstroComponent, + renderAstroTemplateResult as renderAstroComponent, renderComponent, + renderComponentToIterable, Renderer as Renderer, renderHead, renderHTMLElement, @@ -16,26 +18,18 @@ export { renderSlot, renderTemplate as render, renderTemplate, + renderUniqueStylesheet, renderToString, stringifyChunk, voidElementNames, } from './render/index.js'; -export type { AstroComponentFactory, RenderInstruction } from './render/index.js'; -import type { AstroComponentFactory } from './render/index.js'; +export { createComponent } from './astro-component.js'; +export type { AstroComponentFactory, AstroComponentInstance, RenderInstruction } from './render/index.js'; import { markHTMLString } from './escape.js'; import { Renderer } from './render/index.js'; - import { addAttribute } from './render/index.js'; -// Used in creating the component. aka the main export. -export function createComponent(cb: AstroComponentFactory) { - // Add a flag to this callback to mark it as an Astro component - // INVESTIGATE does this need to cast - (cb as any).isAstroComponentFactory = true; - return cb; -} - export function mergeSlots(...slotted: unknown[]) { const slots: Record any> = {}; for (const slot of slotted) { diff --git a/packages/astro/src/runtime/server/jsx.ts b/packages/astro/src/runtime/server/jsx.ts index 8b976b2e8485..651ccc945bc1 100644 --- a/packages/astro/src/runtime/server/jsx.ts +++ b/packages/astro/src/runtime/server/jsx.ts @@ -5,7 +5,7 @@ import { escapeHTML, HTMLString, markHTMLString, - renderComponent, + renderComponentToIterable, renderToString, spreadAttributes, voidElementNames, @@ -177,7 +177,7 @@ Did you forget to import the component or is it possible there is a typo?`); props[Skip.symbol] = skip; let output: ComponentIterable; if (vnode.type === ClientOnlyPlaceholder && vnode.props['client:only']) { - output = await renderComponent( + output = await renderComponentToIterable( result, vnode.props['client:display-name'] ?? '', null, @@ -185,7 +185,7 @@ Did you forget to import the component or is it possible there is a typo?`); slots ); } else { - output = await renderComponent( + output = await renderComponentToIterable( result, typeof vnode.type === 'function' ? vnode.type.name : vnode.type, vnode.type, diff --git a/packages/astro/src/runtime/server/render/any.ts b/packages/astro/src/runtime/server/render/any.ts index 1a19ef519c2a..119dbc105493 100644 --- a/packages/astro/src/runtime/server/render/any.ts +++ b/packages/astro/src/runtime/server/render/any.ts @@ -1,5 +1,6 @@ import { escapeHTML, isHTMLString, markHTMLString } from '../escape.js'; -import { AstroComponent, renderAstroComponent } from './astro.js'; +import { isRenderTemplateResult, renderAstroTemplateResult } from './astro/index.js'; +import { isAstroComponentInstance } from './astro/index.js'; import { SlotString } from './slot.js'; export async function* renderChild(child: any): AsyncIterable { @@ -25,13 +26,10 @@ export async function* renderChild(child: any): AsyncIterable { } else if (!child && child !== 0) { // do nothing, safe to ignore falsey values. } - // Add a comment explaining why each of these are needed. - // Maybe create clearly named function for what this is doing. - else if ( - child instanceof AstroComponent || - Object.prototype.toString.call(child) === '[object AstroComponent]' - ) { - yield* renderAstroComponent(child); + else if(isRenderTemplateResult(child)) { + yield* renderAstroTemplateResult(child); + } else if(isAstroComponentInstance(child)) { + yield* child.render(); } else if (ArrayBuffer.isView(child)) { yield child; } else if ( diff --git a/packages/astro/src/runtime/server/render/astro.ts b/packages/astro/src/runtime/server/render/astro.ts deleted file mode 100644 index 077bc6ca24d0..000000000000 --- a/packages/astro/src/runtime/server/render/astro.ts +++ /dev/null @@ -1,146 +0,0 @@ -import type { SSRResult } from '../../../@types/astro'; -import type { AstroComponentFactory } from './index'; -import type { RenderInstruction } from './types'; - -import { HTMLBytes, markHTMLString } from '../escape.js'; -import { HydrationDirectiveProps } from '../hydration.js'; -import { isPromise } from '../util.js'; -import { renderChild } from './any.js'; -import { HTMLParts } from './common.js'; - -// Issue warnings for invalid props for Astro components -function validateComponentProps(props: any, displayName: string) { - if (props != null) { - for (const prop of Object.keys(props)) { - if (HydrationDirectiveProps.has(prop)) { - // eslint-disable-next-line - console.warn( - `You are attempting to render <${displayName} ${prop} />, but ${displayName} is an Astro component. Astro components do not render in the client and should not have a hydration directive. Please use a framework component for client rendering.` - ); - } - } - } -} - -// The return value when rendering a component. -// This is the result of calling render(), should this be named to RenderResult or...? -export class AstroComponent { - private htmlParts: TemplateStringsArray; - private expressions: any[]; - private error: Error | undefined; - - constructor(htmlParts: TemplateStringsArray, expressions: any[]) { - this.htmlParts = htmlParts; - this.error = undefined; - this.expressions = expressions.map((expression) => { - // Wrap Promise expressions so we can catch errors - // There can only be 1 error that we rethrow from an Astro component, - // so this keeps track of whether or not we have already done so. - if (isPromise(expression)) { - return Promise.resolve(expression).catch((err) => { - if (!this.error) { - this.error = err; - throw err; - } - }); - } - return expression; - }); - } - - get [Symbol.toStringTag]() { - return 'AstroComponent'; - } - - async *[Symbol.asyncIterator]() { - const { htmlParts, expressions } = this; - - for (let i = 0; i < htmlParts.length; i++) { - const html = htmlParts[i]; - const expression = expressions[i]; - - yield markHTMLString(html); - yield* renderChild(expression); - } - } -} - -// Determines if a component is an .astro component -export function isAstroComponent(obj: any): obj is AstroComponent { - return ( - typeof obj === 'object' && Object.prototype.toString.call(obj) === '[object AstroComponent]' - ); -} - -export function isAstroComponentFactory(obj: any): obj is AstroComponentFactory { - return obj == null ? false : obj.isAstroComponentFactory === true; -} - -export async function* renderAstroComponent( - component: InstanceType -): AsyncIterable { - for await (const value of component) { - if (value || value === 0) { - for await (const chunk of renderChild(value)) { - switch (chunk.type) { - case 'directive': { - yield chunk; - break; - } - default: { - yield markHTMLString(chunk); - break; - } - } - } - } - } -} - -// Calls a component and renders it into a string of HTML -export async function renderToString( - result: SSRResult, - componentFactory: AstroComponentFactory, - props: any, - children: any -): Promise { - const Component = await componentFactory(result, props, children); - - if (!isAstroComponent(Component)) { - const response: Response = Component; - throw response; - } - - let parts = new HTMLParts(); - for await (const chunk of renderAstroComponent(Component)) { - parts.append(chunk, result); - } - return parts.toString(); -} - -export async function renderToIterable( - result: SSRResult, - componentFactory: AstroComponentFactory, - displayName: string, - props: any, - children: any -): Promise> { - validateComponentProps(props, displayName); - const Component = await componentFactory(result, props, children); - - if (!isAstroComponent(Component)) { - // eslint-disable-next-line no-console - console.warn( - `Returning a Response is only supported inside of page components. Consider refactoring this logic into something like a function that can be used in the page.` - ); - - const response = Component; - throw response; - } - - return renderAstroComponent(Component); -} - -export async function renderTemplate(htmlParts: TemplateStringsArray, ...expressions: any[]) { - return new AstroComponent(htmlParts, expressions); -} diff --git a/packages/astro/src/runtime/server/render/astro/factory.ts b/packages/astro/src/runtime/server/render/astro/factory.ts new file mode 100644 index 000000000000..50cda589dbb2 --- /dev/null +++ b/packages/astro/src/runtime/server/render/astro/factory.ts @@ -0,0 +1,53 @@ +import type { SSRResult, PropagationHint } from '../../../../@types/astro'; +import type { HeadAndContent } from './head-and-content'; +import type { RenderTemplateResult } from './render-template'; + +import { renderAstroTemplateResult } from './render-template.js'; +import { isHeadAndContent } from './head-and-content.js'; +import { HTMLParts } from '../common.js'; + +export type AstroFactoryReturnValue = RenderTemplateResult | Response | HeadAndContent; + +// The callback passed to to $$createComponent +export interface AstroComponentFactory { + (result: any, props: any, slots: any): AstroFactoryReturnValue; + isAstroComponentFactory?: boolean; + moduleId: string | undefined; + propagation?: PropagationHint; +} + +export function isAstroComponentFactory(obj: any): obj is AstroComponentFactory { + return obj == null ? false : obj.isAstroComponentFactory === true; +} + +// Calls a component and renders it into a string of HTML +export async function renderToString( + result: SSRResult, + componentFactory: AstroComponentFactory, + props: any, + children: any +): Promise { + const factoryResult = await componentFactory(result, props, children); + + if (factoryResult instanceof Response) { + const response = factoryResult; + throw response; + } + + let parts = new HTMLParts(); + const templateResult = isHeadAndContent(factoryResult) ? factoryResult.content : factoryResult; + for await (const chunk of renderAstroTemplateResult(templateResult)) { + parts.append(chunk, result); + } + + + return parts.toString(); +} + +export function isAPropagatingComponent(result: SSRResult, factory: AstroComponentFactory): boolean { + let hint: PropagationHint = factory.propagation || 'none'; + if(factory.moduleId && result.propagation.has(factory.moduleId) && hint === 'none') { + hint = result.propagation.get(factory.moduleId)!; + } + return hint === 'in-tree' || hint === 'self'; +} diff --git a/packages/astro/src/runtime/server/render/astro/head-and-content.ts b/packages/astro/src/runtime/server/render/astro/head-and-content.ts new file mode 100644 index 000000000000..e4491142441e --- /dev/null +++ b/packages/astro/src/runtime/server/render/astro/head-and-content.ts @@ -0,0 +1,24 @@ +import type { RenderTemplateResult } from './render-template'; + +const headAndContentSym = Symbol.for('astro.headAndContent'); + +export type HeadAndContent = { + [headAndContentSym]: true; + head: string | RenderTemplateResult; + content: RenderTemplateResult; +} + +export function isHeadAndContent(obj: unknown): obj is HeadAndContent { + return typeof obj === 'object' && !!((obj as any)[headAndContentSym]); +} + +export function createHeadAndContent( + head: string | RenderTemplateResult, + content: RenderTemplateResult +): HeadAndContent { + return { + [headAndContentSym]: true, + head, + content + } +} diff --git a/packages/astro/src/runtime/server/render/astro/index.ts b/packages/astro/src/runtime/server/render/astro/index.ts new file mode 100644 index 000000000000..0dc39805d84c --- /dev/null +++ b/packages/astro/src/runtime/server/render/astro/index.ts @@ -0,0 +1,25 @@ + +export { + createAstroComponentInstance, + isAstroComponentInstance +} from './instance.js'; +export { + isAstroComponentFactory, + renderToString +} from './factory.js'; +export { + isRenderTemplateResult, + renderAstroTemplateResult, + renderTemplate +} from './render-template.js'; +export { + isHeadAndContent, + createHeadAndContent +} from './head-and-content.js'; + +export type { + AstroComponentFactory +} from './factory'; +export type { + AstroComponentInstance +} from './instance'; diff --git a/packages/astro/src/runtime/server/render/astro/instance.ts b/packages/astro/src/runtime/server/render/astro/instance.ts new file mode 100644 index 000000000000..db3916a49680 --- /dev/null +++ b/packages/astro/src/runtime/server/render/astro/instance.ts @@ -0,0 +1,82 @@ +import type { SSRResult } from '../../../../@types/astro'; +import type { AstroComponentFactory, AstroFactoryReturnValue } from './factory.js'; + +import { HydrationDirectiveProps } from '../../hydration.js'; +import { renderChild } from '../any.js'; +import { isHeadAndContent } from './head-and-content.js'; +import { isAPropagatingComponent } from './factory.js'; +import { isPromise } from '../../util.js'; + +type ComponentProps = Record; + +const astroComponentInstanceSym = Symbol.for('astro.componentInstance'); + +export class AstroComponentInstance { + [astroComponentInstanceSym] = true; + + private readonly result: SSRResult; + private readonly props: ComponentProps; + private readonly slots: any; + private readonly factory: AstroComponentFactory; + private returnValue: ReturnType | undefined; + constructor(result: SSRResult, props: ComponentProps, slots: any, factory: AstroComponentFactory) { + this.result = result; + this.props = props; + this.slots = slots; + this.factory = factory; + } + + async init() { + this.returnValue = this.factory(this.result, this.props, this.slots); + return this.returnValue; + } + + async *render() { + if(this.returnValue === undefined) { + await this.init(); + } + + let value: AstroFactoryReturnValue | undefined = this.returnValue; + if(isPromise(value)) { + value = await value; + } + if(isHeadAndContent(value)) { + yield * value.content; + } else { + yield * renderChild(value); + } + } +} + +// Issue warnings for invalid props for Astro components +function validateComponentProps(props: any, displayName: string) { + if (props != null) { + for (const prop of Object.keys(props)) { + if (HydrationDirectiveProps.has(prop)) { + // eslint-disable-next-line + console.warn( + `You are attempting to render <${displayName} ${prop} />, but ${displayName} is an Astro component. Astro components do not render in the client and should not have a hydration directive. Please use a framework component for client rendering.` + ); + } + } + } +} + +export function createAstroComponentInstance( + result: SSRResult, + displayName: string, + factory: AstroComponentFactory, + props: ComponentProps, + slots: any = {} +) { + validateComponentProps(props, displayName); + const instance = new AstroComponentInstance(result, props, slots, factory); + if(isAPropagatingComponent(result, factory) && !result.propagators.has(factory)) { + result.propagators.set(factory, instance); + } + return instance; +} + +export function isAstroComponentInstance(obj: unknown): obj is AstroComponentInstance { + return typeof obj === 'object' && !!((obj as any)[astroComponentInstanceSym]); +} diff --git a/packages/astro/src/runtime/server/render/astro/render-template.ts b/packages/astro/src/runtime/server/render/astro/render-template.ts new file mode 100644 index 000000000000..2c637f3c8192 --- /dev/null +++ b/packages/astro/src/runtime/server/render/astro/render-template.ts @@ -0,0 +1,83 @@ +import type { RenderInstruction } from '../types'; + +import { HTMLBytes, markHTMLString } from '../../escape.js'; +import { isPromise } from '../../util.js'; +import { renderChild } from '../any.js'; + +const renderTemplateResultSym = Symbol.for('astro.renderTemplateResult'); + +// The return value when rendering a component. +// This is the result of calling render(), should this be named to RenderResult or...? +export class RenderTemplateResult { + public [renderTemplateResultSym] = true; + private htmlParts: TemplateStringsArray; + private expressions: any[]; + private error: Error | undefined; + constructor(htmlParts: TemplateStringsArray, expressions: unknown[]) { + this.htmlParts = htmlParts; + this.error = undefined; + this.expressions = expressions.map((expression) => { + // Wrap Promise expressions so we can catch errors + // There can only be 1 error that we rethrow from an Astro component, + // so this keeps track of whether or not we have already done so. + if (isPromise(expression)) { + return Promise.resolve(expression).catch((err) => { + if (!this.error) { + this.error = err; + throw err; + } + }); + } + return expression; + }); + } + + // TODO this is legacy and should be removed in 2.0 + get [Symbol.toStringTag]() { + return 'AstroComponent'; + } + + async *[Symbol.asyncIterator]() { + const { htmlParts, expressions } = this; + + for (let i = 0; i < htmlParts.length; i++) { + const html = htmlParts[i]; + const expression = expressions[i]; + + yield markHTMLString(html); + yield* renderChild(expression); + } + } +} + +// Determines if a component is an .astro component +export function isRenderTemplateResult(obj: unknown): obj is RenderTemplateResult { + return ( + typeof obj === 'object' && !!((obj as any)[renderTemplateResultSym]) + ); +} + +export async function* renderAstroTemplateResult( + component: RenderTemplateResult +): AsyncIterable { + for await (const value of component) { + if (value || value === 0) { + for await (const chunk of renderChild(value)) { + switch (chunk.type) { + case 'directive': { + yield chunk; + break; + } + default: { + yield markHTMLString(chunk); + break; + } + } + } + } + } +} + +export function renderTemplate(htmlParts: TemplateStringsArray, ...expressions: any[]) { + return new RenderTemplateResult(htmlParts, expressions); +} diff --git a/packages/astro/src/runtime/server/render/component.ts b/packages/astro/src/runtime/server/render/component.ts index f461e4f12956..0e25d70142c8 100644 --- a/packages/astro/src/runtime/server/render/component.ts +++ b/packages/astro/src/runtime/server/render/component.ts @@ -12,15 +12,18 @@ import { extractDirectives, generateHydrateScript } from '../hydration.js'; import { serializeProps } from '../serialize.js'; import { shorthash } from '../shorthash.js'; import { + createAstroComponentInstance, isAstroComponentFactory, - renderAstroComponent, + isAstroComponentInstance, + renderAstroTemplateResult, renderTemplate, - renderToIterable, -} from './astro.js'; + type AstroComponentInstance +} from './astro/index.js'; import { Fragment, Renderer, stringifyChunk } from './common.js'; import { componentIsHTMLElement, renderHTMLElement } from './dom.js'; import { renderSlot, renderSlots } from './slot.js'; import { formatList, internalSpreadAttributes, renderElement, voidElementNames } from './util.js'; +import { isPromise } from '../util.js'; const rendererAliases = new Map([['solid', 'solid-js']]); @@ -45,65 +48,25 @@ function guessRenderers(componentUrl?: string): string[] { } } -type ComponentType = 'fragment' | 'html' | 'astro-factory' | 'unknown'; export type ComponentIterable = AsyncIterable; -function getComponentType(Component: unknown): ComponentType { - if (Component === Fragment) { - return 'fragment'; - } - if (Component && typeof Component === 'object' && (Component as any)['astro:html']) { - return 'html'; - } - if (isAstroComponentFactory(Component)) { - return 'astro-factory'; - } - return 'unknown'; +function isFragmentComponent(Component: unknown) { + return Component === Fragment; +} + +function isHTMLComponent(Component: unknown) { + return ( + Component && typeof Component === 'object' && (Component as any)['astro:html'] + ); } -export async function renderComponent( +async function renderFrameworkComponent( result: SSRResult, displayName: string, Component: unknown, _props: Record, slots: any = {}, - route?: RouteData | undefined ): Promise { - Component = (await Component) ?? Component; - - switch (getComponentType(Component)) { - case 'fragment': { - const children = await renderSlot(result, slots?.default); - if (children == null) { - return children; - } - return markHTMLString(children); - } - - // .html components - case 'html': { - const { slotInstructions, children } = await renderSlots(result, slots); - const html = (Component as any).render({ slots: children }); - const hydrationHtml = slotInstructions - ? slotInstructions.map((instr) => stringifyChunk(result, instr)).join('') - : ''; - return markHTMLString(hydrationHtml + html); - } - - case 'astro-factory': { - async function* renderAstroComponentInline(): AsyncGenerator< - string | HTMLBytes | RenderInstruction, - void, - undefined - > { - let iterable = await renderToIterable(result, Component as any, displayName, _props, slots); - yield* iterable; - } - - return renderAstroComponentInline(); - } - } - if (!Component && !_props['client:only']) { throw new Error( `Unable to render ${displayName} because it is ${Component}!\nDid you forget to import the component or is it possible there is a typo?` @@ -284,7 +247,7 @@ If you're still stuck, please open an issue on GitHub or join us at https://astr // as a string and the user is responsible for adding a script tag for the component definition. if (!html && typeof Component === 'string') { const childSlots = Object.values(children).join(''); - const iterable = renderAstroComponent( + const iterable = renderAstroTemplateResult( await renderTemplate`<${Component}${internalSpreadAttributes(props)}${markHTMLString( childSlots === '' && voidElementNames.test(Component) ? `/>` @@ -365,3 +328,68 @@ If you're still stuck, please open an issue on GitHub or join us at https://astr return renderAll(); } + +async function renderFragmentComponent(result: SSRResult, slots: any = {}) { + const children = await renderSlot(result, slots?.default); + if (children == null) { + return children; + } + return markHTMLString(children); +} + +async function renderHTMLComponent( + result: SSRResult, + Component: unknown, + _props: Record, + slots: any = {} +) { + const { slotInstructions, children } = await renderSlots(result, slots); + const html = (Component as any).render({ slots: children }); + const hydrationHtml = slotInstructions + ? slotInstructions.map((instr) => stringifyChunk(result, instr)).join('') + : ''; + return markHTMLString(hydrationHtml + html); +} + +export function renderComponent( + result: SSRResult, + displayName: string, + Component: unknown, + props: Record, + slots: any = {} +): Promise | ComponentIterable | AstroComponentInstance { + if(isPromise(Component)) { + return Promise.resolve(Component).then(Unwrapped => { + return renderComponent(result, displayName, Unwrapped, props, slots) as any; + }); + } + + if(isFragmentComponent(Component)) { + return renderFragmentComponent(result, slots); + } + + // .html components + if(isHTMLComponent(Component)) { + return renderHTMLComponent(result, Component, props, slots); + } + + if(isAstroComponentFactory(Component)) { + return createAstroComponentInstance(result, displayName, Component, props, slots); + } + + return renderFrameworkComponent(result, displayName, Component, props, slots); +} + +export function renderComponentToIterable( + result: SSRResult, + displayName: string, + Component: unknown, + props: Record, + slots: any = {} +): Promise | ComponentIterable { + const renderResult = renderComponent(result, displayName, Component, props, slots); + if(isAstroComponentInstance(renderResult)) { + return renderResult.render(); + } + return renderResult; +} diff --git a/packages/astro/src/runtime/server/render/head.ts b/packages/astro/src/runtime/server/render/head.ts index 9afed33fe9f4..701432c2ad2c 100644 --- a/packages/astro/src/runtime/server/render/head.ts +++ b/packages/astro/src/runtime/server/render/head.ts @@ -2,6 +2,7 @@ import type { SSRResult } from '../../../@types/astro'; import { markHTMLString } from '../escape.js'; import { renderElement } from './util.js'; +import { renderChild } from './any.js'; // Filter out duplicate elements in our set const uniqueElements = (item: any, index: number, all: any[]) => { @@ -12,8 +13,14 @@ const uniqueElements = (item: any, index: number, all: any[]) => { ); }; -export function renderHead(result: SSRResult): Promise { - result._metadata.hasRenderedHead = true; +async function * renderExtraHead(result: SSRResult, base: string) { + yield base; + for(const part of result.extraHead) { + yield * renderChild(part); + } +} + +function renderAllHeadContent(result: SSRResult) { const styles = Array.from(result.styles) .filter(uniqueElements) .map((style) => renderElement('style', style)); @@ -27,16 +34,30 @@ export function renderHead(result: SSRResult): Promise { const links = Array.from(result.links) .filter(uniqueElements) .map((link) => renderElement('link', link, false)); - return markHTMLString(links.join('\n') + styles.join('\n') + scripts.join('\n')); + + const baseHeadContent = markHTMLString(links.join('\n') + styles.join('\n') + scripts.join('\n')) + + if(result.extraHead.length > 0) { + return renderExtraHead(result, baseHeadContent); + } else { + return baseHeadContent; + } } +export function createRenderHead(result: SSRResult) { + result._metadata.hasRenderedHead = true; + return renderAllHeadContent.bind(null, result); +} + +export const renderHead = createRenderHead; + // This function is called by Astro components that do not contain a component // This accommodates the fact that using a is optional in Astro, so this // is called before a component's first non-head HTML element. If the head was // already injected it is a noop. -export async function* maybeRenderHead(result: SSRResult): AsyncIterable { +export async function* maybeRenderHead(result: SSRResult) { if (result._metadata.hasRenderedHead) { return; } - yield renderHead(result); + yield createRenderHead(result)(); } diff --git a/packages/astro/src/runtime/server/render/index.ts b/packages/astro/src/runtime/server/render/index.ts index 537482691af5..15a4d19774bb 100644 --- a/packages/astro/src/runtime/server/render/index.ts +++ b/packages/astro/src/runtime/server/render/index.ts @@ -1,17 +1,12 @@ -import { renderTemplate } from './astro.js'; +export type { RenderInstruction } from './types'; +export type { AstroComponentFactory, AstroComponentInstance } from './astro/index'; -export { renderAstroComponent, renderTemplate, renderToString } from './astro.js'; +export { createHeadAndContent, renderAstroTemplateResult, renderToString, renderTemplate } from './astro/index.js'; export { Fragment, Renderer, stringifyChunk } from './common.js'; -export { renderComponent } from './component.js'; +export { renderComponent, renderComponentToIterable } from './component.js'; export { renderHTMLElement } from './dom.js'; export { maybeRenderHead, renderHead } from './head.js'; export { renderPage } from './page.js'; export { renderSlot } from './slot.js'; -export type { RenderInstruction } from './types'; export { addAttribute, defineScriptVars, voidElementNames } from './util.js'; - -// The callback passed to to $$createComponent -export interface AstroComponentFactory { - (result: any, props: any, slots: any): ReturnType | Response; - isAstroComponentFactory?: boolean; -} +export { renderUniqueStylesheet } from './stylesheet.js'; diff --git a/packages/astro/src/runtime/server/render/page.ts b/packages/astro/src/runtime/server/render/page.ts index 7b9c1ac5651e..9f9c7ae4090a 100644 --- a/packages/astro/src/runtime/server/render/page.ts +++ b/packages/astro/src/runtime/server/render/page.ts @@ -5,7 +5,13 @@ import type { AstroComponentFactory } from './index'; import { AstroError, AstroErrorData } from '../../../core/errors/index.js'; import { isHTMLString } from '../escape.js'; import { createResponse } from '../response.js'; -import { isAstroComponent, isAstroComponentFactory, renderAstroComponent } from './astro.js'; +import { + isAstroComponentFactory, + isAstroComponentInstance, + isRenderTemplateResult, + isHeadAndContent, + renderAstroTemplateResult +} from './astro/index.js'; import { chunkToByteArray, encoder, HTMLParts } from './common.js'; import { renderComponent } from './component.js'; import { maybeRenderHead } from './head.js'; @@ -45,6 +51,22 @@ async function iterableToHTMLBytes( return parts.toArrayBuffer(); } +// Recursively calls component instances that might have head content +// to be propagated up. +async function bufferHeadContent(result: SSRResult) { + const iterator = result.propagators.values(); + while(true) { + const { value, done } = iterator.next(); + if(done) { + break; + } + const returnValue = await value.init(); + if(isHeadAndContent(returnValue)) { + result.extraHead.push(returnValue.head); + } + } +} + export async function renderPage( result: SSRResult, componentFactory: AstroComponentFactory | NonAstroPageComponent, @@ -57,16 +79,19 @@ export async function renderPage( const pageProps: Record = { ...(props ?? {}), 'server:root': true }; let output: ComponentIterable; - try { - output = await renderComponent( + const renderResult = await renderComponent( result, componentFactory.name, componentFactory, pageProps, null, - route ); + if(isAstroComponentInstance(renderResult)) { + output = renderResult.render(); + } else { + output = renderResult; + } } catch (e) { if (AstroError.is(e) && !e.loc) { e.setLocation({ @@ -94,9 +119,13 @@ export async function renderPage( }); } const factoryReturnValue = await componentFactory(result, props, children); + const factoryIsHeadAndContent = isHeadAndContent(factoryReturnValue); + if (isRenderTemplateResult(factoryReturnValue) || factoryIsHeadAndContent) { + // Wait for head content to be buffered up + await bufferHeadContent(result); + const templateResult = factoryIsHeadAndContent ? factoryReturnValue.content : factoryReturnValue; - if (isAstroComponent(factoryReturnValue)) { - let iterable = renderAstroComponent(factoryReturnValue); + let iterable = renderAstroTemplateResult(templateResult); let init = result.response; let headers = new Headers(init.headers); let body: BodyInit; diff --git a/packages/astro/src/runtime/server/render/stylesheet.ts b/packages/astro/src/runtime/server/render/stylesheet.ts new file mode 100644 index 000000000000..cc704bc0b65c --- /dev/null +++ b/packages/astro/src/runtime/server/render/stylesheet.ts @@ -0,0 +1,25 @@ +import { SSRResult } from '../../../@types/astro'; +import { renderElement } from './util.js'; +import { markHTMLString } from '../escape.js'; + +const stylesheetRel = 'stylesheet'; + +export function renderStylesheet({ href }: { href: string }) { + return markHTMLString(renderElement('link', { + props: { + rel: stylesheetRel, + href + }, + children: '' + }, false)); +} + +export function renderUniqueStylesheet(result: SSRResult, link: { href: string }) { + for (const existingLink of result.links) { + if(existingLink.props.rel === stylesheetRel && existingLink.props.href === link.href) { + return ''; + } + } + + return renderStylesheet(link); +} diff --git a/packages/astro/src/vite-plugin-astro/index.ts b/packages/astro/src/vite-plugin-astro/index.ts index 09bf658f9f98..4d9accab25e0 100644 --- a/packages/astro/src/vite-plugin-astro/index.ts +++ b/packages/astro/src/vite-plugin-astro/index.ts @@ -18,6 +18,8 @@ import { normalizeFilename } from '../vite-plugin-utils/index.js'; import { cachedFullCompilation } from './compile.js'; import { handleHotUpdate } from './hmr.js'; import { parseAstroRequest, ParsedRequestResult } from './query.js'; +export type { AstroPluginMetadata }; +export { getAstroMetadata } from './metadata.js'; interface AstroPluginOptions { settings: AstroSettings; @@ -108,6 +110,7 @@ export default function astro({ settings, logging }: AstroPluginOptions): vite.P if (!compileResult) { return null; } + switch (query.type) { case 'style': { if (typeof query.index === 'undefined') { @@ -198,6 +201,7 @@ export default function astro({ settings, logging }: AstroPluginOptions): vite.P astroConfig: config, viteConfig: resolvedConfig, filename, + id, source, }; @@ -215,6 +219,7 @@ export default function astro({ settings, logging }: AstroPluginOptions): vite.P clientOnlyComponents: transformResult.clientOnlyComponents, hydratedComponents: transformResult.hydratedComponents, scripts: transformResult.scripts, + propagation: 'none', }; return { @@ -236,6 +241,7 @@ export default function astro({ settings, logging }: AstroPluginOptions): vite.P astroConfig: config, viteConfig: resolvedConfig, filename: context.file, + id: context.modules[0]?.id ?? undefined, source: await context.read(), }; const compile = () => cachedCompilation(compileProps); diff --git a/packages/astro/src/vite-plugin-astro/metadata.ts b/packages/astro/src/vite-plugin-astro/metadata.ts new file mode 100644 index 000000000000..866d0127778c --- /dev/null +++ b/packages/astro/src/vite-plugin-astro/metadata.ts @@ -0,0 +1,9 @@ +import type { PluginMetadata } from './types'; +import type { ModuleInfo } from '../core/module-loader'; + +export function getAstroMetadata(modInfo: ModuleInfo): PluginMetadata['astro'] | undefined { + if(modInfo.meta?.astro) { + return modInfo.meta.astro as PluginMetadata['astro']; + } + return undefined; +} diff --git a/packages/astro/src/vite-plugin-astro/types.ts b/packages/astro/src/vite-plugin-astro/types.ts index bf6a1cee51ba..c9ac8332ca99 100644 --- a/packages/astro/src/vite-plugin-astro/types.ts +++ b/packages/astro/src/vite-plugin-astro/types.ts @@ -1,9 +1,11 @@ import type { TransformResult } from '@astrojs/compiler'; +import type { PropagationHint } from '../@types/astro'; export interface PluginMetadata { astro: { hydratedComponents: TransformResult['hydratedComponents']; clientOnlyComponents: TransformResult['clientOnlyComponents']; scripts: TransformResult['scripts']; + propagation: PropagationHint; }; } diff --git a/packages/astro/src/vite-plugin-head-propagation/index.ts b/packages/astro/src/vite-plugin-head-propagation/index.ts new file mode 100644 index 000000000000..dd8355c0f8c1 --- /dev/null +++ b/packages/astro/src/vite-plugin-head-propagation/index.ts @@ -0,0 +1,54 @@ +import type { AstroSettings } from '../@types/astro'; +import type { ModuleInfo } from 'rollup'; + +import * as vite from 'vite'; +import { getAstroMetadata } from '../vite-plugin-astro/index.js'; + +const injectExp = /^\/\/\s*astro-head-inject/; +/** + * If any component is marked as doing head injection, walk up the tree + * and mark parent Astro components as having head injection in the tree. + * This is used at runtime to determine if we should wait for head content + * to be be populated before rendering the entire tree. + */ +export default function configHeadPropagationVitePlugin({ + settings, +}: { + settings: AstroSettings; +}): vite.Plugin { + function addHeadInjectionInTree(graph: vite.ModuleGraph, id: string, getInfo: (id: string) => ModuleInfo | null, seen: Set = new Set()) { + const mod = server.moduleGraph.getModuleById(id); + for(const parent of mod?.importers || []) { + if(parent.id) { + if(seen.has(parent.id)) { + continue; + } + const info = getInfo(parent.id); + if(info?.meta.astro) { + const astroMetadata = getAstroMetadata(info); + if(astroMetadata) { + astroMetadata.propagation = 'in-tree'; + } + } + addHeadInjectionInTree(graph, parent.id, getInfo, seen); + } + } + } + + let server: vite.ViteDevServer; + return { + name: 'astro:head-propagation', + configureServer(_server) { + server = _server; + }, + transform(source, id) { + if(!server) { + return; + } + + if(injectExp.test(source)) { + addHeadInjectionInTree(server.moduleGraph, id, (child) => this.getModuleInfo(child)); + } + } + }; +} diff --git a/packages/astro/src/vite-plugin-load-fallback/index.ts b/packages/astro/src/vite-plugin-load-fallback/index.ts index 76ca4e2bdf4f..86346421c762 100644 --- a/packages/astro/src/vite-plugin-load-fallback/index.ts +++ b/packages/astro/src/vite-plugin-load-fallback/index.ts @@ -44,7 +44,7 @@ export default function loadFallbackPlugin({ async resolveId(id, parent) { // See if this can be loaded from our fs if (parent) { - const candidateId = npath.posix.join(npath.posix.dirname(parent), id); + const candidateId = npath.posix.join(npath.posix.dirname(slashify(parent)), id); try { // Check to see if this file exists and is not a directory. const stats = await fs.promises.stat(candidateId); diff --git a/packages/astro/src/vite-plugin-markdown-legacy/index.ts b/packages/astro/src/vite-plugin-markdown-legacy/index.ts index a224a3193454..f55f4724852d 100644 --- a/packages/astro/src/vite-plugin-markdown-legacy/index.ts +++ b/packages/astro/src/vite-plugin-markdown-legacy/index.ts @@ -207,6 +207,7 @@ ${setup}`.trim(); viteConfig: resolvedConfig, filename, source: astroResult, + id, }; let transformResult = await cachedCompilation(compileProps); @@ -232,6 +233,7 @@ ${tsResult}`; clientOnlyComponents: transformResult.clientOnlyComponents, hydratedComponents: transformResult.hydratedComponents, scripts: transformResult.scripts, + propagation: 'none' }; return { diff --git a/packages/astro/src/vite-plugin-markdown/index.ts b/packages/astro/src/vite-plugin-markdown/index.ts index f13bf5ca4b03..46d84fffc076 100644 --- a/packages/astro/src/vite-plugin-markdown/index.ts +++ b/packages/astro/src/vite-plugin-markdown/index.ts @@ -157,6 +157,7 @@ export default function markdown({ settings, logging }: AstroPluginOptions): Plu hydratedComponents: [], clientOnlyComponents: [], scripts: [], + propagation: 'none', } as PluginMetadata['astro'], vite: { lang: 'ts', diff --git a/packages/astro/test/units/dev/head-injection.test.js b/packages/astro/test/units/dev/head-injection.test.js new file mode 100644 index 000000000000..5f57d2400042 --- /dev/null +++ b/packages/astro/test/units/dev/head-injection.test.js @@ -0,0 +1,160 @@ +import { expect } from 'chai'; +import * as cheerio from 'cheerio'; + +import { runInContainer } from '../../../dist/core/dev/index.js'; +import { createFs, createRequestAndResponse } from '../test-utils.js'; + +const root = new URL('../../fixtures/alias/', import.meta.url); + +describe('head injection', () => { + it('Dynamic injection from component created in the page frontmatter', async () => { + const fs = createFs( + { + '/src/components/Other.astro': ` + +
Other
+ `, + '/src/common/head.js': ` + // astro-head-inject + import Other from '../components/Other.astro'; + import { + createComponent, + createHeadAndContent, + renderComponent, + renderTemplate, + renderUniqueStylesheet, + } from 'astro/runtime/server/index.js'; + + export function renderEntry() { + return createComponent({ + factory(result, props, slots) { + return createHeadAndContent( + renderUniqueStylesheet(result, { + href: '/some/fake/styles.css' + }), + renderTemplate\`$\{renderComponent(result, 'Other', Other, props, slots)}\` + ); + }, + propagation: 'self' + }); + } + `.trim(), + '/src/pages/index.astro': ` + --- + import { renderEntry } from '../common/head.js'; + const Head = renderEntry(); + --- +

testing

+ + `, + }, + root + ); + + await runInContainer({ + fs, root, + userConfig: { + vite: { server: { middlewareMode: true } } + } + }, async (container) => { + const { req, res, done, text } = createRequestAndResponse({ + method: 'GET', + url: '/', + }); + container.handle(req, res); + await done; + const html = await text(); + const $ = cheerio.load(html); + + expect($('link[rel=stylesheet][href="/some/fake/styles.css"]')).to.have.a.lengthOf(1); + expect($('#other')).to.have.a.lengthOf(1); + }); + }); + + it('Dynamic injection from a layout component', async () => { + const fs = createFs( + { + '/src/components/Other.astro': ` + +
Other
+ `, + '/src/common/head.js': ` + // astro-head-inject + import Other from '../components/Other.astro'; + import { + createComponent, + createHeadAndContent, + renderComponent, + renderTemplate, + renderUniqueStylesheet, + } from 'astro/runtime/server/index.js'; + + export function renderEntry() { + return createComponent({ + factory(result, props, slots) { + return createHeadAndContent( + renderUniqueStylesheet(result, { + href: '/some/fake/styles.css' + }), + renderTemplate\`$\{renderComponent(result, 'Other', Other, props, slots)}\` + ); + }, + propagation: 'self' + }); + } + `.trim(), + '/src/components/Layout.astro': ` + --- + import { renderEntry } from '../common/head.js'; + const ExtraHead = renderEntry(); + --- + + + Normal head stuff + + + + + + + `, + '/src/pages/index.astro': ` + --- + import Layout from '../components/Layout.astro'; + --- + +

Test page

+
+ `, + }, + root + ); + + await runInContainer({ + fs, root, + userConfig: { + vite: { server: { middlewareMode: true } } + } + }, async (container) => { + const { req, res, done, text } = createRequestAndResponse({ + method: 'GET', + url: '/', + }); + container.handle(req, res); + await done; + const html = await text(); + const $ = cheerio.load(html); + + expect($('link[rel=stylesheet][href="/some/fake/styles.css"]')).to.have.a.lengthOf(1); + expect($('#other')).to.have.a.lengthOf(1); + }); + }); +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 82d8de6de9d5..fd205edfcdf8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -373,7 +373,7 @@ importers: packages/astro: specifiers: - '@astrojs/compiler': ^0.29.15 + '@astrojs/compiler': ^0.30.0 '@astrojs/language-server': ^0.28.3 '@astrojs/markdown-remark': ^1.1.3 '@astrojs/telemetry': ^1.0.1 @@ -471,7 +471,7 @@ importers: yargs-parser: ^21.0.1 zod: ^3.17.3 dependencies: - '@astrojs/compiler': 0.29.15 + '@astrojs/compiler': 0.30.0 '@astrojs/language-server': 0.28.3 '@astrojs/markdown-remark': link:../markdown/remark '@astrojs/telemetry': link:../telemetry @@ -3893,6 +3893,10 @@ packages: /@astrojs/compiler/0.29.15: resolution: {integrity: sha512-vicPD8oOPNkcFZvz71Uz/nJcadovurUQ3L0yMZNPb6Nn6T1nHhlSHt5nAKaurB2pYU9DrxOFWZS2/RdV+JsWmQ==} + /@astrojs/compiler/0.30.0: + resolution: {integrity: sha512-av2HV5NuyzI5E12hpn4k7XNEHbfF81/JUISPu6CclC5yKCxTS7z64hRU68tA8k7dYLATcxvjQtvN2H/dnxHaMw==} + dev: false + /@astrojs/language-server/0.28.3: resolution: {integrity: sha512-fPovAX/X46eE2w03jNRMpQ7W9m2mAvNt4Ay65lD9wl1Z5vIQYxlg7Enp9qP225muTr4jSVB5QiLumFJmZMAaVA==} hasBin: true