181 lines
5.8 KiB
TypeScript
181 lines
5.8 KiB
TypeScript
/// SPDX-License-Identifier: GPL-3.0-or-later
|
|
/// SPDX-FileCopyrightText: Copyright © 2016-2023 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 contextElementName: string | undefined;
|
|
readonly pieces: Array<HtmlFragment>;
|
|
|
|
constructor(pieces: Array<HtmlFragment> = [], contextElementName?: string) {
|
|
this.contextElementName = contextElementName;
|
|
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 = {};
|
|
const source = this.toString(nodeMap);
|
|
if (this.contextElementName !== void 0) {
|
|
n.innerHTML = `<${this.contextElementName}>${source}</${this.contextElementName}>`;
|
|
} else {
|
|
n.innerHTML = source;
|
|
}
|
|
for (const p of Array.from(n.content.querySelectorAll('placeholder'))) {
|
|
const e = nodeMap[p.id];
|
|
if (e) {
|
|
p.parentNode!.insertBefore(e, p);
|
|
p.remove();
|
|
}
|
|
}
|
|
if (this.contextElementName !== void 0) {
|
|
return Array.from(n.content.firstElementChild!.childNodes);
|
|
} else {
|
|
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 type HtmlFragmentsTemplate =
|
|
(constantParts: TemplateStringsArray, ... variableParts: Array<HtmlFragment>) => HtmlFragments;
|
|
|
|
export type NodeTemplate<N extends ChildNode> =
|
|
(constantParts: TemplateStringsArray, ... variableParts: Array<HtmlFragment>) => N;
|
|
|
|
export interface TagTemplate {
|
|
tag(): NodeTemplate<Element>;
|
|
tag<N extends ChildNode>(c: { new (...args: any): N }): NodeTemplate<N>;
|
|
};
|
|
|
|
export function makeTemplate(
|
|
contextElementName?: string,
|
|
): HtmlFragmentsTemplate & TagTemplate {
|
|
const templater = (constantParts: TemplateStringsArray, ... variableParts: Array<HtmlFragment>) => {
|
|
const pieces: Array<HtmlFragment> = [];
|
|
function pushConst(i: number) {
|
|
const r0 = constantParts.raw[i];
|
|
const r = i === 0 ? r0.trimLeft() : r0;
|
|
if (r) pieces.push(r);
|
|
}
|
|
pushConst(0);
|
|
variableParts.forEach((vp, vpIndex) => {
|
|
flattenInto(pieces, vp, { escapeStrings: true });
|
|
pushConst(vpIndex + 1);
|
|
});
|
|
return new HtmlFragments(pieces, contextElementName);
|
|
};
|
|
templater.tag = <N extends ChildNode>(c?: { new (...args: any): N }) =>
|
|
(constantParts: TemplateStringsArray, ... variableParts: Array<HtmlFragment>): any => {
|
|
const n = templater(constantParts, ... variableParts).node();
|
|
if (n instanceof (c ?? Element)) {
|
|
return n;
|
|
} else {
|
|
throw new TypeError(`Template generated ${n}, but instance of ${c} was expected`);
|
|
}
|
|
};
|
|
return templater;
|
|
}
|
|
|
|
export const template = makeTemplate();
|
|
|
|
export function raw(str: string, contextElementName?: string) {
|
|
return new HtmlFragments([str], contextElementName);
|
|
}
|
|
|
|
export default template;
|