240 lines
8.3 KiB
TypeScript
240 lines
8.3 KiB
TypeScript
/// SPDX-License-Identifier: GPL-3.0-or-later
|
|
/// SPDX-FileCopyrightText: Copyright © 2016-2024 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 onePlaceholderRe = new RegExp(`x-${tag}-(\\d+)-${tag}-x`);
|
|
const allPlaceholdersRe = 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 = allPlaceholdersRe.exec(s)) !== null) {
|
|
constantParts.push(s.substring(lastConstantStart, match.index));
|
|
placeholders.push(parseInt(match[1], 10));
|
|
lastConstantStart = allPlaceholdersRe.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;
|
|
}
|
|
|
|
type PlaceholderAction = (variableParts: HtmlFragment[], node: Node) => void;
|
|
|
|
function nodeInserter(n: number): PlaceholderAction {
|
|
return (vs, node) => {
|
|
function walk(f: HtmlFragment) {
|
|
if (Array.isArray(f)) {
|
|
f.forEach(walk);
|
|
} else {
|
|
let newNode: Node;
|
|
switch (typeof f) {
|
|
case 'string': newNode = document.createTextNode(f); break;
|
|
case 'number': newNode = document.createTextNode('' + f); break;
|
|
default: newNode = f; break;
|
|
}
|
|
node.parentNode?.insertBefore(newNode, node);
|
|
}
|
|
}
|
|
walk(vs[n]);
|
|
node.parentNode?.removeChild(node);
|
|
};
|
|
}
|
|
|
|
function attributesInserter(n: number): PlaceholderAction {
|
|
return (vs, node) => {
|
|
const e = document.createElement('template');
|
|
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));
|
|
};
|
|
}
|
|
|
|
function attributeValueInserter(
|
|
attrName: string,
|
|
constantParts: string[],
|
|
placeholders: number[],
|
|
): PlaceholderAction {
|
|
return (vs, node) => {
|
|
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(''));
|
|
};
|
|
}
|
|
|
|
export class HtmlFragmentBuilder {
|
|
template: HTMLTemplateElement = document.createElement('template');
|
|
placeholderActions: { path: number[], action: PlaceholderAction }[] = [];
|
|
|
|
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 indexTextNode(n: Text, path: number[]): ChildNode | null {
|
|
const { constantParts, placeholders } =
|
|
splitByPlaceholders(n.textContent ?? '');
|
|
constantParts.forEach((c, i) => {
|
|
if (i > 0) {
|
|
const placeholder = document.createElement('x-placeholder');
|
|
n.parentNode?.insertBefore(placeholder, n);
|
|
}
|
|
n.parentNode?.insertBefore(document.createTextNode(c), n);
|
|
});
|
|
const 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({
|
|
path: currentPath,
|
|
action: nodeInserter(n),
|
|
});
|
|
});
|
|
path[path.length - 1] += constantParts.length + placeholders.length;
|
|
return nextN;
|
|
}
|
|
|
|
private indexElement(e: Element, path: number[]) {
|
|
const actions: PlaceholderAction[] = [];
|
|
for (let i = 0; i < e.attributes.length; i++) {
|
|
const attr = e.attributes[i];
|
|
const attrName = attr.name;
|
|
const nameIsPlaceholder = attrName.match(onePlaceholderRe);
|
|
if (nameIsPlaceholder !== null) {
|
|
e.removeAttributeNode(attr);
|
|
i--;
|
|
const n = parseInt(nameIsPlaceholder[1], 10);
|
|
actions.push(attributesInserter(n));
|
|
} else {
|
|
const { constantParts, placeholders } =
|
|
splitByPlaceholders(attr.value);
|
|
if (constantParts.length !== 1) {
|
|
actions.push(attributeValueInserter(
|
|
attrName,
|
|
constantParts,
|
|
placeholders));
|
|
}
|
|
}
|
|
}
|
|
if (actions.length) {
|
|
this.placeholderActions.push({
|
|
path: path.slice(),
|
|
action: (vs, n) => actions.forEach(a => a(vs, n)),
|
|
});
|
|
}
|
|
}
|
|
|
|
private indexPlaceholders() {
|
|
const path: number[] = [];
|
|
const walk = (parentNode: ParentNode) => {
|
|
path.push(0);
|
|
let nextN = parentNode.firstChild;
|
|
while (nextN !== null) {
|
|
const n = nextN;
|
|
switch (n.nodeType) {
|
|
case Node.TEXT_NODE:
|
|
nextN = this.indexTextNode(n as Text, path);
|
|
break;
|
|
case Node.ELEMENT_NODE: {
|
|
const e = n as Element;
|
|
this.indexElement(e, path)
|
|
walk(e);
|
|
nextN = e.nextSibling;
|
|
path[path.length - 1]++;
|
|
break;
|
|
}
|
|
default:
|
|
nextN = n.nextSibling;
|
|
path[path.length - 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(({ path, action }) => {
|
|
action(variableParts, followPath(template, path));
|
|
});
|
|
}
|
|
}
|
|
|
|
// 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;
|
|
};
|
|
}
|