diff options
Diffstat (limited to '')
| -rw-r--r-- | .changeset/shy-dogs-return.md | 5 | ||||
| -rw-r--r-- | packages/astro/src/runtime/server/astro-global.ts | 54 | ||||
| -rw-r--r-- | packages/astro/src/runtime/server/endpoint.ts | 74 | ||||
| -rw-r--r-- | packages/astro/src/runtime/server/hydration.ts | 10 | ||||
| -rw-r--r-- | packages/astro/src/runtime/server/index.ts | 976 | ||||
| -rw-r--r-- | packages/astro/src/runtime/server/render/any.ts | 51 | ||||
| -rw-r--r-- | packages/astro/src/runtime/server/render/astro.ts | 124 | ||||
| -rw-r--r-- | packages/astro/src/runtime/server/render/common.ts | 43 | ||||
| -rw-r--r-- | packages/astro/src/runtime/server/render/component.ts | 350 | ||||
| -rw-r--r-- | packages/astro/src/runtime/server/render/dom.ts | 42 | ||||
| -rw-r--r-- | packages/astro/src/runtime/server/render/head.ts | 43 | ||||
| -rw-r--r-- | packages/astro/src/runtime/server/render/index.ts | 17 | ||||
| -rw-r--r-- | packages/astro/src/runtime/server/render/page.ts | 99 | ||||
| -rw-r--r-- | packages/astro/src/runtime/server/render/types.ts | 8 | ||||
| -rw-r--r-- | packages/astro/src/runtime/server/render/util.ts | 128 | 
15 files changed, 1071 insertions, 953 deletions
| diff --git a/.changeset/shy-dogs-return.md b/.changeset/shy-dogs-return.md new file mode 100644 index 000000000..3d22d8275 --- /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 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 000000000..5ffca377a --- /dev/null +++ b/packages/astro/src/runtime/server/astro-global.ts @@ -0,0 +1,54 @@ +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 () => { +		throw new Error('Deprecated: Astro.fetchContent() has been replaced with Astro.glob().'); +	}; +} + +/** Create the Astro.glob() runtime function. */ +function createAstroGlobFn() { +	const globHandler = (importMetaGlobResult: Record<string, any>, 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<string, Module>). +	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; +		}, +	}; +} diff --git a/packages/astro/src/runtime/server/endpoint.ts b/packages/astro/src/runtime/server/endpoint.ts new file mode 100644 index 000000000..95bea8b64 --- /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 e14924dfe..c4cfc6ec6 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<string | number, any>): 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 df3de955a..c60aaf59b 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,36 @@ 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'; -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<any> { -	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<typeof render> | 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 +45,6 @@ export function createComponent(cb: AstroComponentFactory) {  	return cb;  } -export async function renderSlot(result: any, slotted: string, fallback?: any): Promise<string> { -	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<string, () => any> = {};  	for (const slot of slotted) { @@ -161,34 +58,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 +69,10 @@ export function __astro_tag_component__(Component: unknown, rendererName: string  	});  } -export async function renderComponent( -	result: SSRResult, -	displayName: string, -	Component: unknown, -	_props: Record<string | number, any>, -	slots: any = {} -): Promise<string | AsyncIterable<string | RenderInstruction>> { -	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<string, string> = {}; -		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<string, string> = {}; -	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}</${Component}>` -			)}` -		); -		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( -		`<!--${metadata.componentExport!.value}:${metadata.componentUrl}-->\n${html}\n${serializeProps( -			props -		)}` -	); - -	const island = await generateHydrateScript( -		{ renderer: renderer!, result, astroId, props }, -		metadata as Required<AstroComponentMetadata> -	); - -	// 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' ? `<astro-slot>` : `<astro-slot name="${key}">`)) { -					unrenderedSlots.push(key); -				} -			} -		} -	} else { -		unrenderedSlots = Object.keys(children); -	} -	const template = -		unrenderedSlots.length > 0 -			? unrenderedSlots -					.map( -						(key) => -							`<template data-astro-template${key !== 'default' ? `="${key}"` : ''}>${ -								children[key] -							}</template>` -					) -					.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<string, any>, 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<string, Module>). -	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<string, any>) => -	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 `<Component {...value} /> -function internalSpreadAttributes(values: Record<any, any>, shouldEscape = true) { -	let output = ''; -	for (const [key, value] of Object.entries(values)) { -		output += addAttribute(value, key, shouldEscape); -	} -	return markHTMLString(output); -} -  // Adds support for `<Component {...value} />  export function spreadAttributes(  	values: Record<any, any>, -	name?: string, +	_name?: string,  	{ class: scopedClassName }: { class?: string } = {}  ) {  	let output = ''; @@ -654,374 +105,3 @@ export function defineStyleVars(defs: Record<any, any> | Record<any, any>[]) {  	}  	return markHTMLString(output);  } - -// converts (most) arbitrary strings to valid JS identifiers -const toIdent = (k: string) => -	k.trim().replace(/(?:(?<!^)\b\w|\s+|[^\w]+)/g, (match, index) => { -		if (/[^\w]|\s/.test(match)) return ''; -		return index === 0 ? match : match.toUpperCase(); -	}); - -// Adds variables to an inline script. -export function defineScriptVars(vars: Record<any, any>) { -	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<string> { -	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<AsyncIterable<string | RenderInstruction>> { -	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<Response> { -	if (!componentFactory.isAstroComponentFactory) { -		const pageProps: Record<string, any> = { ...(props ?? {}), 'server:root': true }; -		const output = await renderComponent( -			result, -			componentFactory.name, -			componentFactory, -			pageProps, -			null -		); -		let html = output.toString(); -		if (!/<!doctype html/i.test(html)) { -			let rest = html; -			html = `<!DOCTYPE html>`; -			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 (!/<!doctype html/i.test(html)) { -										controller.enqueue(encoder.encode('<!DOCTYPE html>\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 (!/<!doctype html/i.test(html)) { -						body += '<!DOCTYPE html>\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<SSRResult>(); -export function renderHead(result: SSRResult): Promise<string> { -	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 <head> component -// This accomodates the fact that using a <head> 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<string> { -	if (alreadyHeadRenderedResults.has(result)) { -		return; -	} -	yield renderHead(result); -} - -export interface RenderInstruction { -	type: 'directive'; -	result: SSRResult; -	hydration: HydrationMetadata; -} - -export async function* renderAstroComponent( -	component: InstanceType<typeof AstroComponent> -): AsyncIterable<string | RenderInstruction> { -	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)}</${name}>` -	); -} - -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}</${name}>`; -} 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 000000000..2f4987708 --- /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<any> { +	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<string> { +	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 000000000..c9e9ac91f --- /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<typeof AstroComponent> +): AsyncIterable<string | RenderInstruction> { +	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<string> { +	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<AsyncIterable<string | RenderInstruction>> { +	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 000000000..cebbf5966 --- /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 000000000..38e6add65 --- /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<string | number, any>, +	slots: any = {} +): Promise<string | AsyncIterable<string | RenderInstruction>> { +	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<string, string> = {}; +			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<string, string> = {}; +	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}</${Component}>` +			)}` +		); +		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( +		`<!--${metadata.componentExport!.value}:${metadata.componentUrl}-->\n${html}\n${serializeProps( +			props +		)}` +	); + +	const island = await generateHydrateScript( +		{ renderer: renderer!, result, astroId, props }, +		metadata as Required<AstroComponentMetadata> +	); + +	// 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' ? `<astro-slot>` : `<astro-slot name="${key}">`)) { +					unrenderedSlots.push(key); +				} +			} +		} +	} else { +		unrenderedSlots = Object.keys(children); +	} +	const template = +		unrenderedSlots.length > 0 +			? unrenderedSlots +					.map( +						(key) => +							`<template data-astro-template${key !== 'default' ? `="${key}"` : ''}>${ +								children[key] +							}</template>` +					) +					.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 000000000..cf6024a88 --- /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)}</${name}>` +	); +} + +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 000000000..bb0fffc2e --- /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<SSRResult>(); +export function renderHead(result: SSRResult): Promise<string> { +	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 <head> component +// This accomodates the fact that using a <head> 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<string> { +	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 000000000..e74c3ffb6 --- /dev/null +++ b/packages/astro/src/runtime/server/render/index.ts @@ -0,0 +1,17 @@ +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'; +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<typeof renderTemplate> | 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 000000000..99c047e57 --- /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<Response> { +	if (!componentFactory.isAstroComponentFactory) { +		const pageProps: Record<string, any> = { ...(props ?? {}), 'server:root': true }; +		const output = await renderComponent( +			result, +			componentFactory.name, +			componentFactory, +			pageProps, +			null +		); +		let html = output.toString(); +		if (!/<!doctype html/i.test(html)) { +			let rest = html; +			html = `<!DOCTYPE html>`; +			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 (!/<!doctype html/i.test(html)) { +										controller.enqueue(encoder.encode('<!DOCTYPE html>\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 (!/<!doctype html/i.test(html)) { +						body += '<!DOCTYPE html>\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 000000000..3cc534ac6 --- /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 000000000..d3585fb81 --- /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(/(?:(?<!^)\b\w|\s+|[^\w]+)/g, (match, index) => { +		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<string, any>) => +	Object.entries(obj) +		.map(([k, v]) => `${kebab(k)}:${v}`) +		.join(';'); + +// Adds variables to an inline script. +export function defineScriptVars(vars: Record<any, any>) { +	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 `<Component {...value} /> +export function internalSpreadAttributes(values: Record<any, any>, 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}</${name}>`; +} | 
