176 lines
4.6 KiB
TypeScript
176 lines
4.6 KiB
TypeScript
import { escapeHtml } from "./security.ts";
|
|
|
|
export type Renderable = Html | string | number | boolean | null | undefined | Renderable[];
|
|
|
|
export type Props = Record<string, unknown> & {
|
|
children?: Renderable;
|
|
dangerouslySetInnerHTML?: { __html?: unknown };
|
|
};
|
|
|
|
export type Component<P extends Props = Props> = (props: P) => Renderable;
|
|
export type ElementType = string | Component | typeof Fragment;
|
|
|
|
export class Html {
|
|
constructor(public readonly html: string) {}
|
|
|
|
toString(): string {
|
|
return this.html;
|
|
}
|
|
}
|
|
|
|
export const Fragment = Symbol.for("wrnexus.fragment");
|
|
|
|
const VOID_ELEMENTS = new Set([
|
|
"area",
|
|
"base",
|
|
"br",
|
|
"col",
|
|
"embed",
|
|
"hr",
|
|
"img",
|
|
"input",
|
|
"link",
|
|
"meta",
|
|
"param",
|
|
"source",
|
|
"track",
|
|
"wbr",
|
|
]);
|
|
const SAFE_TAG_NAME = /^[A-Za-z][A-Za-z0-9._:-]*$/;
|
|
const SAFE_ATTR_NAME = /^[A-Za-z_:][A-Za-z0-9:._-]*$/;
|
|
|
|
function isHtml(value: unknown): value is Html {
|
|
return value instanceof Html;
|
|
}
|
|
|
|
function raw(value: string): Html {
|
|
return new Html(value);
|
|
}
|
|
|
|
export function mustache(expr: string): Html;
|
|
export function mustache(strings: TemplateStringsArray, ...values: unknown[]): Html;
|
|
export function mustache(input: string | TemplateStringsArray, ...values: unknown[]): Html {
|
|
const expr =
|
|
typeof input === "string"
|
|
? input
|
|
: input.reduce((out, part, index) => {
|
|
const value = index < values.length ? String(values[index]) : "";
|
|
return out + part + value;
|
|
}, "");
|
|
|
|
return raw(`{{${expr.trim()}}}`);
|
|
}
|
|
|
|
function renderChild(value: Renderable): string {
|
|
if (value === null || value === undefined || typeof value === "boolean") return "";
|
|
if (Array.isArray(value)) return value.map(renderChild).join("");
|
|
if (isHtml(value)) return value.html;
|
|
return escapeHtml(String(value));
|
|
}
|
|
|
|
function renderComponentResult(value: Renderable): string {
|
|
if (value === null || value === undefined || typeof value === "boolean") return "";
|
|
if (Array.isArray(value)) return value.map(renderComponentResult).join("");
|
|
if (isHtml(value)) return value.html;
|
|
|
|
// WrNexus page/component strings are HTML by convention.
|
|
if (typeof value === "string") return value;
|
|
return escapeHtml(String(value));
|
|
}
|
|
|
|
function attrName(name: string): string {
|
|
if (name === "className") return "class";
|
|
if (name === "htmlFor") return "for";
|
|
return name;
|
|
}
|
|
|
|
function styleToString(value: Record<string, unknown>): string {
|
|
return Object.entries(value)
|
|
.filter(([, v]) => v !== null && v !== undefined && v !== false)
|
|
.map(([k, v]) => `${k.replace(/[A-Z]/g, (ch) => `-${ch.toLowerCase()}`)}: ${String(v)}`)
|
|
.join("; ");
|
|
}
|
|
|
|
function renderAttrs(props: Props): string {
|
|
const attrs: string[] = [];
|
|
|
|
for (const [key, value] of Object.entries(props)) {
|
|
if (
|
|
key === "children" ||
|
|
key === "key" ||
|
|
key === "ref" ||
|
|
key === "dangerouslySetInnerHTML" ||
|
|
value === null ||
|
|
value === undefined ||
|
|
value === false
|
|
) {
|
|
continue;
|
|
}
|
|
|
|
if (typeof value === "function") continue;
|
|
|
|
const name = attrName(key);
|
|
if (!SAFE_ATTR_NAME.test(name)) continue;
|
|
if (value === true) {
|
|
attrs.push(name);
|
|
continue;
|
|
}
|
|
|
|
const rendered =
|
|
key === "style" && typeof value === "object" && !Array.isArray(value)
|
|
? styleToString(value as Record<string, unknown>)
|
|
: String(value);
|
|
|
|
attrs.push(`${name}="${escapeHtml(rendered)}"`);
|
|
}
|
|
|
|
return attrs.length ? ` ${attrs.join(" ")}` : "";
|
|
}
|
|
|
|
export function jsx(type: ElementType, props: Props | null): Html {
|
|
const safeProps = props ?? {};
|
|
|
|
if (type === Fragment) {
|
|
return raw(renderChild(safeProps.children));
|
|
}
|
|
|
|
if (typeof type === "function") {
|
|
return raw(renderComponentResult(type(safeProps)));
|
|
}
|
|
|
|
if (!SAFE_TAG_NAME.test(type)) throw new TypeError(`Invalid JSX tag name: ${type}`);
|
|
|
|
const attrs = renderAttrs(safeProps);
|
|
if (VOID_ELEMENTS.has(type)) {
|
|
return raw(`<${type}${attrs}>`);
|
|
}
|
|
|
|
const children =
|
|
safeProps.dangerouslySetInnerHTML && "__html" in safeProps.dangerouslySetInnerHTML
|
|
? String(safeProps.dangerouslySetInnerHTML.__html ?? "")
|
|
: renderChild(safeProps.children);
|
|
|
|
return raw(`<${type}${attrs}>${children}</${type}>`);
|
|
}
|
|
|
|
export const jsxs = jsx;
|
|
|
|
// TypeScript's automatic JSX runtime looks for this exported namespace.
|
|
// eslint-disable-next-line @typescript-eslint/no-namespace
|
|
export namespace JSX {
|
|
export type Element = Html;
|
|
export type ElementType = string | Component;
|
|
|
|
export interface ElementChildrenAttribute {
|
|
children: unknown;
|
|
}
|
|
|
|
export interface IntrinsicAttributes {
|
|
key?: string | number;
|
|
}
|
|
|
|
export interface IntrinsicElements {
|
|
[tagName: string]: Props;
|
|
}
|
|
}
|