145 lines
4.2 KiB
TypeScript
145 lines
4.2 KiB
TypeScript
/// SPDX-License-Identifier: GPL-3.0-or-later
|
|
/// SPDX-FileCopyrightText: Copyright © 2016-2021 Tony Garnock-Jones <tonyg@leastfixedpoint.com>
|
|
|
|
let nextId = 1;
|
|
|
|
export function escape(s: string): string {
|
|
return s
|
|
.replace(/&/g, "&")
|
|
.replace(/</g, "<")
|
|
.replace(/>/g, ">")
|
|
.replace(/"/g, """)
|
|
.replace(/'/g, "'");
|
|
}
|
|
|
|
export type PlaceholderNodeMap = { [id: string]: Node };
|
|
|
|
export type HtmlFragment = string | number | Array<HtmlFragment> | Node | FlattenInto;
|
|
|
|
export interface FlattenInto {
|
|
flattenInto(acc: Array<HtmlFragment>, options?: FlattenIntoOptions): void;
|
|
}
|
|
|
|
export interface FlattenIntoOptions {
|
|
nodeMap?: PlaceholderNodeMap;
|
|
escapeStrings?: boolean;
|
|
}
|
|
|
|
export function isFlattenInto(x: any): x is FlattenInto {
|
|
return typeof x === 'object' && x !== null && typeof x.flattenInto === 'function';
|
|
}
|
|
|
|
export class HtmlFragments implements FlattenInto {
|
|
readonly pieces: Array<HtmlFragment>;
|
|
|
|
constructor(pieces: Array<HtmlFragment> = []) {
|
|
this.pieces = pieces;
|
|
}
|
|
|
|
appendTo(n: ParentNode): Array<Node> {
|
|
const ns = [... this.nodes()];
|
|
n.append(... ns);
|
|
return ns;
|
|
}
|
|
|
|
replaceContentOf(n: Element) {
|
|
n.innerHTML = '';
|
|
return this.appendTo(n);
|
|
}
|
|
|
|
node(): ChildNode {
|
|
return this.nodes()[0];
|
|
}
|
|
|
|
nodes(): Array<ChildNode> {
|
|
let n = document.createElement('template');
|
|
const nodeMap: PlaceholderNodeMap = {};
|
|
n.innerHTML = this.toString(nodeMap);
|
|
for (const p of Array.from(n.querySelectorAll('placeholder'))) {
|
|
const e = nodeMap[p.id];
|
|
if (e) {
|
|
p.parentNode!.insertBefore(e, p);
|
|
p.remove();
|
|
}
|
|
}
|
|
return Array.from(n.content.childNodes);
|
|
}
|
|
|
|
toString(nodeMap?: PlaceholderNodeMap) {
|
|
const allPieces: Array<string> = [];
|
|
this.flattenInto(allPieces, { nodeMap });
|
|
return allPieces.join('');
|
|
}
|
|
|
|
flattenInto(acc: Array<string>, options: FlattenIntoOptions) {
|
|
flattenInto(acc, this.pieces, { ... options, escapeStrings: false });
|
|
}
|
|
|
|
join(pieces: Array<HtmlFragment>): Array<HtmlFragment> {
|
|
return join(pieces, this);
|
|
}
|
|
}
|
|
|
|
export function flattenInto(acc: Array<HtmlFragment>,
|
|
p: HtmlFragment,
|
|
options: FlattenIntoOptions = {})
|
|
{
|
|
switch (typeof p) {
|
|
case 'string': acc.push((options.escapeStrings ?? true) ? escape(p) : p); break;
|
|
case 'number': acc.push('' + p); break;
|
|
case 'object':
|
|
if (isFlattenInto(p)) {
|
|
p.flattenInto(acc, { nodeMap: options.nodeMap });
|
|
} else if (Array.isArray(p)) {
|
|
p.forEach(q => flattenInto(acc, q, options));
|
|
} else if (typeof Node !== 'undefined' && p instanceof Node) {
|
|
if (options.nodeMap !== void 0) {
|
|
const id = `__SYNDICATE__html__${nextId++}`;
|
|
options.nodeMap[id] = p;
|
|
acc.push(`<placeholder id="${id}"></placeholder>`);
|
|
} else {
|
|
acc.push(p);
|
|
}
|
|
}
|
|
break;
|
|
default:
|
|
((_n: never) => {})(p);
|
|
}
|
|
}
|
|
|
|
export function join(pieces: Array<HtmlFragment>, separator: HtmlFragment): Array<HtmlFragment> {
|
|
if (pieces.length <= 1) {
|
|
return [];
|
|
} else {
|
|
const result = [pieces[0]];
|
|
for (let i = 1; i < pieces.length; i++) {
|
|
result.push(separator);
|
|
result.push(pieces[i]);
|
|
}
|
|
return result;
|
|
}
|
|
}
|
|
|
|
export function template(
|
|
constantParts: TemplateStringsArray,
|
|
... variableParts: Array<HtmlFragment>): HtmlFragments
|
|
{
|
|
const pieces: Array<HtmlFragment> = [];
|
|
function pushConst(i: number) {
|
|
const r = constantParts.raw[i].trimLeft();
|
|
if (r) pieces.push(r);
|
|
}
|
|
pushConst(0);
|
|
variableParts.forEach((vp, vpIndex) => {
|
|
flattenInto(pieces, vp, { escapeStrings: true });
|
|
pushConst(vpIndex + 1);
|
|
});
|
|
return new HtmlFragments(pieces);
|
|
};
|
|
|
|
export function raw(str: string) {
|
|
return new HtmlFragments([str]);
|
|
}
|
|
|
|
export default template;
|