diff --git a/examples/attributes/attributes-element.js b/examples/attributes/attributes-element.js new file mode 100644 index 0000000..fda8969 --- /dev/null +++ b/examples/attributes/attributes-element.js @@ -0,0 +1,29 @@ +import { TemplateElement, defineElement, html, Directive, defineDirective } from '../../index.js'; + +const directive = defineDirective(class extends Directive {}); + +class AttributesElement extends TemplateElement { + properties() { + return { + text: 'Hello', + }; + } + + template() { + const templateResult = html` +
no interpolation
+
single attribute interpolation
+
multiple attribute interpolations
+
multiple interpolations in single attribute
+
boolean attributes
+
+ property attributes .data and .list +
+
@event listener attribute
+
directive interpolation
+ `; + console.log('templateResult', templateResult.toString()); + return templateResult; + } +} +defineElement('attributes-element', AttributesElement); diff --git a/examples/attributes/index.html b/examples/attributes/index.html new file mode 100644 index 0000000..04438e4 --- /dev/null +++ b/examples/attributes/index.html @@ -0,0 +1,13 @@ + + + + + + + + +
+ +
+ + 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..8127e35 --- /dev/null +++ b/examples/raw-text-node/raw-text-node-element.js @@ -0,0 +1,16 @@ +import { TemplateElement, defineElement, html } from '../../index.js'; + +class RawTextNodeElement extends TemplateElement { + properties() { + return { + text: 'Hello', + }; + } + + template() { + const templateResult = html``; + console.log('templateResult', templateResult.toString()); + return templateResult; + } +} +defineElement('raw-text-node-element', RawTextNodeElement); diff --git a/src/dom-parts/AttributePart.js b/src/dom-parts/AttributePart.js index 7081ade..efb9535 100644 --- a/src/dom-parts/AttributePart.js +++ b/src/dom-parts/AttributePart.js @@ -7,8 +7,6 @@ import { Part } from './Part.js'; * @return {(function(*): void)|*} */ const processBooleanAttribute = (node, name, oldValue) => { - // TODO: It would be better if the ?boolean= attribute would not be there in the first place... - node.removeAttribute(`?${name}`); return (newValue) => { const value = !!newValue?.valueOf() && newValue !== 'false'; if (oldValue !== value) { @@ -23,8 +21,6 @@ const processBooleanAttribute = (node, name, oldValue) => { * @return {(function(*): void)|*} */ const processPropertyAttribute = (node, name) => { - // TODO: It would be better if the .property= attribute would not be there in the first place... - node.removeAttribute(`.${name}`); return (value) => { node[name] = value; }; @@ -36,9 +32,6 @@ const processPropertyAttribute = (node, name) => { * @return {(function(*): void)|*} */ const processEventAttribute = (node, name) => { - // TODO: It would be better if the event attribute would not be there in the first place... - node.removeAttribute(name); - let oldValue = undefined; let type = name.startsWith('@') ? name.slice(1) : name.toLowerCase().slice(2); 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..043effe 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; @@ -12,52 +13,115 @@ const partPositions = /[\x01\x02]/g; // \x03 COMMENT.ATTRIBUTE_TOKEN // \x04 Node.ATTRIBUTE_TOKEN -const interpolation = new RegExp(`(|(\\S[-\\w]+)="\x04(")?|\x04)`, 'g'); +const ssrPlaceholder = /{{dom-part\?(.*?)}}/g; +function makeSSRPlaceholder(type, params) { + const urlSearchParams = new URLSearchParams({ type, ...params }); + const queryString = urlSearchParams.toString(); + return `{{dom-part?${queryString}}}`; +} /** * Given a template, find part positions as both nodes and attributes and * return a string with placeholders as either comment nodes or named attributes. * @param {TemplateStringsArray | string[]} templateStrings a template literal tag array - * @param {string} attributePlaceholders replace placeholders inside attribute values with this value + * @param {boolean} ssr replace interpolations with placeholders * @returns {string} X/HTML with prefixed comments or attributes */ -export const createTemplateString = (templateStrings, attributePlaceholders = '') => { - // TODO: make it easier to identify attribute and node parts for SSR and leave the comments at those positions to be replaced in toString() +export const createTemplateString = (templateStrings, ssr = false) => { 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.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, + ssr ? makeSSRPlaceholder('directive') : '', + ); return ``; } // remove quotes from attribute value to normalize the value - const value = - valueWithQuotes?.startsWith('"') || valueWithQuotes?.startsWith("'") - ? valueWithQuotes.slice(1, -1) - : valueWithQuotes; + const hasQuotes = valueWithQuotes?.startsWith('"') || valueWithQuotes?.startsWith("'"); + const quotes = hasQuotes ? valueWithQuotes.at(0) : ''; + const value = hasQuotes ? 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) { + let replacement = ''; + if (ssr) { + for (let index = 0; index < partsCount - 1; index++) { + replacement = replacement + makeSSRPlaceholder('noop'); + } + replacement = + replacement + + makeSSRPlaceholder('attribute', { + name, + interpolations: partsCount, + initialValue: value.replaceAll('\x01', '\x03'), + quotes, + }); + } + 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}>\n + <${elementTagWithAttributes.trimEnd()}>\n `.trim(); }); + /** + * Processes the HTML template content by identifying specific "raw text" nodes (e.g.,