From 5960ece55193f2903e13347cfe642d7b8e0eaa6b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Eddy=20Lo=CC=88wen?= Date: Wed, 6 Nov 2024 11:11:50 +0100 Subject: [PATCH 01/10] feat: implements support for interpolations inside text only nodes like textarea --- examples/raw-text-node/index.html | 13 +++ .../raw-text-node/raw-text-node-element.js | 14 +++ src/dom-parts/RawTextNodePart.js | 64 +++++++++++ src/dom-parts/TemplatePart.js | 4 + src/dom-parts/TemplateResult.js | 43 ++++++++ test/unit/raw-text-node-part.test.js | 101 ++++++++++++++++++ test/unit/renderer/template-bindings.js | 0 test/unit/template-bindings.test.js | 7 +- 8 files changed, 244 insertions(+), 2 deletions(-) create mode 100644 examples/raw-text-node/index.html create mode 100644 examples/raw-text-node/raw-text-node-element.js create mode 100644 src/dom-parts/RawTextNodePart.js create mode 100644 test/unit/raw-text-node-part.test.js create mode 100644 test/unit/renderer/template-bindings.js diff --git a/examples/raw-text-node/index.html b/examples/raw-text-node/index.html new file mode 100644 index 0000000..d09872a --- /dev/null +++ b/examples/raw-text-node/index.html @@ -0,0 +1,13 @@ + + + + + + + + +
+ +
+ + diff --git a/examples/raw-text-node/raw-text-node-element.js b/examples/raw-text-node/raw-text-node-element.js new file mode 100644 index 0000000..3a09841 --- /dev/null +++ b/examples/raw-text-node/raw-text-node-element.js @@ -0,0 +1,14 @@ +import { TemplateElement, defineElement, html } from '../../index.js'; + +class RawTextNodeElement extends TemplateElement { + properties() { + return { + text: 'Hello', + }; + } + + template() { + return html``; + } +} +defineElement('raw-text-node-element', RawTextNodeElement); diff --git a/src/dom-parts/RawTextNodePart.js b/src/dom-parts/RawTextNodePart.js new file mode 100644 index 0000000..330305f --- /dev/null +++ b/src/dom-parts/RawTextNodePart.js @@ -0,0 +1,64 @@ +import { Part } from './Part.js'; + +/** @type {Map} */ +const rawTextNodePartsCache = new WeakMap(); + +/** + * For updating a node that can only be updated via node.textContent + * The nodes are: script | style | textarea | title + */ +export class RawTextNodePart extends Part { + /** @type {Node} */ + node = undefined; + + interpolations = 1; + values = []; + currentValueIndex = 0; + initialValue = undefined; + + /** + * @param {Node} node + * @param {string} initialValue + */ + constructor(node, initialValue) { + // If we have multiple raw text node parts for the same node, it means we have multiple + // interpolations inside that node. Instead of creating a new part, we will return the same + // as before and let it defer the update until the last interpolation gets updated + const rawTextNodePart = rawTextNodePartsCache.get(node.nextElementSibling); + if (rawTextNodePart) { + rawTextNodePart.interpolations++; + node.__part = rawTextNodePart; // add Part to comment node for debugging in the browser + return rawTextNodePart; + } + + super(); + this.initialValue = initialValue; + node.__part = this; // add Part to comment node for debugging in the browser + this.node = node.nextElementSibling; + rawTextNodePartsCache.set(node.nextElementSibling, this); + } + + /** + * @param {string} value + */ + update(value) { + // If we only have one sole interpolation, we can just apply the update + if (this.interpolations === 1) { + this.node.textContent = value; + return; + } + + // Instead of applying the update immediately, we check if the part has multiple interpolations + // and store the values for each interpolation in a list + this.values[this.currentValueIndex++] = value; + + // Only the last call to update (for the last interpolation) will actually trigger the update + // on the DOM processor. Here we can reset everything before the next round of updates + if (this.interpolations === this.currentValueIndex) { + this.currentValueIndex = 0; + let replaceIndex = 0; + // Note: this will coarse the values into strings, but it's probably ok since we are writing to raw text (only) nodes?! + this.node.textContent = this.initialValue.replace(/\x03/g, () => this.values[replaceIndex++]); + } + } +} diff --git a/src/dom-parts/TemplatePart.js b/src/dom-parts/TemplatePart.js index 7af76bc..24bfac7 100644 --- a/src/dom-parts/TemplatePart.js +++ b/src/dom-parts/TemplatePart.js @@ -4,6 +4,7 @@ import { TemplateResult } from './TemplateResult.js'; import { AttributePart } from './AttributePart.js'; import { ChildNodePart, processNodePart } from './ChildNodePart.js'; import { NodePart } from './NodePart.js'; +import { RawTextNodePart } from './RawTextNodePart.js'; /** @type {Map} */ const partsCache = new WeakMap(); @@ -124,6 +125,9 @@ export class TemplatePart extends Part { if (part.type === 'attribute') { return new AttributePart(part.node, part.name, part.initialValue); } + if (part.type === 'raw-text-node') { + return new RawTextNodePart(part.node, part.initialValue); + } if (part.type === 'directive') { return new NodePart(part.node, templateResult.values[index]); } diff --git a/src/dom-parts/TemplateResult.js b/src/dom-parts/TemplateResult.js index 76400eb..1dab1f3 100644 --- a/src/dom-parts/TemplateResult.js +++ b/src/dom-parts/TemplateResult.js @@ -3,6 +3,7 @@ import { encodeAttribute, isObjectLike } from '../util/AttributeParser.js'; import { TemplatePart } from './TemplatePart.js'; const voidElements = /^(?:area|base|br|col|embed|hr|img|input|link|meta|source|track|wbr)$/i; +const rawTextElements = /<(script|style|textarea|title)([^>]*)>(.*?)<\/\1>/i; const elements = /<([a-z]+[a-z0-9:._-]*)([^>]*?)(\/?)>/g; // TODO: v this will not match any values with escaped quotes like onClick='console.log("\'test")' const attributes = /([^\s]*)=((?:")[^"]*(?:")|(?:')[^']*(?:')|[^\s\/>]*)|([^\s\/>]*)/g; @@ -55,6 +56,37 @@ export const createTemplateString = (templateStrings, attributePlaceholders = '' <${elementTagWithAttributes}>\n `.trim(); }); + /** + * Processes the HTML template content by identifying specific "raw text" nodes (e.g., + + + +
+ +
+ + diff --git a/src/dom-parts/TemplateResult.js b/src/dom-parts/TemplateResult.js index 1dab1f3..97c6ef0 100644 --- a/src/dom-parts/TemplateResult.js +++ b/src/dom-parts/TemplateResult.js @@ -13,8 +13,28 @@ const partPositions = /[\x01\x02]/g; // \x03 COMMENT.ATTRIBUTE_TOKEN // \x04 Node.ATTRIBUTE_TOKEN +const partPlaceholders = { + CHILD_NODE: '<>', + ATTRIBUTE: '<>', + DIRECTIVE: '<>', + RAW_TEXT_NODE: '<>', +}; + const interpolation = new RegExp(`(|(\\S[-\\w]+)="\x04(")?|\x04)`, 'g'); +const placeholder = /{{dom-part\?(.*?)}}/g; +function makePlaceholder(type, params) { + const urlSearchParams = new URLSearchParams({ type, ...params }); + const queryString = urlSearchParams.toString(); + return `{{dom-part?${queryString}}}`; +} + +// TODO: instead of only adding placeholders for attributes for ssr, lets add placeholders for all interpolations. +// TODO: lets use some kind of "Custom Escape Sequence" or "Control Character" for all the different parts +// TODO: that way when ssring, we can split (with regex) the result and get a modified strings array and cache that +// TODO: and also by looping over splits, we can know exactly what kind of part we have and create the update function for it +// TODO: and the best part is, we only need to write the value then and not the part comment markers and everything else + /** * Given a template, find part positions as both nodes and attributes and * return a string with placeholders as either comment nodes or named attributes. @@ -29,12 +49,13 @@ export const createTemplateString = (templateStrings, attributePlaceholders = '' let template = templateStrings.join('\x01').trim(); // find (match) all elements to identify their attributes template = template.replace(elements, (_, name, attributesString, trailingSlash) => { - let elementTagWithAttributes = name + attributesString.replaceAll('\x01', attributePlaceholders).trimEnd(); + let elementTagWithAttributes = name + attributesString; // TODO the closing case is weird. if (trailingSlash.length) elementTagWithAttributes += voidElements.test(name) ? ' /' : '> { if (directive && directive === '\x01') { + elementTagWithAttributes = elementTagWithAttributes.replace(attribute, ''); return ``; } @@ -48,12 +69,17 @@ export const createTemplateString = (templateStrings, attributePlaceholders = '' for (let index = 0; index < partsCount; index++) { parts.push(``); } + + if (parts.length > 0) { + elementTagWithAttributes = elementTagWithAttributes.replace(attribute, ''); + } + return parts.join(''); }); // TODO create test to check if lf are in place return ` ${attributesString.includes('\x01') ? attributeParts : ''}\n - <${elementTagWithAttributes}>\n + <${elementTagWithAttributes.trimEnd()}>\n `.trim(); }); /** @@ -100,6 +126,114 @@ export const createTemplateString = (templateStrings, attributePlaceholders = '' return `\n${template}\n`.trim(); }; +export const createTemplateString2 = (templateStrings, ssr = false) => { + // TODO: make it easier to identify attribute and node parts for SSR and leave the comments at those positions to be replaced in toString() + let partIndex = 0; + // join all interpolations (for values) with a special placeholder and remove any whitespace + let template = templateStrings.join('\x01').trim(); + // find (match) all elements to identify their attributes + template = template.replace(elements, (_, name, attributesString, trailingSlash) => { + let elementTagWithAttributes = name + attributesString; + // TODO the closing case is weird. + if (trailingSlash.length) elementTagWithAttributes += voidElements.test(name) ? ' /' : '> { + if (directive && directive === '\x01') { + elementTagWithAttributes = elementTagWithAttributes.replace( + attribute, + ssr ? makePlaceholder('directive') : '', + ); + return ``; + } + + // remove quotes from attribute value to normalize the value + const value = + valueWithQuotes?.startsWith('"') || valueWithQuotes?.startsWith("'") + ? valueWithQuotes.slice(1, -1) + : valueWithQuotes; + const partsCount = (attribute.match(/\x01/g) || []).length; + const parts = []; + for (let index = 0; index < partsCount; index++) { + // TODO: maybe we could use url parameters here?! -> ?type=attribute&name=${name}&initialValue=${initialValue} + parts.push(``); + } + + if (parts.length > 0) { + // TODO: we need to handle special attributes (?|.|@) differently ?! + // TODO: or we can tell multiple attribute interpolations to not set the attribute name?! + let replacement = ''; + if (ssr) { + for (let index = 0; index < partsCount - 1; index++) { + replacement = replacement + makePlaceholder('noop'); + } + replacement = + replacement + + makePlaceholder('attribute', { + name, + interpolations: partsCount, + initialValue: value.replaceAll('\x01', '\x03'), + }); + } + elementTagWithAttributes = elementTagWithAttributes.replace(attribute, replacement); + } + + return parts.join(''); + }); + // TODO create test to check if lf are in place + return ` + ${attributesString.includes('\x01') ? attributeParts : ''}\n + <${elementTagWithAttributes.trimEnd()}>\n + `.trim(); + }); + /** + * Processes the HTML template content by identifying specific "raw text" nodes (e.g.,