Refactor runtime (#4201)

* Refactor runtime

* Add back in generator change

* Adding a changeset

* Fix build
This commit is contained in:
Matthew Phillips 2022-08-08 15:35:01 -04:00 committed by GitHub
parent f207c417e0
commit 25d36d9558
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
15 changed files with 1070 additions and 952 deletions

View file

@ -0,0 +1,5 @@
---
'astro': patch
---
Adds warning in dev when using client: directive on Astro component

View file

@ -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;
},
};
}

View file

@ -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);
}

View file

@ -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(', ')}`
);
}

File diff suppressed because it is too large Load diff

View file

@ -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;
}

View file

@ -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);
}

View file

@ -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();
}
}
}

View file

@ -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();
}

View file

@ -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;
}

View file

@ -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);
}

View file

@ -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;
}

View file

@ -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;
}
}

View file

@ -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;
}

View file

@ -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, '&#38;').replace(/"/g, '&#34;') : 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}>`;
}