x-template.js (612 lines of code) (raw):
import { XParser } from './x-parser.js';
/** 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();
// Sentinel to initialize the “last values” array.
static #UNSET = Symbol();
// Sentinels to manage internal state on nodes.
static #STATE = Symbol();
static #ARRAY_STATE = Symbol();
// It’s more performant to clone a single fragment, so we keep a reference.
static #fragment = new DocumentFragment();
// We decode character references via “setHTMLUnsafe” on this container.
static #htmlEntityContainer = document.createElement('template');
// Mapping of tagged template function “strings” to caches computations.
static #stringsToAnalysis = new WeakMap();
// Mapping of opaque references to internal update objects.
static #symbolToUpdate = new WeakMap();
/**
* Default template engine interface — what you get inside “template”.
* @type {{[key: string]: Function}}
*/
static interface = Object.freeze({
// Long-term interface.
render: TemplateEngine.render,
html: TemplateEngine.html,
// Deprecated interface.
ifDefined: TemplateEngine.#interfaceDeprecated('ifDefined', TemplateEngine.ifDefined),
repeat: TemplateEngine.#interfaceDeprecated('repeat', TemplateEngine.repeat),
});
/**
* Declare HTML markup to be interpolated.
* ```js
* html`<div attr="${obj.attr}" .prop="${obj.prop}">${obj.content}</div>`;
* ```
* @param {string[]} strings
* @param {any[]} values
* @returns {any}
*/
static html(strings, ...values) {
return TemplateEngine.#createRawResult(strings, values);
}
/**
* Core rendering entry point for x-element template engine.
* Accepts a "container" element and renders the given "raw result" into it.
* @param {HTMLElement} container
* @param {any} rawResult
*/
static render(container, rawResult) {
if (!(container instanceof Node)) {
throw new Error(`Unexpected non-node render container "${container}".`);
}
rawResult = TemplateEngine.#isRawResult(rawResult) ? rawResult : null;
const state = TemplateEngine.#getState(container, TemplateEngine.#STATE);
if (rawResult) {
if (!TemplateEngine.#canReuseDom(state.preparedResult, rawResult)) {
TemplateEngine.#removeWithin(container);
const preparedResult = TemplateEngine.#inject(rawResult, container);
state.preparedResult = preparedResult;
} else {
TemplateEngine.#update(state.preparedResult, rawResult);
}
} else {
TemplateEngine.#clearObject(state);
TemplateEngine.#removeWithin(container);
}
}
/**
* Updater to manage an attribute which may be undefined.
* In the following example, the "ifDefined" updater will remove the
* attribute if it's undefined. Else, it sets the key-value pair.
* ```js
* html`<a href="${ifDefined(obj.href)}"></div>`;
* ```
* @deprecated
* @param {any} value
* @returns {any}
*/
static ifDefined(value) {
const symbol = Object.create(null);
const updater = TemplateEngine.#ifDefined;
TemplateEngine.#symbolToUpdate.set(symbol, { updater, value });
return symbol;
}
/**
* Shim for prior "repeat" function. Use native entries array.
* @deprecated
* @param {any[]} items
* @param {Function} identify
* @param {Function} [callback]
* @returns {any}
*/
static repeat(items, identify, callback) {
if (arguments.length === 2) {
callback = identify;
identify = null;
}
if (!Array.isArray(items)) {
throw new Error(`Unexpected repeat items "${items}" provided, expected an array.`);
}
if (arguments.length !== 2 && typeof identify !== 'function') {
throw new Error(`Unexpected repeat identify "${identify}" provided, expected a function.`);
} else if (typeof callback !== 'function') {
throw new Error(`Unexpected repeat callback "${callback}" provided, expected a function.`);
}
return identify
? items.map((...args) => [identify(...args), callback(...args)])
: items.map((...args) => callback(...args)); // Just a basic array.
}
// Deprecated. Will remove in future release.
static #ifDefined(node, name, value, lastValue) {
if (value !== lastValue) {
value === undefined || value === null
? node.removeAttribute(name)
: node.setAttribute(name, value);
}
}
// We only decode things we know to be encoded since it’s non-performant.
static #decode(encoded) {
this.#htmlEntityContainer.setHTMLUnsafe(encoded);
const decoded = this.#htmlEntityContainer.content.textContent;
return decoded;
}
// Walk over a pre-validated set of tokens from our parser. Note that because
// the parser is _very_ strict, we can make a lot of simplifying assumptions.
static #onToken(
// These areguments are passed in through a “bind”.
state, onBoolean, onDefined, onAttribute, onProperty, onContent, onText,
// These arguments are passed in through the “onToken” callback.
type, index, start, end, substring
) {
switch (type) {
case XParser.tokenTypes.startTagName: {
const tagName = substring;
const childNode = globalThis.document.createElement(tagName);
state.tagName === 'template'
// @ts-ignore — TypeScript doesn’t get that this is a template.
? state.element.content.appendChild(childNode)
: state.element.appendChild(childNode);
state.parentElements.push(state.element);
state.parentTagNames.push(state.tagName);
state.element = childNode;
state.tagName = tagName;
state.childNodesIndex += 1;
state.path.push(state.childNodesIndex);
break;
}
case XParser.tokenTypes.voidTagClose:
state.element = state.parentElements.pop();
state.tagName = state.parentTagNames.pop();
state.childNodesIndex = state.path.pop();
break;
case XParser.tokenTypes.startTagClose:
// Assume we’re traversing into the new element and reset index.
state.childNodesIndex = -1;
break;
case XParser.tokenTypes.endTagName:
state.childNodesIndex = state.path.pop();
state.element = state.parentElements.pop();
state.tagName = state.parentTagNames.pop();
break;
case XParser.tokenTypes.attributeName:
case XParser.tokenTypes.boundAttributeName:
case XParser.tokenTypes.boundBooleanName:
case XParser.tokenTypes.boundDefinedName:
case XParser.tokenTypes.boundPropertyName:
state.name = substring;
break;
case XParser.tokenTypes.booleanName: {
// @ts-ignore — TypeScript doesn’t get that this is an element.
state.element.setAttribute(substring, '');
break;
}
case XParser.tokenTypes.comment:
state.element.appendChild(document.createComment(substring));
state.childNodesIndex += 1;
break;
case XParser.tokenTypes.textPlaintext:
case XParser.tokenTypes.attributeValuePlaintext:
state.text += substring;
break;
case XParser.tokenTypes.textReference:
case XParser.tokenTypes.attributeValueReference:
state.text += substring;
state.encoded = true;
break;
case XParser.tokenTypes.textEnd: {
const decoded = state.encoded ? TemplateEngine.#decode(state.text) : state.text;
if (
state.tagName === 'pre' &&
state.childNodesIndex === -1 &&
decoded.startsWith('\n')
) {
// First newline is stripped according to the <pre> tag specification.
// https://html.spec.whatwg.org/multipage/grouping-content.html#the-pre-element
state.element.appendChild(document.createTextNode(decoded.slice(1)));
} else {
state.element.appendChild(document.createTextNode(decoded));
}
state.childNodesIndex += 1;
state.encoded = false;
state.text = '';
break;
}
case XParser.tokenTypes.attributeValueEnd: {
const decoded = state.encoded ? TemplateEngine.#decode(state.text) : state.text;
// @ts-ignore — TypeScript doesn’t get that this is an element.
state.element.setAttribute(state.name, decoded);
state.name = null;
state.encoded = false;
state.text = '';
break;
}
case XParser.tokenTypes.boundTextValue:
onText(state.path);
break;
case XParser.tokenTypes.boundContentValue:
// @ts-ignore — TypeScript doesn’t get that this is an element.
state.element.append(document.createComment(''), document.createComment(''));
state.childNodesIndex += 2;
state.path.push(state.childNodesIndex);
onContent(state.path);
state.path.pop();
break;
case XParser.tokenTypes.boundAttributeValue:
onAttribute(state.name, state.path);
state.name = null;
break;
case XParser.tokenTypes.boundBooleanValue:
onBoolean(state.name, state.path);
state.name = null;
break;
case XParser.tokenTypes.boundDefinedValue:
onDefined(state.name, state.path);
state.name = null;
break;
case XParser.tokenTypes.boundPropertyValue:
onProperty(state.name, state.path);
state.name = null;
break;
}
}
// 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(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;
}
}
}
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;
}
static #commitAttribute(node, name, value, lastValue) {
const update = TemplateEngine.#symbolToUpdate.get(value);
if (update) {
// If there’s an update, it _has_ to be #ifDefined at this point.
const lastUpdate = TemplateEngine.#symbolToUpdate.get(lastValue);
TemplateEngine.#ifDefined(node, name, update.value, lastUpdate?.value);
} else {
node.setAttribute(name, value);
}
}
static #commitBoolean(node, name, value) {
const update = TemplateEngine.#symbolToUpdate.get(value);
if (update) {
TemplateEngine.#throwIfDefinedError(TemplateEngine.#BOOLEAN);
} else {
value ? node.setAttribute(name, '') : node.removeAttribute(name);
}
}
static #commitDefined(node, name, value) {
const update = TemplateEngine.#symbolToUpdate.get(value);
if (update) {
TemplateEngine.#throwIfDefinedError(TemplateEngine.#DEFINED);
} else {
value === undefined || value === null
? node.removeAttribute(name)
: node.setAttribute(name, value);
}
}
static #commitProperty(node, name, value) {
const update = TemplateEngine.#symbolToUpdate.get(value);
if (update) {
TemplateEngine.#throwIfDefinedError(TemplateEngine.#PROPERTY);
} else {
node[name] = value;
}
}
// TODO: Future state here once “ifDefined” is gone.
// static #commitAttribute(node, name, value) {
// node.setAttribute(name, value);
// }
// static #commitBoolean(node, name, value) {
// value ? node.setAttribute(name, '') : node.removeAttribute(name);
// }
// static #commitDefined(node, name, value) {
// value === undefined || value === null
// ? node.removeAttribute(name)
// : node.setAttribute(name, value);
// }
// static #commitProperty(node, name, value) {
// node[name] = value;
// }
static #commitContentResultValue(node, startNode, value) {
const state = TemplateEngine.#getState(node, TemplateEngine.#STATE);
const rawResult = value;
if (!TemplateEngine.#canReuseDom(state.preparedResult, rawResult)) {
TemplateEngine.#removeBetween(startNode, node);
TemplateEngine.#clearObject(state);
const preparedResult = TemplateEngine.#inject(rawResult, node, true);
state.preparedResult = preparedResult;
} else {
TemplateEngine.#update(state.preparedResult, rawResult);
}
}
// Validates array value and returns a “rawResult”.
static #parseArrayValue(value, index) {
// Values should look like "<raw result>".
const rawResult = value;
if (!TemplateEngine.#isRawResult(rawResult)) {
throw new Error(`Unexpected non-template value found in array item at ${index} "${rawResult}".`);
}
return rawResult;
}
// Validates array entry and returns an “id” and a “rawResult”.
static #parseArrayEntry(entry, index, ids) {
// Entries should look like "[<key>, <raw result>]".
if (entry.length !== 2) {
throw new Error(`Unexpected entry length found in map entry at ${index} with length "${entry.length}".`);
}
const [id, rawResult] = entry;
if (typeof id !== 'string') {
throw new Error(`Unexpected non-string key found in map entry at ${index} "${id}".`);
}
if (ids.has(id)) {
throw new Error(`Unexpected duplicate key found in map entry at ${index} "${id}".`);
}
ids.add(id);
if (!TemplateEngine.#isRawResult(rawResult)) {
throw new Error(`Unexpected non-template value found in map entry at ${index} "${rawResult}".`);
}
return [id, rawResult];
}
// Helper to create / insert “cursors” in managed array of nodes.
static #createArrayItem(node, id, rawResult) {
const cursors = TemplateEngine.#createCursors(node);
const preparedResult = TemplateEngine.#inject(rawResult, cursors.node, true);
return { id, preparedResult, ...cursors };
}
// Helper to destroy, create, and replace “cursors” in managed array of nodes.
static #recreateArrayItem(item, rawResult) {
// Add new comment cursors before removing old comment cursors.
const cursors = TemplateEngine.#createCursors(item.startNode);
TemplateEngine.#removeThrough(item.startNode, item.node);
item.preparedResult = TemplateEngine.#inject(rawResult, cursors.node, true);
item.startNode = cursors.startNode;
item.node = cursors.node;
}
// Loops over given array of “values” to manage an array of nodes.
static #commitContentArrayValues(node, startNode, values) {
const arrayState = TemplateEngine.#getState(startNode, TemplateEngine.#ARRAY_STATE);
if (!arrayState.map) {
// There is no mapping in our state — create an empty one as our base.
TemplateEngine.#clearObject(arrayState);
arrayState.map = new Map();
}
if (values.length > 0 && arrayState.map.size > 0) {
// Update existing values.
for (let index = 0; index < Math.min(arrayState.map.size, values.length); index++) {
const id = String(index);
const value = values[index];
const rawResult = TemplateEngine.#parseArrayValue(value, index);
const item = arrayState.map.get(id);
if (!TemplateEngine.#canReuseDom(item.preparedResult, rawResult)) {
TemplateEngine.#recreateArrayItem(item, rawResult);
} else {
TemplateEngine.#update(item.preparedResult, rawResult);
}
}
}
if (values.length > arrayState.map.size) {
// Add new values.
for (let index = arrayState.map.size; index < values.length; index++) {
const id = String(index);
const value = values[index];
const rawResult = TemplateEngine.#parseArrayValue(value, index);
const item = TemplateEngine.#createArrayItem(node, id, rawResult);
arrayState.map.set(id, item);
}
}
if (arrayState.map.size > values.length) {
// Delete removed values.
const index = values.length;
const id = String(index);
const item = arrayState.map.get(id);
TemplateEngine.#removeThrough(item.startNode, node);
arrayState.map.delete(id);
}
}
// Loops over given array of “entries” to manage an array of nodes.
static #commitContentArrayEntries(node, startNode, entries) {
const arrayState = TemplateEngine.#getState(startNode, TemplateEngine.#ARRAY_STATE);
if (!arrayState.map) {
// There is no mapping in our state — create an empty one as our base.
TemplateEngine.#clearObject(arrayState);
arrayState.map = new Map();
}
// A mapping has already been created — we need to update the items.
const ids = new Set(); // Populated in “parseListValue”.
let index = 0;
for (const entry of entries) {
const [id, rawResult] = TemplateEngine.#parseArrayEntry(entry, index, ids);
let item = arrayState.map.get(id);
if (item) {
if (!TemplateEngine.#canReuseDom(item.preparedResult, rawResult)) {
TemplateEngine.#recreateArrayItem(item, rawResult);
} else {
TemplateEngine.#update(item.preparedResult, rawResult);
}
} else {
item = TemplateEngine.#createArrayItem(node, id, rawResult);
arrayState.map.set(id, item);
}
index++;
}
for (const [id, item] of arrayState.map.entries()) {
if (!ids.has(id)) {
TemplateEngine.#removeThrough(item.startNode, item.node);
arrayState.map.delete(id);
}
}
let lastItem;
for (const id of ids) {
const item = arrayState.map.get(id);
// TODO: We should be able to make the following code more performant.
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);
}
lastItem = item;
}
}
// TODO: #254: Future state where the “moveBefore” API is better-supported.
// // Loops over given array of “entries” to manage an array of nodes.
// static #commitContentArrayEntries(node, startNode, entries) {
// const arrayState = TemplateEngine.#getState(startNode, TemplateEngine.#ARRAY_STATE);
// if (!arrayState.map) {
// // There is no mapping in our state — create an empty one as our base.
// TemplateEngine.#clearObject(arrayState);
// arrayState.map = new Map();
// }
//
// const idsToRemove = new Set(arrayState.map.keys());
// const ids = new Set(); // Populated in “parseArrayEntry”.
// let reference = startNode.nextSibling;
// for (let index = 0; index < entries.length; index++) {
// const entry = entries[index];
// const [id, rawResult] = TemplateEngine.#parseArrayEntry(entry, index, ids);
// let item = arrayState.map.get(id);
// if (item) {
// // Update existing item.
// idsToRemove.delete(id);
// if (!TemplateEngine.#canReuseDom(item.preparedResult, rawResult)) {
// const referenceWasStartNode = reference === item.startNode;
// TemplateEngine.#recreateArrayItem(item, rawResult);
// reference = referenceWasStartNode ? item.startNode : reference;
// } else {
// TemplateEngine.#update(item.preparedResult, rawResult);
// }
// } else {
// // Create new item.
// item = TemplateEngine.#createArrayItem(node, id, rawResult);
// arrayState.map.set(id, item);
// }
// // Move to the correct location
// if (item.startNode !== reference) {
// const nodesToMove = [item.startNode];
// while (nodesToMove[nodesToMove.length - 1] !== item.node) {
// nodesToMove.push(nodesToMove[nodesToMove.length - 1].nextSibling);
// }
// TemplateEngine.#moveAllBefore(reference.parentNode, reference, nodesToMove);
// }
//
// // Move our position forward.
// reference = item.node.nextSibling;
// }
//
// // Remove any ids which are not longer in the entries.
// for (const id of idsToRemove) {
// const item = arrayState.map.get(id);
// TemplateEngine.#removeThrough(item.startNode, item.node);
// arrayState.map.delete(id);
// }
// }
static #commitContentFragmentValue(node, startNode, value) {
if (value.childElementCount === 0) {
throw new Error(`Unexpected child element count of zero for given DocumentFragment.`);
}
const previousSibling = node.previousSibling;
if (previousSibling !== startNode) {
TemplateEngine.#removeBetween(startNode, node);
}
node.parentNode.insertBefore(value, node);
}
static #commitContentTextValue(node, startNode, value) {
// TODO: Is there a way to more-performantly skip this init step? E.g., if
// the prior value here was not “unset” and we didn’t just reset? We
// could cache the target node in these cases or something?
const previousSibling = node.previousSibling;
if (previousSibling === startNode) {
// The `?? ''` is a shortcut for creating a text node and then
// setting its textContent. It’s exactly equivalent to the
// following code, but faster.
// const textNode = document.createTextNode('');
// textNode.textContent = value;
const textNode = document.createTextNode(value ?? '');
node.parentNode.insertBefore(textNode, node);
} else {
previousSibling.textContent = value;
}
}
static #commitContent(node, startNode, value, lastValue) {
const category = TemplateEngine.#getCategory(value);
const lastCategory = TemplateEngine.#getCategory(lastValue);
if (category !== lastCategory && lastValue !== TemplateEngine.#UNSET) {
// Reset content under certain conditions. E.g., `map` >> `null`.
const state = TemplateEngine.#getState(node, TemplateEngine.#STATE);
const arrayState = TemplateEngine.#getState(startNode, TemplateEngine.#ARRAY_STATE);
TemplateEngine.#removeBetween(startNode, node);
TemplateEngine.#clearObject(state);
TemplateEngine.#clearObject(arrayState);
}
switch (category) {
case 'result': TemplateEngine.#commitContentResultValue(node, startNode, value); break;
case 'array': TemplateEngine.#commitContentArrayValues(node, startNode, value); break;
case 'map': TemplateEngine.#commitContentArrayEntries(node, startNode, value); break;
case 'fragment': TemplateEngine.#commitContentFragmentValue(node, startNode, value); break;
default: TemplateEngine.#commitContentTextValue(node, startNode, value); break;
}
}
static #commitText(node, value) {
node.textContent = value;
}
// Bind the current values from a result by walking through each target and
// updating the DOM if things have changed.
static #commit(preparedResult) {
preparedResult.values ??= preparedResult.rawResult.values;
preparedResult.lastValues ??= preparedResult.values.map(() => TemplateEngine.#UNSET);
const { targets, values, lastValues } = preparedResult;
for (let iii = 0; iii < targets.length; iii++) {
const value = values[iii];
const lastValue = lastValues[iii];
if (value !== lastValue) {
const target = targets[iii];
target(value, lastValue);
}
}
}
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) {
// Create and prepare a document fragment to be injected.
const { [TemplateEngine.#ANALYSIS]: analysis } = rawResult;
const fragment = analysis.fragment.cloneNode(true);
const targets = TemplateEngine.#findTargets(fragment, analysis.lookups);
const preparedResult = { rawResult, fragment, targets };
// Bind values via our live targets into our disconnected DOM.
TemplateEngine.#commit(preparedResult);
// 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 = fragment.childNodes;
before
? TemplateEngine.#insertAllBefore(node.parentNode, node, nodes)
: TemplateEngine.#insertAllBefore(node, null, nodes);
return preparedResult;
}
static #update(preparedResult, rawResult) {
preparedResult.lastValues = preparedResult.values;
preparedResult.values = rawResult.values;
TemplateEngine.#commit(preparedResult);
}
static #createRawResult(strings, values) {
const analysis = TemplateEngine.#setIfMissing(TemplateEngine.#stringsToAnalysis, strings, () => ({}));
if (!analysis.done) {
const fragment = TemplateEngine.#fragment.cloneNode(false);
const state = {
path: [],
parentElements: [],
parentTagNames: [],
element: fragment,
tagName: null,
childNodesIndex: -1,
encoded: false,
text: '',
name: null,
};
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 onToken = TemplateEngine.#onToken.bind(null, state, onBoolean, onDefined, onAttribute, onProperty, onContent, onText);
XParser.parse(strings, onToken);
analysis.fragment = fragment;
analysis.lookups = lookups;
analysis.done = true;
}
// This is a leaking implementation detail, but fixing the leak comes at
// a non-negligible performance cost.
return { [TemplateEngine.#ANALYSIS]: analysis, strings, values };
}
static #isRawResult(value) {
return !!value?.[TemplateEngine.#ANALYSIS];
}
static #getCategory(value) {
if (typeof value === 'object') {
if (TemplateEngine.#isRawResult(value)) {
return 'result';
} else if (Array.isArray(value)) {
return Array.isArray(value[0]) ? 'map' : 'array';
} else if (value instanceof DocumentFragment) {
return 'fragment';
}
}
}
static #throwIfDefinedError(binding) {
throw new Error(`The ifDefined update must be used on ${TemplateEngine.#getBindingText(TemplateEngine.#ATTRIBUTE)}, not on ${TemplateEngine.#getBindingText(binding)}.`);
}
static #canReuseDom(preparedResult, rawResult) {
return preparedResult?.rawResult.strings === rawResult?.strings;
}
static #createCursors(referenceNode) {
const startNode = document.createComment('');
const node = document.createComment('');
referenceNode.parentNode.insertBefore(startNode, referenceNode);
referenceNode.parentNode.insertBefore(node, referenceNode);
return { startNode, node };
}
// TODO: #254: Future state when we leverage “moveBefore”.
// static #moveAllBefore(parentNode, referenceNode, nodes) {
// // Iterate backwards over the live node collection since we’re mutating it.
// // Note that passing “null” as the reference node moves nodes to the end.
// for (let iii = nodes.length - 1; iii >= 0; iii--) {
// const node = nodes[iii];
// parentNode.moveBefore(node, referenceNode);
// referenceNode = node;
// }
// }
static #insertAllBefore(parentNode, referenceNode, nodes) {
// Iterate backwards over the live node collection since we’re mutating it.
// Note that passing “null” as the reference node appends nodes to the end.
for (let iii = nodes.length - 1; iii >= 0; iii--) {
const node = nodes[iii];
parentNode.insertBefore(node, referenceNode);
referenceNode = node;
}
}
// TODO: Future state — we may choose to iterate differently as an
// optimization in later versions.
// static #removeWithin(node) {
// let childNode = node.lastChild;
// while (childNode) {
// const nextChildNode = childNode.previousSibling;
// node.removeChild(childNode);
// childNode = nextChildNode;
// }
// }
static #removeWithin(node) {
// Iterate backwards over the live node collection since we’re mutating it.
const childNodes = node.childNodes;
for (let iii = childNodes.length - 1; iii >= 0; iii--) {
node.removeChild(childNodes[iii]);
}
}
// TODO: Future state — we may choose to iterate differently as an
// optimization in later versions.
// static #removeBetween(startNode, node, parentNode) {
// parentNode ??= node.parentNode;
// let childNode = node.previousSibling;
// while(childNode !== startNode) {
// const nextChildNode = childNode.previousSibling;
// parentNode.removeChild(childNode);
// childNode = nextChildNode;
// }
// }
static #removeBetween(startNode, node) {
while(node.previousSibling !== startNode) {
node.previousSibling.remove();
}
}
// TODO: Future state — we may choose to iterate differently as an
// optimization in later versions.
// static #removeThrough(startNode, node, parentNode) {
// parentNode ??= node.parentNode;
// TemplateEngine.#removeBetween(startNode, node, parentNode);
// parentNode.removeChild(startNode);
// parentNode.removeChild(node);
// }
static #removeThrough(startNode, node) {
TemplateEngine.#removeBetween(startNode, node);
startNode.remove();
node.remove();
}
static #clearObject(object) {
for (const key of Object.keys(object)) {
delete object[key];
}
}
// 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;
}
static #getState(object, key) {
// Values set in this file are ALL truthy.
let value = object[key];
if (!value) {
value = {};
object[key] = value;
}
return value;
}
static #getBindingText(binding) {
switch (binding) {
case TemplateEngine.#ATTRIBUTE: return 'an attribute';
case TemplateEngine.#BOOLEAN: return 'a boolean attribute';
case TemplateEngine.#DEFINED: return 'a defined attribute';
case TemplateEngine.#PROPERTY: return 'a property';
}
}
static #interfaceDeprecatedMessages = new Set();
static #interfaceDeprecated(name, callback) {
return (...args) => {
const message = `Deprecated "${name}" from default templating engine interface.`;
if (!this.#interfaceDeprecatedMessages.has(message)) {
this.#interfaceDeprecatedMessages.add(message);
console.warn(new Error(message)); // eslint-disable-line no-console
}
return callback(...args);
};
}
}
// Long-term interface.
export const render = TemplateEngine.interface.render.bind(TemplateEngine);
export const html = TemplateEngine.interface.html.bind(TemplateEngine);
// Deprecated interface.
export const ifDefined = TemplateEngine.interface.ifDefined.bind(TemplateEngine);
export const repeat = TemplateEngine.interface.repeat.bind(TemplateEngine);