diff --git a/test/test-template-engine.js b/test/test-template-engine.js index d257935..5cd2657 100644 --- a/test/test-template-engine.js +++ b/test/test-template-engine.js @@ -19,6 +19,7 @@ const localMessages = [ 'Deprecated "unsafeSVG" from default templating engine interface.', 'Deprecated "repeat" from default templating engine interface.', 'Deprecated "map" from default templating engine interface.', + 'Support for the "style" tag is deprecated and will be removed in future versions.', ]; console.warn = (...args) => { // eslint-disable-line no-console if (!localMessages.includes(args[0]?.message)) { @@ -29,13 +30,17 @@ console.warn = (...args) => { // eslint-disable-line no-console }; // Simple helper for asserting thrown messages. -const assertThrows = (callback, expectedMessage) => { +const assertThrows = (callback, expectedMessage, options) => { let thrown = false; try { callback(); } catch (error) { thrown = true; - assert(error.message === expectedMessage, error.message); + if (options?.startsWith === true) { + assert(error.message.startsWith(expectedMessage), error.message); + } else { + assert(error.message === expectedMessage, error.message); + } } assert(thrown, 'no error was thrown'); }; @@ -80,6 +85,20 @@ describe('html rendering', () => { assert(container.children[0].getAttribute('foo') === `--{<&>'"}--`); }); + it('renders named html entities which require surrogate pairs', () => { + const container = document.createElement('div'); + render(container, html`
--𝕓𝕓--𝕓--
`); + assert(container.childElementCount === 1); + assert(container.children[0].textContent === `--\uD835\uDD53\uD835\uDD53--\uD835\uDD53--`); + }); + + it('renders malformed, named html entities', () => { + const container = document.createElement('div'); + render(container, html`
--&:^);--
`); + assert(container.childElementCount === 1); + assert(container.children[0].textContent === `--&:^);--`); + }); + it('renders surprisingly-accepted characters in text', () => { const container = document.createElement('div'); render(container, html`>'"&& & &
&`); @@ -654,18 +673,6 @@ describe('html rendering', () => { assert(container.querySelector('textarea').value === 'foo'); }); - it('title elements with no interpolation work', () => { - const container = document.createElement('div'); - render(container, html`<em>this</em> is the “default” value`); - assert(container.querySelector('title').textContent === 'this is the “default” value'); - }); - - it('title elements with strict interpolation work', () => { - const container = document.createElement('div'); - render(container, html`${'foo'}`); - assert(container.querySelector('title').textContent === 'foo'); - }); - it('renders instantiated elements as dumb text', () => { const getTemplate = ({ element }) => { return html`${element}`; @@ -775,24 +782,12 @@ describe('html rendering', () => { #item = null; set item(value) { updates.push(`outer-${value}`); this.#item = value; } get item() { return this.#item; } - connectedCallback() { - // Prevent property shadowing by deleting before setting on connect. - const item = this.item ?? '???'; - Reflect.deleteProperty(this, 'item'); - Reflect.set(this, 'item', item); - } } customElements.define('test-depth-first-outer', TestDepthFirstOuter); class TestDepthFirstInner extends HTMLElement { #item = null; set item(value) { updates.push(`inner-${value}`); this.#item = value; } get item() { return this.#item; } - connectedCallback() { - // Prevent property shadowing by deleting before setting on connect. - const item = this.item ?? '???'; - Reflect.deleteProperty(this, 'item'); - Reflect.set(this, 'item', item); - } } customElements.define('test-depth-first-inner', TestDepthFirstInner); @@ -1099,91 +1094,62 @@ describe('html errors', () => { div { background-color: ${'red'}; } `; - const expectedMessage = 'Interpolation of + Unforgiving.#throughStyle.lastIndex = nextStringIndex; + if (Unforgiving.#throughStyle.test(string)) { + const content = string.slice(nextStringIndex, Unforgiving.#throughStyle.lastIndex - closeTagLength); + element.value.textContent = content; + } else { + const errorMessagesKey = Unforgiving.#namedErrorsToErrorMessagesKey.get('style-interpolation'); + const errorMessage = Unforgiving.#errorMessages.get(errorMessagesKey); + throw new Error(`[${errorMessagesKey}] ${errorMessage}`); + } + childNodesIndex.value = path.pop(); + element.value = element.value[Unforgiving.#parentNode]; + Unforgiving.#closeTag.lastIndex = Unforgiving.#throughStyle.lastIndex; + return Unforgiving.#closeTag; + } + + static #addUnboundContent(string, stringIndex, element, childNodesIndex, nextStringIndex) { + const encoded = string.slice(stringIndex, nextStringIndex); + const decoded = Unforgiving.#replaceHtmlEntities(encoded); + element.value.appendChild(document.createTextNode(decoded)); + childNodesIndex.value += 1; + } + + static #addUnboundComment(string, stringIndex, element, childNodesIndex, nextStringIndex) { + const content = string.slice(stringIndex, nextStringIndex); + const data = content.slice(4, -3); + // https://w3c.github.io/html-reference/syntax.html#comments + if (data.startsWith('>') || data.startsWith('->') || data.includes('--') || data.endsWith('-')) { + const errorMessagesKey = Unforgiving.#namedErrorsToErrorMessagesKey.get('malformed-comment'); + const errorMessage = Unforgiving.#errorMessages.get(errorMessagesKey); + const substringMessage = `See substring \`${content}\`.`; + throw new Error(`[${errorMessagesKey}] ${errorMessage}\n${substringMessage}`); + } + element.value.appendChild(document.createComment(data)); + childNodesIndex.value += 1; + } + + static #addBoundContent(onContent, path, element, childNodesIndex) { + element.value.append(document.createComment(''), document.createComment('')); + childNodesIndex.value += 2; + path.push(childNodesIndex.value); + onContent(path); + path.pop(); + } + + // This can only happen with a “textarea” element, currently. + static #addBoundText(onText, string, path, element, sloppyStartInterpolation) { + // If the prior match isn’t our opening tag… that’s a problem. If the next + // match isn’t our closing tag… that’s also a problem. + // Because we tightly control the end-tag format, we can predict what the + // next string’s prefix should be. + if (sloppyStartInterpolation || !string.startsWith(``)) { + const errorMessagesKey = Unforgiving.#namedErrorsToErrorMessagesKey.get('complex-textarea-interpolation'); + const errorMessage = Unforgiving.#errorMessages.get(errorMessagesKey); + throw new Error(`[${errorMessagesKey}] ${errorMessage}`); + } + onText(path); + } + + static #addUnboundBoolean(string, stringIndex, element, nextStringIndex) { + const attributeName = string.slice(stringIndex, nextStringIndex); + element.value.setAttribute(attributeName, ''); + } + + static #addUnboundAttribute(string, stringIndex, element, nextStringIndex) { + const unboundAttribute = string.slice(stringIndex, nextStringIndex); + const equalsIndex = unboundAttribute.indexOf('='); + const attributeName = unboundAttribute.slice(0, equalsIndex); + const encoded = unboundAttribute.slice(equalsIndex + 2, -1); + const decoded = Unforgiving.#replaceHtmlEntities(encoded); + element.value.setAttribute(attributeName, decoded); + } + + static #addBoundBoolean(onBoolean, string, stringIndex, path, nextStringIndex) { + const boundBoolean = string.slice(stringIndex, nextStringIndex); + const equalsIndex = boundBoolean.indexOf('='); + const attributeName = boundBoolean.slice(1, equalsIndex); + onBoolean(attributeName, path); + } + + static #addBoundDefined(onDefined, string, stringIndex, path, nextStringIndex) { + const boundDefined = string.slice(stringIndex, nextStringIndex); + const equalsIndex = boundDefined.indexOf('='); + const attributeName = boundDefined.slice(2, equalsIndex); + onDefined(attributeName, path); + } + + static #addBoundAttribute(onAttribute, string, stringIndex, path, nextStringIndex) { + const boundAttribute = string.slice(stringIndex, nextStringIndex); + const equalsIndex = boundAttribute.indexOf('='); + const attributeName = boundAttribute.slice(0, equalsIndex); + onAttribute(attributeName, path); + } + + static #addBoundProperty(onProperty, string, stringIndex, path, nextStringIndex) { + const boundProperty = string.slice(stringIndex, nextStringIndex); + const equalsIndex = boundProperty.indexOf('='); + const propertyName = boundProperty.slice(1, equalsIndex); + onProperty(propertyName, path); + } + + static #validateTagName(namespace, tagName) { + switch (namespace) { + case Unforgiving.html: + if ( + tagName.indexOf('-') === -1 && + !Unforgiving.#allowedHtmlElements.has(tagName) + ) { + const errorMessagesKey = Unforgiving.#namedErrorsToErrorMessagesKey.get('forbidden-html-element'); + const errorMessage = Unforgiving.#errorMessages.get(errorMessagesKey); + const substringMessage = `The <${tagName}> html element is forbidden.`; + throw new Error(`[${errorMessagesKey}] ${errorMessage}\n${substringMessage}`); + } + break; + case Unforgiving.svg: + if (!Unforgiving.#allowedSvgElements.has(tagName)) { + const errorMessagesKey = Unforgiving.#namedErrorsToErrorMessagesKey.get('forbidden-svg-element'); + const errorMessage = Unforgiving.#errorMessages.get(errorMessagesKey); + const substringMessage = `The <${tagName}> svg element is forbidden.`; + throw new Error(`[${errorMessagesKey}] ${errorMessage}\n${substringMessage}`); + } + break; + case Unforgiving.math: + if (!Unforgiving.#allowedMathElements.has(tagName)) { + const errorMessagesKey = Unforgiving.#namedErrorsToErrorMessagesKey.get('forbidden-math-element'); + const errorMessage = Unforgiving.#errorMessages.get(errorMessagesKey); + const substringMessage = `The <${tagName}> math element is forbidden.`; + throw new Error(`[${errorMessagesKey}] ${errorMessage}\n${substringMessage}`); + } + break; + } + } + + static #addElement(string, stringIndex, path, element, childNodesIndex, nextStringIndex) { + const prefixedTagName = string.slice(stringIndex, nextStringIndex); + const tagName = prefixedTagName.slice(1); + const currentNamespace = element.value[Unforgiving.#namespace]; + Unforgiving.#validateTagName(currentNamespace, tagName); + let namespace; + switch (tagName) { + case 'svg': namespace = Unforgiving.svg; break; + case 'math': namespace = Unforgiving.math; break; + default: namespace = currentNamespace; break; + } + const childNode = document.createElementNS(namespace, tagName); + element.value[Unforgiving.#localName] === 'template' + ? element.value.content.appendChild(childNode) + : element.value.appendChild(childNode); + childNode[Unforgiving.#localName] = tagName; + childNode[Unforgiving.#parentNode] = element.value; + childNode[Unforgiving.#namespace] = namespace; + element.value = childNode; + childNodesIndex.value += 1; + path.push(childNodesIndex.value); + } + + static #finalizeElement(strings, stringsIndex, string, stringIndex, path, element, childNodesIndex, nextStringIndex) { + const closeTag = string.slice(stringIndex, nextStringIndex); + const tagName = closeTag.slice(2, -1); + const expectedTagName = element.value[Unforgiving.#localName]; + if (tagName !== expectedTagName) { + const { parsed } = Unforgiving.#getErrorInfo(strings, stringsIndex, string, stringIndex); + const errorMessagesKey = Unforgiving.#namedErrorsToErrorMessagesKey.get('mismatched-closing-tag'); + const errorMessage = Unforgiving.#errorMessages.get(errorMessagesKey); + const substringMessage = `The closing tag does not match <${expectedTagName}>.`; + const parsedThroughMessage = `Your HTML was parsed through: \`${parsed}\`.`; + throw new Error(`[${errorMessagesKey}] ${errorMessage}\n${substringMessage}\n${parsedThroughMessage}`); + } + childNodesIndex.value = path.pop(); + element.value = element.value[Unforgiving.#parentNode]; + } + + static #styleDeprecationWarning() { + if (!Unforgiving.#hasWarnedAboutStyleDeprecation) { + Unforgiving.#hasWarnedAboutStyleDeprecation = true; + const error = new Error('Support for the "style" tag is deprecated and will be removed in future versions.'); + console.warn(error); // eslint-disable-line no-console + } + } + + ////////////////////////////////////////////////////////////////////////////// + // Public parsing interface ////////////////////////////////////////////////// + ////////////////////////////////////////////////////////////////////////////// + + static html = 'http://www.w3.org/1999/xhtml'; + static svg = 'http://www.w3.org/2000/svg'; + static math = 'http://www.w3.org/1998/Math/MathML'; + + static parse(strings, onBoolean, onDefined, onAttribute, onProperty, onContent, onText, namespace) { + const fragment = Unforgiving.#fragment.cloneNode(false); + fragment[Unforgiving.#namespace] = namespace ??= Unforgiving.html; + + const path = []; + const childNodesIndex = { value: -1 }; // Wrapper to allow better factoring. + const element = { value: fragment }; // Wrapper to allow better factoring. + + const stringsLength = strings.length; + let stringsIndex = 0; + let string = null; + let stringLength = null; + let stringIndex = null; + let nextStringIndex = null; + let value = Unforgiving.#initial; + + while (stringsIndex < stringsLength) { + string = strings[stringsIndex]; + + Unforgiving.#validateRawString(strings.raw[stringsIndex]); + if (stringsIndex > 0) { + switch (value) { + case Unforgiving.#initial: + case Unforgiving.#boundContent: + case Unforgiving.#unboundContent: + case Unforgiving.#openTagEnd: + case Unforgiving.#closeTag: + if (element.value[Unforgiving.#localName] === 'textarea') { + // The textarea tag only accepts text, we restrict interpolation + // there. See note on “replaceable character data” in the + // following reference document: + // https://w3c.github.io/html-reference/syntax.html#text-syntax + const sloppyStartInterpolation = value !== Unforgiving.#openTagEnd; + Unforgiving.#addBoundText(onText, string, path, element, sloppyStartInterpolation); + } else { + Unforgiving.#addBoundContent(onContent, path, element, childNodesIndex); + } + value = Unforgiving.#boundContent; + nextStringIndex = value.lastIndex; + break; + } + } + + stringLength = string.length; + stringIndex = 0; + while (stringIndex < stringLength) { + // The string will be empty if we have a template like this `${…}${…}`. + // See related logic at the end of the inner loop; + if (string.length > 0) { + const nextValue = Unforgiving.#validTransition(string, stringIndex, value); + if (!nextValue) { + Unforgiving.#throwTransitionError(strings, stringsIndex, string, stringIndex, value); + } + value = nextValue; + nextStringIndex = value.lastIndex; + } + + // When we transition into certain values, we need to take action. + switch (value) { + case Unforgiving.#unboundContent: + Unforgiving.#addUnboundContent(string, stringIndex, element, childNodesIndex, nextStringIndex); + break; + case Unforgiving.#unboundComment: + Unforgiving.#addUnboundComment(string, stringIndex, element, childNodesIndex, nextStringIndex); + break; + case Unforgiving.#openTagStart: + Unforgiving.#addElement(string, stringIndex, path, element, childNodesIndex, nextStringIndex); + break; + case Unforgiving.#unboundBoolean: + Unforgiving.#addUnboundBoolean(string, stringIndex, element, nextStringIndex); + break; + case Unforgiving.#unboundAttribute: + Unforgiving.#addUnboundAttribute(string, stringIndex, element, nextStringIndex); + break; + case Unforgiving.#boundBoolean: + Unforgiving.#addBoundBoolean(onBoolean, string, stringIndex, path, nextStringIndex); + break; + case Unforgiving.#boundDefined: + Unforgiving.#addBoundDefined(onDefined, string, stringIndex, path, nextStringIndex); + break; + case Unforgiving.#boundAttribute: + Unforgiving.#addBoundAttribute(onAttribute, string, stringIndex, path, nextStringIndex); + break; + case Unforgiving.#boundProperty: + Unforgiving.#addBoundProperty(onProperty, string, stringIndex, path, nextStringIndex); + break; + case Unforgiving.#openTagEnd: + if (element.value[Unforgiving.#namespace] === Unforgiving.html) { + const tagName = element.value[Unforgiving.#localName]; + if (Unforgiving.#voidHtmlElements.has(tagName)) { + value = Unforgiving.#finalizeVoidElement(path, element, childNodesIndex, nextStringIndex); + nextStringIndex = value.lastIndex; + } else if (tagName === 'style') { + Unforgiving.#styleDeprecationWarning(); + value = Unforgiving.#finalizeStyle(string, path, element, childNodesIndex, nextStringIndex); + nextStringIndex = value.lastIndex; + } else if ( + tagName === 'textarea' && + Unforgiving.#openTagEnd.lastIndex !== string.length + ) { + value = Unforgiving.#finalizeTextarea(string, path, element, childNodesIndex, nextStringIndex); + nextStringIndex = value.lastIndex; + } else if ( + tagName === 'template' && + // @ts-ignore — TypeScript doesn’t get that this is a “template”. + element.value.hasAttribute('shadowrootmode') + ) { + const errorMessagesKey = Unforgiving.#namedErrorsToErrorMessagesKey.get('declarative-shadow-root'); + const errorMessage = Unforgiving.#errorMessages.get(errorMessagesKey); + throw new Error(`[${errorMessagesKey}] ${errorMessage}`); + } else { + // Assume we’re traversing into the new element and reset index. + childNodesIndex.value = -1; + } + } else { + // Assume we’re traversing into the new element and reset index. + childNodesIndex.value = -1; + } + break; + case Unforgiving.#closeTag: + Unforgiving.#finalizeElement(strings, stringsIndex, string, stringIndex, path, element, childNodesIndex, nextStringIndex); + break; + } + stringIndex = nextStringIndex; // Update out pointer from our pattern match. + } + stringsIndex++; + } + Unforgiving.#validateExit(fragment, element); + return fragment; + } +} /** Internal implementation details for template engine. */ class TemplateEngine { @@ -796,6 +1812,77 @@ class TemplateEngine { } } + // TODO: Future state here — we’ll eventually just guard against value changes + // at a higher level and will remove all updater logic. + // 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 #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); + // } + // if (category === 'result') { + // 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); + // } + // } else if (category === 'array' || category === 'map') { + // TemplateEngine.#list(node, startNode, value, category); + // } else if (category === 'fragment') { + // 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); + // } else { + // // 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 #commitText(node, value) { + // node.textContent = value; + // } + static #commitContent(node, startNode, value, lastValue) { const introspection = TemplateEngine.#getValueIntrospection(value); const lastIntrospection = TemplateEngine.#getValueIntrospection(lastValue); @@ -882,6 +1969,23 @@ class TemplateEngine { } } + // TODO: Future state — we’ll later do change-by-reference detection here. + // // 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); + // } + // } + // } + // Bind the current values from a result by walking through each target and // updating the DOM if things have changed. static #commit(preparedResult) { @@ -935,7 +2039,7 @@ class TemplateEngine { // 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”. + // 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); @@ -970,8 +2074,8 @@ class TemplateEngine { 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); + const namespace = language === TemplateEngine.#SVG ? Unforgiving.svg : Unforgiving.html; + const fragment = Unforgiving.parse(strings, onBoolean, onDefined, onAttribute, onProperty, onContent, onText, namespace); analysis.fragment = fragment; analysis.lookups = lookups; analysis.done = true; @@ -1059,6 +2163,16 @@ class TemplateEngine { } } + // 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; @@ -1067,12 +2181,31 @@ class TemplateEngine { } } + // 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();