// 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 let current: State | null = null; export function setCurrent(r: State | null) { current = r ?? null; } /* Convert a UI description into a string synchronously. */ export function sync(node: Node, addon: A = {} as A) { const state = init(false, addon); const resolved = resolveNode(state, node); return { text: stringifyNode(resolved), addon }; } /* Convert a UI description into a string asynchronously. */ export function async(node: Node, addon: A = {} as A) { const state = init(true, addon); const resolved = resolveNode(state, node); if (state.async === 0) { return Promise.resolve({ text: stringifyNode(resolved), addon }); } const { resolve, reject, promise } = Promise.withResolvers>(); state.asyncDone = () => { const rejections = state.rejections; if (!rejections) return resolve({ text: stringifyNode(resolved), addon }); if (rejections.length === 1) return reject(rejections[0]); return reject(new AggregateError(rejections)); }; return promise; } export type Addons = Record; export interface Result { text: string; addon: A; } export function userData(def: () => T) { const k: unique symbol = Symbol(); return { key: k, get: () => ((UNWRAP(current).addon[k] as T) ??= def() as T), set: (value: T) => void (UNWRAP(current).addon[k] = value), }; } /** Inline HTML into a render without escaping it */ export function raw(node: ResolvedNode): Raw { return [kRaw, node]; } export function element(type: Element[1], props: Element[2] = {}): Element { return [kElement, type, props]; } export function resolvedElement( type: ResolvedElement[1], props: ResolvedElement[2], children: ResolvedElement[3], ): ResolvedElement { return [kElement, type, props, children]; } export interface State { /** * 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 kRaw = Symbol("Raw"); /** Node represents a webpage that can be 'rendered' into HTML. */ export type Node = | number | string // Escape HTML | Node[] // Concat | Element // Stringify | Raw // Insert | Promise // Await // Ignore | (undefined | null | boolean); export type Element = [ tag: typeof kElement, type: string | Component, props: Record, _?: "", // children source?: SrcLoc, ]; export type Raw = [tag: typeof kRaw, 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; /** Emitted by JSX runtime */ export interface SrcLoc { fileName: string; lineNumber: number; columnNumber: number; } /** * Resolution narrows the type 'Node' into 'ResolvedNode'. Async trees are * marked in 'State'. This operation performs everything besides the final * string concatenation. This function is agnostic across async/sync modes. */ export function resolveNode(r: State, 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 escapeHtmlContent(node); if (typeof node === "number") return String(node); // no escaping ever if (typeof node === "symbol" && node.toString() === kElement.toString()) { throw new Error(`There are two instances of Clover SSR loaded!`); } 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") { current = r; try { return resolveNode(r, tag(props)); } catch (e) { const { 4: src } = node; if (e && typeof e === "object") (e as { src?: string }).src = src; throw e; } finally { current = null; } } if (typeof tag !== "string") throw new Error("Unexpected " + inspect(type)); const children = props?.children; if (children) return [kElement, tag, props, resolveNode(r, children)]; return node; } if (type === kRaw) return node[1]; return node.map((elem) => resolveNode(r, elem)); } export type ResolvedNode = | ResolvedNode[] // Concat | ResolvedElement // Render | string; // Raw 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 stringifyNode(node: ResolvedNode): string { if (typeof node === "string") return node; ASSERT(node, "Unresolved Render Node"); const type = node[0]; if (type === kElement) { return stringifyElement(node as ResolvedElement); } node = node as ResolvedNode[]; // TS cannot infer. let out = type ? stringifyNode(type) : ""; let len = node.length; for (let i = 1; i < len; i++) { const elem = node[i]; if (elem) out += stringifyNode(elem); } return out; } function stringifyElement(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(escapeAttribute(String(value)))}`; break; case "className": // Legacy React Compat case "class": attr = `class=${ quoteIfNeeded(escapeAttribute(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 += stringifyNode(children); if ( tag !== "br" && tag !== "img" && tag !== "input" && tag !== "meta" && tag !== "link" && tag !== "hr" ) { out += ``; } return out; } export function stringifyStyleAttribute(style: Record) { let out = ``; for (const styleName in style) { if (out) out += ";"; out += `${styleName.replace(/[A-Z]/g, "-$&").toLowerCase()}:${ escapeAttribute( String(style[styleName]), ) }`; } return "style=" + quoteIfNeeded(out); } export function quoteIfNeeded(text: string) { if (text.match(/["/>]/)) return '"' + text + '"'; return text; } // -- utility functions -- export function init(allowAsync: boolean, addon: Addons): State { return { async: allowAsync ? 0 : -1, rejections: null, asyncDone: null, addon, }; } 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 escapeHtmlContent = (unsafeText: string) => String(unsafeText) .replace(/&/g, "&") .replace(//g, ">") .replace(/"/g, """); // TODO: combine into one function which decides if an attribute needs quotes // and escapes it correctly depending on the context. const escapeAttribute = (unsafeText: string) => String(unsafeText) .replace(/&/g, "&") .replace(//g, ">") .replace(/"/g, """); /** @deprecated */ export const escapeHtml = (unsafeText: string) => String(unsafeText) .replace(/&/g, "&") .replace(//g, ">") .replace(/"/g, """) .replace(/'/g, "'") .replace(/`/g, "`"); export function inspect(object: unknown) { try { return require("node:util").inspect(object); } catch { return typeof object; } }