diff --git a/CHANGELOG.md b/CHANGELOG.md index 0e81914..f1877ad 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - The `ifDefined` updater now deletes the attribute on `null` in addition to `undefined`. This makes it behave identically to `nullish`. However, both updaters are deprecated and the `??attr` binding should be used instead. +- Interpolation of `textarea` is stricter. This used to be handled with some + leniency — ``. Now, you have to fit the + interpolation exactly — ``. ### Deprecated @@ -27,6 +30,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 syntax like `??foo="${bar}"`. - The `repeat` updater is deprecated, use `map` instead. - The `unsafeHTML` and `unsafeSVG` updaters are deprecated, use `unsafe`. +- The `plaintext` tag is no longer handled. This is a deprecated html tag which + required special handling… but it’s unlikely that anyone is using that. ### Fixed diff --git a/test/test-template-engine.js b/test/test-template-engine.js index edd1d43..2d89ff4 100644 --- a/test/test-template-engine.js +++ b/test/test-template-engine.js @@ -65,6 +65,75 @@ describe('html rendering', () => { container.remove(); }); + // Unlike a NodeList, a NamedNodeMap (i.e., “.attributes”) is not technically + // ordered in any way. This test just confirms that the template engine logic + // doesn’t get confused in any way post-parse. + it('renders elements with many attributes in a weird order', () => { + const getTemplate = ({ + property1, + z99, + defined, + dataFoo, + property2, + title, + dataBar, + className, + property3, + boolean, + ariaLabel, + content, + }) => { + return html` +
+ ${content} +
+ `; + }; + const container = document.createElement('div'); + document.body.append(container); + render(container, getTemplate({ + property1: null, + z99: true, + defined: false, + dataFoo: 10, + property2: -1, + title: 'a title', + dataBar: 'data attribute', + className: 'a b c', + property3: new URL('https://github.com/Netflix/x-element'), + boolean: 'yes', + ariaLabel: 'this is what it does', + content: 'influencing', + })); + const target = container.querySelector('#target'); + assert(target.property1 === null); + assert(target.getAttribute('z-99') === 'true'); + assert(target.getAttribute('defined') === 'false'); + assert(target.getAttribute('data-foo') === '10'); + assert(target.property2 === -1); + assert(target.getAttribute('title') === 'a title'); + assert(target.getAttribute('data-bar') === 'data attribute'); + assert(target.getAttribute('class') === 'a b c'); + assert(target.property3.href === 'https://github.com/Netflix/x-element'); + assert(target.getAttribute('boolean') === ''); + assert(target.getAttribute('aria-label') === 'this is what it does'); + assert(target.textContent.trim() === 'influencing'); + container.remove(); + }); + it('renders multiple, interpolated content', () => { const getTemplate = ({ one, two }) => { return html` @@ -1058,6 +1127,38 @@ describe('rendering errors', () => { container.remove(); }); + it('throws when attempting non-trivial interpolation of a textarea tag', () => { + const getTemplate = ({ content }) => { + return html``; + }; + const container = document.createElement('div'); + document.body.append(container); + let error; + try { + render(container, getTemplate({ content: 'foo' })); + } catch (e) { + error = e; + } + assert(error?.message === `Only basic interpolation of "textarea" tags is allowed.`, error.message); + container.remove(); + }); + + it('throws when attempting non-trivial interpolation of a title tag', () => { + const getTemplate = ({ content }) => { + return html`please ${content} no`; + }; + const container = document.createElement('div'); + document.body.append(container); + let error; + try { + render(container, getTemplate({ defaultValue: 'foo' })); + } catch (e) { + error = e; + } + assert(error?.message === `Only basic interpolation of "title" tags is allowed.`, error.message); + container.remove(); + }); + it('throws for unquoted attributes', () => { const templateResultReference = html`
Gotta double-quote those.
`; const container = document.createElement('div'); diff --git a/x-element.js b/x-element.js index 59d976f..bc5ff8a 100644 --- a/x-element.js +++ b/x-element.js @@ -1018,6 +1018,7 @@ class TemplateEngine { static #DEFINED_PREFIX = 'x-element-defined'; static #PROPERTY_PREFIX = 'x-element-property'; static #CONTENT_PREFIX = 'x-element-content'; + static #ATTRIBUTE_PADDING = 6; // Patterns to find special edges in original html strings. static #OPEN_REGEX = /<[a-z][a-z0-9-]*(?=\s)/g; @@ -1475,6 +1476,9 @@ class TemplateEngine { TemplateEngine.#mapInner(node, startNode, identify, callback, value, 'repeat'); } + // 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) { if (!state.inside) { // We're outside the opening tag. @@ -1502,6 +1506,29 @@ class TemplateEngine { } } + // 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(type, strings) { const htmlStrings = []; const state = { inside: false, index: 0 }; @@ -1527,13 +1554,15 @@ class TemplateEngine { case '??': prefix = TemplateEngine.#DEFINED_PREFIX; syntax = 4; break; case '?': prefix = TemplateEngine.#BOOLEAN_PREFIX; syntax = 3; break; } - string = string.slice(0, -syntax - attribute.length) + `${prefix}-${iii}="${attribute}`; + const index = String(iii).padStart(TemplateEngine.#ATTRIBUTE_PADDING, '0'); + string = string.slice(0, -syntax - attribute.length) + `${prefix}-${index}="${attribute}`; } else { // We found a match like this: html`
`. // The syntax takes up 3 characters: `.${property}="`. const syntax = 3; const prefix = TemplateEngine.#PROPERTY_PREFIX; - string = string.slice(0, -syntax - property.length) + `${prefix}-${iii}="${property}`; + const index = String(iii).padStart(TemplateEngine.#ATTRIBUTE_PADDING, '0'); + string = string.slice(0, -syntax - property.length) + `${prefix}-${index}="${property}`; } state.index = 1; // Accounts for an expected quote character next. } else { @@ -1563,8 +1592,35 @@ class TemplateEngine { return template.content; } - static #findLookups(node, nodeType = Node.DOCUMENT_FRAGMENT_NODE, path = []) { - const lookups = []; + // 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. The only special handling we need to do is to + // ensure that we don’t rely on the ordering of NamedNodeMap objects since + // the spec doesn’t guarantee anything there (though in practice, it would + // probably work…). + // + // 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) { // Copy the live NamedNodeMap since we need to mutate it during iteration. @@ -1581,8 +1637,9 @@ class TemplateEngine { ? 'defined' : null; if (type) { + const index = Number(name.slice(-TemplateEngine.#ATTRIBUTE_PADDING)); const value = attribute.value; - lookups.push({ path, type, name: value }); + lookups[index] = { path, type, name: value }; node.removeAttribute(name); } } @@ -1593,13 +1650,14 @@ class TemplateEngine { node.textContent.includes(TemplateEngine.#CONTENT_PREFIX) ) { throw new Error(`Interpolation of "${localName}" tags is not allowed.`); - } else if ( - localName === 'plaintext' || - localName === 'textarea' || - localName === 'title' - ) { + } else if (localName === 'textarea' || localName === 'title') { if (node.textContent.includes(TemplateEngine.#CONTENT_PREFIX)) { - lookups.push({ path, type: 'text' }); + if (node.textContent === ``) { + node.textContent = ''; + lookups.push({ path, type: 'text' }); + } else { + throw new Error(`Only basic interpolation of "${localName}" tags is allowed.`); + } } } } else if ( @@ -1621,13 +1679,18 @@ class TemplateEngine { for (const childNode of node.childNodes) { const childNodeType = childNode.nodeType; if (childNodeType === Node.ELEMENT_NODE || Node.COMMENT_NODE) { - lookups.push(...TemplateEngine.#findLookups(childNode, childNodeType, [...path, iii++])); + TemplateEngine.#findLookups(childNode, childNodeType, lookups, [...path, iii++]); } } } - return lookups; + 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. static #findTargets(fragment, lookups) { const targets = []; const cache = new Map(); @@ -1659,6 +1722,7 @@ class TemplateEngine { return targets; } + // Create and prepare a document fragment to be injected into some container. static #ready(result) { if (result.readied) { throw new Error(`Unexpected re-injection of template result.`); @@ -1678,16 +1742,6 @@ class TemplateEngine { Object.assign(result, { fragment, entries }); } - static #inject(result, node, options) { - const nodes = result.type === 'svg' - ? result.fragment.firstChild.childNodes - : result.fragment.childNodes; - options?.before - ? TemplateEngine.#insertAllBefore(node.parentNode, node, nodes) - : TemplateEngine.#insertAllBefore(node, null, nodes); - result.fragment = null; - } - static #assign(result, newResult) { result.lastValues = result.values; result.values = newResult.values; @@ -1845,6 +1899,8 @@ class TemplateEngine { } } + // Bind the current values from a result by walking through each target and + // updating the DOM if things have changed. static #commit(result) { result.lastValues ??= result.values.map(() => TemplateEngine.#UNSET); const { entries, values, lastValues } = result; @@ -1862,6 +1918,18 @@ class TemplateEngine { } } + // Attach a document fragment into some container. Note that all the DOM in + // the fragment will already have values correctly bound. + static #inject(result, node, options) { + const nodes = result.type === 'svg' + ? result.fragment.firstChild.childNodes + : result.fragment.childNodes; + options?.before + ? TemplateEngine.#insertAllBefore(node.parentNode, node, nodes) + : TemplateEngine.#insertAllBefore(node, null, nodes); + result.fragment = null; + } + static #throwUpdaterError(updater, type) { switch (updater) { case TemplateEngine.#live: