@webtides/element-js 1.1.2 → 1.1.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -16,6 +16,20 @@ to [Semantic Versioning](http://semver.org/).
16
16
  <!-- ### Removed -->
17
17
  <!-- ### Fixed -->
18
18
 
19
+ ## [1.1.4] - 2024-11-12
20
+
21
+ ### FIXED
22
+
23
+ - Fixed rendering interpolations inside textarea nodes ([#133](https://github.com/webtides/element-js/issues/133))
24
+
25
+ ## [1.1.3] - 2024-11-11
26
+
27
+ ### FIXED
28
+
29
+ - Fixed unsafeHtml having more than one child ([#135](https://github.com/webtides/element-js/issues/135))
30
+ - Fixed early returns from templates and fixes static html changes not reflecting to DOM ([#132](https://github.com/webtides/element-js/issues/132))
31
+
32
+
19
33
  ## [1.1.2] - 2024-10-30
20
34
 
21
35
  ### FIXED
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@webtides/element-js",
3
- "version": "1.1.2",
3
+ "version": "1.1.4",
4
4
  "module": "index.js",
5
5
  "main": "index.js",
6
6
  "type": "module",
@@ -7,8 +7,6 @@ import { Part } from './Part.js';
7
7
  * @return {(function(*): void)|*}
8
8
  */
9
9
  const processBooleanAttribute = (node, name, oldValue) => {
10
- // TODO: It would be better if the ?boolean= attribute would not be there in the first place...
11
- node.removeAttribute(`?${name}`);
12
10
  return (newValue) => {
13
11
  const value = !!newValue?.valueOf() && newValue !== 'false';
14
12
  if (oldValue !== value) {
@@ -23,8 +21,6 @@ const processBooleanAttribute = (node, name, oldValue) => {
23
21
  * @return {(function(*): void)|*}
24
22
  */
25
23
  const processPropertyAttribute = (node, name) => {
26
- // TODO: It would be better if the .property= attribute would not be there in the first place...
27
- node.removeAttribute(`.${name}`);
28
24
  return (value) => {
29
25
  node[name] = value;
30
26
  };
@@ -36,9 +32,6 @@ const processPropertyAttribute = (node, name) => {
36
32
  * @return {(function(*): void)|*}
37
33
  */
38
34
  const processEventAttribute = (node, name) => {
39
- // TODO: It would be better if the event attribute would not be there in the first place...
40
- node.removeAttribute(name);
41
-
42
35
  let oldValue = undefined;
43
36
  let type = name.startsWith('@') ? name.slice(1) : name.toLowerCase().slice(2);
44
37
 
@@ -8,6 +8,9 @@ import { TemplatePart } from './TemplatePart.js';
8
8
  * @return {ChildNode}
9
9
  */
10
10
  const removeChildNodes = (node) => {
11
+ if (!Array.isArray(node) && !(node instanceof TemplatePart) && !node.hasChildNodes()) {
12
+ return node;
13
+ }
11
14
  const range = globalThis.document?.createRange();
12
15
  const firstChild = Array.isArray(node)
13
16
  ? node[0]
@@ -48,8 +51,25 @@ const removeNodesBetweenComments = (commentNode) => {
48
51
  * @return {Node[]}
49
52
  */
50
53
  const diffChildNodes = function (parentNode, domChildNodes, templateChildNodes, anchorNode) {
54
+ // We always want to have the two arrays (domChildNodes and templateChildNodes) to have the same length.
55
+ // We also don't ever want to replace/diff the last node of the domChildNodes as it will always be the
56
+ // closing comment marker (/template-part) aka the anchorNode. So we push it to the end by inserting undefined items.
57
+ if (templateChildNodes.length > domChildNodes.length) {
58
+ const nodesToFill = templateChildNodes.length - domChildNodes.length;
59
+ for (let i = 0; i < nodesToFill; i++) {
60
+ domChildNodes.splice(domChildNodes.length - 1, 0, undefined);
61
+ }
62
+ }
63
+
64
+ if (domChildNodes.length > templateChildNodes.length) {
65
+ const nodesToFill = domChildNodes.length - templateChildNodes.length;
66
+ for (let i = 0; i < nodesToFill; i++) {
67
+ templateChildNodes.splice(templateChildNodes.length - 1, 0, undefined);
68
+ }
69
+ }
70
+
71
+ const length = domChildNodes.length;
51
72
  // Diff each node in the child node lists
52
- let length = Math.max(templateChildNodes.length, domChildNodes.length);
53
73
  for (let index = 0; index < length; index++) {
54
74
  let domChildNode = domChildNodes[index];
55
75
  let templateChildNode = templateChildNodes[index];
@@ -58,6 +78,8 @@ const diffChildNodes = function (parentNode, domChildNodes, templateChildNodes,
58
78
  if (!domChildNode) {
59
79
  if (templateChildNode instanceof TemplatePart) {
60
80
  anchorNode.before(...templateChildNode.childNodes);
81
+ } else if (Array.isArray(templateChildNode)) {
82
+ anchorNode.before(...templateChildNode);
61
83
  } else if (typeof templateChildNode === 'object' && 'ELEMENT_NODE' in templateChildNode) {
62
84
  parentNode.insertBefore(templateChildNode, anchorNode);
63
85
  } else {
@@ -75,7 +97,12 @@ const diffChildNodes = function (parentNode, domChildNodes, templateChildNodes,
75
97
  }
76
98
 
77
99
  // If DOM node is equal to the template node, don't do anything
78
- if (domChildNode === templateChildNode) {
100
+ if (
101
+ domChildNode === templateChildNode ||
102
+ (domChildNode.nodeType === COMMENT_NODE &&
103
+ templateChildNode.nodeType === COMMENT_NODE &&
104
+ domChildNode.isEqualNode(templateChildNode))
105
+ ) {
79
106
  continue;
80
107
  }
81
108
 
@@ -91,7 +118,7 @@ const diffChildNodes = function (parentNode, domChildNodes, templateChildNodes,
91
118
  * @return {(function(*): void)|*}
92
119
  */
93
120
  export const processNodePart = (comment, initialValue) => {
94
- let nodes = [];
121
+ let nodes = initialValue instanceof TemplatePart ? [...initialValue.childNodes] : [];
95
122
  let oldValue = initialValue instanceof TemplatePart ? [...initialValue.childNodes] : initialValue;
96
123
  // this is for string values to be inserted into the DOM. A cached TextNode will be used so that we don't have to constantly create new DOM nodes.
97
124
  let cachedTextNode = undefined;
@@ -145,7 +172,7 @@ export const processNodePart = (comment, initialValue) => {
145
172
  oldValue = newValue;
146
173
  nodes = diffChildNodes(comment.parentNode, nodes, [newValue], comment);
147
174
  } else if (newValue instanceof TemplatePart) {
148
- oldValue = newValue;
175
+ oldValue = [...newValue.childNodes];
149
176
  nodes = diffChildNodes(comment.parentNode, nodes, [...newValue.childNodes], comment);
150
177
  } else {
151
178
  console.warn('Could not process value', newValue);
@@ -0,0 +1,64 @@
1
+ import { Part } from './Part.js';
2
+
3
+ /** @type {Map<Element, RawTextNodePart>} */
4
+ const rawTextNodePartsCache = new WeakMap();
5
+
6
+ /**
7
+ * For updating a node that can only be updated via node.textContent
8
+ * The nodes are: script | style | textarea | title
9
+ */
10
+ export class RawTextNodePart extends Part {
11
+ /** @type {Node} */
12
+ node = undefined;
13
+
14
+ interpolations = 1;
15
+ values = [];
16
+ currentValueIndex = 0;
17
+ initialValue = undefined;
18
+
19
+ /**
20
+ * @param {Node} node
21
+ * @param {string} initialValue
22
+ */
23
+ constructor(node, initialValue) {
24
+ // If we have multiple raw text node parts for the same node, it means we have multiple
25
+ // interpolations inside that node. Instead of creating a new part, we will return the same
26
+ // as before and let it defer the update until the last interpolation gets updated
27
+ const rawTextNodePart = rawTextNodePartsCache.get(node.nextElementSibling);
28
+ if (rawTextNodePart) {
29
+ rawTextNodePart.interpolations++;
30
+ node.__part = rawTextNodePart; // add Part to comment node for debugging in the browser
31
+ return rawTextNodePart;
32
+ }
33
+
34
+ super();
35
+ this.initialValue = initialValue;
36
+ node.__part = this; // add Part to comment node for debugging in the browser
37
+ this.node = node.nextElementSibling;
38
+ rawTextNodePartsCache.set(node.nextElementSibling, this);
39
+ }
40
+
41
+ /**
42
+ * @param {string} value
43
+ */
44
+ update(value) {
45
+ // If we only have one sole interpolation, we can just apply the update
46
+ if (this.interpolations === 1) {
47
+ this.node.textContent = value;
48
+ return;
49
+ }
50
+
51
+ // Instead of applying the update immediately, we check if the part has multiple interpolations
52
+ // and store the values for each interpolation in a list
53
+ this.values[this.currentValueIndex++] = value;
54
+
55
+ // Only the last call to update (for the last interpolation) will actually trigger the update
56
+ // on the DOM processor. Here we can reset everything before the next round of updates
57
+ if (this.interpolations === this.currentValueIndex) {
58
+ this.currentValueIndex = 0;
59
+ let replaceIndex = 0;
60
+ // Note: this will coarse the values into strings, but it's probably ok since we are writing to raw text (only) nodes?!
61
+ this.node.textContent = this.initialValue.replace(/\x03/g, () => this.values[replaceIndex++]);
62
+ }
63
+ }
64
+ }
@@ -2,8 +2,9 @@ import { COMMENT_NODE, convertStringToTemplate } from '../util/DOMHelper.js';
2
2
  import { Part } from './Part.js';
3
3
  import { TemplateResult } from './TemplateResult.js';
4
4
  import { AttributePart } from './AttributePart.js';
5
- import { ChildNodePart } from './ChildNodePart.js';
5
+ import { ChildNodePart, processNodePart } from './ChildNodePart.js';
6
6
  import { NodePart } from './NodePart.js';
7
+ import { RawTextNodePart } from './RawTextNodePart.js';
7
8
 
8
9
  /** @type {Map<TemplateStringsArray, Part[]>} */
9
10
  const partsCache = new WeakMap();
@@ -50,8 +51,11 @@ export class TemplatePart extends Part {
50
51
  // if not SSRed, childNodes will only ever have two comment nodes, the start and the end marker
51
52
  if (childNodes.length > 2) {
52
53
  serverSideRendered = true;
53
- this.childNodes = childNodes;
54
54
  }
55
+
56
+ this.childNodes = childNodes;
57
+ this.startNode = startNode;
58
+ this.endNode = endNode;
55
59
  }
56
60
 
57
61
  this.parseValue(value);
@@ -61,6 +65,13 @@ export class TemplatePart extends Part {
61
65
  // We need a childNodes list that is NOT live so that we don't loose elements when they get removed from the dom and we can (re)add them back in later.
62
66
  this.childNodes = [...this.childNodes];
63
67
  }
68
+
69
+ this.startNode = this.childNodes[0];
70
+ this.endNode = this.childNodes[this.childNodes.length - 1];
71
+
72
+ if (this.endNode) {
73
+ this.processor = processNodePart(this.endNode, this);
74
+ }
64
75
  }
65
76
 
66
77
  /**
@@ -69,6 +80,8 @@ export class TemplatePart extends Part {
69
80
  update(value) {
70
81
  this.parseValue(value);
71
82
  this.updateParts(value.values);
83
+ this.childNodes = [...this.childNodes];
84
+ this.processor?.(this);
72
85
  }
73
86
 
74
87
  /**
@@ -85,15 +98,13 @@ export class TemplatePart extends Part {
85
98
  */
86
99
  parseValue(templateResult) {
87
100
  if (this.strings !== templateResult.strings) {
88
- if (this.childNodes.length === 0) {
89
- let fragment = fragmentsCache.get(templateResult.strings);
90
- if (!fragment) {
91
- fragment = this.parseFragment(templateResult);
92
- fragmentsCache.set(templateResult.strings, fragment);
93
- }
94
- const importedFragment = globalThis.document?.importNode(fragment, true);
95
- this.childNodes = importedFragment.childNodes;
101
+ let fragment = fragmentsCache.get(templateResult.strings);
102
+ if (!fragment) {
103
+ fragment = this.parseFragment(templateResult);
104
+ fragmentsCache.set(templateResult.strings, fragment);
96
105
  }
106
+ const importedFragment = globalThis.document?.importNode(fragment, true);
107
+ this.childNodes = importedFragment.childNodes;
97
108
 
98
109
  let parts = partsCache.get(templateResult.strings);
99
110
  if (!parts) {
@@ -114,6 +125,9 @@ export class TemplatePart extends Part {
114
125
  if (part.type === 'attribute') {
115
126
  return new AttributePart(part.node, part.name, part.initialValue);
116
127
  }
128
+ if (part.type === 'raw-text-node') {
129
+ return new RawTextNodePart(part.node, part.initialValue);
130
+ }
117
131
  if (part.type === 'directive') {
118
132
  return new NodePart(part.node, templateResult.values[index]);
119
133
  }
@@ -121,6 +135,7 @@ export class TemplatePart extends Part {
121
135
  });
122
136
  this.strings = templateResult.strings;
123
137
  }
138
+ return this.childNodes;
124
139
  }
125
140
 
126
141
  /**
@@ -3,6 +3,7 @@ import { encodeAttribute, isObjectLike } from '../util/AttributeParser.js';
3
3
  import { TemplatePart } from './TemplatePart.js';
4
4
 
5
5
  const voidElements = /^(?:area|base|br|col|embed|hr|img|input|link|meta|source|track|wbr)$/i;
6
+ const rawTextElements = /<(script|style|textarea|title)([^>]*)>(.*?)<\/\1>/i;
6
7
  const elements = /<([a-z]+[a-z0-9:._-]*)([^>]*?)(\/?)>/g;
7
8
  // TODO: v this will not match any values with escaped quotes like onClick='console.log("\'test")'
8
9
  const attributes = /([^\s]*)=((?:")[^"]*(?:")|(?:')[^']*(?:')|[^\s\/>]*)|([^\s\/>]*)/g;
@@ -12,52 +13,115 @@ const partPositions = /[\x01\x02]/g;
12
13
  // \x03 COMMENT.ATTRIBUTE_TOKEN
13
14
  // \x04 Node.ATTRIBUTE_TOKEN
14
15
 
15
- const interpolation = new RegExp(`(<!--dom-part-(\\d+)--><!--/dom-part-(\\d+)-->|(\\S[-\\w]+)="\x04(")?|\x04)`, 'g');
16
+ const ssrPlaceholder = /{{dom-part\?(.*?)}}/g;
17
+ function makeSSRPlaceholder(type, params) {
18
+ const urlSearchParams = new URLSearchParams({ type, ...params });
19
+ const queryString = urlSearchParams.toString();
20
+ return `{{dom-part?${queryString}}}`;
21
+ }
16
22
 
17
23
  /**
18
24
  * Given a template, find part positions as both nodes and attributes and
19
25
  * return a string with placeholders as either comment nodes or named attributes.
20
26
  * @param {TemplateStringsArray | string[]} templateStrings a template literal tag array
21
- * @param {string} attributePlaceholders replace placeholders inside attribute values with this value
27
+ * @param {boolean} ssr replace interpolations with placeholders
22
28
  * @returns {string} X/HTML with prefixed comments or attributes
23
29
  */
24
- export const createTemplateString = (templateStrings, attributePlaceholders = '') => {
25
- // TODO: make it easier to identify attribute and node parts for SSR and leave the comments at those positions to be replaced in toString()
30
+ export const createTemplateString = (templateStrings, ssr = false) => {
26
31
  let partIndex = 0;
27
32
  // join all interpolations (for values) with a special placeholder and remove any whitespace
28
33
  let template = templateStrings.join('\x01').trim();
29
34
  // find (match) all elements to identify their attributes
30
35
  template = template.replace(elements, (_, name, attributesString, trailingSlash) => {
31
- let elementTagWithAttributes = name + attributesString.replaceAll('\x01', attributePlaceholders).trimEnd();
36
+ let elementTagWithAttributes = name + attributesString;
32
37
  // TODO the closing case is weird.
33
38
  if (trailingSlash.length) elementTagWithAttributes += voidElements.test(name) ? ' /' : '></' + name;
34
39
  // collect all attribute parts so that we can place matching comment nodes
35
40
  const attributeParts = attributesString.replace(attributes, (attribute, name, valueWithQuotes, directive) => {
36
41
  if (directive && directive === '\x01') {
42
+ elementTagWithAttributes = elementTagWithAttributes.replace(
43
+ attribute,
44
+ ssr ? makeSSRPlaceholder('directive') : '',
45
+ );
37
46
  return `<!--\x02$-->`;
38
47
  }
39
48
 
40
49
  // remove quotes from attribute value to normalize the value
41
- const value =
42
- valueWithQuotes?.startsWith('"') || valueWithQuotes?.startsWith("'")
43
- ? valueWithQuotes.slice(1, -1)
44
- : valueWithQuotes;
50
+ const hasQuotes = valueWithQuotes?.startsWith('"') || valueWithQuotes?.startsWith("'");
51
+ const quotes = hasQuotes ? valueWithQuotes.at(0) : '';
52
+ const value = hasQuotes ? valueWithQuotes.slice(1, -1) : valueWithQuotes;
45
53
  const partsCount = (attribute.match(/\x01/g) || []).length;
46
54
  const parts = [];
47
55
  for (let index = 0; index < partsCount; index++) {
56
+ // TODO: maybe we could use url parameters here?! -> ?type=attribute&name=${name}&initialValue=${initialValue}
48
57
  parts.push(`<!--\x02:${name}=${value.replaceAll('\x01', '\x03')}-->`);
49
58
  }
59
+
60
+ if (parts.length > 0) {
61
+ let replacement = '';
62
+ if (ssr) {
63
+ for (let index = 0; index < partsCount - 1; index++) {
64
+ replacement = replacement + makeSSRPlaceholder('noop');
65
+ }
66
+ replacement =
67
+ replacement +
68
+ makeSSRPlaceholder('attribute', {
69
+ name,
70
+ interpolations: partsCount,
71
+ initialValue: value.replaceAll('\x01', '\x03'),
72
+ quotes,
73
+ });
74
+ }
75
+ elementTagWithAttributes = elementTagWithAttributes.replace(attribute, replacement);
76
+ }
77
+
50
78
  return parts.join('');
51
79
  });
52
80
  // TODO create test to check if lf are in place
53
81
  return `
54
82
  ${attributesString.includes('\x01') ? attributeParts : ''}\n
55
- <${elementTagWithAttributes}>\n
83
+ <${elementTagWithAttributes.trimEnd()}>\n
56
84
  `.trim();
57
85
  });
86
+ /**
87
+ * Processes the HTML template content by identifying specific "raw text" nodes (e.g., <script>, <style>, <textarea>, <title>)
88
+ * and inserts comments before each instance of an interpolation within these nodes.
89
+ * The placeholders are represented by a control character (in this case, "\x01").
90
+ *
91
+ * Explanation of logic:
92
+ * - The main regex, `rawTextElements`, identifies and captures specific HTML tags (script, style, textarea, title)
93
+ * along with their attributes and content. This regex captures:
94
+ * 1. `tag`: The tag name (e.g., "textarea").
95
+ * 2. `attributes`: Any attributes within the opening tag (e.g., `foo="bar"`).
96
+ * 3. `content`: The inner content within the tag (which may contain interpolations).
97
+ * - Inside the replace function:
98
+ * - The number of placeholder occurrences (`\x01` instances) in `content` is counted to determine
99
+ * how many parts (comments) should be generated.
100
+ * - An array `parts` is created, where each entry represents a comment to be inserted before the raw text node.
101
+ * Each comment includes a modified version of `content` where each placeholder (`\x01`) is replaced with a
102
+ * different control character (`\x03`) for later differentiation.
103
+ * - The `content` is then modified by removing each interpolation (`\x01` is removed), leaving the raw text
104
+ * content without markers.
105
+ * - The processed comments in `parts` are joined and placed before the modified raw text node, preserving
106
+ * its tag, attributes, and inner content without placeholders.
107
+ * - The function finally returns the modified template string with comments in the correct places.
108
+ */
109
+ template = template.replace(rawTextElements, (match, tag, attributes, content) => {
110
+ const partsCount = (content.match(/\x01/g) || []).length;
111
+ const parts = [];
112
+ for (let index = 0; index < partsCount; index++) {
113
+ // TODO: maybe we could use url parameters here?! -> ?type=raw-text-node&initialValue=${initialValue}
114
+ parts.push(`<!--\x02/raw-text-node=${content.replaceAll('\x01', '\x03')}-->`);
115
+ }
116
+ const parsedContent = content.replaceAll('\x01', ssr ? makeSSRPlaceholder('raw-text-node') : '');
117
+ return `${parts.join('')}<${tag}${attributes}>${parsedContent}</${tag}>`;
118
+ });
58
119
  // replace interpolation placeholders with our indexed markers
59
120
  template = template.replace(partPositions, (partPosition) => {
60
121
  if (partPosition === '\x01') {
122
+ if (ssr) {
123
+ return `<!--dom-part-${partIndex}-->${makeSSRPlaceholder('node')}<!--/dom-part-${partIndex++}-->`;
124
+ }
61
125
  return `<!--dom-part-${partIndex}--><!--/dom-part-${partIndex++}-->`;
62
126
  } else if (partPosition === '\x02') {
63
127
  return `dom-part-${partIndex++}`;
@@ -71,13 +135,11 @@ export const createTemplateString = (templateStrings, attributePlaceholders = ''
71
135
  /**
72
136
  * @param {String} name
73
137
  * @param {any} value
74
- * @param {Boolean} isSingleValue
138
+ * @param {string} quotes
75
139
  * @return {String}
76
140
  */
77
- const attribute = (name, value, isSingleValue = true) => {
78
- return ` ${name}="${encodeAttribute(isObjectLike(value) ? JSON.stringify(value) : value)}${
79
- isSingleValue ? '"' : ''
80
- }`;
141
+ const attribute = (name, value, quotes = '"') => {
142
+ return ` ${name}=${quotes}${encodeAttribute(isObjectLike(value) ? JSON.stringify(value) : value)}${quotes}`;
81
143
  };
82
144
 
83
145
  /**
@@ -111,11 +173,14 @@ const getValue = (value) => {
111
173
  return value == null ? '' : encodeAttribute(String(value));
112
174
  };
113
175
 
114
- /** @type {Map<TemplateStringsArray, *[]>} */
115
- const parsedUpdates = new WeakMap();
176
+ /** @type {Map<TemplateStringsArray, string>} */
177
+ const ssrTemplateStringsCache = new WeakMap();
178
+
179
+ /** @type {Map<TemplateStringsArray, (() => String)[]>} */
180
+ const ssrUpdatesCache = new WeakMap();
116
181
 
117
182
  /** @type {Map<Element, TemplatePart>} */
118
- const templateParts = new WeakMap();
183
+ const templatePartsCache = new WeakMap();
119
184
 
120
185
  export class TemplateResult {
121
186
  /**
@@ -132,7 +197,7 @@ export class TemplateResult {
132
197
  */
133
198
  renderInto(domNode) {
134
199
  let serverSideRendered = false;
135
- let templatePart = templateParts.get(domNode);
200
+ let templatePart = templatePartsCache.get(domNode);
136
201
  if (!templatePart) {
137
202
  const startNode = Array.from(domNode.childNodes)
138
203
  .filter((node) => node.nodeType === COMMENT_NODE)
@@ -141,7 +206,7 @@ export class TemplateResult {
141
206
  serverSideRendered = startNode !== undefined;
142
207
 
143
208
  templatePart = new TemplatePart(startNode, this);
144
- templateParts.set(domNode, templatePart);
209
+ templatePartsCache.set(domNode, templatePart);
145
210
 
146
211
  if (!serverSideRendered) {
147
212
  domNode.replaceChildren(...templatePart.childNodes);
@@ -152,100 +217,130 @@ export class TemplateResult {
152
217
  }
153
218
 
154
219
  /**
155
- * @param {String[]} strings
220
+ * @param {TemplateStringsArray} strings
156
221
  * @return {(() => String)[]}
157
222
  */
158
- parse(strings) {
159
- const html = createTemplateString(strings, '\x04');
160
- const parts = [];
161
- let i = 0;
162
- let match = null;
163
- while ((match = interpolation.exec(html))) {
164
- const pre = html.slice(i, match.index);
165
- i = match.index + match[0].length;
166
- if (match[2]) {
167
- // ChildNodePart
168
- const index = match[2];
169
- parts.push((value) => `${pre}<!--dom-part-${index}-->${getValue(value)}<!--/dom-part-${index}-->`);
170
- } else if (match[4]) {
171
- // AttributePart with single interpolation or the first interpolation right after the attribute=
172
- const isSingleValue = match[5] !== undefined;
173
- let name = match[4];
174
- switch (true) {
175
- case name[0] === '?':
176
- const booleanName = name.slice(1).toLowerCase();
177
- parts.push((value) => {
178
- if (!value) return '';
179
- return `${pre} ${booleanName}=""`;
180
- });
181
- break;
182
- case name[0] === '.':
183
- const lower = name.slice(1).toLowerCase();
184
- parts.push((value) => {
185
- let result = pre;
186
- // null, undefined, and false are not shown at all
187
- if (value === null || value === undefined || value === '') {
188
- result += attribute(lower, '');
189
- } else {
190
- // in all other cases, just escape it in quotes
191
- result += attribute(lower, value, isSingleValue);
192
- }
193
- return result;
194
- });
195
- break;
196
- case name[0] === '@':
197
- name = 'on' + name.slice(1);
198
- case name[0] === 'o' && name[1] === 'n':
199
- parts.push((value) => {
200
- return pre;
201
- });
202
- break;
203
- default:
204
- parts.push((value) => {
205
- let result = pre;
206
- if (value != null) {
207
- result += attribute(name, value, isSingleValue);
208
- }
209
- return result;
223
+ parseSSRUpdates(strings) {
224
+ let templateString = ssrTemplateStringsCache.get(strings);
225
+
226
+ if (!templateString) {
227
+ templateString = createTemplateString(strings, true);
228
+ ssrTemplateStringsCache.set(strings, templateString);
229
+ }
230
+
231
+ let updates = ssrUpdatesCache.get(strings);
232
+
233
+ if (!updates) {
234
+ const partMatches = [...templateString.matchAll(ssrPlaceholder)].map((match) => match[0]);
235
+ updates = partMatches.flatMap((match) => {
236
+ const placeholderContent = match.replace('{{', '').replace('}}', '');
237
+ const [_, paramsString] = placeholderContent.split('?');
238
+ const searchParams = new URLSearchParams(paramsString);
239
+ const type = searchParams.get('type');
240
+ if (type === 'noop') return [];
241
+ if (type === 'attribute') {
242
+ const name = searchParams.get('name');
243
+ const quotes = searchParams.get('quotes');
244
+
245
+ if (name.startsWith('?')) {
246
+ const booleanName = name.replace('?', '');
247
+ return [
248
+ {
249
+ type: 'attribute',
250
+ processor: (value) => {
251
+ if (!value) return '';
252
+ return `${booleanName}=""`;
253
+ },
254
+ },
255
+ ];
256
+ }
257
+
258
+ if (name.startsWith('.')) {
259
+ const propertyName = name.replace('.', '');
260
+ return [
261
+ {
262
+ type: 'attribute',
263
+ processor: (value) => {
264
+ // null, undefined, and false are not shown at all
265
+ if (value === null || value === undefined || value === '') {
266
+ return '';
267
+ }
268
+ // in all other cases, just escape it in quotes
269
+ return attribute(propertyName, value, quotes);
270
+ },
271
+ },
272
+ ];
273
+ }
274
+
275
+ if (name.startsWith('@') || name.startsWith('on')) {
276
+ return [
277
+ {
278
+ type: 'attribute',
279
+ processor: (value) => {
280
+ // event handlers have nothing to do on the server side
281
+ return '';
282
+ },
283
+ },
284
+ ];
285
+ }
286
+
287
+ const interpolations = searchParams.get('interpolations');
288
+ let values = [];
289
+ const attributeUpdates = [];
290
+ for (let index = 0; index < interpolations - 1; index++) {
291
+ attributeUpdates.push({
292
+ type: 'noop',
293
+ processor: (value) => {
294
+ values.push(value);
295
+ return '';
296
+ },
210
297
  });
211
- break;
212
- }
213
- } else {
214
- // AttributePart in the middle of an attribute value or NodePart
215
- parts.push((value) => {
216
- let result = pre;
217
- // TODO: we currently cannot distinguish between NodeParts and AttributeParts before
218
- if (isObjectLike(value) && value.directiveClass) {
219
- // NodePart
220
- const directive = new value.directiveClass();
221
- result += directive.stringify(...value.values);
222
- } else if (value != null) {
223
- result += encodeAttribute(isObjectLike(value) ? JSON.stringify(value) : value);
224
298
  }
225
- return result;
226
- });
227
- }
299
+ attributeUpdates.push({
300
+ type: 'attribute',
301
+ name: searchParams.get('name'),
302
+ interpolations,
303
+ initialValue: searchParams.get('initialValue'),
304
+ processor: (value) => {
305
+ values.push(value);
306
+ const initialValue = searchParams.get('initialValue');
307
+ let replaceIndex = 0;
308
+ // Note: this will coarse the values into strings, but it's probably ok since there can only be multiple values in string attributes?!
309
+ const parsedValue = initialValue.replace(/\x03/g, () => values[replaceIndex++]);
310
+ values = [];
311
+ return attribute(searchParams.get('name'), parsedValue, quotes);
312
+ },
313
+ });
314
+ return attributeUpdates;
315
+ }
316
+ if (type === 'node') return [{ type: 'node', processor: (value) => getValue(value) }];
317
+ if (type === 'raw-text-node') return [{ type: 'raw-text-node', processor: (value) => getValue(value) }];
318
+ if (type === 'directive')
319
+ return [
320
+ {
321
+ type: 'directive',
322
+ processor: (value) => {
323
+ const directive = new value.directiveClass();
324
+ return directive.stringify(...value.values);
325
+ },
326
+ },
327
+ ];
328
+ });
329
+ ssrUpdatesCache.set(strings, updates);
228
330
  }
229
331
 
230
- // We couldn't correctly parse parts from the template
231
- if (parts.length !== strings.length - 1) {
332
+ // We couldn't correctly parse updates from the template
333
+ if (updates.length !== strings.length - 1) {
232
334
  throw {
233
335
  name: 'ParseTemplateError',
234
- message: 'Could not parse parts from template correctly. Parts length has not the expected length.',
336
+ message: 'Could not parse updates from template correctly. Updates length has not the expected length.',
235
337
  strings,
236
- templateString: html,
237
- parts,
338
+ templateString,
339
+ updates,
238
340
  };
239
341
  }
240
342
 
241
- if (parts.length) {
242
- const last = parts[parts.length - 1];
243
- const chunk = html.slice(i);
244
- parts[parts.length - 1] = (value) => last(value) + chunk;
245
- } else {
246
- parts.push(() => html);
247
- }
248
- return parts;
343
+ return updates;
249
344
  }
250
345
 
251
346
  /**
@@ -294,6 +389,17 @@ export class TemplateResult {
294
389
  });
295
390
  continue;
296
391
  }
392
+ if (/^dom-part-\d+\/raw-text-node/.test(node.data)) {
393
+ // For html`<textarea>${'foo'} bar</textarea>` we will get:
394
+ // <!--dom-part-0/raw-text-node=\x03 bar-->
395
+ const [_, ...initialValue] = node.data.split('=');
396
+ parts.push({
397
+ type: 'raw-text-node',
398
+ path: getNodePath(node),
399
+ initialValue: initialValue.join('='),
400
+ });
401
+ continue;
402
+ }
297
403
  if (/^dom-part-\d+\$/.test(node.data)) {
298
404
  parts.push({ type: 'directive', path: getNodePath(node) });
299
405
  }
@@ -328,13 +434,12 @@ export class TemplateResult {
328
434
  }
329
435
 
330
436
  toString() {
331
- let updates = parsedUpdates.get(this.strings);
332
-
333
- if (!updates) {
334
- updates = this.parse(this.strings);
335
- parsedUpdates.set(this.strings, updates);
336
- }
437
+ const updates = this.parseSSRUpdates(this.strings);
438
+ let template = ssrTemplateStringsCache.get(this.strings);
337
439
 
338
- return this.values.length ? this.values.map((value, index) => updates[index](value)).join('') : updates[0]();
440
+ let index = 0;
441
+ return template.replaceAll(ssrPlaceholder, () => {
442
+ return updates[index].processor(this.values[index++]);
443
+ });
339
444
  }
340
445
  }
@@ -56,15 +56,15 @@ const choose = (value, cases, defaultCase) => {
56
56
  /**
57
57
  * Renders a given string as HTML instead of text or as unescaped string in SSR mode
58
58
  * @param {string} string
59
- * @returns {(function(): Node) | { __unsafeHTML: true, string: string }}
59
+ * @returns {(function(): [Node]) | { __unsafeHTML: true, string: string }}
60
60
  */
61
61
  const unsafeHTML = (string) => {
62
62
  if (!globalThis.DOMParser) {
63
63
  return { __unsafeHTML: true, string };
64
64
  }
65
- const node = convertStringToHTML(string).firstChild;
66
- const importedNode = globalThis.document?.importNode(node, true);
67
- return () => importedNode;
65
+ const body = convertStringToHTML(string);
66
+ const childNodes = [...body.childNodes];
67
+ return () => childNodes;
68
68
  };
69
69
 
70
70
  /**