@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 +14 -0
- package/package.json +1 -1
- package/src/dom-parts/AttributePart.js +0 -7
- package/src/dom-parts/ChildNodePart.js +31 -4
- package/src/dom-parts/RawTextNodePart.js +64 -0
- package/src/dom-parts/TemplatePart.js +25 -10
- package/src/dom-parts/TemplateResult.js +214 -109
- package/src/dom-parts/directives.js +4 -4
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
|
@@ -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 (
|
|
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
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
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
|
|
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 {
|
|
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,
|
|
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
|
|
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
|
|
42
|
-
|
|
43
|
-
|
|
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 {
|
|
138
|
+
* @param {string} quotes
|
|
75
139
|
* @return {String}
|
|
76
140
|
*/
|
|
77
|
-
const attribute = (name, value,
|
|
78
|
-
return ` ${name}
|
|
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
|
|
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
|
|
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 =
|
|
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
|
-
|
|
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 {
|
|
220
|
+
* @param {TemplateStringsArray} strings
|
|
156
221
|
* @return {(() => String)[]}
|
|
157
222
|
*/
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
231
|
-
if (
|
|
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
|
|
336
|
+
message: 'Could not parse updates from template correctly. Updates length has not the expected length.',
|
|
235
337
|
strings,
|
|
236
|
-
templateString
|
|
237
|
-
|
|
338
|
+
templateString,
|
|
339
|
+
updates,
|
|
238
340
|
};
|
|
239
341
|
}
|
|
240
342
|
|
|
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;
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
66
|
-
const
|
|
67
|
-
return () =>
|
|
65
|
+
const body = convertStringToHTML(string);
|
|
66
|
+
const childNodes = [...body.childNodes];
|
|
67
|
+
return () => childNodes;
|
|
68
68
|
};
|
|
69
69
|
|
|
70
70
|
/**
|