diff --git a/packages/html2/src/html.ts b/packages/html2/src/html.ts
index 446b26b..cbc3e8e 100644
--- a/packages/html2/src/html.ts
+++ b/packages/html2/src/html.ts
@@ -1,7 +1,7 @@
/// SPDX-License-Identifier: GPL-3.0-or-later
/// SPDX-FileCopyrightText: Copyright © 2016-2023 Tony Garnock-Jones
-let nextId = 1;
+import { randomId } from "@syndicate-lang/core";
export function escape(s: string): string {
return s
@@ -12,169 +12,185 @@ export function escape(s: string): string {
.replace(/'/g, "'");
}
-export type PlaceholderNodeMap = { [id: string]: Node };
+export type HtmlFragment = string | number | Node | Array;
-export type HtmlFragment = string | number | Array | Node | FlattenInto;
-
-export interface FlattenInto {
- flattenInto(acc: Array, options?: FlattenIntoOptions): void;
+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`;
}
-export interface FlattenIntoOptions {
- nodeMap?: PlaceholderNodeMap;
- escapeStrings?: boolean;
+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 };
}
-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;
-
- constructor(pieces: Array = [], contextElementName?: string) {
- this.contextElementName = contextElementName;
- this.pieces = pieces;
- }
-
- appendTo(n: ParentNode): Array {
- 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 {
- 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}>`;
+function renderFragment(f: HtmlFragment, escapeStrings: boolean): string[] {
+ const result: string[] = [];
+ function walk(f: HtmlFragment) {
+ if (Array.isArray(f)) {
+ f.forEach(walk);
} 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();
+ 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");
}
}
- 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 = [];
- this.flattenInto(allPieces, { nodeMap });
- return allPieces.join('');
- }
-
- flattenInto(acc: Array, options: FlattenIntoOptions) {
- flattenInto(acc, this.pieces, { ... options, escapeStrings: false });
- }
-
- join(pieces: Array): Array {
- return join(pieces, this);
}
+ walk(f);
+ return result;
}
-export function flattenInto(acc: Array,
- 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(``);
- } else {
- acc.push(p);
+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;
+}
+
+export class HtmlFragmentBuilder {
+ template: HTMLTemplateElement = document.createElement('template');
+ placeholderActions: Array<(variableParts: HtmlFragment[], topNodes: ChildNode[]) => void> = [];
+
+ 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) n.parentNode?.insertBefore(document.createElement('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((vs, topNodes) => {
+ const node = followPath(topNodes, currentPath);
+ function walk(f: HtmlFragment) {
+ if (Array.isArray(f)) {
+ f.forEach(walk);
+ } else {
+ switch (typeof f) {
+ case 'string': node.parentNode?.insertBefore(document.createTextNode(f), node); break;
+ case 'number': node.parentNode?.insertBefore(document.createTextNode('' + f), node); break;
+ default: node.parentNode?.insertBefore(f, node); break;
+ }
+ }
+ }
+ walk(vs[n]);
+ node.parentNode?.removeChild(node);
+ });
+ });
+ 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((vs, topNodes) => {
+ const node = followPath(topNodes, currentPath);
+ const e = document.createElement('template');
+ e.innerHTML = ``;
+ Array.from(e.attributes).forEach(a =>
+ (node as Element).setAttribute(a.name, a.value));
+ });
+ } else {
+ const { constantParts, placeholders } = splitByPlaceholders(attr.value);
+ if (constantParts.length !== 1) {
+ this.placeholderActions.push((vs, topNodes) => {
+ const node = followPath(topNodes, currentPath);
+ 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(''));
+ });
+ }
+ }
+ }
+ walk(e);
+ nextN = e.nextSibling;
+ bump(1);
+ break;
+ }
+ default:
+ nextN = n.nextSibling;
+ bump(1);
+ break;
}
}
- break;
- default:
- ((_n: never) => {})(p);
- }
-}
-
-export function join(pieces: Array, separator: HtmlFragment): Array {
- 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) => HtmlFragments;
-
-export type NodeTemplate =
- (constantParts: TemplateStringsArray, ... variableParts: Array) => N;
-
-export interface TagTemplate {
- tag(): NodeTemplate;
- tag(c: { new (...args: any): N }): NodeTemplate;
-};
-
-export function makeTemplate(
- contextElementName?: string,
-): HtmlFragmentsTemplate & TagTemplate {
- const templater = (constantParts: TemplateStringsArray, ... variableParts: Array) => {
- const pieces: Array = [];
- 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 = (c?: { new (...args: any): N }) =>
- (constantParts: TemplateStringsArray, ... variableParts: Array): 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`);
- }
+ path.pop();
};
- return templater;
+ walk(this.template.content);
+ }
+
+ clone(): ChildNode[] {
+ return Array.from(
+ (this.template.cloneNode(true) as HTMLTemplateElement).content.childNodes);
+ }
+
+ update(template: ChildNode[], variableParts: Array) {
+ this.placeholderActions.forEach(a => a(variableParts, template));
+ }
}
-export const template = makeTemplate();
+// Nifty trick: TemplateStringsArray instances are interned so it makes sense to key a cache
+// based on their object identity!
+const templateCache = new WeakMap();
-export function raw(str: string, contextElementName?: string) {
- return new HtmlFragments([str], contextElementName);
+export type HtmlTemplater =
+ (constantParts: TemplateStringsArray, ... variableParts: Array)
+ => 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;
+ };
}
-
-export default template;
diff --git a/packages/html2/src/index.ts b/packages/html2/src/index.ts
index 4b37dae..3604222 100644
--- a/packages/html2/src/index.ts
+++ b/packages/html2/src/index.ts
@@ -2,8 +2,8 @@
/// SPDX-FileCopyrightText: Copyright © 2023 Tony Garnock-Jones
import { Turn, Facet, Dataflow, Dataspace, Ref } from "@syndicate-lang/core";
-import { HtmlFragmentsTemplate, HtmlFragment, template } from "./html";
-export * from "./html";
+import { HtmlTemplater, template } from "./html";
+export { HtmlTemplater, template, HtmlFragment } from "./html";
export assertion type LocationHash(hash: string);
export type LocationHash = ReturnType;
@@ -17,82 +17,55 @@ type Wrapped = {
options?: AddEventListenerOptions | boolean,
};
-export type NodeGenerator = (t: HtmlFragmentsTemplate) => HtmlFragment;
+export type NodeGenerator = (t: HtmlTemplater) => ReturnType;
export class Widget implements EventTarget {
- facet: Facet;
- node: Dataflow.Field;
- callbacks =
- new Map>();
- _parent: Dataflow.Field;
+ readonly facet: Facet;
+ private _node: ChildNode | null = null;
+ parentField: Dataflow.Field;
+ callbacks = new Map>();
+
+ get node(): ChildNode {
+ return this._node!;
+ }
constructor (private nodeGenerator: NodeGenerator) {
this.facet = Turn.activeFacet;
on stop {
- this.node.value?.remove();
+ this.node.remove();
}
- field node: ChildNode | null = null;
- this.node = node;
-
- field parent: ParentNode | null = null;
- this._parent = parent;
+ field parentField: ParentNode | null = null;
+ this.parentField = parentField;
+ const thisTemplate = template();
dataflow {
- const oldNode = this.node.value;
-
- const fragments = template`${this.nodeGenerator(template)}`;
- const newNodes = fragments.nodes();
- if (newNodes.length > 1) {
- throw new Error(`@syndicate-lang/html2: Multiple nodes returned from template: ${fragments.toString()}`);
+ const nodes = nodeGenerator(thisTemplate);
+ if (nodes.length !== 1) {
+ throw new Error(`@syndicate-lang/html2: Expected exactly one node from template`);
}
- const newNode = newNodes[0];
-
- if (oldNode !== newNode) {
- if (oldNode !== null) {
- for (const [type, cbs] of this.callbacks.entries()) {
- for (const entry of cbs.values()) {
- oldNode.removeEventListener(type, entry.wrapped, entry.options);
- }
- }
- }
-
- if (newNode !== null) {
- for (const [type, cbs] of this.callbacks.entries()) {
- for (const entry of cbs.values()) {
- newNode.addEventListener(type, entry.wrapped, entry.options);
- }
- }
- }
-
- const wasFocus = oldNode && document.activeElement === oldNode;
- oldNode?.parentNode?.replaceChild(newNode, oldNode);
- if (wasFocus) {
- if (newNode && newNode instanceof HTMLElement) {
- newNode.focus();
- }
- }
-
- this.node.value = newNode;
+ if (this._node === null) {
+ this._node = nodes[0];
+ } else if (this._node !== nodes[0]) {
+ throw new Error(`@syndicate-lang/html2: Node generator is not stable`);
}
}
dataflow {
- if (this.node.value === null) return;
- const p = this._parent.value;
- if (this.node.value.parentNode !== p) {
+ const p = this.parentField.value;
+ if (this.node.parentNode !== p) {
if (p === null) {
- this.node.value.remove();
+ this.node.remove();
} else {
- p.appendChild(this.node.value);
+ p.appendChild(this.node);
}
}
}
}
get parent(): ParentNode | null {
- return this._parent.value;
+ return this.parentField.value;
}
set parent(p: string | ParentNode | null) {
@@ -103,7 +76,7 @@ export class Widget implements EventTarget {
if (typeof p === 'string') {
p = wrt.querySelector(p);
}
- this._parent.value = p;
+ this.parentField.value = p;
return this;
}
@@ -145,11 +118,11 @@ export class Widget implements EventTarget {
};
cbs.set(callback, entry);
- this.node.value?.addEventListener(type, entry.wrapped, options);
+ this.node.addEventListener(type, entry.wrapped, options);
}
dispatchEvent(event: Event): boolean {
- return !this.node.value || this.node.value.dispatchEvent(event);
+ return this.node.dispatchEvent(event);
}
removeEventListener(
@@ -165,7 +138,7 @@ export class Widget implements EventTarget {
const r = cbs.get(callback);
if (r === void 0) return;
- this.node.value?.removeEventListener(type, r.wrapped, options);
+ this.node.removeEventListener(type, r.wrapped, options);
cbs.delete(callback);
if (cbs.size === 0) this.callbacks.delete(type);
@@ -185,35 +158,43 @@ export class ValueWidget extends Widget {
field valueAsNumber: number = NaN;
this._valueAsNumber = valueAsNumber;
- const readValues = (n: any) => {
- this._value.value = n?.value ?? '';
- this._valueAsNumber.value = n?.valueAsNumber ?? NaN;
- };
+ if ('value' in this.node) {
+ const readValues = (n: any) => {
+ this.suppressCycleWarning();
+ this._value.value = n?.value ?? '';
+ this._valueAsNumber.value = n?.valueAsNumber ?? NaN;
+ };
- this.on('change', e => readValues(e.target));
+ this.on('change', e => readValues(e.target));
+ readValues(this.node);
- dataflow {
- if (this.node.value && 'value' in this.node.value) {
- readValues(this.node.value);
- }
- }
- dataflow {
- if (this.node.value && 'value' in this.node.value) {
- (this.node.value as any).value = '' + this._valueAsNumber.value;
- }
- }
- dataflow {
- if (this.node.value && 'value' in this.node.value) {
- this.node.value.value = this._value.value;
- }
+ dataflow { this.valueAsNumber = this._valueAsNumber.value; }
+ dataflow { this.value = this._value.value; }
}
}
- get value(): string { return this._value.value; }
- set value(v: string) { this._value.value = v; }
+ get value(): string {
+ return this._value.value;
+ }
- get valueAsNumber(): number { return this._valueAsNumber.value; }
- set valueAsNumber(v: number) { this._valueAsNumber.value = v; }
+ set value(v: string) {
+ (this.node as any).value = v;
+ this._value.value = v;
+ }
+
+ get valueAsNumber(): number {
+ return this._valueAsNumber.value;
+ }
+
+ set valueAsNumber(v: number) {
+ (this.node as any).value = Number.isNaN(v) ? '' : '' + v;
+ this._valueAsNumber.value = v;
+ }
+
+ suppressCycleWarning(): void {
+ this._value.suppressCycleWarning();
+ this._valueAsNumber.suppressCycleWarning();
+ }
}
function spawnLocationHashTracker(ds: Ref) {