From 626194fc996a12c3341179b809caafaee962b7ed Mon Sep 17 00:00:00 2001 From: Tony Garnock-Jones Date: Tue, 4 Jun 2024 14:13:56 +0200 Subject: [PATCH] More tests, more bugs, more fixes --- packages/html2/src/html.ts | 146 +++++++++++++++++++++++-------- packages/html2/test/html.test.ts | 58 ++++++++++++ 2 files changed, 166 insertions(+), 38 deletions(-) diff --git a/packages/html2/src/html.ts b/packages/html2/src/html.ts index a324512..dc14a4d 100644 --- a/packages/html2/src/html.ts +++ b/packages/html2/src/html.ts @@ -58,10 +58,31 @@ function followPath(topNodes: ChildNode[], path: number[]): Node { return n; } -type PlaceholderAction = (variableParts: HtmlFragment[], node: Node) => void; +type PlaceholderState = { [key: string]: any }; -function nodeInserter(n: number): PlaceholderAction { - return (vs, node) => { +interface PlaceholderAction { + reset(state: PlaceholderState, node: Node): void; + act(state: PlaceholderState, variableParts: HtmlFragment[], node: Node): void; +} + +class NodeInserter implements PlaceholderAction { + constructor(public variablePartIndex: number) {} + + reset(state: PlaceholderState, node: Node) { + if ('nodeCount' in state) { + const previouslyInsertedNodes = state.nodeCount as number; + node.parentNode?.insertBefore(makePlaceholder(), node); + for (let n = 0; n < previouslyInsertedNodes; n++) { + const nextNode = node.nextSibling; + node.parentNode?.removeChild(node); + if (nextNode === null) break; + node = nextNode; + } + } + state.nodeCount = 0; + } + + act(state: PlaceholderState, vs: HtmlFragment[], node: Node): void { function walk(f: HtmlFragment) { if (Array.isArray(f)) { f.forEach(walk); @@ -88,35 +109,80 @@ function nodeInserter(n: number): PlaceholderAction { } } node.parentNode?.insertBefore(newNode, node); + state.nodeCount++; } } - walk(vs[n]); + walk(vs[this.variablePartIndex]); node.parentNode?.removeChild(node); - }; + } } -function attributesInserter(n: number): PlaceholderAction { - return (vs, node) => { +class AttributesInserter implements PlaceholderAction { + constructor(public variablePartIndex: number) {} + + reset(state: PlaceholderState, node: Node): void { + const attrNames: string[] = (state.attrNames ?? []); + attrNames.forEach(n => (node as Element).removeAttribute(n)); + state.attrNames = []; + } + + act(state: PlaceholderState, vs: HtmlFragment[], node: Node): void { const e = document.createElement('template'); + const n = this.variablePartIndex; e.innerHTML = ``; - Array.from(e.content.firstElementChild!.attributes).forEach(a => - (node as Element).setAttribute(a.name, a.value)); - }; + Array.from(e.content.firstElementChild!.attributes).forEach(a => { + state.attrNames.push(a.name); + (node as Element).setAttribute(a.name, a.value); + }); + } } -function attributeValueInserter( - attrName: string, - constantParts: string[], - placeholders: number[], -): PlaceholderAction { - return (vs, node) => { - const pieces = [constantParts[0]]; - placeholders.forEach((n, i) => { +class AttributeValueInserter implements PlaceholderAction { + constructor( + public attrName: string, + public constantParts: string[], + public placeholders: number[], + ) {} + + reset(_state: PlaceholderState, _node: Node): void {} + + act(_state: PlaceholderState, vs: HtmlFragment[], node: Node): void { + const pieces = [this.constantParts[0]]; + this.placeholders.forEach((n, i) => { pieces.push(...renderFragment(vs[n], false)); - pieces.push(constantParts[i + 1]); + pieces.push(this.constantParts[i + 1]); }); - (node as Element).setAttribute(attrName, pieces.join('')); - }; + (node as Element).setAttribute(this.attrName, pieces.join('')); + } +} + +class CompoundAction implements PlaceholderAction { + constructor(public actions: PlaceholderAction[]) {} + + reset(states: PlaceholderState, n: Node) { + this.actions.forEach((a, i) => { + const state = states[i] ??= {}; + a.reset(state, n); + }); + } + + act(states: PlaceholderState, vs: HtmlFragment[], n: Node) { + this.actions.forEach((a, i) => { + const state = states[i] ??= {}; + a.act(state, vs, n); + }); + } +} + +export class HtmlFragmentInstance { + state: { [path: string]: PlaceholderState } = {}; + constructor( + public container: ChildNode[], + ) {} +} + +function makePlaceholder(): Element { + return document.createElement('x-placeholder'); } export class HtmlFragmentBuilder { @@ -138,8 +204,7 @@ export class HtmlFragmentBuilder { splitByPlaceholders(n.textContent ?? ''); constantParts.forEach((c, i) => { if (i > 0) { - const placeholder = document.createElement('x-placeholder'); - n.parentNode?.insertBefore(placeholder, n); + n.parentNode?.insertBefore(makePlaceholder(), n); } n.parentNode?.insertBefore(document.createTextNode(c), n); }); @@ -147,10 +212,10 @@ export class HtmlFragmentBuilder { n.parentNode?.removeChild(n); placeholders.forEach((n, i) => { const currentPath = path.slice(); - currentPath[currentPath.length - 1] = i * 2 + 1; + currentPath[currentPath.length - 1] += i * 2 + 1; this.placeholderActions.push({ path: currentPath, - action: nodeInserter(n), + action: new NodeInserter(n), }); }); path[path.length - 1] += constantParts.length + placeholders.length; @@ -167,12 +232,12 @@ export class HtmlFragmentBuilder { e.removeAttributeNode(attr); i--; const n = parseInt(nameIsPlaceholder[1], 10); - actions.push(attributesInserter(n)); + actions.push(new AttributesInserter(n)); } else { const { constantParts, placeholders } = splitByPlaceholders(attr.value); if (constantParts.length !== 1) { - actions.push(attributeValueInserter( + actions.push(new AttributeValueInserter( attrName, constantParts, placeholders)); @@ -182,7 +247,7 @@ export class HtmlFragmentBuilder { if (actions.length) { this.placeholderActions.push({ path: path.slice(), - action: (vs, n) => actions.forEach(a => a(vs, n)), + action: actions.length === 1 ? actions[0] : new CompoundAction(actions), }); } } @@ -217,14 +282,19 @@ export class HtmlFragmentBuilder { walk(this.template.content); } - clone(): ChildNode[] { - return Array.from( - (this.template.cloneNode(true) as HTMLTemplateElement).content.childNodes); + clone(): HtmlFragmentInstance { + return new HtmlFragmentInstance(Array.from( + (this.template.cloneNode(true) as HTMLTemplateElement).content.childNodes)); } - update(template: ChildNode[], variableParts: Array) { + update(template: HtmlFragmentInstance, variableParts: Array) { this.placeholderActions.forEach(({ path, action }) => { - action(variableParts, followPath(template, path)); + const state = template.state[path.map(n => '' + n).join('.')] ??= {}; + action.reset(state, followPath(template.container, path)); + }); + this.placeholderActions.forEach(({ path, action }) => { + const state = template.state[path.map(n => '' + n).join('.')] ??= {}; + action.act(state, variableParts, followPath(template.container, path)); }); } } @@ -238,17 +308,17 @@ export type HtmlTemplater = => ChildNode[]; export function template(): HtmlTemplater { - let container: ChildNode[] | null = null; + let instance: HtmlFragmentInstance | null = null; return (constantParts, ... variableParts) => { let b = templateCache.get(constantParts); if (b === void 0) { b = new HtmlFragmentBuilder(constantParts); templateCache.set(constantParts, b); } - if (container === null) { - container = b.clone(); + if (instance === null) { + instance = b.clone(); } - b.update(container, variableParts); - return container; + b.update(instance, variableParts); + return instance.container; }; } diff --git a/packages/html2/test/html.test.ts b/packages/html2/test/html.test.ts index 1fa4205..6a85c50 100644 --- a/packages/html2/test/html.test.ts +++ b/packages/html2/test/html.test.ts @@ -20,6 +20,11 @@ describe('basic templating', () => { compareHTML(template()`${y}`, 'abc'); }); + it('should substitute multiple strings', () => { + const y = 'abc'; + compareHTML(template()`${y} ${y} ${y}`, 'abc abc abc'); + }); + it('should substitute a node', () => { const y = template()`q`; compareHTML(template()`${y}`, 'q'); @@ -41,4 +46,57 @@ describe('basic templating', () => { compareHTML(template()`${ps.map(p => p())}`, '123234'); }); + + it('should substitute into attributes, with children', () => { + const v = () => 'aaa'; + const ps = [() => '123', () => '234']; + const f = 'z'; + compareHTML(template()`${f}${ps.map(p => p())}`, + 'z123234'); + }); + + it('should substitute into attributes, with children and whitespace', () => { + const v = () => 'aaa'; + const ps = [() => '123', () => '234']; + const f = 'z'; + compareHTML(template()`${f} + ${ps.map(p => p())} + `, + `z + 123234 + `); + }); + + it('example from paradise.js', () => { + const pieces = ['C', 'D']; + compareHTML(template()`

+ ${'B'} + ${pieces.map(p => p)} +

`, + `

+ B + CD +

`); + }); + + it('should be callable multiple times', () => { + const v = () => 'aaa'; + const ps = [() => '123', () => '234', () => '345']; + const expected = '123234345'; + const t = template(); + compareHTML(t`${ps.map(p => p())}`, expected); + compareHTML(t`${ps.map(p => p())}`, expected); + compareHTML(t`${ps.map(p => p())}`, expected); + }); + + it('should be callable multiple times with extra items', () => { + const v = () => 'aaa'; + const t = template(); + const ps = [() => '1']; + compareHTML(t`${ps.map(p => p())}`, '1'); + ps.push(() => '2'); + compareHTML(t`${ps.map(p => p())}`, '12'); + ps.push(() => '3'); + compareHTML(t`${ps.map(p => p())}`, '123'); + }); });