sitegen/framework/engine/ssr.ts

297 lines
9.3 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 type Addons = Record<string | symbol, unknown>;
export function ssrSync(node: Node): Result;
export function ssrSync<A extends Addons>(node: Node, addon: A): Result<A>;
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<Result>;
export function ssrAsync<A extends Addons>(
node: Node,
addon: A,
): Promise<Result<A>>;
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<Result>();
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<A extends Addons = Addons> {
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<Node> // Await
// Ignore
| undefined
| null
| boolean;
export type Element = [
tag: typeof kElement,
type: string | Component,
props: Record<string, unknown>,
];
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<any, any>,
) => Exclude<Node, undefined>;
/**
* 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<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 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 += `</${tag}>`;
}
return out;
}
export function renderStyleAttribute(style: Record<string, string>) {
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<T>(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<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 escapeHtml = (unsafeText: string) =>
String(unsafeText)
.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;")
.replace(/"/g, "&quot;").replace(/'/g, "&#x27;").replace(/`/g, "&#x60;");