@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 +11 -0
- package/package.json +1 -1
- package/src/dom-parts/AttributePart.js +5 -20
- package/src/dom-parts/RawTextNodePart.js +64 -0
- package/src/dom-parts/TemplatePart.js +4 -0
- package/src/dom-parts/TemplateResult.js +250 -121
- package/src/util/AttributeParser.js +4 -10
- package/src/util/DOMHelper.js +30 -0
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
|
@@ -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
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
orphan = true;
|
|
69
|
-
}
|
|
56
|
+
oldValue = value;
|
|
57
|
+
if (value == null) {
|
|
58
|
+
node.removeAttribute(name);
|
|
70
59
|
} else {
|
|
71
|
-
|
|
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
|
-
|
|
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 {
|
|
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,
|
|
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
|
|
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
|
-
|
|
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
|
|
42
|
-
|
|
43
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 {
|
|
159
|
+
* @param {string} quotes
|
|
75
160
|
* @return {String}
|
|
76
161
|
*/
|
|
77
|
-
const attribute = (name, value,
|
|
78
|
-
return ` ${name}
|
|
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
|
|
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 ? '' :
|
|
194
|
+
return value == null ? '' : encodeHTML(String(value));
|
|
112
195
|
};
|
|
113
196
|
|
|
114
|
-
/** @type {Map<TemplateStringsArray,
|
|
115
|
-
const
|
|
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
|
|
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 =
|
|
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
|
-
|
|
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 {
|
|
241
|
+
* @param {TemplateStringsArray} strings
|
|
156
242
|
* @return {(() => String)[]}
|
|
157
243
|
*/
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
const
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
}
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
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
|
-
|
|
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
|
|
231
|
-
if (
|
|
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
|
|
357
|
+
message: 'Could not parse updates from template correctly. Updates length has not the expected length.',
|
|
235
358
|
strings,
|
|
236
|
-
templateString
|
|
237
|
-
|
|
359
|
+
templateString,
|
|
360
|
+
updates,
|
|
238
361
|
};
|
|
239
362
|
}
|
|
240
363
|
|
|
241
|
-
|
|
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
|
|
287
|
-
|
|
288
|
-
|
|
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:
|
|
410
|
+
name: searchParams.get('name'),
|
|
411
|
+
initialValue: searchParams.get('initialValue'),
|
|
294
412
|
});
|
|
295
413
|
continue;
|
|
296
414
|
}
|
|
297
|
-
if (/^dom-part-\d
|
|
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
|
-
|
|
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
|
-
|
|
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(/</g, '<')
|
|
135
|
-
.replace(/>/g, '>')
|
|
136
|
-
.replace(/"/g, '"')
|
|
137
|
-
.replace(/'/g, "'");
|
|
133
|
+
return `${attribute}`.replace(/"/g, '"').replace(/&/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(
|
|
143
|
+
.replace(/&/g, '&')
|
|
148
144
|
.replace(/"/g, '"')
|
|
149
|
-
.replace(/</g, '<')
|
|
150
|
-
.replace(/>/g, '>')
|
|
151
145
|
.replace(/\r\n/g, '\n')
|
|
152
146
|
.replace(/[\r\n]/g, '\n');
|
|
153
147
|
}
|
package/src/util/DOMHelper.js
CHANGED
|
@@ -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(/&/g, '&')
|
|
317
|
+
.replace(/</g, '<')
|
|
318
|
+
.replace(/>/g, '>')
|
|
319
|
+
.replace(/"/g, '"')
|
|
320
|
+
.replace(/'/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, '&')
|
|
331
|
+
.replace(/</g, '<')
|
|
332
|
+
.replace(/>/g, '>')
|
|
333
|
+
.replace(/"/g, '"')
|
|
334
|
+
.replace(/'/g, ''')
|
|
335
|
+
.replace(/\r\n/g, '\n')
|
|
336
|
+
.replace(/[\r\n]/g, '\n');
|
|
337
|
+
}
|