More tests, more bugs, more fixes

This commit is contained in:
Tony Garnock-Jones 2024-06-04 14:13:56 +02:00
parent a8fd5aa2fe
commit 626194fc99
2 changed files with 166 additions and 38 deletions

View File

@ -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 = `<x-dummy ${renderFragment(vs[n], false).join('')}></x-dummy>`;
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<HtmlFragment>) {
update(template: HtmlFragmentInstance, variableParts: Array<HtmlFragment>) {
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;
};
}

View File

@ -20,6 +20,11 @@ describe('basic templating', () => {
compareHTML(template()`<x>${y}</x>`, '<x>abc</x>');
});
it('should substitute multiple strings', () => {
const y = 'abc';
compareHTML(template()`<x>${y} ${y} ${y}</x>`, '<x>abc abc abc</x>');
});
it('should substitute a node', () => {
const y = template()`<z>q</z>`;
compareHTML(template()`<x>${y}</x>`, '<x><z>q</z></x>');
@ -41,4 +46,57 @@ describe('basic templating', () => {
compareHTML(template()`<q><x t="${v()}">${ps.map(p => p())}</x></q>`,
'<q><x t="aaa">123234</x></q>');
});
it('should substitute into attributes, with children', () => {
const v = () => 'aaa';
const ps = [() => '123', () => '234'];
const f = 'z';
compareHTML(template()`<q><x t="${v()}">${f}</x>${ps.map(p => p())}</q>`,
'<q><x t="aaa">z</x>123234</q>');
});
it('should substitute into attributes, with children and whitespace', () => {
const v = () => 'aaa';
const ps = [() => '123', () => '234'];
const f = 'z';
compareHTML(template()`<q><x ttt="${v()}">${f}</x>
${ps.map(p => p())}
</q>`,
`<q><x ttt="aaa">z</x>
123234
</q>`);
});
it('example from paradise.js', () => {
const pieces = ['C', 'D'];
compareHTML(template()`<p class="event">
<span class="timestamp" title="${'A'}">${'B'}</span>
${pieces.map(p => p)}
</p>`,
`<p class="event">
<span class="timestamp" title="A">B</span>
CD
</p>`);
});
it('should be callable multiple times', () => {
const v = () => 'aaa';
const ps = [() => '123', () => '234', () => '345'];
const expected = '<q><x t="aaa">123234345</x></q>';
const t = template();
compareHTML(t`<q><x t="${v()}">${ps.map(p => p())}</x></q>`, expected);
compareHTML(t`<q><x t="${v()}">${ps.map(p => p())}</x></q>`, expected);
compareHTML(t`<q><x t="${v()}">${ps.map(p => p())}</x></q>`, expected);
});
it('should be callable multiple times with extra items', () => {
const v = () => 'aaa';
const t = template();
const ps = [() => '1'];
compareHTML(t`<q><x t="${v()}">${ps.map(p => p())}</x></q>`, '<q><x t="aaa">1</x></q>');
ps.push(() => '2');
compareHTML(t`<q><x t="${v()}">${ps.map(p => p())}</x></q>`, '<q><x t="aaa">12</x></q>');
ps.push(() => '3');
compareHTML(t`<q><x t="${v()}">${ps.map(p => p())}</x></q>`, '<q><x t="aaa">123</x></q>');
});
});