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
This commit is contained in:
co-authored by
Claude Fable 5
parent
501e8f5927
commit
84230b1770
+5
-2
@@ -11,8 +11,11 @@
|
||||
"node": ">=24"
|
||||
},
|
||||
"scripts": {
|
||||
"test": "node --test packages/core/test/ packages/html/test/"
|
||||
"test": "node --test packages/core/test/ packages/html/test/ packages/component/test/"
|
||||
},
|
||||
"repository": "choojs/choo",
|
||||
"license": "MIT"
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"happy-dom": "^20.14.0"
|
||||
}
|
||||
}
|
||||
|
||||
+260
-10
@@ -1,15 +1,265 @@
|
||||
// Phase 2 (rendering core) lands here: a runtime-only tagged template that
|
||||
// parses each template once (keyed by its strings array in a WeakMap) and
|
||||
// instantiates DOM by cloning. No compile step, ever.
|
||||
// The v8 browser renderer: a runtime-only tagged template.
|
||||
//
|
||||
// Until then, importing @choojs/html in a browser build fails loudly rather
|
||||
// than silently rendering nothing.
|
||||
// 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
|
||||
|
||||
export default function html () {
|
||||
throw new Error(
|
||||
'@choojs/html: the browser renderer is not implemented yet (v8 Phase 2). ' +
|
||||
'Server-side rendering via the "node" condition works today.'
|
||||
)
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -8,6 +8,8 @@
|
||||
"browser": "./browser.js",
|
||||
"default": "./server.js"
|
||||
},
|
||||
"./browser": "./browser.js",
|
||||
"./server": "./server.js",
|
||||
"./raw": "./raw.js",
|
||||
"./morph": "./morph.js"
|
||||
},
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
// Browser renderer, exercised in happy-dom. Globals are installed before
|
||||
// the dynamic import because browser.js touches `document` at parse time.
|
||||
|
||||
import { test, before } from 'node:test'
|
||||
import assert from 'node:assert'
|
||||
import { Window } from 'happy-dom'
|
||||
|
||||
let html, raw, morph
|
||||
|
||||
before(async () => {
|
||||
const win = new Window()
|
||||
globalThis.window = win
|
||||
globalThis.document = win.document
|
||||
;({ default: html } = await import('../browser.js'))
|
||||
;({ default: raw } = await import('../raw.js'))
|
||||
;({ default: morph } = await import('../morph.js'))
|
||||
})
|
||||
|
||||
test('renders an element with text', () => {
|
||||
const el = html`<p>hello</p>`
|
||||
assert.strictEqual(el.tagName, 'P')
|
||||
assert.strictEqual(el.textContent, 'hello')
|
||||
})
|
||||
|
||||
test('interpolated strings become text, never markup', () => {
|
||||
const el = html`<p>${'<script>alert(1)</script>'}</p>`
|
||||
assert.strictEqual(el.querySelector('script'), null)
|
||||
assert.strictEqual(el.textContent, '<script>alert(1)</script>')
|
||||
})
|
||||
|
||||
test('the same call site reuses its parsed template', () => {
|
||||
const view = (x) => html`<span>${x}</span>`
|
||||
const a = view('a')
|
||||
const b = view('b')
|
||||
assert.notStrictEqual(a, b, 'each render is a fresh node')
|
||||
assert.strictEqual(a.textContent, 'a')
|
||||
assert.strictEqual(b.textContent, 'b')
|
||||
})
|
||||
|
||||
test('nested templates embed as nodes', () => {
|
||||
const inner = html`<em>hi</em>`
|
||||
const el = html`<p>${inner}</p>`
|
||||
assert.strictEqual(el.querySelector('em'), inner)
|
||||
})
|
||||
|
||||
test('arrays render as siblings', () => {
|
||||
const items = ['a', 'b', 'c'].map((x) => html`<li>${x}</li>`)
|
||||
const el = html`<ul>${items}</ul>`
|
||||
assert.strictEqual(el.querySelectorAll('li').length, 3)
|
||||
assert.strictEqual(el.textContent, 'abc')
|
||||
})
|
||||
|
||||
test('null and undefined children render as nothing', () => {
|
||||
const el = html`<p>${null}${undefined}ok</p>`
|
||||
assert.strictEqual(el.textContent, 'ok')
|
||||
})
|
||||
|
||||
test('numbers and booleans stringify', () => {
|
||||
const el = html`<p>${42} ${false}</p>`
|
||||
assert.strictEqual(el.textContent, '42 false')
|
||||
})
|
||||
|
||||
test('raw() parses vouched HTML', () => {
|
||||
const el = html`<p>${raw('<em>hi</em>')}</p>`
|
||||
assert.ok(el.querySelector('em'))
|
||||
})
|
||||
|
||||
test('unquoted attribute holes', () => {
|
||||
const el = html`<div class=${'a b'}></div>`
|
||||
assert.strictEqual(el.getAttribute('class'), 'a b')
|
||||
})
|
||||
|
||||
test('quoted attribute holes compose with static text', () => {
|
||||
const el = html`<div class="btn ${'primary'} lg"></div>`
|
||||
assert.strictEqual(el.getAttribute('class'), 'btn primary lg')
|
||||
})
|
||||
|
||||
test('boolean attributes toggle', () => {
|
||||
const on = html`<input disabled=${true} />`
|
||||
const off = html`<input disabled=${false} />`
|
||||
assert.ok(on.hasAttribute('disabled'))
|
||||
assert.strictEqual(on.disabled, true)
|
||||
assert.ok(!off.hasAttribute('disabled'))
|
||||
})
|
||||
|
||||
test('event handlers attach as properties', () => {
|
||||
let clicks = 0
|
||||
const el = html`<button onclick=${() => clicks++}>go</button>`
|
||||
assert.strictEqual(el.getAttribute('onclick'), null, 'no handler serialized to markup')
|
||||
el.click()
|
||||
assert.strictEqual(clicks, 1)
|
||||
})
|
||||
|
||||
test('spread objects set attributes and handlers', () => {
|
||||
let clicks = 0
|
||||
const el = html`<div ${{ class: 'x', hidden: true, onclick: () => clicks++ }}></div>`
|
||||
assert.strictEqual(el.getAttribute('class'), 'x')
|
||||
assert.ok(el.hasAttribute('hidden'))
|
||||
el.click()
|
||||
assert.strictEqual(clicks, 1)
|
||||
})
|
||||
|
||||
test('value attribute also sets the property', () => {
|
||||
const el = html`<input value=${'typed'} />`
|
||||
assert.strictEqual(el.value, 'typed')
|
||||
})
|
||||
|
||||
test('multiple roots return a fragment', () => {
|
||||
const frag = html`<li>a</li><li>b</li>`
|
||||
assert.strictEqual(frag.nodeType, 11)
|
||||
assert.strictEqual(frag.childNodes.length, 2)
|
||||
})
|
||||
|
||||
test('leading/trailing whitespace still unwraps a single root', () => {
|
||||
const el = html`
|
||||
<p>hi</p>
|
||||
`
|
||||
assert.strictEqual(el.tagName, 'P')
|
||||
})
|
||||
|
||||
test('document-level roots survive parsing, attributes included', () => {
|
||||
// <template> silently drops <body>/<head>/<html>; we must not
|
||||
const el = html`<body class=${'app'}><h1>${'hi'}</h1></body>`
|
||||
assert.strictEqual(el.tagName, 'BODY')
|
||||
assert.strictEqual(el.getAttribute('class'), 'app')
|
||||
assert.strictEqual(el.querySelector('h1').textContent, 'hi')
|
||||
})
|
||||
|
||||
test('re-render + morph updates in place and keeps handlers', () => {
|
||||
let clicks = 0
|
||||
const view = (n) => html`<div><button onclick=${() => clicks++}>count ${n}</button></div>`
|
||||
const tree = view(0)
|
||||
document.body.appendChild(tree)
|
||||
|
||||
morph(tree, view(1))
|
||||
assert.strictEqual(tree.textContent, 'count 1', 'text updated')
|
||||
tree.querySelector('button').click()
|
||||
assert.strictEqual(clicks, 1, 'handler from new tree is live')
|
||||
document.body.removeChild(tree)
|
||||
})
|
||||
|
||||
test('server and browser renderers agree on simple markup', async () => {
|
||||
// spot check: identical template, identical serialization
|
||||
const { default: serverHtml } = await import('../server.js')
|
||||
const browserEl = html`<p class=${'x'}>hi ${'there'}</p>`
|
||||
const serverStr = serverHtml`<p class=${'x'}>hi ${'there'}</p>`
|
||||
assert.strictEqual(browserEl.outerHTML, serverStr.toString())
|
||||
})
|
||||
Reference in New Issue
Block a user