Files
Bendik Aagaard LynghaugandClaude Fable 5 84230b1770 feat(html): browser renderer — runtime-cached tagged templates
Each template literal parses once, keyed by its strings array in a
WeakMap: static parts become a <template> with hole markers, renders
clone and fill. Interpolated values never pass through innerHTML (child
values become text nodes or adopted DOM; attributes go through
setAttribute). Event handlers set as properties so nanomorph copies them.
Document-level roots (<body>/<head>/<html>) parse via DOMParser because
<template> drops them. Replaces the v7 browserify transform entirely —
production speed is now a runtime property.

Tests run in happy-dom (new devDependency), including a check that server
and browser renderers serialize identical markup.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014NgfSjHE11oFpoSnLVKLXd
2026-09-08 16:56:53 +02:00

266 lines
8.1 KiB
JavaScript

// The v8 browser renderer: a runtime-only tagged template.
//
// Each unique template literal is parsed once, keyed by its (frozen,
// per-call-site) strings array in a WeakMap: the static parts become a
// <template> element with markers where the holes are, plus a list of
// instructions (node paths + hole indices). Every render clones the
// template and fills the holes. No compile step, no HTML re-parsing on
// re-render — production speed is a property of the runtime, not of a
// build tool (this replaces nanohtml's browserify/babel transform).
//
// Interpolated values never pass through innerHTML: child values become
// text nodes or adopted DOM nodes, attribute values go through
// setAttribute. Only the author-written static strings are parsed as HTML.
//
// Known limits (documented, matching or narrowing nanohtml's):
// - dynamic tag names (html`<${tag}>`) are not supported
// - holes inside <script>/<style>/<textarea> raw text are not supported
// - an SVG fragment must include its <svg> root to get the right namespace
const BOOL_PROPS = [
'async', 'autofocus', 'autoplay', 'checked', 'controls', 'default',
'defaultchecked', 'defer', 'disabled', 'formnovalidate', 'hidden',
'ismap', 'loop', 'multiple', 'muted', 'novalidate', 'open', 'playsinline',
'readonly', 'required', 'reversed', 'selected'
]
const templateCache = new WeakMap()
const TEXT = 0
const TAG = 1
const DQ = 2
const SQ = 3
const COMMENT = 4
export default function html (strings, ...values) {
let entry = templateCache.get(strings)
if (!entry) {
entry = parseTemplate(strings)
templateCache.set(strings, entry)
}
return instantiate(entry, values)
}
export { html }
// Build the marker HTML for a template literal and parse it once.
function parseTemplate (strings) {
let src = ''
let state = TEXT
for (let i = 0; i < strings.length; i++) {
const s = strings[i]
for (let j = 0; j < s.length; j++) {
const c = s[j]
switch (state) {
case TEXT:
if (c === '<') {
if (s.startsWith('!--', j + 1)) {
state = COMMENT
j += 3
} else {
state = TAG
}
}
break
case TAG:
if (c === '>') state = TEXT
else if (c === '"') state = DQ
else if (c === "'") state = SQ
break
case DQ:
if (c === '"') state = TAG
break
case SQ:
if (c === "'") state = TAG
break
case COMMENT:
if (c === '-' && s.startsWith('->', j + 1)) {
state = TEXT
j += 2
}
break
}
}
src += s
if (i < strings.length - 1) {
if (state === TEXT) {
src += `<!--__h${i}__-->`
} else if (state === DQ || state === SQ) {
src += `__h${i}__`
} else if (state === TAG) {
if (/=\s*$/.test(s)) {
// unquoted attribute value: attr=${x}
src += `"__h${i}__"`
} else {
// attribute-name position: spread object, <div ${attrs}>
src += ` __h${i}__ `
}
}
// COMMENT position: holes inside comments are dropped
}
}
const instructions = []
// <template> parsing drops document-level tags (<body>, <head>, <html>),
// but choo views legitimately return html`<body>…`. Parse those as a
// full document instead and use the element itself as the clone root.
const docRoot = /^\s*<(html|head|body)[\s>]/i.exec(src)
if (docRoot) {
const parser = new window.DOMParser()
const doc = parser.parseFromString(src, 'text/html')
const tag = docRoot[1].toLowerCase()
const root = tag === 'html' ? doc.documentElement : tag === 'head' ? doc.head : doc.body
scanAttrs(root, [], instructions)
walk(root, [], instructions)
return { root, instructions }
}
const template = document.createElement('template')
template.innerHTML = src
walk(template.content, [], instructions)
return { root: template.content, instructions }
}
function walk (node, path, out) {
const kids = node.childNodes
for (let i = 0; i < kids.length; i++) {
const child = kids[i]
const childPath = path.concat(i)
if (child.nodeType === 8) { // comment
const m = /^__h(\d+)__$/.exec(child.data)
if (m) out.push({ type: 'child', path: childPath, hole: +m[1] })
} else if (child.nodeType === 1) { // element
scanAttrs(child, childPath, out)
walk(child, childPath, out)
}
}
}
function scanAttrs (el, path, out) {
for (const attr of [...el.attributes]) {
const nameMatch = /^__h(\d+)__$/.exec(attr.name)
if (nameMatch) {
el.removeAttribute(attr.name)
out.push({ type: 'spread', path, hole: +nameMatch[1] })
continue
}
if (/__h\d+__/.test(attr.value)) {
const sole = /^__h(\d+)__$/.exec(attr.value)
if (sole) {
// whole value is one hole: apply with type awareness at render
el.removeAttribute(attr.name)
out.push({ type: 'attr', path, name: attr.name, hole: +sole[1] })
} else {
// static text mixed with holes: string composition
const parts = attr.value.split(/__h(\d+)__/)
.map((p, idx) => idx % 2 ? +p : p)
.filter((p) => p !== '')
out.push({ type: 'parts', path, name: attr.name, parts })
}
}
}
}
function instantiate (entry, values) {
const frag = entry.root.cloneNode(true)
// Resolve every target before mutating: child replacements change
// sibling indices, so paths are only valid against the pristine clone.
const targets = entry.instructions.map((instr) => resolvePath(frag, instr.path))
entry.instructions.forEach((instr, i) => {
const node = targets[i]
if (instr.type === 'child') {
node.replaceWith(toNode(values[instr.hole]))
} else if (instr.type === 'attr') {
setAttr(node, instr.name, values[instr.hole])
} else if (instr.type === 'spread') {
const obj = values[instr.hole]
if (obj && typeof obj === 'object') {
for (const key of Object.keys(obj)) setAttr(node, key, obj[key])
}
} else if (instr.type === 'parts') {
const joined = instr.parts
.map((p) => typeof p === 'number' ? toAttrString(values[p]) : p)
.join('')
node.setAttribute(instr.name, joined)
}
})
// A document-level root (<body> etc.) is already an element
if (frag.nodeType === 1) return frag
// Single element root unwraps (choo views return one node);
// anything else stays a fragment.
let result = null
for (const child of frag.childNodes) {
if (child.nodeType === 3 && !child.data.trim()) continue
if (result === null) {
result = child
} else {
result = frag
break
}
}
return result === null ? frag : result
}
function resolvePath (root, path) {
let node = root
for (const index of path) node = node.childNodes[index]
return node
}
function toNode (value) {
if (value === null || value === undefined) {
return document.createTextNode('')
}
if (value.nodeType) return value
if (Array.isArray(value)) {
const frag = document.createDocumentFragment()
for (const item of value) frag.appendChild(toNode(item))
return frag
}
if (value.__encoded) {
// raw() output: author-vouched HTML, parsed on purpose
const t = document.createElement('template')
t.innerHTML = String(value)
return t.content
}
return document.createTextNode(String(value))
}
function setAttr (el, name, value) {
if (typeof value === 'function') {
// event handlers are set as properties so nanomorph can copy them
if (name.startsWith('on')) el[name] = value
return
}
if (BOOL_PROPS.includes(name)) {
el[name] = !!value
if (value) el.setAttribute(name, name)
else el.removeAttribute(name)
return
}
if (value === null || value === undefined || value === false) {
el.removeAttribute(name)
return
}
if (value === true) {
el.setAttribute(name, '')
return
}
el.setAttribute(name, String(value))
if (name === 'value' && 'value' in el) el.value = String(value)
}
function toAttrString (value) {
if (value === null || value === undefined) return ''
if (typeof value === 'function') return ''
return String(value)
}