syndicate-js/packages/html2/src/html.ts

227 lines
8.6 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, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
}
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;
}
type PlaceholderAction = (variableParts: HtmlFragment[], topNodes: ChildNode[]) => void;
function nodeInserter(n: number, path: number[]): PlaceholderAction {
return (vs, topNodes) => {
const node = followPath(topNodes, path);
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, path: number[]): PlaceholderAction {
return (vs, topNodes) => {
const node = followPath(topNodes, path);
const e = document.createElement('template');
e.innerHTML = `<x-dummy ${renderFragment(vs[n], true).join('')}></x-dummy>`;
Array.from(e.attributes).forEach(a =>
(node as Element).setAttribute(a.name, a.value));
};
}
function attributeValueInserter(
attrName: string,
constantParts: string[],
placeholders: number[],
path: number[],
): PlaceholderAction {
return (vs, topNodes) => {
const node = followPath(topNodes, path);
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: 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 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) {
const placeholder = document.createElement('x-placeholder');
n.parentNode?.insertBefore(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(nodeInserter(n, currentPath));
});
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(attributesInserter(n, currentPath));
} else {
const { constantParts, placeholders } =
splitByPlaceholders(attr.value);
if (constantParts.length !== 1) {
this.placeholderActions.push(attributeValueInserter(
attrName,
constantParts,
placeholders,
currentPath));
}
}
}
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;
};
}