import { escapeHtml } from "./security.ts"; export type Renderable = Html | string | number | boolean | null | undefined | Renderable[]; export type Props = Record & { children?: Renderable; dangerouslySetInnerHTML?: { __html?: unknown }; }; export type Component

= (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 { 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(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}`); } 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; } }