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
+
+ console.log('clicked')}">@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) ? ' /' : '>' + name;
// collect all attribute parts so that we can place matching comment nodes
const attributeParts = attributesString.replace(attributes, (attribute, name, valueWithQuotes, directive) => {
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.,