diff --git a/ts/x-element.d.ts b/ts/x-element.d.ts index 27bb286..b432dfe 100644 --- a/ts/x-element.d.ts +++ b/ts/x-element.d.ts @@ -147,55 +147,55 @@ export default class XElement extends HTMLElement { static template(html: Function, engine: { [key: string]: Function; }): (properties: object, host: HTMLElement) => any; - static "__#2@#analyzeConstructor"(constructor: any): void; - static "__#2@#validateProperties"(constructor: any, properties: any, entries: any): void; - static "__#2@#validateProperty"(constructor: any, key: any, property: any): void; - static "__#2@#validatePropertyAttribute"(constructor: any, key: any, property: any, attribute: any): void; - static "__#2@#propertyIsCyclic"(property: any, inputMap: any, seen?: Set): boolean; - static "__#2@#validateListeners"(constructor: any, listeners: any, entries: any): void; - static "__#2@#mutateProperty"(constructor: any, propertyMap: any, key: any, property: any): void; - static "__#2@#addPropertyInitial"(constructor: any, property: any): void; - static "__#2@#addPropertyDefault"(constructor: any, property: any): void; - static "__#2@#addPropertySync"(constructor: any, property: any): void; - static "__#2@#addPropertyReflect"(constructor: any, property: any): void; - static "__#2@#addPropertyCompute"(constructor: any, property: any): void; - static "__#2@#addPropertyObserve"(constructor: any, property: any): void; - static "__#2@#constructHost"(host: any): void; - static "__#2@#createInternal"(host: any): any; - static "__#2@#createProperties"(host: any): any; - static "__#2@#connectHost"(host: any): void; - static "__#2@#disconnectHost"(host: any): void; - static "__#2@#initializeHost"(host: any): boolean; - static "__#2@#upgradeOwnProperties"(host: any): void; - static "__#2@#getPreUpgradePropertyValue"(host: any, property: any): { + static "__#3@#analyzeConstructor"(constructor: any): void; + static "__#3@#validateProperties"(constructor: any, properties: any, entries: any): void; + static "__#3@#validateProperty"(constructor: any, key: any, property: any): void; + static "__#3@#validatePropertyAttribute"(constructor: any, key: any, property: any, attribute: any): void; + static "__#3@#propertyIsCyclic"(property: any, inputMap: any, seen?: Set): boolean; + static "__#3@#validateListeners"(constructor: any, listeners: any, entries: any): void; + static "__#3@#mutateProperty"(constructor: any, propertyMap: any, key: any, property: any): void; + static "__#3@#addPropertyInitial"(constructor: any, property: any): void; + static "__#3@#addPropertyDefault"(constructor: any, property: any): void; + static "__#3@#addPropertySync"(constructor: any, property: any): void; + static "__#3@#addPropertyReflect"(constructor: any, property: any): void; + static "__#3@#addPropertyCompute"(constructor: any, property: any): void; + static "__#3@#addPropertyObserve"(constructor: any, property: any): void; + static "__#3@#constructHost"(host: any): void; + static "__#3@#createInternal"(host: any): any; + static "__#3@#createProperties"(host: any): any; + static "__#3@#connectHost"(host: any): void; + static "__#3@#disconnectHost"(host: any): void; + static "__#3@#initializeHost"(host: any): boolean; + static "__#3@#upgradeOwnProperties"(host: any): void; + static "__#3@#getPreUpgradePropertyValue"(host: any, property: any): { value: any; found: boolean; }; - static "__#2@#initializeProperty"(host: any, property: any): void; - static "__#2@#addListener"(host: any, element: any, type: any, callback: any, options: any): void; - static "__#2@#addListeners"(host: any): void; - static "__#2@#removeListener"(host: any, element: any, type: any, callback: any, options: any): void; - static "__#2@#removeListeners"(host: any): void; - static "__#2@#getListener"(host: any, listener: any): any; - static "__#2@#updateHost"(host: any): void; - static "__#2@#toPathString"(host: any): string; - static "__#2@#invalidateProperty"(host: any, property: any): Promise; - static "__#2@#getPropertyValue"(host: any, property: any): any; - static "__#2@#validatePropertyValue"(host: any, property: any, value: any): void; - static "__#2@#setPropertyValue"(host: any, property: any, value: any): void; - static "__#2@#serializeProperty"(host: any, property: any, value: any): any; - static "__#2@#deserializeProperty"(host: any, property: any, value: any): any; - static "__#2@#propertyHasAttribute"(property: any): boolean; - static "__#2@#getTypeName"(value: any): any; - static "__#2@#notNullish"(value: any): boolean; - static "__#2@#typeIsWrong"(type: any, value: any): boolean; - static "__#2@#camelToKebab"(camel: any): any; - static "__#2@#constructors": WeakMap; - static "__#2@#hosts": WeakMap; - static "__#2@#propertyKeys": Set; - static "__#2@#serializableTypes": Set; - static "__#2@#caseMap": Map; - static "__#2@#prototypeInterface": Set; + static "__#3@#initializeProperty"(host: any, property: any): void; + static "__#3@#addListener"(host: any, element: any, type: any, callback: any, options: any): void; + static "__#3@#addListeners"(host: any): void; + static "__#3@#removeListener"(host: any, element: any, type: any, callback: any, options: any): void; + static "__#3@#removeListeners"(host: any): void; + static "__#3@#getListener"(host: any, listener: any): any; + static "__#3@#updateHost"(host: any): void; + static "__#3@#toPathString"(host: any): string; + static "__#3@#invalidateProperty"(host: any, property: any): Promise; + static "__#3@#getPropertyValue"(host: any, property: any): any; + static "__#3@#validatePropertyValue"(host: any, property: any, value: any): void; + static "__#3@#setPropertyValue"(host: any, property: any, value: any): void; + static "__#3@#serializeProperty"(host: any, property: any, value: any): any; + static "__#3@#deserializeProperty"(host: any, property: any, value: any): any; + static "__#3@#propertyHasAttribute"(property: any): boolean; + static "__#3@#getTypeName"(value: any): any; + static "__#3@#notNullish"(value: any): boolean; + static "__#3@#typeIsWrong"(type: any, value: any): boolean; + static "__#3@#camelToKebab"(camel: any): any; + static "__#3@#constructors": WeakMap; + static "__#3@#hosts": WeakMap; + static "__#3@#propertyKeys": Set; + static "__#3@#serializableTypes": Set; + static "__#3@#caseMap": Map; + static "__#3@#prototypeInterface": Set; /** * Extends HTMLElement.prototype.connectedCallback. */ diff --git a/ts/x-template.d.ts.map b/ts/x-template.d.ts.map index fdf5606..909e788 100644 --- a/ts/x-template.d.ts.map +++ b/ts/x-template.d.ts.map @@ -1 +1 @@ -{"version":3,"file":"x-template.d.ts","sourceRoot":"","sources":["../x-template.js"],"names":[],"mappings":"AAihCA,yBAA2E;AAC3E,uBAAuE;AACvE,sBAAqE;AAGrE,sBAAqE;AACrE,uBAAuE;AACvE,6BAAmF;AACnF,4BAAiF;AACjF,4BAAiF;AACjF,0BAA6E;AAC7E,yBAA2E;AAG3E,8BAAqF;AACrF,+BAAuF;AACvF,wBAAyE;AACzE,2BAA+E;AAC/E,4BAAiF;AACjF,wBAAyE;AACzE,2BAA+E;AAC/E,kCAA6F;AAC7F,wBAAyE"} \ No newline at end of file +{"version":3,"file":"x-template.d.ts","sourceRoot":"","sources":["../x-template.js"],"names":[],"mappings":"AAonCA,yBAA2E;AAC3E,uBAAuE;AACvE,sBAAqE;AAGrE,sBAAqE;AACrE,uBAAuE;AACvE,6BAAmF;AACnF,4BAAiF;AACjF,4BAAiF;AACjF,0BAA6E;AAC7E,yBAA2E;AAG3E,8BAAqF;AACrF,+BAAuF;AACvF,wBAAyE;AACzE,2BAA+E;AAC/E,4BAAiF;AACjF,wBAAyE;AACzE,2BAA+E;AAC/E,kCAA6F;AAC7F,wBAAyE"} \ No newline at end of file diff --git a/x-template.js b/x-template.js index c8833e6..2cf5677 100644 --- a/x-template.js +++ b/x-template.js @@ -1,31 +1,290 @@ -/** Internal implementation details for template engine. */ -class TemplateEngine { +/** Forgiving HTML parser which leverages innerHTML. */ +class Forgiving { // Special markers added to markup enabling discovery post-instantiation. - static #NEXT_MARKER = 'x-element-next:'; // The ":" helps for debugging. - static #CONTENT_MARKER = 'x-element-content'; + static #NEXT_MARKER = 'forgiving-next:'; // The ":" helps for debugging. + static #CONTENT_MARKER = 'forgiving-content'; // Types of bindings that we can have. static #ATTRIBUTE = 'attribute'; static #BOOLEAN = 'boolean'; static #DEFINED = 'defined'; static #PROPERTY = 'property'; - static #CONTENT = 'content'; - static #TEXT = 'text'; + // TODO: Could be more forgiving here! // Patterns to find special edges in original html strings. static #OPEN_REGEX = /<[a-z][a-z0-9-]*(?=\s)/g; static #STEP_REGEX = /(?:\s+[a-z][a-z0-9-]*(?=[\s>])|\s+[a-z][a-zA-Z0-9-]*="[^"]*")+/y; static #ATTRIBUTE_OR_PROPERTY_REGEX = /\s+(?:(?\?{0,2})?(?([a-z][a-zA-Z0-9-]*))|\.(?[a-z][a-zA-Z0-9_]*))="$/y; static #CLOSE_REGEX = />/g; - // Sentinel to hold raw result language. Also leveraged to determine whether a - // value is a raw result or not. Template engine supports html and svg. - static #HTML = 'html'; - static #SVG = 'svg'; + // Walk through each string from our tagged template function “strings” array + // in a stateful way so that we know what kind of bindings are implied at + // each interpolated value. + static #exhaustString(string, state, context) { + if (!state.inside) { + // We're outside the opening tag. + Forgiving.#OPEN_REGEX.lastIndex = state.index; + const openMatch = Forgiving.#OPEN_REGEX.exec(string); + if (openMatch) { + state.inside = true; + state.index = Forgiving.#OPEN_REGEX.lastIndex; + state.lastOpenContext = context; + state.lastOpenIndex = openMatch.index; + Forgiving.#exhaustString(string, state, context); + } + } else { + // We're inside the opening tag. + Forgiving.#STEP_REGEX.lastIndex = state.index; + if (Forgiving.#STEP_REGEX.test(string)) { + state.index = Forgiving.#STEP_REGEX.lastIndex; + } + Forgiving.#CLOSE_REGEX.lastIndex = state.index; + if (Forgiving.#CLOSE_REGEX.test(string)) { + state.inside = false; + state.index = Forgiving.#CLOSE_REGEX.lastIndex; + Forgiving.#exhaustString(string, state, context); + } + } + } + + // Flesh out an html string from our tagged template function “strings” array + // and add special markers that we can detect later, after instantiation. + // + // E.g., the user might have passed this interpolation: + // + //
+ // ${content} + //
+ // + // … and we would instrument it as follows: + // + //
+ // + //
+ // + static #createHtml(language, strings) { + const keyToKeyState = new Map(); + const htmlStrings = []; + const state = { inside: false, index: 0, lastOpenContext: 0, lastOpenIndex: 0 }; + // We don’t have to test the last string since it is already on the other + // side of the last interpolation, by definition. Hence the “- 1” below. + // Note that this final string is added just after the loop completes. + for (let iii = 0; iii < strings.length - 1; iii++) { + // The index may be set to “1” here, which indicates we are slicing off a + // trailing quote character from a attribute-or-property match. After + // slicing, we reset the index to zero so regular expressions know to + // match from the start in “exhaustString”. + let string = strings[iii]; + if (state.index !== 0) { + string = string.slice(state.index); + state.index = 0; + } + Forgiving.#exhaustString(string, state, iii); + if (state.inside) { + Forgiving.#ATTRIBUTE_OR_PROPERTY_REGEX.lastIndex = state.index; + const match = Forgiving.#ATTRIBUTE_OR_PROPERTY_REGEX.exec(string); + if (match) { + const { questions, attribute, property } = match.groups; + if (attribute) { + // We found a match like this: html``. + // … or this: html`
`. + // … or this: html`
`. + // Syntax is 3-5 characters: `${questions}${attribute}="` + `"`. + let syntax = 3; + let kind = Forgiving.#ATTRIBUTE; + switch (questions) { + case '??': kind = Forgiving.#DEFINED; syntax = 5; break; + case '?': kind = Forgiving.#BOOLEAN; syntax = 4; break; + } + string = string.slice(0, -syntax - attribute.length); + const key = state.lastOpenContext; + const keyState = Forgiving.#setIfMissing(keyToKeyState, key, () => ({ index: state.lastOpenIndex, items: [] })); + keyState.items.push(`${kind}=${attribute}`); + } else { + // We found a match like this: html`
`. + // Syntax is 4 characters: `.${property}="` + `"`. + const syntax = 4; + const kind = Forgiving.#PROPERTY; + string = string.slice(0, -syntax - property.length); + const key = state.lastOpenContext; + const keyState = Forgiving.#setIfMissing(keyToKeyState, key, () => ({ index: state.lastOpenIndex, items: [] })); + keyState.items.push(`${kind}=${property}`); + } + state.index = 1; // Accounts for an expected quote character next. + } else { + // It’s “on or after” because interpolated JS can span multiple lines. + const handled = [...strings.slice(0, iii), string.slice(0, state.index)].join(''); + const lineCount = handled.split('\n').length; + throw new Error(`Found invalid template on or after line ${lineCount} in substring \`${string}\`. Failed to parse \`${string.slice(state.index)}\`.`); + } + } else { + // Assume it’s a match like this: html`
${value}
`. + string += ``; + state.index = 0; // No characters to account for. Reset to zero. + } + htmlStrings[iii] = string; + } + // Again, there might be a quote we need to slice off here still. + let lastString = strings.at(-1); + if (state.index > 0) { + lastString = lastString.slice(state.index); + } + htmlStrings.push(lastString); + for (const [iii, { index, items }] of keyToKeyState.entries()) { + const comment = ``; + const htmlString = htmlStrings[iii]; + htmlStrings[iii] = `${htmlString.slice(0, index)}${comment}${htmlString.slice(index)}`; + } + const html = htmlStrings.join(''); + return language === Forgiving.svg + ? `${html}` + : html; + } + + static #createFragment(language, strings) { + const template = document.createElement('template'); + const html = Forgiving.#createHtml(language, strings); + template.innerHTML = html; + return template.content; + } + + // Walk through our fragment that we added special markers to and notify + // integrator when we hit target “paths”. The integrator can use this with + // a subsequent clone of the fragment to establish “targets”. And, while we + // walk, clean up our bespoke markers. + // Note that we are always walking the interpolated strings and the resulting, + // instantiated DOM _in the same depth-first manner_. This means that the + // ordering is fairly reliable. + // + // For example, we walk this structure: + // + //
+ // + //
+ // + // And end up with this (which is ready to be injected into a container): + // + //
+ // + // + //
+ // + static #walkFragment( + onBoolean, + onDefined, + onAttribute, + onProperty, + onContent, + onText, + node, + nodeType = Node.DOCUMENT_FRAGMENT_NODE, + path = [], + ) { + // @ts-ignore — TypeScript doesn’t seem to understand the nodeType param. + if (nodeType === Node.ELEMENT_NODE) { + // Special case to handle elements which only allow text content (no comments). + const { localName } = node; + if ( + (localName === 'style' || localName === 'script') && + node.textContent.includes(Forgiving.#CONTENT_MARKER) + ) { + throw new Error(`Interpolation of "${localName}" tags is not allowed.`); + } else if (localName === 'textarea' || localName === 'title') { + if (node.textContent.includes(Forgiving.#CONTENT_MARKER)) { + if (node.textContent === ``) { + node.textContent = ''; + onText(path); + } else { + throw new Error(`Only basic interpolation of "${localName}" tags is allowed.`); + } + } + } + } + if (nodeType === Node.DOCUMENT_FRAGMENT_NODE || nodeType === Node.ELEMENT_NODE) { + // It’s expensive to make a copy of “childNodes”. Instead, we carefully + // manage our index as we iterate over the live collection. + const childNodes = node.childNodes; + for (let iii = 0; iii < childNodes.length; iii++) { + const childNode = childNodes[iii]; + const childNodeType = childNode.nodeType; + if (childNodeType === Node.COMMENT_NODE) { + const textContent = childNode.textContent; + if (textContent.startsWith(Forgiving.#CONTENT_MARKER)) { + childNode.textContent = ''; + const startNode = document.createComment(''); + node.insertBefore(startNode, childNode); + iii++; + onContent([...path, iii]); + } else if (textContent.startsWith(Forgiving.#NEXT_MARKER)) { + const data = textContent.slice(Forgiving.#NEXT_MARKER.length); + const items = data.split(','); + for (const item of items) { + const [binding, name] = item.split('='); + switch (binding) { + case Forgiving.#ATTRIBUTE: onAttribute(name, [...path, iii]); break; + case Forgiving.#BOOLEAN: onBoolean(name, [...path, iii]); break; + case Forgiving.#DEFINED: onDefined(name, [...path, iii]); break; + case Forgiving.#PROPERTY: onProperty(name, [...path, iii]); break; + } + } + iii--; + node.removeChild(childNode); + } + } else if (childNodeType === Node.ELEMENT_NODE) { + Forgiving.#walkFragment( + onBoolean, + onDefined, + onAttribute, + onProperty, + onContent, + onText, + childNode, + childNodeType, + [...path, iii], + ); + } + } + } + } + + // TODO: Replace with Map.prototype.getOrInsert when TC39 proposal lands. + // https://github.com/tc39/proposal-upsert + static #setIfMissing(map, key, callback) { + // Values set in this file are ALL truthy, so "get" is used (versus "has"). + let value = map.get(key); + if (!value) { + value = callback(); + map.set(key, value); + } + return value; + } + + // Languages. + static html = 'html'; + static svg = 'svg'; + + static parse(strings, onBoolean, onDefined, onAttribute, onProperty, onContent, onText, language) { + const fragment = Forgiving.#createFragment(language, strings); + Forgiving.#walkFragment(onBoolean, onDefined, onAttribute, onProperty, onContent, onText, fragment); + return fragment; + } +} + +/** Internal implementation details for template engine. */ +class TemplateEngine { + // Types of bindings that we can have. + static #ATTRIBUTE = 'attribute'; + static #BOOLEAN = 'boolean'; + static #DEFINED = 'defined'; + static #PROPERTY = 'property'; + static #CONTENT = 'content'; + static #TEXT = 'text'; // Sentinel to hold internal result information. Also leveraged to determine // whether a value is a raw result or not. static #ANALYSIS = Symbol(); + static #HTML = Symbol(); + static #SVG = Symbol(); // Sentinel to initialize the “last values” array. static #UNSET = Symbol(); @@ -329,251 +588,48 @@ class TemplateEngine { } } - // Walk through each string from our tagged template function “strings” array - // in a stateful way so that we know what kind of bindings are implied at - // each interpolated value. - static #exhaustString(string, state, context) { - if (!state.inside) { - // We're outside the opening tag. - TemplateEngine.#OPEN_REGEX.lastIndex = state.index; - const openMatch = TemplateEngine.#OPEN_REGEX.exec(string); - if (openMatch) { - state.inside = true; - state.index = TemplateEngine.#OPEN_REGEX.lastIndex; - state.lastOpenContext = context; - state.lastOpenIndex = openMatch.index; - TemplateEngine.#exhaustString(string, state, context); - } - } else { - // We're inside the opening tag. - TemplateEngine.#STEP_REGEX.lastIndex = state.index; - if (TemplateEngine.#STEP_REGEX.test(string)) { - state.index = TemplateEngine.#STEP_REGEX.lastIndex; - } - TemplateEngine.#CLOSE_REGEX.lastIndex = state.index; - if (TemplateEngine.#CLOSE_REGEX.test(string)) { - state.inside = false; - state.index = TemplateEngine.#CLOSE_REGEX.lastIndex; - TemplateEngine.#exhaustString(string, state, context); - } - } - } - - // Flesh out an html string from our tagged template function “strings” array - // and add special markers that we can detect later, after instantiation. - // - // E.g., the user might have passed this interpolation: - // - //
- // ${content} - //
- // - // … and we would instrument it as follows: - // - //
- // - //
- // - static #createHtml(language, strings) { - const keyToKeyState = new Map(); - const htmlStrings = []; - const state = { inside: false, index: 0, lastOpenContext: 0, lastOpenIndex: 0 }; - // We don’t have to test the last string since it is already on the other - // side of the last interpolation, by definition. Hence the “- 1” below. - // Note that this final string is added just after the loop completes. - for (let iii = 0; iii < strings.length - 1; iii++) { - // The index may be set to “1” here, which indicates we are slicing off a - // trailing quote character from a attribute-or-property match. After - // slicing, we reset the index to zero so regular expressions know to - // match from the start in “exhaustString”. - let string = strings[iii]; - if (state.index !== 0) { - string = string.slice(state.index); - state.index = 0; - } - TemplateEngine.#exhaustString(string, state, iii); - if (state.inside) { - TemplateEngine.#ATTRIBUTE_OR_PROPERTY_REGEX.lastIndex = state.index; - const match = TemplateEngine.#ATTRIBUTE_OR_PROPERTY_REGEX.exec(string); - if (match) { - const { questions, attribute, property } = match.groups; - if (attribute) { - // We found a match like this: html``. - // … or this: html`
`. - // … or this: html`
`. - // Syntax is 3-5 characters: `${questions}${attribute}="` + `"`. - let syntax = 3; - let kind = TemplateEngine.#ATTRIBUTE; - switch (questions) { - case '??': kind = TemplateEngine.#DEFINED; syntax = 5; break; - case '?': kind = TemplateEngine.#BOOLEAN; syntax = 4; break; - } - string = string.slice(0, -syntax - attribute.length); - const key = state.lastOpenContext; - const keyState = TemplateEngine.#setIfMissing(keyToKeyState, key, () => ({ index: state.lastOpenIndex, items: [] })); - keyState.items.push(`${kind}=${attribute}`); - } else { - // We found a match like this: html`
`. - // Syntax is 4 characters: `.${property}="` + `"`. - const syntax = 4; - const kind = TemplateEngine.#PROPERTY; - string = string.slice(0, -syntax - property.length); - const key = state.lastOpenContext; - const keyState = TemplateEngine.#setIfMissing(keyToKeyState, key, () => ({ index: state.lastOpenIndex, items: [] })); - keyState.items.push(`${kind}=${property}`); - } - state.index = 1; // Accounts for an expected quote character next. - } else { - // It’s “on or after” because interpolated JS can span multiple lines. - const handled = [...strings.slice(0, iii), string.slice(0, state.index)].join(''); - const lineCount = handled.split('\n').length; - throw new Error(`Found invalid template on or after line ${lineCount} in substring \`${string}\`. Failed to parse \`${string.slice(state.index)}\`.`); - } - } else { - // Assume it’s a match like this: html`
${value}
`. - string += ``; - state.index = 0; // No characters to account for. Reset to zero. - } - htmlStrings[iii] = string; - } - // Again, there might be a quote we need to slice off here still. - let lastString = strings.at(-1); - if (state.index > 0) { - lastString = lastString.slice(state.index); - } - htmlStrings.push(lastString); - for (const [iii, { index, items }] of keyToKeyState.entries()) { - const comment = ``; - const htmlString = htmlStrings[iii]; - htmlStrings[iii] = `${htmlString.slice(0, index)}${comment}${htmlString.slice(index)}`; - } - const html = htmlStrings.join(''); - return language === TemplateEngine.#SVG - ? `${html}` - : html; - } - - static #createFragment(language, strings) { - const template = document.createElement('template'); - const html = TemplateEngine.#createHtml(language, strings); - template.innerHTML = html; - return template.content; - } - - // Walk through our fragment that we added special markers to and collect - // paths to each future target. We use “paths” because each future instance - // will clone this fragment and so paths are all we can really cache. And, - // while we go through, clean up our bespoke markers. - // Note that we are always walking the interpolated strings and the resulting, - // instantiated DOM _in the same depth-first manner_. This means that the - // ordering is fairly reliable. - // - // For example, we walk this structure: - // - //
- // - //
- // - // And end up with this (which is ready to be injected into a container): - // - //
- // - // - //
- // - static #findLookups(node, nodeType = Node.DOCUMENT_FRAGMENT_NODE, lookups = [], path = []) { - // @ts-ignore — TypeScript doesn’t seem to understand the nodeType param. - if (nodeType === Node.ELEMENT_NODE) { - // Special case to handle elements which only allow text content (no comments). - const { localName } = node; - if ( - (localName === 'style' || localName === 'script') && - node.textContent.includes(TemplateEngine.#CONTENT_MARKER) - ) { - throw new Error(`Interpolation of "${localName}" tags is not allowed.`); - } else if (localName === 'textarea' || localName === 'title') { - if (node.textContent.includes(TemplateEngine.#CONTENT_MARKER)) { - if (node.textContent === ``) { - node.textContent = ''; - lookups.push({ path, binding: TemplateEngine.#TEXT }); - } else { - throw new Error(`Only basic interpolation of "${localName}" tags is allowed.`); - } - } - } - } - if (nodeType === Node.DOCUMENT_FRAGMENT_NODE || nodeType === Node.ELEMENT_NODE) { - // It’s expensive to make a copy of “childNodes”. Instead, we carefully - // manage our index as we iterate over the live collection. - const childNodes = node.childNodes; - for (let iii = 0; iii < childNodes.length; iii++) { - const childNode = childNodes[iii]; - const childNodeType = childNode.nodeType; - if (childNodeType === Node.COMMENT_NODE) { - const textContent = childNode.textContent; - if (textContent.startsWith(TemplateEngine.#CONTENT_MARKER)) { - childNode.textContent = ''; - const startNode = document.createComment(''); - node.insertBefore(startNode, childNode); - iii++; - lookups.push({ path: [...path, iii], binding: TemplateEngine.#CONTENT }); - } else if (textContent.startsWith(TemplateEngine.#NEXT_MARKER)) { - const data = textContent.slice(TemplateEngine.#NEXT_MARKER.length); - const items = data.split(','); - for (const item of items) { - const [binding, name] = item.split('='); - lookups.push({ path: [...path, iii], binding, name }); - } - iii--; - node.removeChild(childNode); - } - } else if (childNodeType === Node.ELEMENT_NODE) { - TemplateEngine.#findLookups(childNode, childNodeType, lookups, [...path, iii]); - } - } - } - if (nodeType === Node.DOCUMENT_FRAGMENT_NODE) { - return lookups; - } - } - // After cloning our common fragment, we use the “lookups” to cache live // references to DOM nodes so that we can surgically perform updates later in // an efficient manner. Lookups are like directions to find our real targets. // As a performance boost, we pre-bind references so that the interface is // just a simple function call when we need to bind new values. - static #findTargets(fragment, lookups) { - const targets = []; - const cache = new Map(); - const find = path => { - let node = fragment; - for (const index of path) { - // eslint-disable-next-line no-loop-func - node = TemplateEngine.#setIfMissing(cache, node, () => node.childNodes)[index]; + static #findTargets(node, lookups, targets) { + targets ??= []; + if (lookups.values) { + for (const { binding, name } of lookups.values) { + switch (binding) { + case TemplateEngine.#ATTRIBUTE: + targets.push(TemplateEngine.#commitAttribute.bind(null, node, name)); + break; + case TemplateEngine.#BOOLEAN: + targets.push(TemplateEngine.#commitBoolean.bind(null, node, name)); + break; + case TemplateEngine.#DEFINED: + targets.push(TemplateEngine.#commitDefined.bind(null, node, name)); + break; + case TemplateEngine.#PROPERTY: + targets.push(TemplateEngine.#commitProperty.bind(null, node, name)); + break; + case TemplateEngine.#CONTENT: + targets.push(TemplateEngine.#commitContent.bind(null, node, node.previousSibling)); + break; + case TemplateEngine.#TEXT: + targets.push(TemplateEngine.#commitText.bind(null, node)); + break; + } } - return node; - }; - for (const { path, binding, name } of lookups) { - const node = find(path); - switch (binding) { - case TemplateEngine.#ATTRIBUTE: - targets.push(TemplateEngine.#commitAttribute.bind(null, node, name)); - break; - case TemplateEngine.#BOOLEAN: - targets.push(TemplateEngine.#commitBoolean.bind(null, node, name)); - break; - case TemplateEngine.#DEFINED: - targets.push(TemplateEngine.#commitDefined.bind(null, node, name)); - break; - case TemplateEngine.#PROPERTY: - targets.push(TemplateEngine.#commitProperty.bind(null, node, name)); - break; - case TemplateEngine.#CONTENT: - targets.push(TemplateEngine.#commitContent.bind(null, node, node.previousSibling)); - break; - case TemplateEngine.#TEXT: - targets.push(TemplateEngine.#commitText.bind(null, node)); - break; + } + if (lookups.map) { + // It’s not possible to require a prior child node in this iteration. We + // are always going forward. Therefore, we can start from a prior cursor. + let iii = 0; + let childNode = node.firstChild; + for (const [index, subLookups] of lookups.map) { + while (iii < index) { + childNode = childNode.nextSibling; + iii++; + } + TemplateEngine.#findTargets(childNode, subLookups, targets); } } return targets; @@ -633,14 +689,13 @@ class TemplateEngine { let index = 0; for (const value of values) { const [id, rawResult] = TemplateEngine.#parseListValue(value, index, category, ids); - if (arrayState.map.has(id)) { - const item = arrayState.map.get(id); + let item = arrayState.map.get(id); + if (item) { if (!TemplateEngine.#canReuseDom(item.preparedResult, rawResult)) { // Add new comment cursors before removing old comment cursors. const cursors = TemplateEngine.#createCursors(item.startNode); TemplateEngine.#removeThrough(item.startNode, item.node); - const preparedResult = TemplateEngine.#inject(rawResult, cursors.node, true); - item.preparedResult = preparedResult; + item.preparedResult = TemplateEngine.#inject(rawResult, cursors.node, true); item.startNode = cursors.startNode; item.node = cursors.node; } else { @@ -649,21 +704,25 @@ class TemplateEngine { } else { const cursors = TemplateEngine.#createCursors(node); const preparedResult = TemplateEngine.#inject(rawResult, cursors.node, true); - const item = { id, preparedResult, ...cursors }; + item = { id, preparedResult, ...cursors }; arrayState.map.set(id, item); } - const item = arrayState.map.get(id); - const referenceNode = lastItem ? lastItem.node.nextSibling : startNode.nextSibling; - if (referenceNode !== item.startNode) { - const nodesToMove = [item.startNode]; - while (nodesToMove[nodesToMove.length - 1] !== item.node) { - nodesToMove.push(nodesToMove[nodesToMove.length - 1].nextSibling); + // TODO: We should be able to make the following code more performant. + if (category === 'map') { + const referenceNode = lastItem ? lastItem.node.nextSibling : startNode.nextSibling; + if (referenceNode !== item.startNode) { + const nodesToMove = [item.startNode]; + while (nodesToMove[nodesToMove.length - 1] !== item.node) { + nodesToMove.push(nodesToMove[nodesToMove.length - 1].nextSibling); + } + TemplateEngine.#insertAllBefore(referenceNode.parentNode, referenceNode, nodesToMove); } - TemplateEngine.#insertAllBefore(referenceNode.parentNode, referenceNode, nodesToMove); } lastItem = item; index++; } + // TODO: Can we more performantly mark some of this stuff in the above + // loop? Versus looping again here? for (const [id, item] of arrayState.map.entries()) { if (!ids.has(id)) { TemplateEngine.#removeThrough(item.startNode, item.node); @@ -837,11 +896,47 @@ class TemplateEngine { } } + static #textValue = { binding: TemplateEngine.#TEXT }; + static #storeTextLookup(lookups, path) { + const value = TemplateEngine.#textValue; + TemplateEngine.#storeLookup(lookups, value, path); + } + + static #contentValue = { binding: TemplateEngine.#CONTENT }; + static #storeContentLookup(lookups, path) { + const value = TemplateEngine.#contentValue; + TemplateEngine.#storeLookup(lookups, value, path); + } + + static #storeKeyLookup(lookups, binding, name, path) { + const value = { binding, name }; + TemplateEngine.#storeLookup(lookups, value, path); + } + + // TODO: This function is a bit of a performance bottleneck. It starts from + // the top of the object each time because it wants to avoid creating paths + // that do not end in bindings… However, then we have to do a lot of checking + // perhaps there’s a better way! + static #storeLookup(lookups, value, path) { + let reference = lookups; + for (let iii = 0; iii < path.length; iii++) { + const index = path[iii]; + reference.map ??= []; + let lastEntry = reference.map.at(-1); + if (lastEntry?.[0] !== index) { + lastEntry = [index, {}]; + reference.map.push(lastEntry); + } + reference = lastEntry[1]; + } + reference.values ??= []; + reference.values.push(value); + } + // Inject a given result into a node for the first time. static #inject(rawResult, node, before) { // Get fragment created from a tagged template function’s “strings”. const { [TemplateEngine.#ANALYSIS]: analysis } = rawResult; - const language = analysis.language; const fragment = analysis.fragment.cloneNode(true); const targets = TemplateEngine.#findTargets(fragment, analysis.lookups); const preparedResult = { rawResult, fragment, targets }; @@ -851,9 +946,7 @@ class TemplateEngine { // Attach a document fragment into the node. Note that all the DOM in the // fragment will already have values correctly committed on the line above. - const nodes = language === TemplateEngine.#SVG - ? fragment.firstChild.childNodes - : fragment.childNodes; + const nodes = fragment.childNodes; before ? TemplateEngine.#insertAllBefore(node.parentNode, node, nodes) : TemplateEngine.#insertAllBefore(node, null, nodes); @@ -870,9 +963,15 @@ class TemplateEngine { static #createRawResult(language, strings, values) { const analysis = TemplateEngine.#setIfMissing(TemplateEngine.#stringsToAnalysis, strings, () => ({})); if (!analysis.done) { - const fragment = TemplateEngine.#createFragment(language, strings); - const lookups = TemplateEngine.#findLookups(fragment); - analysis.language = language; + const lookups = {}; + const onBoolean = TemplateEngine.#storeKeyLookup.bind(null, lookups, TemplateEngine.#BOOLEAN); + const onDefined = TemplateEngine.#storeKeyLookup.bind(null, lookups, TemplateEngine.#DEFINED); + const onAttribute = TemplateEngine.#storeKeyLookup.bind(null, lookups, TemplateEngine.#ATTRIBUTE); + const onProperty = TemplateEngine.#storeKeyLookup.bind(null, lookups, TemplateEngine.#PROPERTY); + const onContent = TemplateEngine.#storeContentLookup.bind(null, lookups); + const onText = TemplateEngine.#storeTextLookup.bind(null, lookups); + const forgivingLanguage = language === TemplateEngine.#SVG ? Forgiving.svg : Forgiving.html; + const fragment = Forgiving.parse(strings, onBoolean, onDefined, onAttribute, onProperty, onContent, onText, forgivingLanguage); analysis.fragment = fragment; analysis.lookups = lookups; analysis.done = true;