sitegen/framework/engine/render.ts
chloe caruso c5ac450f21 feat: dynamic page regeneration (#24)
the asset system is reworked to support "dynamic" entries, where each
entry is a separate file on disk containing the latest generation's
headers+raw+gzip+zstd. when calling view.regenerate, it will look for
pages that had "export const regenerate" during generation, and render
those pages using the view system, but then store the results as assets
instead of sending as a response.

pages configured as regenerable are also bundled as views, using the
non-aliasing key "page:${page.id}". this cannot alias because file
paths may not contain a colon.
2025-08-11 22:43:27 -07:00

343 lines
10 KiB
TypeScript

// 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<A extends Addons>(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<A extends Addons>(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<Result<A>>();
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<symbol, unknown>;
export interface Result<A extends Addons = Addons> {
text: string;
addon: A;
}
export function userData<T>(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<Node> // Await
// Ignore
| (undefined | null | boolean);
export type Element = [
tag: typeof kElement,
type: string | Component,
props: Record<string, unknown>,
_?: "", // 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<any, any>) => Exclude<Node, undefined>;
/** 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<string, unknown>,
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 += `</${tag}>`;
}
return out;
}
export function stringifyStyleAttribute(style: Record<string, string>) {
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<string, boolean | null> | 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, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
// 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, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
/** @deprecated */
export const escapeHtml = (unsafeText: string) =>
String(unsafeText)
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#x27;")
.replace(/`/g, "&#x60;");
export function inspect(object: unknown) {
try {
return require("node:util").inspect(object);
} catch {
return typeof object;
}
}