diff --git a/package.json b/package.json index 399dba4..522d36d 100644 --- a/package.json +++ b/package.json @@ -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" + } } diff --git a/packages/html/browser.js b/packages/html/browser.js index 65c97db..0aef7f0 100644 --- a/packages/html/browser.js +++ b/packages/html/browser.js @@ -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 +// 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 '}
` + assert.strictEqual(el.querySelector('script'), null) + assert.strictEqual(el.textContent, '') +}) + +test('the same call site reuses its parsed template', () => { + const view = (x) => html`${x}` + 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`hi` + const el = html`${inner}
` + assert.strictEqual(el.querySelector('em'), inner) +}) + +test('arrays render as siblings', () => { + const items = ['a', 'b', 'c'].map((x) => html`${null}${undefined}ok
` + assert.strictEqual(el.textContent, 'ok') +}) + +test('numbers and booleans stringify', () => { + const el = html`${42} ${false}
` + assert.strictEqual(el.textContent, '42 false') +}) + +test('raw() parses vouched HTML', () => { + const el = html`${raw('hi')}
` + assert.ok(el.querySelector('em')) +}) + +test('unquoted attribute holes', () => { + const el = html`` + assert.strictEqual(el.getAttribute('class'), 'a b') +}) + +test('quoted attribute holes compose with static text', () => { + const el = html`` + assert.strictEqual(el.getAttribute('class'), 'btn primary lg') +}) + +test('boolean attributes toggle', () => { + const on = html`` + const off = html`` + 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`` + 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`hi
+ ` + assert.strictEqual(el.tagName, 'P') +}) + +test('document-level roots survive parsing, attributes included', () => { + // silently drops //; we must not + const el = html`hi ${'there'}
` + const serverStr = serverHtml`hi ${'there'}
` + assert.strictEqual(browserEl.outerHTML, serverStr.toString()) +})