@webtides/element-js 1.1.3 → 1.1.5

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,17 @@ to [Semantic Versioning](http://semver.org/).
16
16
  <!-- ### Removed -->
17
17
  <!-- ### Fixed -->
18
18
 
19
+ ## [1.1.5] - 2024-11-14
20
+
21
+ ### FIXED
22
+
23
+ - Fixed using special characters in attribute static strings ([#134](https://github.com/webtides/element-js/issues/134))
24
+
25
+ ## [1.1.4] - 2024-11-12
26
+
27
+ ### FIXED
28
+
29
+ - Fixed rendering interpolations inside textarea nodes ([#133](https://github.com/webtides/element-js/issues/133))
19
30
 
20
31
  ## [1.1.3] - 2024-11-11
21
32
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@webtides/element-js",
3
- "version": "1.1.3",
3
+ "version": "1.1.5",
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
 
@@ -56,23 +49,15 @@ const processEventAttribute = (node, name) => {
56
49
  * @return {(function(*): void)|*}
57
50
  */
58
51
  const processAttribute = (node, name) => {
59
- let oldValue,
60
- orphan = true;
61
- const attributeNode = globalThis.document?.createAttributeNS(null, name);
52
+ let oldValue;
62
53
  return (newValue) => {
63
54
  const value = newValue?.valueOf();
64
55
  if (oldValue !== value) {
65
- if ((oldValue = value) == null) {
66
- if (!orphan) {
67
- node.removeAttributeNode(attributeNode);
68
- orphan = true;
69
- }
56
+ oldValue = value;
57
+ if (value == null) {
58
+ node.removeAttribute(name);
70
59
  } else {
71
- attributeNode.value = value;
72
- if (orphan) {
73
- node.setAttributeNodeNS(attributeNode);
74
- orphan = false;
75
- }
60
+ node.setAttribute(name, value);
76
61
  }
77
62
  }
78
63
  };
@@ -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
+ }
@@ -4,6 +4,7 @@ import { TemplateResult } from './TemplateResult.js';
4
4
  import { AttributePart } from './AttributePart.js';
5
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();
@@ -124,6 +125,9 @@ export class TemplatePart extends Part {
124
125
  if (part.type === 'attribute') {
125
126
  return new AttributePart(part.node, part.name, part.initialValue);
126
127
  }
128
+ if (part.type === 'raw-text-node') {
129
+ return new RawTextNodePart(part.node, part.initialValue);
130
+ }
127
131
  if (part.type === 'directive') {
128
132
  return new NodePart(part.node, templateResult.values[index]);
129
133
  }
@@ -1,64 +1,149 @@
1
- import { COMMENT_NODE, getNodePath } from '../util/DOMHelper.js';
1
+ import { COMMENT_NODE, getNodePath, encodeHTML } from '../util/DOMHelper.js';
2
2
  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;
9
10
  const partPositions = /[\x01\x02]/g;
11
+ const ssrPlaceholder = /{{dom-part\?(.*?)}}/g;
10
12
  // \x01 Node.ELEMENT_NODE
11
13
  // \x02 Node.ATTRIBUTE_NODE
12
14
  // \x03 COMMENT.ATTRIBUTE_TOKEN
13
15
  // \x04 Node.ATTRIBUTE_TOKEN
14
16
 
15
- const interpolation = new RegExp(`(<!--dom-part-(\\d+)--><!--/dom-part-(\\d+)-->|(\\S[-\\w]+)="\x04(")?|\x04)`, 'g');
17
+ /**
18
+ * Creates a URL query string for meta information to pass along ssr part placeholders
19
+ * @param type
20
+ * @param params
21
+ * @return {string}
22
+ */
23
+ function makeSSRPlaceholderParams(type, params) {
24
+ const urlSearchParams = new URLSearchParams({ type, ...params });
25
+ const queryString = urlSearchParams.toString();
26
+ return `{{dom-part?${queryString}}}`;
27
+ }
28
+
29
+ /**
30
+ * Creates a URL query string for meta information to pass along part comments
31
+ * @param {string} type
32
+ * @param {{}} params
33
+ * @return {string}
34
+ */
35
+ function makePartParams(type, params) {
36
+ const urlSearchParams = new URLSearchParams({ type, ...params });
37
+ return urlSearchParams.toString();
38
+ }
16
39
 
17
40
  /**
18
41
  * Given a template, find part positions as both nodes and attributes and
19
42
  * return a string with placeholders as either comment nodes or named attributes.
20
43
  * @param {TemplateStringsArray | string[]} templateStrings a template literal tag array
21
- * @param {string} attributePlaceholders replace placeholders inside attribute values with this value
44
+ * @param {boolean} ssr replace interpolations with placeholders
22
45
  * @returns {string} X/HTML with prefixed comments or attributes
23
46
  */
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()
47
+ export const createTemplateString = (templateStrings, ssr = false) => {
26
48
  let partIndex = 0;
27
49
  // join all interpolations (for values) with a special placeholder and remove any whitespace
28
50
  let template = templateStrings.join('\x01').trim();
29
51
  // find (match) all elements to identify their attributes
30
52
  template = template.replace(elements, (_, name, attributesString, trailingSlash) => {
31
- let elementTagWithAttributes = name + attributesString.replaceAll('\x01', attributePlaceholders).trimEnd();
53
+ let elementTagWithAttributes = name + attributesString;
32
54
  // TODO the closing case is weird.
33
55
  if (trailingSlash.length) elementTagWithAttributes += voidElements.test(name) ? ' /' : '></' + name;
34
56
  // collect all attribute parts so that we can place matching comment nodes
35
57
  const attributeParts = attributesString.replace(attributes, (attribute, name, valueWithQuotes, directive) => {
36
58
  if (directive && directive === '\x01') {
37
- return `<!--\x02$-->`;
59
+ elementTagWithAttributes = elementTagWithAttributes.replace(
60
+ attribute,
61
+ ssr ? makeSSRPlaceholderParams('directive') : '',
62
+ );
63
+ const params = makePartParams('directive');
64
+ return `<!--\x02?${params}-->`;
38
65
  }
39
66
 
40
67
  // remove quotes from attribute value to normalize the value
41
- const value =
42
- valueWithQuotes?.startsWith('"') || valueWithQuotes?.startsWith("'")
43
- ? valueWithQuotes.slice(1, -1)
44
- : valueWithQuotes;
68
+ const hasQuotes = valueWithQuotes?.startsWith('"') || valueWithQuotes?.startsWith("'");
69
+ const quotes = hasQuotes ? valueWithQuotes.at(0) : '';
70
+ const value = hasQuotes ? valueWithQuotes.slice(1, -1) : valueWithQuotes;
45
71
  const partsCount = (attribute.match(/\x01/g) || []).length;
46
72
  const parts = [];
47
73
  for (let index = 0; index < partsCount; index++) {
48
- parts.push(`<!--\x02:${name}=${value.replaceAll('\x01', '\x03')}-->`);
74
+ const params = makePartParams('attribute', {
75
+ name,
76
+ initialValue: value.replaceAll('\x01', '\x03'),
77
+ });
78
+ parts.push(`<!--\x02?${params}-->`);
49
79
  }
80
+
81
+ if (parts.length > 0) {
82
+ let replacement = '';
83
+ if (ssr) {
84
+ for (let index = 0; index < partsCount - 1; index++) {
85
+ replacement = replacement + makeSSRPlaceholderParams('noop');
86
+ }
87
+ replacement =
88
+ replacement +
89
+ makeSSRPlaceholderParams('attribute', {
90
+ name,
91
+ interpolations: partsCount,
92
+ initialValue: value.replaceAll('\x01', '\x03'),
93
+ quotes,
94
+ });
95
+ }
96
+ elementTagWithAttributes = elementTagWithAttributes.replace(attribute, replacement);
97
+ }
98
+
50
99
  return parts.join('');
51
100
  });
52
101
  // TODO create test to check if lf are in place
53
102
  return `
54
103
  ${attributesString.includes('\x01') ? attributeParts : ''}\n
55
- <${elementTagWithAttributes}>\n
104
+ <${elementTagWithAttributes.trimEnd()}>\n
56
105
  `.trim();
57
106
  });
107
+ /**
108
+ * Processes the HTML template content by identifying specific "raw text" nodes (e.g., <script>, <style>, <textarea>, <title>)
109
+ * and inserts comments before each instance of an interpolation within these nodes.
110
+ * The placeholders are represented by a control character (in this case, "\x01").
111
+ *
112
+ * Explanation of logic:
113
+ * - The main regex, `rawTextElements`, identifies and captures specific HTML tags (script, style, textarea, title)
114
+ * along with their attributes and content. This regex captures:
115
+ * 1. `tag`: The tag name (e.g., "textarea").
116
+ * 2. `attributes`: Any attributes within the opening tag (e.g., `foo="bar"`).
117
+ * 3. `content`: The inner content within the tag (which may contain interpolations).
118
+ * - Inside the replace function:
119
+ * - The number of placeholder occurrences (`\x01` instances) in `content` is counted to determine
120
+ * how many parts (comments) should be generated.
121
+ * - An array `parts` is created, where each entry represents a comment to be inserted before the raw text node.
122
+ * Each comment includes a modified version of `content` where each placeholder (`\x01`) is replaced with a
123
+ * different control character (`\x03`) for later differentiation.
124
+ * - The `content` is then modified by removing each interpolation (`\x01` is removed), leaving the raw text
125
+ * content without markers.
126
+ * - The processed comments in `parts` are joined and placed before the modified raw text node, preserving
127
+ * its tag, attributes, and inner content without placeholders.
128
+ * - The function finally returns the modified template string with comments in the correct places.
129
+ */
130
+ template = template.replace(rawTextElements, (match, tag, attributes, content) => {
131
+ const partsCount = (content.match(/\x01/g) || []).length;
132
+ const parts = [];
133
+ for (let index = 0; index < partsCount; index++) {
134
+ const params = makePartParams('raw-text-node', {
135
+ initialValue: content.replaceAll('\x01', '\x03'),
136
+ });
137
+ parts.push(`<!--\x02?${params}-->`);
138
+ }
139
+ const parsedContent = content.replaceAll('\x01', ssr ? makeSSRPlaceholderParams('raw-text-node') : '');
140
+ return `${parts.join('')}<${tag}${attributes}>${parsedContent}</${tag}>`;
141
+ });
58
142
  // replace interpolation placeholders with our indexed markers
59
143
  template = template.replace(partPositions, (partPosition) => {
60
144
  if (partPosition === '\x01') {
61
- return `<!--dom-part-${partIndex}--><!--/dom-part-${partIndex++}-->`;
145
+ const placeholder = ssr ? makeSSRPlaceholderParams('node') : '';
146
+ return `<!--dom-part-${partIndex}-->${placeholder}<!--/dom-part-${partIndex++}-->`;
62
147
  } else if (partPosition === '\x02') {
63
148
  return `dom-part-${partIndex++}`;
64
149
  }
@@ -71,13 +156,11 @@ export const createTemplateString = (templateStrings, attributePlaceholders = ''
71
156
  /**
72
157
  * @param {String} name
73
158
  * @param {any} value
74
- * @param {Boolean} isSingleValue
159
+ * @param {string} quotes
75
160
  * @return {String}
76
161
  */
77
- const attribute = (name, value, isSingleValue = true) => {
78
- return ` ${name}="${encodeAttribute(isObjectLike(value) ? JSON.stringify(value) : value)}${
79
- isSingleValue ? '"' : ''
80
- }`;
162
+ const attribute = (name, value, quotes = '"') => {
163
+ return ` ${name}=${quotes}${encodeAttribute(isObjectLike(value) ? JSON.stringify(value) : value)}${quotes}`;
81
164
  };
82
165
 
83
166
  /**
@@ -87,7 +170,7 @@ const attribute = (name, value, isSingleValue = true) => {
87
170
  const getValue = (value) => {
88
171
  switch (typeof value) {
89
172
  case 'string':
90
- return encodeAttribute(value);
173
+ return encodeHTML(value);
91
174
  case 'boolean':
92
175
  case 'number':
93
176
  return String(value);
@@ -108,14 +191,17 @@ const getValue = (value) => {
108
191
  case 'function':
109
192
  return getValue(value());
110
193
  }
111
- return value == null ? '' : encodeAttribute(String(value));
194
+ return value == null ? '' : encodeHTML(String(value));
112
195
  };
113
196
 
114
- /** @type {Map<TemplateStringsArray, *[]>} */
115
- const parsedUpdates = new WeakMap();
197
+ /** @type {Map<TemplateStringsArray, string>} */
198
+ const ssrTemplateStringsCache = new WeakMap();
199
+
200
+ /** @type {Map<TemplateStringsArray, (() => String)[]>} */
201
+ const ssrUpdatesCache = new WeakMap();
116
202
 
117
203
  /** @type {Map<Element, TemplatePart>} */
118
- const templateParts = new WeakMap();
204
+ const templatePartsCache = new WeakMap();
119
205
 
120
206
  export class TemplateResult {
121
207
  /**
@@ -132,7 +218,7 @@ export class TemplateResult {
132
218
  */
133
219
  renderInto(domNode) {
134
220
  let serverSideRendered = false;
135
- let templatePart = templateParts.get(domNode);
221
+ let templatePart = templatePartsCache.get(domNode);
136
222
  if (!templatePart) {
137
223
  const startNode = Array.from(domNode.childNodes)
138
224
  .filter((node) => node.nodeType === COMMENT_NODE)
@@ -141,7 +227,7 @@ export class TemplateResult {
141
227
  serverSideRendered = startNode !== undefined;
142
228
 
143
229
  templatePart = new TemplatePart(startNode, this);
144
- templateParts.set(domNode, templatePart);
230
+ templatePartsCache.set(domNode, templatePart);
145
231
 
146
232
  if (!serverSideRendered) {
147
233
  domNode.replaceChildren(...templatePart.childNodes);
@@ -152,100 +238,130 @@ export class TemplateResult {
152
238
  }
153
239
 
154
240
  /**
155
- * @param {String[]} strings
241
+ * @param {TemplateStringsArray} strings
156
242
  * @return {(() => String)[]}
157
243
  */
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;
244
+ parseSSRUpdates(strings) {
245
+ let templateString = ssrTemplateStringsCache.get(strings);
246
+
247
+ if (!templateString) {
248
+ templateString = createTemplateString(strings, true);
249
+ ssrTemplateStringsCache.set(strings, templateString);
250
+ }
251
+
252
+ let updates = ssrUpdatesCache.get(strings);
253
+
254
+ if (!updates) {
255
+ const partMatches = [...templateString.matchAll(ssrPlaceholder)].map((match) => match[0]);
256
+ updates = partMatches.flatMap((match) => {
257
+ const placeholderContent = match.replace('{{', '').replace('}}', '');
258
+ const [_, paramsString] = placeholderContent.split('?');
259
+ const searchParams = new URLSearchParams(paramsString);
260
+ const type = searchParams.get('type');
261
+ if (type === 'noop') return [];
262
+ if (type === 'attribute') {
263
+ const name = searchParams.get('name');
264
+ const quotes = searchParams.get('quotes');
265
+
266
+ if (name.startsWith('?')) {
267
+ const booleanName = name.replace('?', '');
268
+ return [
269
+ {
270
+ type: 'attribute',
271
+ processor: (value) => {
272
+ if (!value) return '';
273
+ return `${booleanName}=""`;
274
+ },
275
+ },
276
+ ];
277
+ }
278
+
279
+ if (name.startsWith('.')) {
280
+ const propertyName = name.replace('.', '');
281
+ return [
282
+ {
283
+ type: 'attribute',
284
+ processor: (value) => {
285
+ // null, undefined, and false are not shown at all
286
+ if (value === null || value === undefined || value === '') {
287
+ return '';
288
+ }
289
+ // in all other cases, just escape it in quotes
290
+ return attribute(propertyName, value, quotes);
291
+ },
292
+ },
293
+ ];
294
+ }
295
+
296
+ if (name.startsWith('@') || name.startsWith('on')) {
297
+ return [
298
+ {
299
+ type: 'attribute',
300
+ processor: (value) => {
301
+ // event handlers have nothing to do on the server side
302
+ return '';
303
+ },
304
+ },
305
+ ];
306
+ }
307
+
308
+ const interpolations = searchParams.get('interpolations');
309
+ let values = [];
310
+ const attributeUpdates = [];
311
+ for (let index = 0; index < interpolations - 1; index++) {
312
+ attributeUpdates.push({
313
+ type: 'noop',
314
+ processor: (value) => {
315
+ values.push(value);
316
+ return '';
317
+ },
210
318
  });
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
319
  }
225
- return result;
226
- });
227
- }
320
+ attributeUpdates.push({
321
+ type: 'attribute',
322
+ name: searchParams.get('name'),
323
+ interpolations,
324
+ initialValue: searchParams.get('initialValue'),
325
+ processor: (value) => {
326
+ values.push(value);
327
+ const initialValue = searchParams.get('initialValue');
328
+ let replaceIndex = 0;
329
+ // Note: this will coarse the values into strings, but it's probably ok since there can only be multiple values in string attributes?!
330
+ const parsedValue = initialValue.replace(/\x03/g, () => values[replaceIndex++]);
331
+ values = [];
332
+ return attribute(searchParams.get('name'), parsedValue, quotes);
333
+ },
334
+ });
335
+ return attributeUpdates;
336
+ }
337
+ if (type === 'node') return [{ type: 'node', processor: (value) => getValue(value) }];
338
+ if (type === 'raw-text-node') return [{ type: 'raw-text-node', processor: (value) => getValue(value) }];
339
+ if (type === 'directive')
340
+ return [
341
+ {
342
+ type: 'directive',
343
+ processor: (value) => {
344
+ const directive = new value.directiveClass();
345
+ return directive.stringify(...value.values);
346
+ },
347
+ },
348
+ ];
349
+ });
350
+ ssrUpdatesCache.set(strings, updates);
228
351
  }
229
352
 
230
- // We couldn't correctly parse parts from the template
231
- if (parts.length !== strings.length - 1) {
353
+ // We couldn't correctly parse updates from the template
354
+ if (updates.length !== strings.length - 1) {
232
355
  throw {
233
356
  name: 'ParseTemplateError',
234
- message: 'Could not parse parts from template correctly. Parts length has not the expected length.',
357
+ message: 'Could not parse updates from template correctly. Updates length has not the expected length.',
235
358
  strings,
236
- templateString: html,
237
- parts,
359
+ templateString,
360
+ updates,
238
361
  };
239
362
  }
240
363
 
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;
364
+ return updates;
249
365
  }
250
366
 
251
367
  /**
@@ -283,18 +399,32 @@ export class TemplateResult {
283
399
  parts.push({ type: 'node', path: getNodePath(node) });
284
400
  continue;
285
401
  }
286
- if (/^dom-part-\d+:/.test(node.data)) {
287
- const [_, ...attribute] = node.data.split(':');
288
- const [name, ...initialValue] = attribute.join(':').split('=');
402
+ if (/^dom-part-\d+\?type=attribute&(.*?)$/.test(node.data)) {
403
+ // For html`<div id="${'foo'}" class="${'bar'}"></div>` we will get:
404
+ // <!--dom-part-0?type=attribute&name=id&initialValue=\x03--><!--dom-part-1?type=attribute&name=class&initialValue=\x03-->
405
+ const [_, paramsString] = node.data.split('?');
406
+ const searchParams = new URLSearchParams(paramsString);
289
407
  parts.push({
290
408
  type: 'attribute',
291
409
  path: getNodePath(node),
292
- name: name,
293
- initialValue: initialValue.join('='),
410
+ name: searchParams.get('name'),
411
+ initialValue: searchParams.get('initialValue'),
294
412
  });
295
413
  continue;
296
414
  }
297
- if (/^dom-part-\d+\$/.test(node.data)) {
415
+ if (/^dom-part-\d+\?type=raw-text-node&(.*?)$/.test(node.data)) {
416
+ // For html`<textarea>${'foo'} bar</textarea>` we will get:
417
+ // <!--dom-part-0?type=raw-text-node&initialValue=\x03 bar-->
418
+ const [_, paramsString] = node.data.split('?');
419
+ const searchParams = new URLSearchParams(paramsString);
420
+ parts.push({
421
+ type: 'raw-text-node',
422
+ path: getNodePath(node),
423
+ initialValue: searchParams.get('initialValue'),
424
+ });
425
+ continue;
426
+ }
427
+ if (/^dom-part-\d+\?type=directive$/.test(node.data)) {
298
428
  parts.push({ type: 'directive', path: getNodePath(node) });
299
429
  }
300
430
  }
@@ -328,13 +458,12 @@ export class TemplateResult {
328
458
  }
329
459
 
330
460
  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
- }
461
+ const updates = this.parseSSRUpdates(this.strings);
462
+ let template = ssrTemplateStringsCache.get(this.strings);
337
463
 
338
- return this.values.length ? this.values.map((value, index) => updates[index](value)).join('') : updates[0]();
464
+ let index = 0;
465
+ return template.replaceAll(ssrPlaceholder, () => {
466
+ return updates[index].processor(this.values[index++]);
467
+ });
339
468
  }
340
469
  }
@@ -125,29 +125,23 @@ export function camelToDash(string) {
125
125
  }
126
126
 
127
127
  /**
128
- * Decodes an attribute.
128
+ * Decodes an attribute according to {@link https://html.spec.whatwg.org/multipage/syntax.html#attributes-2}
129
129
  * @param {string} attribute
130
130
  * @returns {string}
131
131
  */
132
132
  export function decodeAttribute(attribute) {
133
- return `${attribute}`
134
- .replace(/&lt;/g, '<')
135
- .replace(/&gt;/g, '>')
136
- .replace(/&quot;/g, '"')
137
- .replace(/&apos;/g, "'");
133
+ return `${attribute}`.replace(/&quot;/g, '"').replace(/&amp;/g, '&');
138
134
  }
139
135
 
140
136
  /**
141
- * Encodes an attribute.
137
+ * Encodes an attribute according to {@link https://html.spec.whatwg.org/multipage/syntax.html#attributes-2}
142
138
  * @param {string} attribute
143
139
  * @returns {string}
144
140
  */
145
141
  export function encodeAttribute(attribute) {
146
142
  return `${attribute}`
147
- .replace(/'/g, '&apos;')
143
+ .replace(/&/g, '&amp;')
148
144
  .replace(/"/g, '&quot;')
149
- .replace(/</g, '&lt;')
150
- .replace(/>/g, '&gt;')
151
145
  .replace(/\r\n/g, '\n')
152
146
  .replace(/[\r\n]/g, '\n');
153
147
  }
@@ -305,3 +305,33 @@ export const diffNodes = function (templateNode, domNode, shouldDiffChildNodes =
305
305
  }
306
306
  }
307
307
  };
308
+
309
+ /**
310
+ * Decodes a html string
311
+ * @param {string} html
312
+ * @returns {string}
313
+ */
314
+ export function decodeHTML(html) {
315
+ return `${html}`
316
+ .replace(/&amp;/g, '&')
317
+ .replace(/&lt;/g, '<')
318
+ .replace(/&gt;/g, '>')
319
+ .replace(/&quot;/g, '"')
320
+ .replace(/&apos;/g, "'");
321
+ }
322
+
323
+ /**
324
+ * Encodes a html string
325
+ * @param {string} html
326
+ * @returns {string}
327
+ */
328
+ export function encodeHTML(html) {
329
+ return `${html}`
330
+ .replace(/&/g, '&amp;')
331
+ .replace(/</g, '&lt;')
332
+ .replace(/>/g, '&gt;')
333
+ .replace(/"/g, '&quot;')
334
+ .replace(/'/g, '&apos;')
335
+ .replace(/\r\n/g, '\n')
336
+ .replace(/[\r\n]/g, '\n');
337
+ }