197 lines
8.5 KiB
TypeScript
197 lines
8.5 KiB
TypeScript
/// SPDX-License-Identifier: GPL-3.0-or-later
|
|
/// SPDX-FileCopyrightText: Copyright © 2016-2023 Tony Garnock-Jones <tonyg@leastfixedpoint.com>
|
|
|
|
import { randomId } from "@syndicate-lang/core";
|
|
|
|
export function escape(s: string): string {
|
|
return s
|
|
.replace(/&/g, "&")
|
|
.replace(/</g, "<")
|
|
.replace(/>/g, ">")
|
|
.replace(/"/g, """)
|
|
.replace(/'/g, "'");
|
|
}
|
|
|
|
export type HtmlFragment = string | number | Node | Array<HtmlFragment>;
|
|
|
|
const tag = randomId(8, true);
|
|
const placeholderRe = new RegExp(`X-${tag}-(\\d+)-${tag}-X`, 'g');
|
|
function placeholder(n: number): string {
|
|
return `X-${tag}-${n}-${tag}-X`;
|
|
}
|
|
|
|
function splitByPlaceholders(s: string): { constantParts: string[], placeholders: number[] } {
|
|
let match: RegExpExecArray | null = null;
|
|
let lastConstantStart = 0;
|
|
const constantParts: string[] = [];
|
|
const placeholders: number[] = [];
|
|
while ((match = placeholderRe.exec(s)) !== null) {
|
|
constantParts.push(s.substring(lastConstantStart, match.index));
|
|
placeholders.push(parseInt(match[1], 10));
|
|
lastConstantStart = placeholderRe.lastIndex;
|
|
}
|
|
constantParts.push(s.substring(lastConstantStart));
|
|
return { constantParts, placeholders };
|
|
}
|
|
|
|
function renderFragment(f: HtmlFragment, escapeStrings: boolean): string[] {
|
|
const result: string[] = [];
|
|
function walk(f: HtmlFragment) {
|
|
if (Array.isArray(f)) {
|
|
f.forEach(walk);
|
|
} else {
|
|
switch (typeof f) {
|
|
case 'string': result.push(escapeStrings? escape(f) : f); break;
|
|
case 'number': result.push('' + f); break;
|
|
default: throw new Error("Cannot render Node in attribute context");
|
|
}
|
|
}
|
|
}
|
|
walk(f);
|
|
return result;
|
|
}
|
|
|
|
function followPath(topNodes: ChildNode[], path: number[]): Node {
|
|
let n = topNodes[path[0]];
|
|
for (let j = 1; j < path.length; j++) n = n.childNodes[path[j]];
|
|
return n;
|
|
}
|
|
|
|
export class HtmlFragmentBuilder {
|
|
template: HTMLTemplateElement = document.createElement('template');
|
|
placeholderActions: Array<(variableParts: HtmlFragment[], topNodes: ChildNode[]) => void> = [];
|
|
|
|
constructor(constantParts: TemplateStringsArray) {
|
|
const pieces: string[] = [];
|
|
constantParts.raw.forEach((r, i) => {
|
|
if (i > 0) pieces.push(placeholder(i - 1));
|
|
pieces.push(r);
|
|
});
|
|
this.template.innerHTML = pieces.join('');
|
|
this.indexPlaceholders();
|
|
}
|
|
|
|
private indexPlaceholders() {
|
|
const path: number[] = [];
|
|
const bump = (n: number) => path[path.length - 1] += n;
|
|
const walk = (parentNode: ParentNode) => {
|
|
path.push(0);
|
|
let nextN = parentNode.firstChild;
|
|
while (nextN !== null) {
|
|
const n = nextN;
|
|
switch (n.nodeType) {
|
|
case Node.TEXT_NODE: {
|
|
const { constantParts, placeholders } = splitByPlaceholders(n.textContent ?? '');
|
|
constantParts.forEach((c, i) => {
|
|
if (i > 0) n.parentNode?.insertBefore(document.createElement('placeholder'), n);
|
|
n.parentNode?.insertBefore(document.createTextNode(c), n);
|
|
});
|
|
nextN = n.nextSibling;
|
|
n.parentNode?.removeChild(n);
|
|
placeholders.forEach((n, i) => {
|
|
const currentPath = path.slice();
|
|
currentPath[currentPath.length - 1] = i * 2 + 1;
|
|
this.placeholderActions.push((vs, topNodes) => {
|
|
const node = followPath(topNodes, currentPath);
|
|
function walk(f: HtmlFragment) {
|
|
if (Array.isArray(f)) {
|
|
f.forEach(walk);
|
|
} else {
|
|
switch (typeof f) {
|
|
case 'string': node.parentNode?.insertBefore(document.createTextNode(f), node); break;
|
|
case 'number': node.parentNode?.insertBefore(document.createTextNode('' + f), node); break;
|
|
default: node.parentNode?.insertBefore(f, node); break;
|
|
}
|
|
}
|
|
}
|
|
walk(vs[n]);
|
|
node.parentNode?.removeChild(node);
|
|
});
|
|
});
|
|
bump(constantParts.length + placeholders.length);
|
|
break;
|
|
}
|
|
case Node.ELEMENT_NODE: {
|
|
const currentPath = path.slice();
|
|
const e = n as Element;
|
|
// TODO: hoist all actions for this node into a single action
|
|
for (let i = 0; i < e.attributes.length; i++) {
|
|
const attr = e.attributes[i];
|
|
const attrName = attr.name;
|
|
const nameIsPlaceholder = attrName.match(placeholderRe);
|
|
if (nameIsPlaceholder !== null) {
|
|
e.removeAttributeNode(attr);
|
|
i--;
|
|
const n = parseInt(nameIsPlaceholder[1], 10);
|
|
this.placeholderActions.push((vs, topNodes) => {
|
|
const node = followPath(topNodes, currentPath);
|
|
const e = document.createElement('template');
|
|
e.innerHTML = `<dummy ${renderFragment(vs[n], true).join('')}></dummy>`;
|
|
Array.from(e.attributes).forEach(a =>
|
|
(node as Element).setAttribute(a.name, a.value));
|
|
});
|
|
} else {
|
|
const { constantParts, placeholders } = splitByPlaceholders(attr.value);
|
|
if (constantParts.length !== 1) {
|
|
this.placeholderActions.push((vs, topNodes) => {
|
|
const node = followPath(topNodes, currentPath);
|
|
const pieces = [constantParts[0]];
|
|
placeholders.forEach((n, i) => {
|
|
pieces.push(...renderFragment(vs[n], false));
|
|
pieces.push(constantParts[i + 1]);
|
|
});
|
|
(node as Element).setAttribute(attrName, pieces.join(''));
|
|
});
|
|
}
|
|
}
|
|
}
|
|
walk(e);
|
|
nextN = e.nextSibling;
|
|
bump(1);
|
|
break;
|
|
}
|
|
default:
|
|
nextN = n.nextSibling;
|
|
bump(1);
|
|
break;
|
|
}
|
|
}
|
|
path.pop();
|
|
};
|
|
walk(this.template.content);
|
|
}
|
|
|
|
clone(): ChildNode[] {
|
|
return Array.from(
|
|
(this.template.cloneNode(true) as HTMLTemplateElement).content.childNodes);
|
|
}
|
|
|
|
update(template: ChildNode[], variableParts: Array<HtmlFragment>) {
|
|
this.placeholderActions.forEach(a => a(variableParts, template));
|
|
}
|
|
}
|
|
|
|
// Nifty trick: TemplateStringsArray instances are interned so it makes sense to key a cache
|
|
// based on their object identity!
|
|
const templateCache = new WeakMap<TemplateStringsArray, HtmlFragmentBuilder>();
|
|
|
|
export type HtmlTemplater =
|
|
(constantParts: TemplateStringsArray, ... variableParts: Array<HtmlFragment>)
|
|
=> ChildNode[];
|
|
|
|
export function template(): HtmlTemplater {
|
|
let container: ChildNode[] | 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();
|
|
}
|
|
b.update(container, variableParts);
|
|
return container;
|
|
};
|
|
}
|