/// SPDX-License-Identifier: GPL-3.0-or-later /// SPDX-FileCopyrightText: Copyright © 2016-2024 Tony Garnock-Jones import { randomId } from "@syndicate-lang/core"; export function escape(s: string): string { return s .replace(/&/g, "&") .replace(//g, ">") .replace(/"/g, """) .replace(/'/g, "'"); } export type HtmlFragment = string | number | Node | Array; 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; case 'object': if (f !== null && 'nodeType' in f) { newNode = f; break; } /* fall through */ default: { let info; try { info = '' + f; } catch (_e) { info = (f as any).toString(); } newNode = document.createTextNode(``); 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 = ``; 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) { 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(); export type HtmlTemplater = (constantParts: TemplateStringsArray, ... variableParts: Array) => 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; }; }