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

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, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
}
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;