this takes what i love about 'next/meta' (originally imported via my 'next-metadata' port) and consolidates it into a simple 250 line library. instead of supporting all meta tags under the sun, only the most essential ones are exposed. less common meta tags can be added with JSX under the 'extra' field. a common problem i had with next-metadata was that open graph embeds copied a lot of data from the main meta tags. to solve this, a highly opiniated 'embed' option exists, which simply passing '{}' will trigger the default behavior of copying the meta title, description, and canonical url into the open graph meta tags.
343 lines
10 KiB
TypeScript
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, "&")
|
|
.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, ">")
|
|
.replace(/"/g, """);
|
|
/** @deprecated */
|
|
export const escapeHtml = (unsafeText: string) =>
|
|
String(unsafeText)
|
|
.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;
|
|
}
|
|
}
|