/**
* The tagged-template HTML engine - `html` builds an escaped-by-construction {@link Template}.
*
* Security model (the part that matters): every interpolated value is HTML-escaped by default.
* The ONLY ways to emit unescaped markup are (1) nesting another `html` template - already escaped
* by construction - and (2) an explicit {@link raw} wrapper, which is the audit-greppable opt-out.
* The escaper covers text and double-quoted attribute contexts (`& < > " '`); interpolations into
* unquoted attributes or inline scripts are NOT safe by design - the docs say "always quote".
*/
/** Branded wrapper marking a string as pre-trusted markup. Construct only via {@link raw}. */
export class RawHtml {
readonly value: string
constructor(value: string) {
this.value = value
}
}
/**
* Mark a string as trusted, pre-escaped markup - it is emitted verbatim. The deliberate escape
* hatch (CMS-sanitized HTML, pre-rendered markdown): every call site is greppable, exactly like
* React's dangerouslySetInnerHTML, without the JSX.
*/
export function raw(trusted: string): RawHtml {
return new RawHtml(trusted)
}
const ESCAPE_RE = /[&<>"']/
const escapeHtml = (s: string): string => {
// Fast path: most text has nothing to escape - one regex probe, zero allocation.
if (!ESCAPE_RE.test(s)) return s
let out = ""
let last = 0
for (let i = 0; i < s.length; i++) {
let entity: string
switch (s.charCodeAt(i)) {
case 38:
entity = "&"
break
case 60:
entity = "<"
break
case 62:
entity = ">"
break
case 34:
entity = """
break
case 39:
entity = "'"
break
default:
continue
}
out += s.slice(last, i) + entity
last = i + 1
}
return out + s.slice(last)
}
/** A rendered HTML fragment - what `html` returns and components produce. Stringified once. */
export class Template {
/** The final markup. Built eagerly at tag time (interpolations are already values by then). */
readonly html: string
constructor(htmlString: string) {
this.html = htmlString
}
toString(): string {
return this.html
}
}
/** What an interpolation may be: escaped primitives, nested templates/raw, arrays of the same.
* `null`/`undefined`/`false` render as nothing (conditional rendering: `cond && html\`…\``). */
export type HtmlValue =
| string
| number
| bigint
| boolean
| null
| undefined
| Template
| RawHtml
| ReadonlyArray
function renderValue(value: HtmlValue): string {
if (value == null || value === false) return ""
if (typeof value === "string") return escapeHtml(value)
if (typeof value === "number" || typeof value === "bigint") return String(value)
if (value === true) return "true"
if (value instanceof Template) return value.html
if (value instanceof RawHtml) return value.value
if (Array.isArray(value)) {
let out = ""
for (const item of value) out += renderValue(item)
return out
}
// Objects/functions are a bug at the call site - render loudly rather than "[object Object]".
throw new TypeError(
`[nifra/web-vanilla] html: unsupported interpolation of type ${typeof value} - interpolate strings, numbers, nested html\`…\`, raw(), or arrays of those`,
)
}
/** The tag: `` html`${user.name}
` `` → an escaped {@link Template}. */
export function html(strings: TemplateStringsArray, ...values: HtmlValue[]): Template {
let out = strings[0] as string
for (let i = 0; i < values.length; i++) {
out += renderValue(values[i] as HtmlValue) + (strings[i + 1] as string)
}
return new Template(out)
}