From 61c3c83792a65d935892bb0a1834a4d72bb2431d Mon Sep 17 00:00:00 2001 From: Matthew Phillips Date: Mon, 8 Aug 2022 14:05:51 -0400 Subject: [PATCH 1/4] Refactor runtime --- .../astro/src/runtime/server/astro-global.ts | 50 + packages/astro/src/runtime/server/endpoint.ts | 74 ++ .../astro/src/runtime/server/hydration.ts | 10 +- packages/astro/src/runtime/server/index.ts | 975 +----------------- .../astro/src/runtime/server/render/any.ts | 51 + .../astro/src/runtime/server/render/astro.ts | 124 +++ .../astro/src/runtime/server/render/common.ts | 43 + .../src/runtime/server/render/component.ts | 350 +++++++ .../astro/src/runtime/server/render/dom.ts | 42 + .../astro/src/runtime/server/render/head.ts | 43 + .../astro/src/runtime/server/render/index.ts | 16 + .../astro/src/runtime/server/render/page.ts | 99 ++ .../astro/src/runtime/server/render/types.ts | 8 + .../astro/src/runtime/server/render/util.ts | 128 +++ 14 files changed, 1060 insertions(+), 953 deletions(-) create mode 100644 packages/astro/src/runtime/server/astro-global.ts create mode 100644 packages/astro/src/runtime/server/endpoint.ts create mode 100644 packages/astro/src/runtime/server/render/any.ts create mode 100644 packages/astro/src/runtime/server/render/astro.ts create mode 100644 packages/astro/src/runtime/server/render/common.ts create mode 100644 packages/astro/src/runtime/server/render/component.ts create mode 100644 packages/astro/src/runtime/server/render/dom.ts create mode 100644 packages/astro/src/runtime/server/render/head.ts create mode 100644 packages/astro/src/runtime/server/render/index.ts create mode 100644 packages/astro/src/runtime/server/render/page.ts create mode 100644 packages/astro/src/runtime/server/render/types.ts create mode 100644 packages/astro/src/runtime/server/render/util.ts diff --git a/packages/astro/src/runtime/server/astro-global.ts b/packages/astro/src/runtime/server/astro-global.ts new file mode 100644 index 000000000000..57b51c8cde93 --- /dev/null +++ b/packages/astro/src/runtime/server/astro-global.ts @@ -0,0 +1,50 @@ +import type { AstroGlobalPartial } from '../../@types/astro'; + +/** Create the Astro.fetchContent() runtime function. */ +function createDeprecatedFetchContentFn() { + return () => { + throw new Error('Deprecated: Astro.fetchContent() has been replaced with Astro.glob().'); + }; +} + +/** Create the Astro.glob() runtime function. */ +function createAstroGlobFn() { + const globHandler = (importMetaGlobResult: Record, globValue: () => any) => { + let allEntries = [...Object.values(importMetaGlobResult)]; + if (allEntries.length === 0) { + throw new Error(`Astro.glob(${JSON.stringify(globValue())}) - no matches found.`); + } + // Map over the `import()` promises, calling to load them. + return Promise.all(allEntries.map((fn) => fn())); + }; + // Cast the return type because the argument that the user sees (string) is different from the argument + // that the runtime sees post-compiler (Record). + return globHandler as unknown as AstroGlobalPartial['glob']; +} + +// This is used to create the top-level Astro global; the one that you can use +// Inside of getStaticPaths. +export function createAstro( + filePathname: string, + _site: string | undefined, + projectRootStr: string +): AstroGlobalPartial { + const site = _site ? new URL(_site) : undefined; + const referenceURL = new URL(filePathname, `http://localhost`); + const projectRoot = new URL(projectRootStr); + return { + site, + fetchContent: createDeprecatedFetchContentFn(), + glob: createAstroGlobFn(), + // INVESTIGATE is there a use-case for multi args? + 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 + // left with only `/src/images/tower.png` + if (resolved.startsWith(projectRoot.pathname)) { + resolved = '/' + resolved.slice(projectRoot.pathname.length); + } + return resolved; + }, + }; +} diff --git a/packages/astro/src/runtime/server/endpoint.ts b/packages/astro/src/runtime/server/endpoint.ts new file mode 100644 index 000000000000..95bea8b64b6c --- /dev/null +++ b/packages/astro/src/runtime/server/endpoint.ts @@ -0,0 +1,74 @@ + +import type { + APIContext, + EndpointHandler, + Params +} from '../../@types/astro'; + +function getHandlerFromModule(mod: EndpointHandler, method: string) { + // If there was an exact match on `method`, return that function. + if (mod[method]) { + return mod[method]; + } + // Handle `del` instead of `delete`, since `delete` is a reserved word in JS. + if (method === 'delete' && mod['del']) { + return mod['del']; + } + // If a single `all` handler was used, return that function. + if (mod['all']) { + return mod['all']; + } + // Otherwise, no handler found. + return undefined; +} + +/** Renders an endpoint request to completion, returning the body. */ +export async function renderEndpoint(mod: EndpointHandler, request: Request, params: Params) { + const chosenMethod = request.method?.toLowerCase(); + const handler = getHandlerFromModule(mod, chosenMethod); + if (!handler || typeof handler !== 'function') { + throw new Error( + `Endpoint handler not found! Expected an exported function for "${chosenMethod}"` + ); + } + + if (handler.length > 1) { + // eslint-disable-next-line no-console + console.warn(` +API routes with 2 arguments have been deprecated. Instead they take a single argument in the form of: + +export function get({ params, request }) { + //... +} + +Update your code to remove this warning.`); + } + + const context = { + request, + params, + }; + + const proxy = new Proxy(context, { + get(target, prop) { + if (prop in target) { + return Reflect.get(target, prop); + } else if (prop in params) { + // eslint-disable-next-line no-console + console.warn(` +API routes no longer pass params as the first argument. Instead an object containing a params property is provided in the form of: + +export function get({ params }) { + // ... +} + +Update your code to remove this warning.`); + return Reflect.get(params, prop); + } else { + return undefined; + } + }, + }) as APIContext & Params; + + return handler.call(mod, proxy, request); +} diff --git a/packages/astro/src/runtime/server/hydration.ts b/packages/astro/src/runtime/server/hydration.ts index e14924dfe7c2..c4cfc6ec6a6f 100644 --- a/packages/astro/src/runtime/server/hydration.ts +++ b/packages/astro/src/runtime/server/hydration.ts @@ -8,7 +8,9 @@ import { escapeHTML } from './escape.js'; import { serializeProps } from './serialize.js'; import { serializeListValue } from './util.js'; -const HydrationDirectives = ['load', 'idle', 'media', 'visible', 'only']; +const HydrationDirectivesRaw = ['load', 'idle', 'media', 'visible', 'only']; +const HydrationDirectives = new Set(HydrationDirectivesRaw); +export const HydrationDirectiveProps = new Set(HydrationDirectivesRaw.map(n => `client:${n}`)); export interface HydrationMetadata { directive: string; @@ -68,11 +70,9 @@ export function extractDirectives(inputProps: Record): Ext extracted.hydration.value = value; // throw an error if an invalid hydration directive was provided - if (HydrationDirectives.indexOf(extracted.hydration.directive) < 0) { + if (!HydrationDirectives.has(extracted.hydration.directive)) { throw new Error( - `Error: invalid hydration directive "${key}". Supported hydration methods: ${HydrationDirectives.map( - (d) => `"client:${d}"` - ).join(', ')}` + `Error: invalid hydration directive "${key}". Supported hydration methods: ${Array.from(HydrationDirectiveProps).join(', ')}` ); } diff --git a/packages/astro/src/runtime/server/index.ts b/packages/astro/src/runtime/server/index.ts index df3de955a2d9..2569f044052a 100644 --- a/packages/astro/src/runtime/server/index.ts +++ b/packages/astro/src/runtime/server/index.ts @@ -1,27 +1,3 @@ -import type { - APIContext, - AstroComponentMetadata, - AstroGlobalPartial, - EndpointHandler, - Params, - SSRElement, - SSRLoadedRenderer, - SSRResult, -} from '../../@types/astro'; - -import { escapeHTML, HTMLString, markHTMLString } from './escape.js'; -import { extractDirectives, generateHydrateScript, HydrationMetadata } from './hydration.js'; -import { createResponse } from './response.js'; -import { - determineIfNeedsHydrationScript, - determinesIfNeedsDirectiveScript, - getPrescripts, - PrescriptType, -} from './scripts.js'; -import { serializeProps } from './serialize.js'; -import { shorthash } from './shorthash.js'; -import { serializeListValue } from './util.js'; - export { escapeHTML, HTMLString, @@ -30,99 +6,35 @@ export { } from './escape.js'; export type { Metadata } from './metadata'; export { createMetadata } from './metadata.js'; +import type { AstroComponentFactory } from './render/index.js'; -export const voidElementNames = - /^(area|base|br|col|command|embed|hr|img|input|keygen|link|meta|param|source|track|wbr)$/i; -const htmlBooleanAttributes = - /^(allowfullscreen|async|autofocus|autoplay|controls|default|defer|disabled|disablepictureinpicture|disableremoteplayback|formnovalidate|hidden|loop|nomodule|novalidate|open|playsinline|readonly|required|reversed|scoped|seamless|itemscope)$/i; -const htmlEnumAttributes = /^(contenteditable|draggable|spellcheck|value)$/i; -// Note: SVG is case-sensitive! -const svgEnumAttributes = /^(autoReverse|externalResourcesRequired|focusable|preserveAlpha)$/i; -// process.env.PACKAGE_VERSION is injected when we build and publish the astro package. -const ASTRO_VERSION = process.env.PACKAGE_VERSION ?? 'development'; - -// INVESTIGATE: -// 2. Less anys when possible and make it well known when they are needed. - -// Used to render slots and expressions -// INVESTIGATE: Can we have more specific types both for the argument and output? -// If these are intentional, add comments that these are intention and why. -// Or maybe type UserValue = any; ? -async function* _render(child: any): AsyncIterable { - child = await child; - if (child instanceof HTMLString) { - yield child; - } else if (Array.isArray(child)) { - for (const value of child) { - yield markHTMLString(await _render(value)); - } - } else if (typeof child === 'function') { - // Special: If a child is a function, call it automatically. - // This lets you do {() => ...} without the extra boilerplate - // of wrapping it in a function and calling it. - yield* _render(child()); - } else if (typeof child === 'string') { - yield markHTMLString(escapeHTML(child)); - } 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 (typeof child === 'object' && Symbol.asyncIterator in child) { - yield* child; - } else { - yield child; - } -} - -// 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[]; - - constructor(htmlParts: TemplateStringsArray, expressions: any[]) { - this.htmlParts = htmlParts; - this.expressions = expressions; - } - - 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]; +import { Renderer } from './render/index.js'; +import { markHTMLString } from './escape.js'; - yield markHTMLString(html); - yield* _render(expression); - } - } -} - -function isAstroComponent(obj: any): obj is AstroComponent { - return ( - typeof obj === 'object' && Object.prototype.toString.call(obj) === '[object AstroComponent]' - ); -} - -export async function render(htmlParts: TemplateStringsArray, ...expressions: any[]) { - return new AstroComponent(htmlParts, expressions); -} +export { createAstro } from './astro-global.js'; +export { + addAttribute, + voidElementNames, + defineScriptVars, + maybeRenderHead, + renderAstroComponent, + renderComponent, + renderHead, + renderHTMLElement, + renderPage, + renderSlot, + renderTemplate, + renderTemplate as render, + renderToString, + stringifyChunk, + Fragment, + Renderer as Renderer +} from './render/index.js'; +export { renderEndpoint } from './endpoint.js'; + +import { addAttribute } from './render/index.js'; -// The callback passed to to $$createComponent -export interface AstroComponentFactory { - (result: any, props: any, slots: any): ReturnType | Response; - isAstroComponentFactory?: boolean; -} +export const ClientOnlyPlaceholder = 'astro-client-only'; // Used in creating the component. aka the main export. export function createComponent(cb: AstroComponentFactory) { @@ -132,22 +44,6 @@ export function createComponent(cb: AstroComponentFactory) { return cb; } -export async function renderSlot(result: any, slotted: string, fallback?: any): Promise { - if (slotted) { - let iterator = _render(slotted); - let content = ''; - for await (const chunk of iterator) { - if ((chunk as any).type === 'directive') { - content += stringifyChunk(result, chunk); - } else { - content += chunk; - } - } - return markHTMLString(content); - } - return fallback; -} - export function mergeSlots(...slotted: unknown[]) { const slots: Record any> = {}; for (const slot of slotted) { @@ -161,34 +57,6 @@ export function mergeSlots(...slotted: unknown[]) { return slots; } -export const Fragment = Symbol.for('astro:fragment'); -export const Renderer = Symbol.for('astro:renderer'); -export const ClientOnlyPlaceholder = 'astro-client-only'; - -function guessRenderers(componentUrl?: string): string[] { - const extname = componentUrl?.split('.').pop(); - switch (extname) { - case 'svelte': - return ['@astrojs/svelte']; - case 'vue': - return ['@astrojs/vue']; - case 'jsx': - case 'tsx': - return ['@astrojs/react', '@astrojs/preact']; - default: - return ['@astrojs/react', '@astrojs/preact', '@astrojs/vue', '@astrojs/svelte']; - } -} - -function formatList(values: string[]): string { - if (values.length === 1) { - return values[0]; - } - return `${values.slice(0, -1).join(', ')} or ${values[values.length - 1]}`; -} - -const rendererAliases = new Map([['solid', 'solid-js']]); - /** @internal Assosciate JSX components with a specific renderer (see /src/vite-plugin-jsx/tag.ts) */ export function __astro_tag_component__(Component: unknown, rendererName: string) { if (!Component) return; @@ -200,428 +68,10 @@ export function __astro_tag_component__(Component: unknown, rendererName: string }); } -export async function renderComponent( - result: SSRResult, - displayName: string, - Component: unknown, - _props: Record, - slots: any = {} -): Promise> { - Component = await Component; - if (Component === Fragment) { - const children = await renderSlot(result, slots?.default); - if (children == null) { - return children; - } - return markHTMLString(children); - } - - if (Component && typeof Component === 'object' && (Component as any)['astro:html']) { - const children: Record = {}; - if (slots) { - await Promise.all( - Object.entries(slots).map(([key, value]) => - renderSlot(result, value as string).then((output) => { - children[key] = output; - }) - ) - ); - } - const html = (Component as any).render({ slots: children }); - return markHTMLString(html); - } - - if (Component && (Component as any).isAstroComponentFactory) { - async function* renderAstroComponentInline(): AsyncGenerator< - string | RenderInstruction, - void, - undefined - > { - let iterable = await renderToIterable(result, Component as any, _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?` - ); - } - - const { renderers } = result._metadata; - const metadata: AstroComponentMetadata = { displayName }; - - const { hydration, isPage, props } = extractDirectives(_props); - let html = ''; - - if (hydration) { - metadata.hydrate = hydration.directive as AstroComponentMetadata['hydrate']; - metadata.hydrateArgs = hydration.value; - metadata.componentExport = hydration.componentExport; - metadata.componentUrl = hydration.componentUrl; - } - const probableRendererNames = guessRenderers(metadata.componentUrl); - - if ( - Array.isArray(renderers) && - renderers.length === 0 && - typeof Component !== 'string' && - !componentIsHTMLElement(Component) - ) { - const message = `Unable to render ${metadata.displayName}! - -There are no \`integrations\` set in your \`astro.config.mjs\` file. -Did you mean to add ${formatList(probableRendererNames.map((r) => '`' + r + '`'))}?`; - throw new Error(message); - } - - const children: Record = {}; - if (slots) { - await Promise.all( - Object.entries(slots).map(([key, value]) => - renderSlot(result, value as string).then((output) => { - children[key] = output; - }) - ) - ); - } - - // Call the renderers `check` hook to see if any claim this component. - let renderer: SSRLoadedRenderer | undefined; - if (metadata.hydrate !== 'only') { - // If this component ran through `__astro_tag_component__`, we already know - // which renderer to match to and can skip the usual `check` calls. - // This will help us throw most relevant error message for modules with runtime errors - if (Component && (Component as any)[Renderer]) { - const rendererName = (Component as any)[Renderer]; - renderer = renderers.find(({ name }) => name === rendererName); - } - - if (!renderer) { - let error; - for (const r of renderers) { - try { - if (await r.ssr.check.call({ result }, Component, props, children)) { - renderer = r; - break; - } - } catch (e) { - error ??= e; - } - } - - // If no renderer is found and there is an error, throw that error because - // it is likely a problem with the component code. - if (!renderer && error) { - throw error; - } - } - - if (!renderer && typeof HTMLElement === 'function' && componentIsHTMLElement(Component)) { - const output = renderHTMLElement(result, Component as typeof HTMLElement, _props, slots); - - return output; - } - } else { - // Attempt: use explicitly passed renderer name - if (metadata.hydrateArgs) { - const passedName = metadata.hydrateArgs; - const rendererName = rendererAliases.has(passedName) - ? rendererAliases.get(passedName) - : passedName; - renderer = renderers.find( - ({ name }) => name === `@astrojs/${rendererName}` || name === rendererName - ); - } - // Attempt: user only has a single renderer, default to that - if (!renderer && renderers.length === 1) { - renderer = renderers[0]; - } - // Attempt: can we guess the renderer from the export extension? - if (!renderer) { - const extname = metadata.componentUrl?.split('.').pop(); - renderer = renderers.filter( - ({ name }) => name === `@astrojs/${extname}` || name === extname - )[0]; - } - } - - // If no one claimed the renderer - if (!renderer) { - if (metadata.hydrate === 'only') { - // TODO: improve error message - throw new Error(`Unable to render ${metadata.displayName}! - -Using the \`client:only\` hydration strategy, Astro needs a hint to use the correct renderer. -Did you mean to pass <${metadata.displayName} client:only="${probableRendererNames - .map((r) => r.replace('@astrojs/', '')) - .join('|')}" /> -`); - } else if (typeof Component !== 'string') { - const matchingRenderers = renderers.filter((r) => probableRendererNames.includes(r.name)); - const plural = renderers.length > 1; - if (matchingRenderers.length === 0) { - throw new Error(`Unable to render ${metadata.displayName}! - -There ${plural ? 'are' : 'is'} ${renderers.length} renderer${ - plural ? 's' : '' - } configured in your \`astro.config.mjs\` file, -but ${plural ? 'none were' : 'it was not'} able to server-side render ${metadata.displayName}. - -Did you mean to enable ${formatList(probableRendererNames.map((r) => '`' + r + '`'))}?`); - } else if (matchingRenderers.length === 1) { - // We already know that renderer.ssr.check() has failed - // but this will throw a much more descriptive error! - renderer = matchingRenderers[0]; - ({ html } = await renderer.ssr.renderToStaticMarkup.call( - { result }, - Component, - props, - children, - metadata - )); - } else { - throw new Error(`Unable to render ${metadata.displayName}! - -This component likely uses ${formatList(probableRendererNames)}, -but Astro encountered an error during server-side rendering. - -Please ensure that ${metadata.displayName}: -1. Does not unconditionally access browser-specific globals like \`window\` or \`document\`. - If this is unavoidable, use the \`client:only\` hydration directive. -2. Does not conditionally return \`null\` or \`undefined\` when rendered on the server. - -If you're still stuck, please open an issue on GitHub or join us at https://astro.build/chat.`); - } - } - } else { - if (metadata.hydrate === 'only') { - html = await renderSlot(result, slots?.fallback); - } else { - ({ html } = await renderer.ssr.renderToStaticMarkup.call( - { result }, - Component, - props, - children, - metadata - )); - } - } - - // HACK! The lit renderer doesn't include a clientEntrypoint for custom elements, allow it - // to render here until we find a better way to recognize when a client entrypoint isn't required. - if ( - renderer && - !renderer.clientEntrypoint && - renderer.name !== '@astrojs/lit' && - metadata.hydrate - ) { - throw new Error( - `${metadata.displayName} component has a \`client:${metadata.hydrate}\` directive, but no client entrypoint was provided by ${renderer.name}!` - ); - } - - // This is a custom element without a renderer. Because of that, render it - // 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( - await render`<${Component}${internalSpreadAttributes(props)}${markHTMLString( - childSlots === '' && voidElementNames.test(Component) - ? `/>` - : `>${childSlots}` - )}` - ); - html = ''; - for await (const chunk of iterable) { - html += chunk; - } - } - - if (!hydration) { - if (isPage || renderer?.name === 'astro:jsx') { - return html; - } - return markHTMLString(html.replace(/\<\/?astro-slot\>/g, '')); - } - - // Include componentExport name, componentUrl, and props in hash to dedupe identical islands - const astroId = shorthash( - `\n${html}\n${serializeProps( - props - )}` - ); - - const island = await generateHydrateScript( - { renderer: renderer!, result, astroId, props }, - metadata as Required - ); - - // Render template if not all astro fragments are provided. - let unrenderedSlots: string[] = []; - if (html) { - if (Object.keys(children).length > 0) { - for (const key of Object.keys(children)) { - if (!html.includes(key === 'default' ? `` : ``)) { - unrenderedSlots.push(key); - } - } - } - } else { - unrenderedSlots = Object.keys(children); - } - const template = - unrenderedSlots.length > 0 - ? unrenderedSlots - .map( - (key) => - `` - ) - .join('') - : ''; - - island.children = `${html ?? ''}${template}`; - - if (island.children) { - island.props['await-children'] = ''; - } - - async function* renderAll() { - yield { type: 'directive', hydration, result }; - yield markHTMLString(renderElement('astro-island', island, false)); - } - - return renderAll(); -} - -/** Create the Astro.fetchContent() runtime function. */ -function createDeprecatedFetchContentFn() { - return () => { - throw new Error('Deprecated: Astro.fetchContent() has been replaced with Astro.glob().'); - }; -} - -/** Create the Astro.glob() runtime function. */ -function createAstroGlobFn() { - const globHandler = (importMetaGlobResult: Record, globValue: () => any) => { - let allEntries = [...Object.values(importMetaGlobResult)]; - if (allEntries.length === 0) { - throw new Error(`Astro.glob(${JSON.stringify(globValue())}) - no matches found.`); - } - // Map over the `import()` promises, calling to load them. - return Promise.all(allEntries.map((fn) => fn())); - }; - // Cast the return type because the argument that the user sees (string) is different from the argument - // that the runtime sees post-compiler (Record). - return globHandler as unknown as AstroGlobalPartial['glob']; -} - -// This is used to create the top-level Astro global; the one that you can use -// Inside of getStaticPaths. -export function createAstro( - filePathname: string, - _site: string | undefined, - projectRootStr: string -): AstroGlobalPartial { - const site = _site ? new URL(_site) : undefined; - const referenceURL = new URL(filePathname, `http://localhost`); - const projectRoot = new URL(projectRootStr); - return { - site, - generator: `Astro v${ASTRO_VERSION}`, - fetchContent: createDeprecatedFetchContentFn(), - glob: createAstroGlobFn(), - // INVESTIGATE is there a use-case for multi args? - 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 - // left with only `/src/images/tower.png` - if (resolved.startsWith(projectRoot.pathname)) { - resolved = '/' + resolved.slice(projectRoot.pathname.length); - } - return resolved; - }, - }; -} - -const toAttributeString = (value: any, shouldEscape = true) => - shouldEscape ? String(value).replace(/&/g, '&').replace(/"/g, '"') : value; - -const kebab = (k: string) => - k.toLowerCase() === k ? k : k.replace(/[A-Z]/g, (match) => `-${match.toLowerCase()}`); -const toStyleString = (obj: Record) => - Object.entries(obj) - .map(([k, v]) => `${kebab(k)}:${v}`) - .join(';'); - -const STATIC_DIRECTIVES = new Set(['set:html', 'set:text']); - -// A helper used to turn expressions into attribute key/value -export function addAttribute(value: any, key: string, shouldEscape = true) { - if (value == null) { - return ''; - } - - if (value === false) { - if (htmlEnumAttributes.test(key) || svgEnumAttributes.test(key)) { - return markHTMLString(` ${key}="false"`); - } - return ''; - } - - // compiler directives cannot be applied dynamically, log a warning and ignore. - if (STATIC_DIRECTIVES.has(key)) { - // eslint-disable-next-line no-console - console.warn(`[astro] The "${key}" directive cannot be applied dynamically at runtime. It will not be rendered as an attribute. - -Make sure to use the static attribute syntax (\`${key}={value}\`) instead of the dynamic spread syntax (\`{...{ "${key}": value }}\`).`); - return ''; - } - - // support "class" from an expression passed into an element (#782) - if (key === 'class:list') { - const listValue = toAttributeString(serializeListValue(value)); - if (listValue === '') { - return ''; - } - return markHTMLString(` ${key.slice(0, -5)}="${listValue}"`); - } - - // support object styles for better JSX compat - if (key === 'style' && !(value instanceof HTMLString) && typeof value === 'object') { - return markHTMLString(` ${key}="${toStyleString(value)}"`); - } - - // support `className` for better JSX compat - if (key === 'className') { - return markHTMLString(` class="${toAttributeString(value, shouldEscape)}"`); - } - - // Boolean values only need the key - if (value === true && (key.startsWith('data-') || htmlBooleanAttributes.test(key))) { - return markHTMLString(` ${key}`); - } else { - return markHTMLString(` ${key}="${toAttributeString(value, shouldEscape)}"`); - } -} - -// Adds support for ` -function internalSpreadAttributes(values: Record, shouldEscape = true) { - let output = ''; - for (const [key, value] of Object.entries(values)) { - output += addAttribute(value, key, shouldEscape); - } - return markHTMLString(output); -} - // Adds support for ` export function spreadAttributes( values: Record, - name?: string, + _name?: string, { class: scopedClassName }: { class?: string } = {} ) { let output = ''; @@ -654,374 +104,3 @@ export function defineStyleVars(defs: Record | Record[]) { } return markHTMLString(output); } - -// converts (most) arbitrary strings to valid JS identifiers -const toIdent = (k: string) => - k.trim().replace(/(?:(? { - if (/[^\w]|\s/.test(match)) return ''; - return index === 0 ? match : match.toUpperCase(); - }); - -// Adds variables to an inline script. -export function defineScriptVars(vars: Record) { - let output = ''; - for (const [key, value] of Object.entries(vars)) { - output += `let ${toIdent(key)} = ${JSON.stringify(value)};\n`; - } - return markHTMLString(output); -} - -function getHandlerFromModule(mod: EndpointHandler, method: string) { - // If there was an exact match on `method`, return that function. - if (mod[method]) { - return mod[method]; - } - // Handle `del` instead of `delete`, since `delete` is a reserved word in JS. - if (method === 'delete' && mod['del']) { - return mod['del']; - } - // If a single `all` handler was used, return that function. - if (mod['all']) { - return mod['all']; - } - // Otherwise, no handler found. - return undefined; -} - -/** Renders an endpoint request to completion, returning the body. */ -export async function renderEndpoint(mod: EndpointHandler, request: Request, params: Params) { - const chosenMethod = request.method?.toLowerCase(); - const handler = getHandlerFromModule(mod, chosenMethod); - if (!handler || typeof handler !== 'function') { - throw new Error( - `Endpoint handler not found! Expected an exported function for "${chosenMethod}"` - ); - } - - if (handler.length > 1) { - // eslint-disable-next-line no-console - console.warn(` -API routes with 2 arguments have been deprecated. Instead they take a single argument in the form of: - -export function get({ params, request }) { - //... -} - -Update your code to remove this warning.`); - } - - const context = { - request, - params, - }; - - const proxy = new Proxy(context, { - get(target, prop) { - if (prop in target) { - return Reflect.get(target, prop); - } else if (prop in params) { - // eslint-disable-next-line no-console - console.warn(` -API routes no longer pass params as the first argument. Instead an object containing a params property is provided in the form of: - -export function get({ params }) { - // ... -} - -Update your code to remove this warning.`); - return Reflect.get(params, prop); - } else { - return undefined; - } - }, - }) as APIContext & Params; - - return handler.call(mod, proxy, request); -} - -// 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 html = ''; - for await (const chunk of renderAstroComponent(Component)) { - html += stringifyChunk(result, chunk); - } - return html; -} - -export async function renderToIterable( - result: SSRResult, - componentFactory: AstroComponentFactory, - props: any, - children: any -): Promise> { - 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: Response = Component; - throw response; - } - - return renderAstroComponent(Component); -} - -const encoder = new TextEncoder(); - -// 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) { - switch ((chunk as any).type) { - case 'directive': { - const { hydration } = chunk as RenderInstruction; - let needsHydrationScript = hydration && determineIfNeedsHydrationScript(result); - let needsDirectiveScript = - hydration && determinesIfNeedsDirectiveScript(result, hydration.directive); - - let prescriptType: PrescriptType = needsHydrationScript - ? 'both' - : needsDirectiveScript - ? 'directive' - : null; - if (prescriptType) { - let prescripts = getPrescripts(prescriptType, hydration.directive); - return markHTMLString(prescripts); - } else { - return ''; - } - } - default: { - return chunk.toString(); - } - } -} - -export async function renderPage( - result: SSRResult, - componentFactory: AstroComponentFactory, - props: any, - children: any, - streaming: boolean -): Promise { - if (!componentFactory.isAstroComponentFactory) { - const pageProps: Record = { ...(props ?? {}), 'server:root': true }; - const output = await renderComponent( - result, - componentFactory.name, - componentFactory, - pageProps, - null - ); - let html = output.toString(); - if (!/`; - for await (let chunk of maybeRenderHead(result)) { - html += chunk; - } - html += rest; - } - return new Response(html, { - headers: new Headers([ - ['Content-Type', 'text/html; charset=utf-8'], - ['Content-Length', Buffer.byteLength(html, 'utf-8').toString()], - ]), - }); - } - const factoryReturnValue = await componentFactory(result, props, children); - - if (isAstroComponent(factoryReturnValue)) { - let iterable = renderAstroComponent(factoryReturnValue); - let init = result.response; - let headers = new Headers(init.headers); - let body: BodyInit; - - if (streaming) { - body = new ReadableStream({ - start(controller) { - async function read() { - let i = 0; - try { - for await (const chunk of iterable) { - let html = stringifyChunk(result, chunk); - - if (i === 0) { - if (!/\n')); - } - } - controller.enqueue(encoder.encode(html)); - i++; - } - controller.close(); - } catch (e) { - controller.error(e); - } - } - read(); - }, - }); - } else { - body = ''; - let i = 0; - for await (const chunk of iterable) { - let html = stringifyChunk(result, chunk); - if (i === 0) { - if (!/\n'; - } - } - body += html; - i++; - } - const bytes = encoder.encode(body); - headers.set('Content-Length', bytes.byteLength.toString()); - } - - let response = createResponse(body, { ...init, headers }); - return response; - } else { - return factoryReturnValue; - } -} - -// Filter out duplicate elements in our set -const uniqueElements = (item: any, index: number, all: any[]) => { - const props = JSON.stringify(item.props); - const children = item.children; - return ( - index === all.findIndex((i) => JSON.stringify(i.props) === props && i.children == children) - ); -}; - -const alreadyHeadRenderedResults = new WeakSet(); -export function renderHead(result: SSRResult): Promise { - alreadyHeadRenderedResults.add(result); - const styles = Array.from(result.styles) - .filter(uniqueElements) - .map((style) => renderElement('style', style)); - // Clear result.styles so that any new styles added will be inlined. - result.styles.clear(); - const scripts = Array.from(result.scripts) - .filter(uniqueElements) - .map((script, i) => { - return renderElement('script', script, false); - }); - 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')); -} - -// This function is called by Astro components that do not contain a component -// This accomodates 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 { - if (alreadyHeadRenderedResults.has(result)) { - return; - } - yield renderHead(result); -} - -export interface RenderInstruction { - type: 'directive'; - result: SSRResult; - hydration: HydrationMetadata; -} - -export async function* renderAstroComponent( - component: InstanceType -): AsyncIterable { - for await (const value of component) { - if (value || value === 0) { - for await (const chunk of _render(value)) { - switch (chunk.type) { - case 'directive': { - yield chunk; - break; - } - default: { - yield markHTMLString(chunk); - break; - } - } - } - } - } -} - -function componentIsHTMLElement(Component: unknown) { - return typeof HTMLElement !== 'undefined' && HTMLElement.isPrototypeOf(Component as object); -} - -export async function renderHTMLElement( - result: SSRResult, - constructor: typeof HTMLElement, - props: any, - slots: any -) { - const name = getHTMLElementName(constructor); - - let attrHTML = ''; - - for (const attr in props) { - attrHTML += ` ${attr}="${toAttributeString(await props[attr])}"`; - } - - return markHTMLString( - `<${name}${attrHTML}>${await renderSlot(result, slots?.default)}` - ); -} - -function getHTMLElementName(constructor: typeof HTMLElement) { - const definedName = ( - customElements as CustomElementRegistry & { getName(_constructor: typeof HTMLElement): string } - ).getName(constructor); - if (definedName) return definedName; - - const assignedName = constructor.name - .replace(/^HTML|Element$/g, '') - .replace(/[A-Z]/g, '-$&') - .toLowerCase() - .replace(/^-/, 'html-'); - return assignedName; -} - -function renderElement( - name: string, - { props: _props, children = '' }: SSRElement, - shouldEscape = true -) { - // Do not print `hoist`, `lang`, `is:global` - const { lang: _, 'data-astro-id': astroId, 'define:vars': defineVars, ...props } = _props; - if (defineVars) { - if (name === 'style') { - delete props['is:global']; - delete props['is:scoped']; - } - if (name === 'script') { - delete props.hoist; - children = defineScriptVars(defineVars) + '\n' + children; - } - } - if ((children == null || children == '') && voidElementNames.test(name)) { - return `<${name}${internalSpreadAttributes(props, shouldEscape)} />`; - } - return `<${name}${internalSpreadAttributes(props, shouldEscape)}>${children}`; -} diff --git a/packages/astro/src/runtime/server/render/any.ts b/packages/astro/src/runtime/server/render/any.ts new file mode 100644 index 000000000000..2f4987708a96 --- /dev/null +++ b/packages/astro/src/runtime/server/render/any.ts @@ -0,0 +1,51 @@ +import { AstroComponent, renderAstroComponent } from './astro.js'; +import { markHTMLString, HTMLString, escapeHTML } from '../escape.js'; +import { stringifyChunk } from './common.js'; + +export async function* renderChild(child: any): AsyncIterable { + child = await child; + if (child instanceof HTMLString) { + yield child; + } else if (Array.isArray(child)) { + for (const value of child) { + yield markHTMLString(await renderChild(value)); + } + } else if (typeof child === 'function') { + // Special: If a child is a function, call it automatically. + // This lets you do {() => ...} without the extra boilerplate + // of wrapping it in a function and calling it. + yield* renderChild(child()); + } else if (typeof child === 'string') { + yield markHTMLString(escapeHTML(child)); + } 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 (typeof child === 'object' && Symbol.asyncIterator in child) { + yield* child; + } else { + yield child; + } +} + +export async function renderSlot(result: any, slotted: string, fallback?: any): Promise { + if (slotted) { + let iterator = renderChild(slotted); + let content = ''; + for await (const chunk of iterator) { + if ((chunk as any).type === 'directive') { + content += stringifyChunk(result, chunk); + } else { + content += chunk; + } + } + return markHTMLString(content); + } + return fallback; +} diff --git a/packages/astro/src/runtime/server/render/astro.ts b/packages/astro/src/runtime/server/render/astro.ts new file mode 100644 index 000000000000..c9e9ac91ff7a --- /dev/null +++ b/packages/astro/src/runtime/server/render/astro.ts @@ -0,0 +1,124 @@ +import type { SSRResult } from '../../../@types/astro'; +import type { RenderInstruction } from './types'; +import type { AstroComponentFactory } from './index'; + +import { HydrationDirectiveProps } from '../hydration.js'; +import { stringifyChunk } from './common.js'; +import { markHTMLString } from '../escape.js'; +import { renderChild } from './any.js'; + +// In dev mode, check props and make sure they are valid for an Astro component +function validateComponentProps(props: any, displayName: string) { + if(import.meta.env?.DEV && 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[]; + + constructor(htmlParts: TemplateStringsArray, expressions: any[]) { + this.htmlParts = htmlParts; + this.expressions = expressions; + } + + 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 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 html = ''; + for await (const chunk of renderAstroComponent(Component)) { + html += stringifyChunk(result, chunk); + } + return html; +} + +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/common.ts b/packages/astro/src/runtime/server/render/common.ts new file mode 100644 index 000000000000..cebbf596684e --- /dev/null +++ b/packages/astro/src/runtime/server/render/common.ts @@ -0,0 +1,43 @@ +import type { SSRResult } from '../../../@types/astro'; +import type { RenderInstruction } from './types.js'; + +import { markHTMLString } from '../escape.js'; +import { + determineIfNeedsHydrationScript, + determinesIfNeedsDirectiveScript, + getPrescripts, +PrescriptType, +} from '../scripts.js'; + +export const Fragment = Symbol.for('astro:fragment'); +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) { + switch ((chunk as any).type) { + case 'directive': { + const { hydration } = chunk as RenderInstruction; + let needsHydrationScript = hydration && determineIfNeedsHydrationScript(result); + let needsDirectiveScript = + hydration && determinesIfNeedsDirectiveScript(result, hydration.directive); + + let prescriptType: PrescriptType = needsHydrationScript + ? 'both' + : needsDirectiveScript + ? 'directive' + : null; + if (prescriptType) { + let prescripts = getPrescripts(prescriptType, hydration.directive); + return markHTMLString(prescripts); + } else { + return ''; + } + } + default: { + return chunk.toString(); + } + } +} diff --git a/packages/astro/src/runtime/server/render/component.ts b/packages/astro/src/runtime/server/render/component.ts new file mode 100644 index 000000000000..38e6add659ee --- /dev/null +++ b/packages/astro/src/runtime/server/render/component.ts @@ -0,0 +1,350 @@ +import type { + AstroComponentMetadata, + SSRLoadedRenderer, + SSRResult, +} from '../../../@types/astro'; +import type { RenderInstruction } from './types.js'; + +import { extractDirectives, generateHydrateScript } from '../hydration.js'; +import { serializeProps } from '../serialize.js'; +import { shorthash } from '../shorthash.js'; +import { Fragment, Renderer } from './common.js'; +import { markHTMLString } from '../escape.js'; +import { renderSlot } from './any.js'; +import { renderToIterable, renderAstroComponent, renderTemplate } from './astro.js'; +import { componentIsHTMLElement, renderHTMLElement } from './dom.js'; +import { formatList, internalSpreadAttributes, renderElement, voidElementNames } from './util.js'; + +const rendererAliases = new Map([['solid', 'solid-js']]); + +function guessRenderers(componentUrl?: string): string[] { + const extname = componentUrl?.split('.').pop(); + switch (extname) { + case 'svelte': + return ['@astrojs/svelte']; + case 'vue': + return ['@astrojs/vue']; + case 'jsx': + case 'tsx': + return ['@astrojs/react', '@astrojs/preact']; + default: + return ['@astrojs/react', '@astrojs/preact', '@astrojs/vue', '@astrojs/svelte']; + } +} + +type ComponentType = 'fragment' | 'html' | 'astro-factory' | 'unknown'; + +function getComponentType(Component: unknown): ComponentType { + if (Component === Fragment) { + return 'fragment'; + } + if(Component && typeof Component === 'object' && (Component as any)['astro:html']) { + return 'html'; + } + if(Component && (Component as any).isAstroComponentFactory) { + return 'astro-factory'; + } + return 'unknown'; +} + +export async function renderComponent( + result: SSRResult, + displayName: string, + Component: unknown, + _props: Record, + slots: any = {} +): Promise> { + Component = await 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 children: Record = {}; + if (slots) { + await Promise.all( + Object.entries(slots).map(([key, value]) => + renderSlot(result, value as string).then((output) => { + children[key] = output; + }) + ) + ); + } + const html = (Component as any).render({ slots: children }); + return markHTMLString(html); + } + + case 'astro-factory': { + async function* renderAstroComponentInline(): AsyncGenerator< + string | 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?` + ); + } + + const { renderers } = result._metadata; + const metadata: AstroComponentMetadata = { displayName }; + + const { hydration, isPage, props } = extractDirectives(_props); + let html = ''; + + if (hydration) { + metadata.hydrate = hydration.directive as AstroComponentMetadata['hydrate']; + metadata.hydrateArgs = hydration.value; + metadata.componentExport = hydration.componentExport; + metadata.componentUrl = hydration.componentUrl; + } + const probableRendererNames = guessRenderers(metadata.componentUrl); + + if ( + Array.isArray(renderers) && + renderers.length === 0 && + typeof Component !== 'string' && + !componentIsHTMLElement(Component) + ) { + const message = `Unable to render ${metadata.displayName}! + +There are no \`integrations\` set in your \`astro.config.mjs\` file. +Did you mean to add ${formatList(probableRendererNames.map((r) => '`' + r + '`'))}?`; + throw new Error(message); + } + + const children: Record = {}; + if (slots) { + await Promise.all( + Object.entries(slots).map(([key, value]) => + renderSlot(result, value as string).then((output) => { + children[key] = output; + }) + ) + ); + } + + // Call the renderers `check` hook to see if any claim this component. + let renderer: SSRLoadedRenderer | undefined; + if (metadata.hydrate !== 'only') { + // If this component ran through `__astro_tag_component__`, we already know + // which renderer to match to and can skip the usual `check` calls. + // This will help us throw most relevant error message for modules with runtime errors + if (Component && (Component as any)[Renderer]) { + const rendererName = (Component as any)[Renderer]; + renderer = renderers.find(({ name }) => name === rendererName); + } + + if (!renderer) { + let error; + for (const r of renderers) { + try { + if (await r.ssr.check.call({ result }, Component, props, children)) { + renderer = r; + break; + } + } catch (e) { + error ??= e; + } + } + + // If no renderer is found and there is an error, throw that error because + // it is likely a problem with the component code. + if (!renderer && error) { + throw error; + } + } + + if (!renderer && typeof HTMLElement === 'function' && componentIsHTMLElement(Component)) { + const output = renderHTMLElement(result, Component as typeof HTMLElement, _props, slots); + + return output; + } + } else { + // Attempt: use explicitly passed renderer name + if (metadata.hydrateArgs) { + const passedName = metadata.hydrateArgs; + const rendererName = rendererAliases.has(passedName) + ? rendererAliases.get(passedName) + : passedName; + renderer = renderers.find( + ({ name }) => name === `@astrojs/${rendererName}` || name === rendererName + ); + } + // Attempt: user only has a single renderer, default to that + if (!renderer && renderers.length === 1) { + renderer = renderers[0]; + } + // Attempt: can we guess the renderer from the export extension? + if (!renderer) { + const extname = metadata.componentUrl?.split('.').pop(); + renderer = renderers.filter( + ({ name }) => name === `@astrojs/${extname}` || name === extname + )[0]; + } + } + + // If no one claimed the renderer + if (!renderer) { + if (metadata.hydrate === 'only') { + // TODO: improve error message + throw new Error(`Unable to render ${metadata.displayName}! + +Using the \`client:only\` hydration strategy, Astro needs a hint to use the correct renderer. +Did you mean to pass <${metadata.displayName} client:only="${probableRendererNames + .map((r) => r.replace('@astrojs/', '')) + .join('|')}" /> +`); + } else if (typeof Component !== 'string') { + const matchingRenderers = renderers.filter((r) => probableRendererNames.includes(r.name)); + const plural = renderers.length > 1; + if (matchingRenderers.length === 0) { + throw new Error(`Unable to render ${metadata.displayName}! + +There ${plural ? 'are' : 'is'} ${renderers.length} renderer${ + plural ? 's' : '' + } configured in your \`astro.config.mjs\` file, +but ${plural ? 'none were' : 'it was not'} able to server-side render ${metadata.displayName}. + +Did you mean to enable ${formatList(probableRendererNames.map((r) => '`' + r + '`'))}?`); + } else if (matchingRenderers.length === 1) { + // We already know that renderer.ssr.check() has failed + // but this will throw a much more descriptive error! + renderer = matchingRenderers[0]; + ({ html } = await renderer.ssr.renderToStaticMarkup.call( + { result }, + Component, + props, + children, + metadata + )); + } else { + throw new Error(`Unable to render ${metadata.displayName}! + +This component likely uses ${formatList(probableRendererNames)}, +but Astro encountered an error during server-side rendering. + +Please ensure that ${metadata.displayName}: +1. Does not unconditionally access browser-specific globals like \`window\` or \`document\`. + If this is unavoidable, use the \`client:only\` hydration directive. +2. Does not conditionally return \`null\` or \`undefined\` when rendered on the server. + +If you're still stuck, please open an issue on GitHub or join us at https://astro.build/chat.`); + } + } + } else { + if (metadata.hydrate === 'only') { + html = await renderSlot(result, slots?.fallback); + } else { + ({ html } = await renderer.ssr.renderToStaticMarkup.call( + { result }, + Component, + props, + children, + metadata + )); + } + } + + // HACK! The lit renderer doesn't include a clientEntrypoint for custom elements, allow it + // to render here until we find a better way to recognize when a client entrypoint isn't required. + if ( + renderer && + !renderer.clientEntrypoint && + renderer.name !== '@astrojs/lit' && + metadata.hydrate + ) { + throw new Error( + `${metadata.displayName} component has a \`client:${metadata.hydrate}\` directive, but no client entrypoint was provided by ${renderer.name}!` + ); + } + + // This is a custom element without a renderer. Because of that, render it + // 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( + await renderTemplate`<${Component}${internalSpreadAttributes(props)}${markHTMLString( + childSlots === '' && voidElementNames.test(Component) + ? `/>` + : `>${childSlots}` + )}` + ); + html = ''; + for await (const chunk of iterable) { + html += chunk; + } + } + + if (!hydration) { + if (isPage || renderer?.name === 'astro:jsx') { + return html; + } + return markHTMLString(html.replace(/\<\/?astro-slot\>/g, '')); + } + + // Include componentExport name, componentUrl, and props in hash to dedupe identical islands + const astroId = shorthash( + `\n${html}\n${serializeProps( + props + )}` + ); + + const island = await generateHydrateScript( + { renderer: renderer!, result, astroId, props }, + metadata as Required + ); + + // Render template if not all astro fragments are provided. + let unrenderedSlots: string[] = []; + if (html) { + if (Object.keys(children).length > 0) { + for (const key of Object.keys(children)) { + if (!html.includes(key === 'default' ? `` : ``)) { + unrenderedSlots.push(key); + } + } + } + } else { + unrenderedSlots = Object.keys(children); + } + const template = + unrenderedSlots.length > 0 + ? unrenderedSlots + .map( + (key) => + `` + ) + .join('') + : ''; + + island.children = `${html ?? ''}${template}`; + + if (island.children) { + island.props['await-children'] = ''; + } + + async function* renderAll() { + yield { type: 'directive', hydration, result }; + yield markHTMLString(renderElement('astro-island', island, false)); + } + + return renderAll(); +} diff --git a/packages/astro/src/runtime/server/render/dom.ts b/packages/astro/src/runtime/server/render/dom.ts new file mode 100644 index 000000000000..cf6024a88e89 --- /dev/null +++ b/packages/astro/src/runtime/server/render/dom.ts @@ -0,0 +1,42 @@ +import type { SSRResult } from '../../../@types/astro'; + +import { markHTMLString } from '../escape.js'; +import { renderSlot } from './any.js'; +import { toAttributeString } from './util.js'; + +export function componentIsHTMLElement(Component: unknown) { + return typeof HTMLElement !== 'undefined' && HTMLElement.isPrototypeOf(Component as object); +} + +export async function renderHTMLElement( + result: SSRResult, + constructor: typeof HTMLElement, + props: any, + slots: any +) { + const name = getHTMLElementName(constructor); + + let attrHTML = ''; + + for (const attr in props) { + attrHTML += ` ${attr}="${toAttributeString(await props[attr])}"`; + } + + return markHTMLString( + `<${name}${attrHTML}>${await renderSlot(result, slots?.default)}` + ); +} + +function getHTMLElementName(constructor: typeof HTMLElement) { + const definedName = ( + customElements as CustomElementRegistry & { getName(_constructor: typeof HTMLElement): string } + ).getName(constructor); + if (definedName) return definedName; + + const assignedName = constructor.name + .replace(/^HTML|Element$/g, '') + .replace(/[A-Z]/g, '-$&') + .toLowerCase() + .replace(/^-/, 'html-'); + return assignedName; +} diff --git a/packages/astro/src/runtime/server/render/head.ts b/packages/astro/src/runtime/server/render/head.ts new file mode 100644 index 000000000000..bb0fffc2ecfd --- /dev/null +++ b/packages/astro/src/runtime/server/render/head.ts @@ -0,0 +1,43 @@ +import type { SSRResult } from '../../../@types/astro'; + +import { markHTMLString } from '../escape.js'; +import { renderElement } from './util.js'; + +// Filter out duplicate elements in our set +const uniqueElements = (item: any, index: number, all: any[]) => { + const props = JSON.stringify(item.props); + const children = item.children; + return ( + index === all.findIndex((i) => JSON.stringify(i.props) === props && i.children == children) + ); +}; + +const alreadyHeadRenderedResults = new WeakSet(); +export function renderHead(result: SSRResult): Promise { + alreadyHeadRenderedResults.add(result); + const styles = Array.from(result.styles) + .filter(uniqueElements) + .map((style) => renderElement('style', style)); + // Clear result.styles so that any new styles added will be inlined. + result.styles.clear(); + const scripts = Array.from(result.scripts) + .filter(uniqueElements) + .map((script, i) => { + return renderElement('script', script, false); + }); + 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')); +} + +// This function is called by Astro components that do not contain a component +// This accomodates 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 { + if (alreadyHeadRenderedResults.has(result)) { + return; + } + yield renderHead(result); +} diff --git a/packages/astro/src/runtime/server/render/index.ts b/packages/astro/src/runtime/server/render/index.ts new file mode 100644 index 000000000000..a6c3b1513aa9 --- /dev/null +++ b/packages/astro/src/runtime/server/render/index.ts @@ -0,0 +1,16 @@ +import { renderTemplate } from './astro.js'; + +export { renderSlot } from './any.js'; +export { renderTemplate, renderAstroComponent, renderToString } from './astro.js'; +export { stringifyChunk, Fragment, Renderer } from './common.js'; +export { renderComponent } from './component.js'; +export { renderHTMLElement } from './dom.js'; +export { renderHead, maybeRenderHead } from './head.js'; +export { renderPage } from './page.js'; +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; +} diff --git a/packages/astro/src/runtime/server/render/page.ts b/packages/astro/src/runtime/server/render/page.ts new file mode 100644 index 000000000000..99c047e57784 --- /dev/null +++ b/packages/astro/src/runtime/server/render/page.ts @@ -0,0 +1,99 @@ +import type { SSRResult } from '../../../@types/astro'; +import type { AstroComponentFactory } from './index'; + +import { isAstroComponent, renderAstroComponent } from './astro.js'; +import { stringifyChunk } from './common.js'; +import { renderComponent } from './component.js'; +import { maybeRenderHead } from './head.js'; +import { createResponse } from '../response.js'; + +const encoder = new TextEncoder(); + +export async function renderPage( + result: SSRResult, + componentFactory: AstroComponentFactory, + props: any, + children: any, + streaming: boolean +): Promise { + if (!componentFactory.isAstroComponentFactory) { + const pageProps: Record = { ...(props ?? {}), 'server:root': true }; + const output = await renderComponent( + result, + componentFactory.name, + componentFactory, + pageProps, + null + ); + let html = output.toString(); + if (!/`; + for await (let chunk of maybeRenderHead(result)) { + html += chunk; + } + html += rest; + } + return new Response(html, { + headers: new Headers([ + ['Content-Type', 'text/html; charset=utf-8'], + ['Content-Length', Buffer.byteLength(html, 'utf-8').toString()], + ]), + }); + } + const factoryReturnValue = await componentFactory(result, props, children); + + if (isAstroComponent(factoryReturnValue)) { + let iterable = renderAstroComponent(factoryReturnValue); + let init = result.response; + let headers = new Headers(init.headers); + let body: BodyInit; + + if (streaming) { + body = new ReadableStream({ + start(controller) { + async function read() { + let i = 0; + try { + for await (const chunk of iterable) { + let html = stringifyChunk(result, chunk); + + if (i === 0) { + if (!/\n')); + } + } + controller.enqueue(encoder.encode(html)); + i++; + } + controller.close(); + } catch (e) { + controller.error(e); + } + } + read(); + }, + }); + } else { + body = ''; + let i = 0; + for await (const chunk of iterable) { + let html = stringifyChunk(result, chunk); + if (i === 0) { + if (!/\n'; + } + } + body += html; + i++; + } + const bytes = encoder.encode(body); + headers.set('Content-Length', bytes.byteLength.toString()); + } + + let response = createResponse(body, { ...init, headers }); + return response; + } else { + return factoryReturnValue; + } +} diff --git a/packages/astro/src/runtime/server/render/types.ts b/packages/astro/src/runtime/server/render/types.ts new file mode 100644 index 000000000000..3cc534ac68c8 --- /dev/null +++ b/packages/astro/src/runtime/server/render/types.ts @@ -0,0 +1,8 @@ +import type { SSRResult } from '../../../@types/astro'; +import type { HydrationMetadata } from '../hydration.js'; + +export interface RenderInstruction { + type: 'directive'; + result: SSRResult; + hydration: HydrationMetadata; +} diff --git a/packages/astro/src/runtime/server/render/util.ts b/packages/astro/src/runtime/server/render/util.ts new file mode 100644 index 000000000000..d3585fb816a0 --- /dev/null +++ b/packages/astro/src/runtime/server/render/util.ts @@ -0,0 +1,128 @@ +import type { SSRElement } from '../../../@types/astro'; + +import { markHTMLString, HTMLString } from '../escape.js'; +import { serializeListValue } from '../util.js'; + +export const voidElementNames = + /^(area|base|br|col|command|embed|hr|img|input|keygen|link|meta|param|source|track|wbr)$/i; +const htmlBooleanAttributes = + /^(allowfullscreen|async|autofocus|autoplay|controls|default|defer|disabled|disablepictureinpicture|disableremoteplayback|formnovalidate|hidden|loop|nomodule|novalidate|open|playsinline|readonly|required|reversed|scoped|seamless|itemscope)$/i; +const htmlEnumAttributes = /^(contenteditable|draggable|spellcheck|value)$/i; +// Note: SVG is case-sensitive! +const svgEnumAttributes = /^(autoReverse|externalResourcesRequired|focusable|preserveAlpha)$/i; + +const STATIC_DIRECTIVES = new Set(['set:html', 'set:text']); + +// converts (most) arbitrary strings to valid JS identifiers +const toIdent = (k: string) => + k.trim().replace(/(?:(? { + if (/[^\w]|\s/.test(match)) return ''; + return index === 0 ? match : match.toUpperCase(); + }); + +export const toAttributeString = (value: any, shouldEscape = true) => + shouldEscape ? String(value).replace(/&/g, '&').replace(/"/g, '"') : value; + +const kebab = (k: string) => + k.toLowerCase() === k ? k : k.replace(/[A-Z]/g, (match) => `-${match.toLowerCase()}`); +const toStyleString = (obj: Record) => + Object.entries(obj) + .map(([k, v]) => `${kebab(k)}:${v}`) + .join(';'); + +// Adds variables to an inline script. +export function defineScriptVars(vars: Record) { + let output = ''; + for (const [key, value] of Object.entries(vars)) { + output += `let ${toIdent(key)} = ${JSON.stringify(value)};\n`; + } + return markHTMLString(output); +} + +export function formatList(values: string[]): string { + if (values.length === 1) { + return values[0]; + } + return `${values.slice(0, -1).join(', ')} or ${values[values.length - 1]}`; +} + +// A helper used to turn expressions into attribute key/value +export function addAttribute(value: any, key: string, shouldEscape = true) { + if (value == null) { + return ''; + } + + if (value === false) { + if (htmlEnumAttributes.test(key) || svgEnumAttributes.test(key)) { + return markHTMLString(` ${key}="false"`); + } + return ''; + } + + // compiler directives cannot be applied dynamically, log a warning and ignore. + if (STATIC_DIRECTIVES.has(key)) { + // eslint-disable-next-line no-console + console.warn(`[astro] The "${key}" directive cannot be applied dynamically at runtime. It will not be rendered as an attribute. + +Make sure to use the static attribute syntax (\`${key}={value}\`) instead of the dynamic spread syntax (\`{...{ "${key}": value }}\`).`); + return ''; + } + + // support "class" from an expression passed into an element (#782) + if (key === 'class:list') { + const listValue = toAttributeString(serializeListValue(value)); + if (listValue === '') { + return ''; + } + return markHTMLString(` ${key.slice(0, -5)}="${listValue}"`); + } + + // support object styles for better JSX compat + if (key === 'style' && !(value instanceof HTMLString) && typeof value === 'object') { + return markHTMLString(` ${key}="${toStyleString(value)}"`); + } + + // support `className` for better JSX compat + if (key === 'className') { + return markHTMLString(` class="${toAttributeString(value, shouldEscape)}"`); + } + + // Boolean values only need the key + if (value === true && (key.startsWith('data-') || htmlBooleanAttributes.test(key))) { + return markHTMLString(` ${key}`); + } else { + return markHTMLString(` ${key}="${toAttributeString(value, shouldEscape)}"`); + } +} + +// Adds support for ` +export function internalSpreadAttributes(values: Record, shouldEscape = true) { + let output = ''; + for (const [key, value] of Object.entries(values)) { + output += addAttribute(value, key, shouldEscape); + } + return markHTMLString(output); +} + +export function renderElement( + name: string, + { props: _props, children = '' }: SSRElement, + shouldEscape = true +) { + // Do not print `hoist`, `lang`, `is:global` + const { lang: _, 'data-astro-id': astroId, 'define:vars': defineVars, ...props } = _props; + if (defineVars) { + if (name === 'style') { + delete props['is:global']; + delete props['is:scoped']; + } + if (name === 'script') { + delete props.hoist; + children = defineScriptVars(defineVars) + '\n' + children; + } + } + if ((children == null || children == '') && voidElementNames.test(name)) { + return `<${name}${internalSpreadAttributes(props, shouldEscape)} />`; + } + return `<${name}${internalSpreadAttributes(props, shouldEscape)}>${children}`; +} From e7e7c5eb398eb878fa4dba40b67f6b3e007794ff Mon Sep 17 00:00:00 2001 From: Matthew Phillips Date: Mon, 8 Aug 2022 14:12:07 -0400 Subject: [PATCH 2/4] Add back in generator change --- packages/astro/src/runtime/server/astro-global.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/astro/src/runtime/server/astro-global.ts b/packages/astro/src/runtime/server/astro-global.ts index 57b51c8cde93..5ffca377ada3 100644 --- a/packages/astro/src/runtime/server/astro-global.ts +++ b/packages/astro/src/runtime/server/astro-global.ts @@ -1,5 +1,8 @@ import type { AstroGlobalPartial } from '../../@types/astro'; +// process.env.PACKAGE_VERSION is injected when we build and publish the astro package. +const ASTRO_VERSION = process.env.PACKAGE_VERSION ?? 'development'; + /** Create the Astro.fetchContent() runtime function. */ function createDeprecatedFetchContentFn() { return () => { @@ -34,6 +37,7 @@ export function createAstro( const projectRoot = new URL(projectRootStr); return { site, + generator: `Astro v${ASTRO_VERSION}`, fetchContent: createDeprecatedFetchContentFn(), glob: createAstroGlobFn(), // INVESTIGATE is there a use-case for multi args? From 1ec28e14e7221e474fe48e5e2818544f73559355 Mon Sep 17 00:00:00 2001 From: Matthew Phillips Date: Mon, 8 Aug 2022 14:13:09 -0400 Subject: [PATCH 3/4] Adding a changeset --- .changeset/shy-dogs-return.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/shy-dogs-return.md diff --git a/.changeset/shy-dogs-return.md b/.changeset/shy-dogs-return.md new file mode 100644 index 000000000000..3d22d827594c --- /dev/null +++ b/.changeset/shy-dogs-return.md @@ -0,0 +1,5 @@ +--- +'astro': patch +--- + +Adds warning in dev when using client: directive on Astro component From d71d81bcc2996a854e067cd6b4567b699b4df87d Mon Sep 17 00:00:00 2001 From: Matthew Phillips Date: Mon, 8 Aug 2022 14:21:14 -0400 Subject: [PATCH 4/4] Fix build --- packages/astro/src/runtime/server/index.ts | 1 + packages/astro/src/runtime/server/render/index.ts | 1 + 2 files changed, 2 insertions(+) diff --git a/packages/astro/src/runtime/server/index.ts b/packages/astro/src/runtime/server/index.ts index 2569f044052a..c60aaf59b86c 100644 --- a/packages/astro/src/runtime/server/index.ts +++ b/packages/astro/src/runtime/server/index.ts @@ -6,6 +6,7 @@ export { } from './escape.js'; export type { Metadata } from './metadata'; export { createMetadata } from './metadata.js'; +export type { AstroComponentFactory, RenderInstruction } from './render/index.js'; import type { AstroComponentFactory } from './render/index.js'; import { Renderer } from './render/index.js'; diff --git a/packages/astro/src/runtime/server/render/index.ts b/packages/astro/src/runtime/server/render/index.ts index a6c3b1513aa9..e74c3ffb6c87 100644 --- a/packages/astro/src/runtime/server/render/index.ts +++ b/packages/astro/src/runtime/server/render/index.ts @@ -1,5 +1,6 @@ import { renderTemplate } from './astro.js'; +export type { RenderInstruction } from './types'; export { renderSlot } from './any.js'; export { renderTemplate, renderAstroComponent, renderToString } from './astro.js'; export { stringifyChunk, Fragment, Renderer } from './common.js';