diff --git a/packages/astro/src/@types/astro-runtime.ts b/packages/astro/src/@types/astro-runtime.ts index f600fa4e0..fa312f840 100644 --- a/packages/astro/src/@types/astro-runtime.ts +++ b/packages/astro/src/@types/astro-runtime.ts @@ -41,7 +41,6 @@ export type GetHydrateCallback = () => Promise<(element: Element, innerHTML: str export type Params = Record; export interface TopLevelAstro { - isPage: boolean; fetchContent(globStr: string): Promise[]>; resolve: (path: string) => string; site: URL; diff --git a/packages/astro/src/runtime/server/hydration.ts b/packages/astro/src/runtime/server/hydration.ts new file mode 100644 index 000000000..3b52efd74 --- /dev/null +++ b/packages/astro/src/runtime/server/hydration.ts @@ -0,0 +1,125 @@ +import type { AstroComponentMetadata } from '../../@types/astro-core'; +import type { SSRElement } from '../../@types/astro-runtime'; +import { valueToEstree } from 'estree-util-value-to-estree'; +import * as astring from 'astring'; +import { serializeListValue } from './util.js'; + +const { generate, GENERATOR } = astring; + +// INVESTIGATE: What features are we getting from this that we need? +// JSON.stringify has a "replacer" argument. +// A more robust version alternative to `JSON.stringify` that can handle most values +// see https://github.com/remcohaszing/estree-util-value-to-estree#readme +const customGenerator: astring.Generator = { + ...GENERATOR, + Literal(node, state) { + if (node.raw != null) { + // escape closing script tags in strings so browsers wouldn't interpret them as + // closing the actual end tag in HTML + state.write(node.raw.replace('', '<\\/script>')); + } else { + GENERATOR.Literal(node, state); + } + }, +}; + +// Serializes props passed into a component so that they can be reused during hydration. +// The value is any +export function serializeProps(value: any) { + return generate(valueToEstree(value), { + generator: customGenerator, + }); +} + +interface ExtractedProps { + hydration: { + directive: string; + value: string; + componentUrl: string; + componentExport: { value: string }; + } | null; + props: Record; +} + +// Used to extract the directives, aka `client:load` information about a component. +// Finds these special props and removes them from what gets passed into the component. +export function extractDirectives(inputProps: Record): ExtractedProps { + let extracted: ExtractedProps = { + hydration: null, + props: {}, + }; + for (const [key, value] of Object.entries(inputProps)) { + if (key.startsWith('client:')) { + if (!extracted.hydration) { + extracted.hydration = { + directive: '', + value: '', + componentUrl: '', + componentExport: { value: '' }, + }; + } + switch (key) { + case 'client:component-path': { + extracted.hydration.componentUrl = value; + break; + } + case 'client:component-export': { + extracted.hydration.componentExport.value = value; + break; + } + default: { + extracted.hydration.directive = key.split(':')[1]; + extracted.hydration.value = value; + break; + } + } + } else if (key === 'class:list') { + // support "class" from an expression passed into a component (#782) + extracted.props[key.slice(0, -5)] = serializeListValue(value); + } else { + extracted.props[key] = value; + } + } + return extracted; +} + + +interface HydrateScriptOptions { + renderer: any; + astroId: string; + props: Record; +} + +/** For hydrated components, generate a ', '<\\/script>')); - } else { - GENERATOR.Literal(node, state); - } - }, -}; - -const serialize = (value: any) => - generate(valueToEstree(value), { - generator: customGenerator, - }); +// 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): Promise { child = await child; if (Array.isArray(child)) { @@ -42,13 +27,18 @@ async function _render(child: any): Promise { return child; } else if (!child && child !== 0) { // do nothing, safe to ignore falsey values. - } else if (child instanceof AstroComponent || child.toString() === '[object AstroComponent]') { + } + // 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 || child.toString() === '[object AstroComponent]') { return await renderAstroComponent(child); } else { return 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[]; @@ -79,108 +69,21 @@ export async function render(htmlParts: TemplateStringsArray, ...expressions: an return new AstroComponent(htmlParts, expressions); } +// The callback passed to to $$createComponent export interface AstroComponentFactory { (result: any, props: any, slots: any): ReturnType; isAstroComponentFactory?: boolean; } +// Used in creating the component. aka the main export. export function createComponent(cb: AstroComponentFactory) { // Add a flag to this callback to mark it as an Astro component + // INVESTIGATE does this need to cast (cb as any).isAstroComponentFactory = true; return cb; } -interface ExtractedProps { - hydration: { - directive: string; - value: string; - componentUrl: string; - componentExport: { value: string }; - } | null; - props: Record; -} - -function extractDirectives(inputProps: Record): ExtractedProps { - let extracted: ExtractedProps = { - hydration: null, - props: {}, - }; - for (const [key, value] of Object.entries(inputProps)) { - if (key.startsWith('client:')) { - if (!extracted.hydration) { - extracted.hydration = { - directive: '', - value: '', - componentUrl: '', - componentExport: { value: '' }, - }; - } - switch (key) { - case 'client:component-path': { - extracted.hydration.componentUrl = value; - break; - } - case 'client:component-export': { - extracted.hydration.componentExport.value = value; - break; - } - default: { - extracted.hydration.directive = key.split(':')[1]; - extracted.hydration.value = value; - break; - } - } - } else if (key === 'class:list') { - // support "class" from an expression passed into a component (#782) - extracted.props[key.slice(0, -5)] = serializeListValue(value); - } else { - extracted.props[key] = value; - } - } - return extracted; -} - -interface HydrateScriptOptions { - renderer: any; - astroId: string; - props: any; -} - -/** For hydrated components, generate a `).join(''); html = html + polyfillScripts; @@ -243,6 +151,8 @@ export async function renderComponent(result: SSRResult, displayName: string, Co // Include componentExport name and componentUrl in hash to dedupe identical islands const astroId = shorthash.unique(`\n${html}`); + // Rather than appending this inline in the page, puts this into the `result.scripts` set that will be appended to the head. + // INVESTIGATE: This will likely be a problem in streaming because the `` will be gone at this point. result.scripts.add(await generateHydrateScript({ renderer, astroId, props }, metadata as Required)); return `${html}`; @@ -271,23 +181,28 @@ function createFetchContentFn(url: URL) { }) .filter(Boolean); }; - return fetchContent; + // This has to be cast because the type of fetchContent is the type of the function + // that receives the import.meta.glob result, but the user is using it as + // another type. + return fetchContent as unknown as TopLevelAstro['fetchContent']; } +// This is used to create the top-level Astro global; the one that you can use +// Inside of getStaticPaths. export function createAstro(fileURLStr: string, site: string): TopLevelAstro { const url = new URL(fileURLStr); - const fetchContent = createFetchContentFn(url) as unknown as TopLevelAstro['fetchContent']; + const fetchContent = createFetchContentFn(url); return { - // TODO I think this is no longer needed. - isPage: false, site: new URL(site), fetchContent, - resolve(...segments) { + // INVESTIGATE is there a use-case for multi args? + resolve(...segments: string[]) { return segments.reduce((u, segment) => new URL(segment, u), url).pathname; }, }; } +// A helper used to turn expressions into attribute key/value export function addAttribute(value: any, key: string) { if (value == null || value === false) { return ''; @@ -301,6 +216,7 @@ export function addAttribute(value: any, key: string) { return ` ${key}="${value}"`; } +// Adds support for ` export function spreadAttributes(values: Record) { let output = ''; for (const [key, value] of Object.entries(values)) { @@ -309,36 +225,9 @@ export function spreadAttributes(values: Record) { return output; } -function serializeListValue(value: any) { - const hash: Record = {}; - push(value); - - return Object.keys(hash).join(' '); - - function push(item: any) { - // push individual iteratables - if (item && typeof item.forEach === 'function') item.forEach(push); - // otherwise, push object value keys by truthiness - else if (item === Object(item)) - Object.keys(item).forEach((name) => { - if (item[name]) push(name); - }); - // otherwise, push any other values as a string - else { - // get the item as a string - item = item == null ? '' : String(item).trim(); - - // add the item if it is filled - if (item) { - item.split(/\s+/).forEach((name: string) => { - hash[name] = true; - }); - } - } - } -} +// Adds CSS variables to an inline style tag export function defineStyleVars(selector: string, vars: Record) { let output = '\n'; for (const [key, value] of Object.entries(vars)) { @@ -347,6 +236,7 @@ export function defineStyleVars(selector: string, vars: Record) { return `${selector} {${output}}`; } +// Adds variables to an inline script. export function defineScriptVars(vars: Record) { let output = ''; for (const [key, value] of Object.entries(vars)) { @@ -355,6 +245,7 @@ export function defineScriptVars(vars: Record) { return output; } +// Calls a component and renders it into a string of HTML export async function renderToString(result: SSRResult, componentFactory: AstroComponentFactory, props: any, children: any) { const Component = await componentFactory(result, props, children); let template = await renderAstroComponent(Component); @@ -368,6 +259,8 @@ const uniqueElements = (item: any, index: number, all: any[]) => { return index === all.findIndex((i) => JSON.stringify(i.props) === props && i.children == children); }; +// Renders a page to completion by first calling the factory callback, waiting for its result, and then appending +// styles and scripts into the head. export async function renderPage(result: SSRResult, Component: AstroComponentFactory, props: any, children: any) { const template = await renderToString(result, Component, props, children); const styles = Array.from(result.styles) diff --git a/packages/astro/src/runtime/server/util.ts b/packages/astro/src/runtime/server/util.ts new file mode 100644 index 000000000..ff1dd55d3 --- /dev/null +++ b/packages/astro/src/runtime/server/util.ts @@ -0,0 +1,30 @@ + +export function serializeListValue(value: any) { + const hash: Record = {}; + + push(value); + + return Object.keys(hash).join(' '); + + function push(item: any) { + // push individual iteratables + if (item && typeof item.forEach === 'function') item.forEach(push); + // otherwise, push object value keys by truthiness + else if (item === Object(item)) + Object.keys(item).forEach((name) => { + if (item[name]) push(name); + }); + // otherwise, push any other values as a string + else { + // get the item as a string + item = item == null ? '' : String(item).trim(); + + // add the item if it is filled + if (item) { + item.split(/\s+/).forEach((name: string) => { + hash[name] = true; + }); + } + } + } +} \ No newline at end of file