/** * Programmatic HTML builder — avoids string concatenation. * * Usage: * const el = new HtmlElement("div", { class: "card" }, ["Hello"]); * el.toString(); // '
Hello
' * * // Builder pattern * const el = htmlElement("div")(htmlElement("p")("Text")); * * // Helper functions * const h: Record = {}; * addHtmlHelpers(h); * const html = h._div({ class: "card" }, h._p("Hello")); */ const VOID_TAGS: ReadonlySet = new Set([ "area", "base", "br", "col", "embed", "hr", "img", "input", "link", "meta", "param", "source", "track", "wbr", ]); const HTML_TAGS: readonly string[] = [ "a", "abbr", "address", "area", "article", "aside", "audio", "b", "base", "bdi", "bdo", "blockquote", "body", "br", "button", "canvas", "caption", "cite", "code", "col", "colgroup", "data", "datalist", "dd", "del", "details", "dfn", "dialog", "div", "dl", "dt", "em", "embed", "fieldset", "figcaption", "figure", "footer", "form", "h1", "h2", "h3", "h4", "h5", "h6", "head", "header", "hgroup", "hr", "html", "i", "iframe", "img", "input", "ins", "kbd", "label", "legend", "li", "link", "main", "map", "mark", "menu", "meta", "meter", "nav", "noscript", "object", "ol", "optgroup", "option", "output", "p", "param", "picture", "pre", "progress", "q", "rp", "rt", "ruby", "s", "samp", "script", "section", "select", "slot", "small", "source", "span", "strong", "style", "sub", "summary", "sup", "table", "tbody", "td", "template", "textarea", "tfoot", "th", "thead", "time", "title", "tr", "track", "u", "ul", "var", "video", "wbr", ]; /** * Raw — marker for trusted, pre-sanitised HTML that must render UNESCAPED. * * String/scalar children of an HtmlElement are HTML-escaped by default to * prevent stored/reflected XSS. Wrap a value in Raw to opt out of escaping * when (and only when) you have already sanitised it yourself. * * new HtmlElement("div", {}, ["x"]).toString() // <b>x</b> (escaped) * new HtmlElement("div", {}, [new Raw("x")]).toString() // x (raw) * * Alias: SafeString. */ export class Raw { readonly value: string; constructor(value: string) { this.value = String(value); } toString(): string { return this.value; } } // Alias — some callers/frameworks prefer the SafeString name (matches Frond/Python). export const SafeString = Raw; type Attrs = Record; type Child = string | number | HtmlElement | Raw; function escapeAttr(value: string): string { return value .replace(/&/g, "&") .replace(/"/g, """) .replace(//g, ">"); } /** * Escape a plain string/scalar child so it cannot inject markup (defeats XSS). */ function escapeText(value: string): string { return value .replace(/&/g, "&") .replace(//g, ">"); } function isAttrs(arg: unknown): arg is Attrs { return typeof arg === "object" && arg !== null && !(arg instanceof HtmlElement) && !(arg instanceof Raw) && !Array.isArray(arg); } /** * A builder produced by htmlElement() is a callable function branded with * `_isHtmlElement`. Its toString() already produces fully-escaped HTML, so it * must render verbatim (not be escaped as a string). */ function isBuilderElement(arg: unknown): boolean { return typeof arg === "function" && (arg as { _isHtmlElement?: boolean })._isHtmlElement === true; } /** * HtmlElement — a single HTML tag with attributes and children. */ export class HtmlElement { readonly tag: string; readonly attrs: Attrs; readonly children: Child[]; constructor(tag: string, attrs: Attrs = {}, children: Child[] = []) { this.tag = tag.toLowerCase(); this.attrs = { ...attrs }; this.children = [...children]; } /** * Render to HTML string. */ toString(): string { let html = `<${this.tag}`; for (const [key, value] of Object.entries(this.attrs)) { if (value === true) { html += ` ${key}`; } else if (value !== false && value !== null && value !== undefined) { html += ` ${key}="${escapeAttr(String(value))}"`; } } if (VOID_TAGS.has(this.tag)) { return html + ">"; } html += ">"; for (const child of this.children) { if (child instanceof HtmlElement) { // Nested elements render themselves (already escape their own children). html += child.toString(); } else if (child instanceof Raw) { // Explicitly trusted markup — emit unescaped. html += child.toString(); } else if (isBuilderElement(child)) { // Callable builder produced by htmlElement() — render via its toString. html += String(child); } else { // Plain string/scalar child — escape to defeat XSS. html += escapeText(String(child)); } } html += ``; return html; } } /** * Create a callable HTML element builder. * Returns a function that, when called with children/attrs, produces a new HtmlElement. * * Usage: * const div = htmlElement("div"); * const el = div({ class: "card" }, "Hello"); * console.log(el.toString()); // '
Hello
' */ export function htmlElement(tag: string, attrs: Attrs = {}, children: Child[] = []) { const el = new HtmlElement(tag, attrs, children); const builder = (...args: (Child | Attrs | Child[])[]) => { const newAttrs: Attrs = { ...el.attrs }; const newChildren: Child[] = [...el.children]; for (const arg of args) { if (isAttrs(arg)) { Object.assign(newAttrs, arg); } else if (Array.isArray(arg)) { newChildren.push(...arg); } else { newChildren.push(arg as Child); } } return htmlElement(tag, newAttrs, newChildren); }; // Expose HtmlElement properties and toString on the builder function builder.tag = el.tag; builder.attrs = el.attrs; builder.children = el.children; builder.toString = () => el.toString(); builder._isHtmlElement = true; return builder; } /** * Injects helper functions (_div, _p, _a, _span, etc.) into the target object. * * Usage: * const h: Record = {}; * addHtmlHelpers(h); * const html = h._div({ class: "card" }, h._p("Hello")); */ export function addHtmlHelpers(target: Record): void { for (const tag of HTML_TAGS) { target[`_${tag}`] = (...args: (Child | Attrs | Child[])[]) => { const attrs: Attrs = {}; const children: Child[] = []; for (const arg of args) { if (isAttrs(arg)) { Object.assign(attrs, arg); } else if (Array.isArray(arg)) { children.push(...arg); } else { children.push(arg as Child); } } return new HtmlElement(tag, attrs, children); }; } }