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
JavaScript
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, "&")
.replace(/"/g, """)
.replace(/</g, "<")
.replace(/>/g, ">");
const escapeText = (v) => String(v).replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
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);
}