import { renderToString } from "preact-render-to-string";import { ComponentChildren, ComponentType, h, options } from "preact";import { AppModule, ErrorPage, Island, RenderFunction, Route, UnknownPage,} from "./types.ts";import { HEAD_CONTEXT } from "../runtime/head.ts";import { CSP_CONTEXT, nonce, NONE, UNSAFE_INLINE } from "../runtime/csp.ts";import { ContentSecurityPolicy } from "../runtime/csp.ts";import { bundleAssetUrl } from "./constants.ts";import { assetHashingHook } from "../runtime/utils.ts";
export interface RenderOptions<Data> { route: Route<Data> | UnknownPage | ErrorPage; islands: Island[]; app: AppModule; imports: string[]; preloads: string[]; url: URL; params: Record<string, string | string[]>; renderFn: RenderFunction; data?: Data; error?: unknown; lang?: string;}
export type InnerRenderFunction = () => string;
export class RenderContext { #id: string; #state: Map<string, unknown> = new Map(); #styles: string[] = []; #url: URL; #route: string; #lang: string;
constructor(id: string, url: URL, route: string, lang: string) { this.#id = id; this.#url = url; this.#route = route; this.#lang = lang; }
get id(): string { return this.#id; }
get state(): Map<string, unknown> { return this.#state; }
get styles(): string[] { return this.#styles; }
get url(): URL { return this.#url; }
get route(): string { return this.#route; }
get lang(): string { return this.#lang; } set lang(lang: string) { this.#lang = lang; }}
function defaultCsp() { return { directives: { defaultSrc: [NONE], styleSrc: [UNSAFE_INLINE] }, reportOnly: false, };}
export async function render<Data>( opts: RenderOptions<Data>,): Promise<[string, ContentSecurityPolicy | undefined]> { const props: Record<string, unknown> = { params: opts.params, url: opts.url, route: opts.route.pattern, data: opts.data, }; if (opts.error) { props.error = opts.error; }
const csp: ContentSecurityPolicy | undefined = opts.route.csp ? defaultCsp() : undefined; const headComponents: ComponentChildren[] = [];
const vnode = h(CSP_CONTEXT.Provider, { value: csp, children: h(HEAD_CONTEXT.Provider, { value: headComponents, children: h(opts.app.default, { Component() { return h(opts.route.component! as ComponentType<unknown>, props); }, }), }), });
const ctx = new RenderContext( crypto.randomUUID(), opts.url, opts.route.pattern, opts.lang ?? "en", );
if (csp) { const newCsp = defaultCsp(); csp.directives = newCsp.directives; csp.reportOnly = newCsp.reportOnly; } headComponents.splice(0, headComponents.length);
ISLANDS.splice(0, ISLANDS.length, ...opts.islands);
ENCOUNTERED_ISLANDS.clear();
ISLAND_PROPS = [];
let bodyHtml: string | null = null;
function render() { bodyHtml = renderToString(vnode); return bodyHtml; }
await opts.renderFn(ctx, render as InnerRenderFunction);
if (bodyHtml === null) { throw new Error("The `render` function was not called by the renderer."); }
const imports = opts.imports.map((url) => { const randomNonce = crypto.randomUUID().replace(/-/g, ""); if (csp) { csp.directives.scriptSrc = [ ...csp.directives.scriptSrc ?? [], nonce(randomNonce), ]; } return [url, randomNonce] as const; });
if (ENCOUNTERED_ISLANDS.size > 0) { { const randomNonce = crypto.randomUUID().replace(/-/g, ""); if (csp) { csp.directives.scriptSrc = [ ...csp.directives.scriptSrc ?? [], nonce(randomNonce), ]; } const url = bundleAssetUrl("/main.js"); imports.push([url, randomNonce] as const); }
let islandImports = ""; let islandRegistry = ""; for (const island of ENCOUNTERED_ISLANDS) { const randomNonce = crypto.randomUUID().replace(/-/g, ""); if (csp) { csp.directives.scriptSrc = [ ...csp.directives.scriptSrc ?? [], nonce(randomNonce), ]; } const url = bundleAssetUrl(`/island-${island.id}.js`); imports.push([url, randomNonce] as const); islandImports += `\nimport ${island.name} from "${url}";`; islandRegistry += `\n ${island.id}: ${island.name},`; } const initCode = `import { revive } from "${ bundleAssetUrl("/main.js") }";${islandImports}\nrevive({${islandRegistry}\n});`;
const randomNonce = crypto.randomUUID().replace(/-/g, ""); if (csp) { csp.directives.scriptSrc = [ ...csp.directives.scriptSrc ?? [], nonce(randomNonce), ]; } (bodyHtml as string) += `<script id="__FRSH_ISLAND_PROPS" type="application/json">${ JSON.stringify(ISLAND_PROPS) }</script><script type="module" nonce="${randomNonce}">${initCode}</script>`; }
const html = template({ bodyHtml, headComponents, imports, preloads: opts.preloads, styles: ctx.styles, lang: ctx.lang, });
return [html, csp];}
export interface TemplateOptions { bodyHtml: string; headComponents: ComponentChildren[]; imports: (readonly [string, string])[]; styles: string[]; preloads: string[]; lang: string;}
export function template(opts: TemplateOptions): string { const page = ( <html lang={opts.lang}> <head> <meta charSet="UTF-8" /> <meta http-equiv="X-UA-Compatible" content="IE=edge" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" /> {opts.preloads.map((src) => <link rel="modulepreload" href={src} />)} {opts.imports.map(([src, nonce]) => ( <script src={src} nonce={nonce} type="module"></script> ))} <style id="__FRSH_STYLE" dangerouslySetInnerHTML={{ __html: opts.styles.join("\n") }} /> {opts.headComponents} </head> <body dangerouslySetInnerHTML={{ __html: opts.bodyHtml }} /> </html> );
return "<!DOCTYPE html>" + renderToString(page);}
const ISLANDS: Island[] = [];const ENCOUNTERED_ISLANDS: Set<Island> = new Set([]);let ISLAND_PROPS: unknown[] = [];const originalHook = options.vnode;let ignoreNext = false;options.vnode = (vnode) => { assetHashingHook(vnode); const originalType = vnode.type as ComponentType<unknown>; if (typeof vnode.type === "function") { const island = ISLANDS.find((island) => island.component === originalType); if (island) { if (ignoreNext) { ignoreNext = false; return; } ENCOUNTERED_ISLANDS.add(island); vnode.type = (props) => { ignoreNext = true; const child = h(originalType, props); ISLAND_PROPS.push(props); return h( `!--frsh-${island.id}:${ISLAND_PROPS.length - 1}--`, null, child, ); }; } } if (originalHook) originalHook(vnode);};