UNPKG

hytypemedia

Version:

Minimal typed HTML templating helpers for Hono/Workers/HTMX. JSX-free, type-safe HTML generation with automatic escaping.

253 lines (252 loc) 6.12 kB
export const raw = (html) => ({ __raw: html }); const rawTextTags = new Set(["script", "style"]); const voidTags = new Set([ "area", "base", "br", "col", "embed", "hr", "img", "input", "link", "meta", "param", "source", "track", "wbr", ]); const escapeAttr = (v) => String(v) .replace(/&/g, "&amp;") .replace(/"/g, "&quot;") .replace(/</g, "&lt;") .replace(/>/g, "&gt;"); const escapeText = (v) => String(v).replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;"); const flatten = (arr) => arr.flat(Infinity); const renderNode = (n, tagName) => { if (n == null || n === false || n === true) return ""; if (typeof n === "object" && n && "__raw" in n) return n.__raw; const s = String(n); return rawTextTags.has(tagName ?? "") ? s : escapeText(s); }; function normaliseClass(v) { if (typeof v === "string") return v.trim().replace(/\s+/g, " "); if (Array.isArray(v)) return v.map(normaliseClass).filter(Boolean).join(" ").trim(); return Object.entries(v) .filter(([, on]) => !!on) .map(([k]) => k) .join(" ") .trim(); } function normaliseStyle(v) { if (typeof v === "string") return v.trim().replace(/\s*;\s*$/, ""); return Object.entries(v) .map(([k, val]) => { const prop = k.replace(/[A-Z]/g, (m) => "-" + m.toLowerCase()); return `${prop}:${String(val)}`; }) .join(";"); } // HTML boolean attributes that should only render when value is strictly true const booleanAttributes = new Set([ "allowfullscreen", "async", "autofocus", "autoplay", "checked", "controls", "default", "defer", "disabled", "formnovalidate", "hidden", "inert", "ismap", "itemscope", "loop", "multiple", "muted", "nomodule", "novalidate", "open", "playsinline", "readonly", "required", "reversed", "selected", ]); function attributesToString(attrs) { if (!attrs) return ""; return Object.entries(attrs) .flatMap(([key, value]) => { if (value == null || value === false) return []; if (value === true) return [key]; if (value === "" && booleanAttributes.has(key)) return []; if (key === "class") return [`class="${escapeAttr(normaliseClass(value))}"`]; if (key === "style") return [`style="${escapeAttr(normaliseStyle(value))}"`]; return [`${key}="${escapeAttr(value)}"`]; }) .join(" "); } function createElement(tagName) { const isVoid = voidTags.has(tagName); return (...args) => { let attrs = null; let children = []; if (args.length && typeof args[0] === "object" && !Array.isArray(args[0]) && !("__raw" in args[0])) { attrs = args.shift(); } children = flatten(args); const attrStr = attributesToString(attrs); const open = attrStr ? `<${tagName} ${attrStr}>` : `<${tagName}>`; if (isVoid) return raw(open); const inner = children.map((c) => renderNode(c, tagName)).join(""); return raw(`${open}${inner}</${tagName}>`); }; } // Build tag helpers const tags = [ "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", "hr", "html", "i", "iframe", "img", "input", "ins", "kbd", "label", "legend", "li", "link", "main", "map", "mark", "meta", "meter", "nav", "noscript", "object", "ol", "optgroup", "option", "output", "p", "picture", "pre", "progress", "q", "rp", "rt", "ruby", "s", "samp", "script", "section", "select", "slot", "small", "source", "span", "strong", "style", "sub", "summary", "sup", "svg", "table", "tbody", "td", "template", "text", "textarea", "tfoot", "th", "thead", "time", "title", "tr", "track", "u", "ul", "var", "video", "wbr", ]; const elements = Object.fromEntries(tags.map((t) => [t === "var" ? "var_" : t, createElement(t)])); // Named exports for convenience export const { 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, hr, html, i, iframe, img, input, ins, kbd, label, legend, li, link, main, map, mark, meta, meter, nav, noscript, object, ol, optgroup, option, output, p, picture, pre, progress, q, rp, rt, ruby, s, samp, script, section, select, slot, small, source, span, strong, style, sub, summary, sup, svg, table, tbody, td, template, text, textarea, tfoot, th, thead, time, title, tr, track, u, ul, var_, video, wbr, } = elements; // Export the full elements object export default elements; // Lightweight helpers export const Fragment = (...children) => raw(flatten(children) .map((n) => renderNode(n)) .join("")); export const doctype = () => raw("<!doctype html>"); export const safeText = (s) => escapeText(s ?? ""); // Convert SafeHtml to string for final output export const toString = (html) => html.__raw; // Helper function for custom elements or dynamic tag names export function customElement(tagName) { return createElement(tagName); }