2025-07-07 20:58:02 -07:00
|
|
|
// 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.
|
2025-08-02 19:22:07 -07:00
|
|
|
export let current: State | null = null;
|
|
|
|
export function setCurrent(r: State | null) {
|
|
|
|
current = r ?? null;
|
|
|
|
}
|
2025-07-07 20:58:02 -07:00
|
|
|
|
2025-08-02 19:22:07 -07:00
|
|
|
/* 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 };
|
2025-07-07 20:58:02 -07:00
|
|
|
}
|
|
|
|
|
2025-08-02 19:22:07 -07:00
|
|
|
/* 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 });
|
2025-07-07 20:58:02 -07:00
|
|
|
}
|
2025-08-02 17:31:58 -07:00
|
|
|
const { resolve, reject, promise } = Promise.withResolvers<Result<A>>();
|
2025-08-02 19:22:07 -07:00
|
|
|
state.asyncDone = () => {
|
|
|
|
const rejections = state.rejections;
|
|
|
|
if (!rejections) return resolve({ text: stringifyNode(resolved), addon });
|
2025-07-07 20:58:02 -07:00
|
|
|
if (rejections.length === 1) return reject(rejections[0]);
|
|
|
|
return reject(new AggregateError(rejections));
|
|
|
|
};
|
|
|
|
return promise;
|
|
|
|
}
|
|
|
|
|
2025-08-02 19:22:07 -07:00
|
|
|
export type Addons = Record<symbol, unknown>;
|
|
|
|
export interface Result<A extends Addons = Addons> {
|
2025-07-07 20:58:02 -07:00
|
|
|
text: string;
|
|
|
|
addon: A;
|
|
|
|
}
|
|
|
|
|
2025-08-02 19:22:07 -07:00
|
|
|
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 {
|
2025-07-07 20:58:02 -07:00
|
|
|
/**
|
|
|
|
* 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");
|
2025-08-02 19:22:07 -07:00
|
|
|
export const kRaw = Symbol("Raw");
|
2025-07-07 20:58:02 -07:00
|
|
|
|
|
|
|
/** Node represents a webpage that can be 'rendered' into HTML. */
|
|
|
|
export type Node =
|
|
|
|
| number
|
|
|
|
| string // Escape HTML
|
|
|
|
| Node[] // Concat
|
2025-08-02 19:22:07 -07:00
|
|
|
| Element // Stringify
|
|
|
|
| Raw // Insert
|
2025-07-07 20:58:02 -07:00
|
|
|
| Promise<Node> // Await
|
|
|
|
// Ignore
|
2025-08-02 19:22:07 -07:00
|
|
|
| (undefined | null | boolean);
|
2025-07-07 20:58:02 -07:00
|
|
|
export type Element = [
|
|
|
|
tag: typeof kElement,
|
|
|
|
type: string | Component,
|
|
|
|
props: Record<string, unknown>,
|
2025-08-02 19:22:07 -07:00
|
|
|
_?: "", // children
|
2025-07-07 20:58:02 -07:00
|
|
|
source?: SrcLoc,
|
|
|
|
];
|
2025-08-02 19:22:07 -07:00
|
|
|
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>;
|
2025-07-07 20:58:02 -07:00
|
|
|
/** Emitted by JSX runtime */
|
|
|
|
export interface SrcLoc {
|
|
|
|
fileName: string;
|
|
|
|
lineNumber: number;
|
|
|
|
columnNumber: number;
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Resolution narrows the type 'Node' into 'ResolvedNode'. Async trees are
|
2025-08-02 19:22:07 -07:00
|
|
|
* marked in 'State'. This operation performs everything besides the final
|
2025-07-07 20:58:02 -07:00
|
|
|
* string concatenation. This function is agnostic across async/sync modes.
|
|
|
|
*/
|
2025-08-02 19:22:07 -07:00
|
|
|
export function resolveNode(r: State, node: unknown): ResolvedNode {
|
2025-07-07 20:58:02 -07:00
|
|
|
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") {
|
2025-08-02 19:22:07 -07:00
|
|
|
current = r;
|
2025-07-07 20:58:02 -07:00
|
|
|
try {
|
|
|
|
return resolveNode(r, tag(props));
|
|
|
|
} catch (e) {
|
|
|
|
const { 4: src } = node;
|
2025-07-08 23:10:41 -07:00
|
|
|
if (e && typeof e === "object") (e as { src?: string }).src = src;
|
|
|
|
throw e;
|
2025-07-07 20:58:02 -07:00
|
|
|
} finally {
|
2025-08-02 19:22:07 -07:00
|
|
|
current = null;
|
2025-07-07 20:58:02 -07:00
|
|
|
}
|
|
|
|
}
|
2025-07-08 23:10:41 -07:00
|
|
|
if (typeof tag !== "string") throw new Error("Unexpected " + inspect(type));
|
2025-07-07 20:58:02 -07:00
|
|
|
const children = props?.children;
|
|
|
|
if (children) return [kElement, tag, props, resolveNode(r, children)];
|
|
|
|
return node;
|
|
|
|
}
|
2025-08-02 19:22:07 -07:00
|
|
|
if (type === kRaw) return node[1];
|
2025-07-07 20:58:02 -07:00
|
|
|
return node.map((elem) => resolveNode(r, elem));
|
|
|
|
}
|
|
|
|
|
|
|
|
export type ResolvedNode =
|
|
|
|
| ResolvedNode[] // Concat
|
|
|
|
| ResolvedElement // Render
|
2025-08-02 19:22:07 -07:00
|
|
|
| string; // Raw HTML
|
2025-07-07 20:58:02 -07:00
|
|
|
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.
|
|
|
|
*/
|
2025-08-02 19:22:07 -07:00
|
|
|
export function stringifyNode(node: ResolvedNode): string {
|
2025-07-07 20:58:02 -07:00
|
|
|
if (typeof node === "string") return node;
|
|
|
|
ASSERT(node, "Unresolved Render Node");
|
|
|
|
const type = node[0];
|
|
|
|
if (type === kElement) {
|
2025-08-02 19:22:07 -07:00
|
|
|
return stringifyElement(node as ResolvedElement);
|
2025-07-07 20:58:02 -07:00
|
|
|
}
|
|
|
|
node = node as ResolvedNode[]; // TS cannot infer.
|
2025-08-02 19:22:07 -07:00
|
|
|
let out = type ? stringifyNode(type) : "";
|
2025-07-07 20:58:02 -07:00
|
|
|
let len = node.length;
|
|
|
|
for (let i = 1; i < len; i++) {
|
|
|
|
const elem = node[i];
|
2025-08-02 19:22:07 -07:00
|
|
|
if (elem) out += stringifyNode(elem);
|
2025-07-07 20:58:02 -07:00
|
|
|
}
|
|
|
|
return out;
|
|
|
|
}
|
2025-08-02 19:22:07 -07:00
|
|
|
function stringifyElement(element: ResolvedElement) {
|
2025-07-07 20:58:02 -07:00
|
|
|
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;
|
|
|
|
}
|
2025-08-02 19:22:07 -07:00
|
|
|
if (needSpace) (out += " "), (needSpace = !attr.endsWith('"'));
|
2025-07-07 20:58:02 -07:00
|
|
|
out += attr;
|
|
|
|
}
|
|
|
|
out += ">";
|
2025-08-02 19:22:07 -07:00
|
|
|
if (children) out += stringifyNode(children);
|
2025-07-07 20:58:02 -07:00
|
|
|
if (
|
2025-08-02 19:22:07 -07:00
|
|
|
tag !== "br" &&
|
|
|
|
tag !== "img" &&
|
|
|
|
tag !== "input" &&
|
|
|
|
tag !== "meta" &&
|
|
|
|
tag !== "link" &&
|
|
|
|
tag !== "hr"
|
2025-07-07 20:58:02 -07:00
|
|
|
) {
|
|
|
|
out += `</${tag}>`;
|
|
|
|
}
|
|
|
|
return out;
|
|
|
|
}
|
2025-08-02 19:22:07 -07:00
|
|
|
export function stringifyStyleAttribute(style: Record<string, string>) {
|
2025-07-07 20:58:02 -07:00
|
|
|
let out = ``;
|
|
|
|
for (const styleName in style) {
|
|
|
|
if (out) out += ";";
|
2025-08-02 19:22:07 -07:00
|
|
|
out += `${styleName.replace(/[A-Z]/g, "-$&").toLowerCase()}:${escapeHtml(
|
|
|
|
String(style[styleName]),
|
|
|
|
)}`;
|
2025-07-07 20:58:02 -07:00
|
|
|
}
|
|
|
|
return "style=" + quoteIfNeeded(out);
|
|
|
|
}
|
|
|
|
export function quoteIfNeeded(text: string) {
|
|
|
|
if (text.includes(" ")) return '"' + text + '"';
|
|
|
|
return text;
|
|
|
|
}
|
|
|
|
|
|
|
|
// -- utility functions --
|
|
|
|
|
2025-08-02 19:22:07 -07:00
|
|
|
export function init(allowAsync: boolean, addon: Addons): State {
|
2025-07-07 20:58:02 -07:00
|
|
|
return {
|
|
|
|
async: allowAsync ? 0 : -1,
|
|
|
|
rejections: null,
|
|
|
|
asyncDone: null,
|
|
|
|
addon,
|
|
|
|
};
|
|
|
|
}
|
|
|
|
|
|
|
|
export type ClsxInput = string | Record<string, boolean | null> | ClsxInput[];
|
|
|
|
export function clsx(mix: ClsxInput) {
|
2025-08-02 19:22:07 -07:00
|
|
|
var k,
|
|
|
|
y,
|
|
|
|
str = "";
|
2025-07-07 20:58:02 -07:00
|
|
|
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)
|
2025-08-02 19:22:07 -07:00
|
|
|
.replace(/&/g, "&")
|
|
|
|
.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;
|
|
|
|
}
|
|
|
|
}
|