// Clover's Rendering Engine is the backbone of her website generator. It // converts objects and components (functions returning 'Node') into HTML. The // engine is simple and self-contained, with integrations for JSX and Marko // (which can interop with each-other) are provided next to this file. // // Add-ons to the rendering engine can provide opaque data, And retrieve it // within component calls with 'getAddonData'. For example, 'sitegen' uses this // to track needed client scripts without introducing patches to the engine. export type Addons = Record; export function ssrSync(node: Node): Result; export function ssrSync(node: Node, addon: A): Result; export function ssrSync(node: Node, addon: Addons = {}) { const r = initRender(false, addon); const resolved = resolveNode(r, node); return { text: renderNode(resolved), addon }; } export function ssrAsync(node: Node): Promise; export function ssrAsync( node: Node, addon: A, ): Promise>; export function ssrAsync(node: Node, addon: Addons = {}) { const r = initRender(true, addon); const resolved = resolveNode(r, node); if (r.async === 0) { return Promise.resolve({ text: renderNode(resolved), addon }); } const { resolve, reject, promise } = Promise.withResolvers(); r.asyncDone = () => { const rejections = r.rejections; if (!rejections) return resolve({ text: renderNode(resolved), addon }); if (rejections.length === 1) return reject(rejections[0]); return reject(new AggregateError(rejections)); }; return promise; } /** Inline HTML into a render without escaping it */ export function html(rawText: ResolvedNode): DirectHtml { return [kDirectHtml, rawText]; } interface Result { text: string; addon: A; } export interface Render { /** * Set to '-1' if rendering synchronously * Number of async promises the render is waiting on. */ async: number | -1; asyncDone: null | (() => void); /** When components reject, those are logged here */ rejections: unknown[] | null; /** Add-ons to the rendering engine store state here */ addon: Addons; } export const kElement = Symbol("Element"); export const kDirectHtml = Symbol("DirectHtml"); /** Node represents a webpage that can be 'rendered' into HTML. */ export type Node = | number | string // Escape HTML | Node[] // Concat | Element // Render | DirectHtml // Insert | Promise // Await // Ignore | undefined | null | boolean; export type Element = [ tag: typeof kElement, type: string | Component, props: Record, ]; export type DirectHtml = [tag: typeof kDirectHtml, html: ResolvedNode]; /** * Components must return a value; 'undefined' is prohibited here * to avoid functions that are missing a return statement. */ export type Component = ( props: Record, ) => Exclude; /** * Resolution narrows the type 'Node' into 'ResolvedNode'. Async trees are * marked in the 'Render'. This operation performs everything besides the final * string concatenation. This function is agnostic across async/sync modes. */ export function resolveNode(r: Render, node: unknown): ResolvedNode { if (!node && node !== 0) return ""; // falsy, non numeric if (typeof node !== "object") { if (node === true) return ""; // booleans are ignored if (typeof node === "string") return escapeHtml(node); if (typeof node === "number") return String(node); // no escaping ever throw new Error(`Cannot render ${inspect(node)} to HTML`); } if (node instanceof Promise) { if (r.async === -1) { throw new Error(`Asynchronous rendering is not supported here.`); } const placeholder: InsertionPoint = [null]; r.async += 1; node .then((result) => void (placeholder[0] = resolveNode(r, result))) // Intentionally catching errors in `resolveNode` .catch((e) => (r.rejections ??= []).push(e)) .finally(() => { if (--r.async == 0) { if (r.asyncDone == null) throw new Error("r.asyncDone == null"); r.asyncDone(); r.asyncDone = null; } }); // This lie is checked with an assertion in `renderNode` return placeholder as [ResolvedNode]; } if (!Array.isArray(node)) { throw new Error(`Invalid node type: ${inspect(node)}`); } const type = node[0]; if (type === kElement) { const { 1: tag, 2: props } = node; if (typeof tag === "function") { currentRender = r; const result = tag(props); currentRender = null; return resolveNode(r, result); } if (typeof tag !== "string") throw new Error("Unexpected " + typeof type); const children = props?.children; if (children) return [kElement, tag, props, resolveNode(r, children)]; return node; } if (type === kDirectHtml) return node[1]; return node.map((elem) => resolveNode(r, elem)); } export type ResolvedNode = | ResolvedNode[] // Concat | ResolvedElement // Render | string; // Direct HTML export type ResolvedElement = [ tag: typeof kElement, type: string, props: Record, children: ResolvedNode, ]; /** * Async rendering is done by creating an array of one item, * which is already a valid 'Node', but the element is written * once the data is available. The 'Render' contains a count * of how many async jobs are left. */ export type InsertionPoint = [null | ResolvedNode]; /** * Convert 'ResolvedNode' into HTML text. This operation happens after all * async work is settled. The HTML is emitted as concisely as possible. */ export function renderNode(node: ResolvedNode): string { if (typeof node === "string") return node; ASSERT(node, "Unresolved Render Node"); const type = node[0]; if (type === kElement) { return renderElement(node as ResolvedElement); } node = node as ResolvedNode[]; // TS cannot infer. let out = type ? renderNode(type) : ""; let len = node.length; for (let i = 1; i < len; i++) { const elem = node[i]; if (elem) out += renderNode(elem); } return out; } function renderElement(element: ResolvedElement) { const { 1: tag, 2: props, 3: children } = element; let out = "<" + tag; let needSpace = true; for (const prop in props) { const value = props[prop]; if (!value || typeof value === "function") continue; let attr; switch (prop) { default: attr = `${prop}=${quoteIfNeeded(escapeHtml(String(value)))}`; break; case "className": // Legacy React Compat case "class": attr = `class=${quoteIfNeeded(escapeHtml(clsx(value as ClsxInput)))}`; break; case "htmlFor": throw new Error("Do not use the `htmlFor` attribute. Use `for`"); // Do not process these case "children": case "ref": case "dangerouslySetInnerHTML": case "key": continue; } if (needSpace) out += " ", needSpace = !attr.endsWith('"'); out += attr; } out += ">"; if (children) out += renderNode(children); if ( tag !== "br" && tag !== "img" && tag !== "input" && tag !== "meta" && tag !== "link" && tag !== "hr" ) { out += ``; } return out; } export function renderStyleAttribute(style: Record) { let out = ``; for (const styleName in style) { if (out) out += ";"; out += `${styleName.replace(/[A-Z]/g, "-$&").toLowerCase()}:${ escapeHtml(String(style[styleName])) }`; } return "style=" + quoteIfNeeded(out); } export function quoteIfNeeded(text: string) { if (text.includes(" ")) return '"' + text + '"'; return text; } // -- utility functions -- export function initRender(allowAsync: boolean, addon: Addons): Render { return { async: allowAsync ? 0 : -1, rejections: null, asyncDone: null, addon, }; } let currentRender: Render | null = null; export function getCurrentRender() { if (!currentRender) throw new Error("No Render Active"); return currentRender; } export function setCurrentRender(r?: Render | null) { currentRender = r ?? null; } export function getUserData(namespace: PropertyKey, def: () => T): T { return (getCurrentRender().addon[namespace] ??= def()) as T; } export function inspect(object: unknown) { try { return require("node:util").inspect(object); } catch { return typeof object; } } export type ClsxInput = string | Record | ClsxInput[]; export function clsx(mix: ClsxInput) { var k, y, str = ""; if (typeof mix === "string") { return mix; } else if (typeof mix === "object") { if (Array.isArray(mix)) { for (k = 0; k < mix.length; k++) { if (mix[k] && (y = clsx(mix[k]))) { str && (str += " "); str += y; } } } else { for (k in mix) { if (mix[k]) { str && (str += " "); str += k; } } } } return str; } export const escapeHtml = (unsafeText: string) => String(unsafeText) .replace(/&/g, "&").replace(//g, ">") .replace(/"/g, """).replace(/'/g, "'").replace(/`/g, "`");