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

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, "&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 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;