@webtides/element-js 1.2.12 → 1.3.1
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/docs/features/templates.md +19 -10
- package/package.json +5 -2
- package/src/TemplateElement.js +5 -3
- package/src/dom-parts/AttributePart.js +37 -7
- package/src/dom-parts/ChildNodePart.js +141 -156
- package/src/dom-parts/Part.js +3 -1
- package/src/dom-parts/PartMarkers.js +84 -26
- package/src/dom-parts/TemplatePart.js +160 -69
- package/src/dom-parts/TemplateResult.js +71 -84
- package/src/dom-parts/directives.js +11 -1
- package/src/util/GlobalStylesStore.js +5 -6
|
@@ -1,5 +1,42 @@
|
|
|
1
1
|
import { COMMENT_NODE } from '../util/DOMHelper.js';
|
|
2
2
|
|
|
3
|
+
const SHOW_COMMENT = 128; // NodeFilter.SHOW_COMMENT
|
|
4
|
+
const SLASH = 47; // '/'
|
|
5
|
+
const PART_MARKER_PREFIX = 'dom-part-';
|
|
6
|
+
const TEMPLATE_MARKER = 'template-part';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Walks the comment markers of one template instance in document order, starting at its `<!--template-part-->`
|
|
10
|
+
* comment, and calls back for every top level part marker with the marker node and the index of its part. Closing
|
|
11
|
+
* markers and everything inside nested templates (between their own template-part comments) are skipped.
|
|
12
|
+
*
|
|
13
|
+
* Works on a freshly cloned template fragment as well as on live, server rendered DOM: there the values sit between
|
|
14
|
+
* the markers, but they are not comments, so the walk sees the same sequence of markers in both cases.
|
|
15
|
+
*
|
|
16
|
+
* @param {Comment} startNode - the `<!--template-part-->` comment of the instance
|
|
17
|
+
* @param {(node: Comment, index: number) => void} [onPartMarker]
|
|
18
|
+
* @return {Comment} the matching `<!--/template-part-->` comment
|
|
19
|
+
*/
|
|
20
|
+
export const walkPartMarkers = (startNode, onPartMarker) => {
|
|
21
|
+
const walker = globalThis.document.createTreeWalker(startNode.parentNode, SHOW_COMMENT);
|
|
22
|
+
walker.currentNode = startNode;
|
|
23
|
+
let depth = 1;
|
|
24
|
+
let node;
|
|
25
|
+
while ((node = walker.nextNode())) {
|
|
26
|
+
const data = node.data;
|
|
27
|
+
if (data === TEMPLATE_MARKER) {
|
|
28
|
+
depth++;
|
|
29
|
+
} else if (data === `/${TEMPLATE_MARKER}`) {
|
|
30
|
+
depth--;
|
|
31
|
+
if (depth === 0) return node;
|
|
32
|
+
} else if (depth === 1 && data.charCodeAt(0) !== SLASH && data.startsWith(PART_MARKER_PREFIX)) {
|
|
33
|
+
// 'dom-part-12?type=attribute&…' → 12, parseInt stops at the '?'
|
|
34
|
+
onPartMarker?.(node, parseInt(data.slice(PART_MARKER_PREFIX.length), 10));
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
throw new Error('PartMarkers: missing closing template-part marker');
|
|
38
|
+
};
|
|
39
|
+
|
|
3
40
|
export class PartMarkers {
|
|
4
41
|
/** @type {Comment} */
|
|
5
42
|
startNode;
|
|
@@ -7,56 +44,77 @@ export class PartMarkers {
|
|
|
7
44
|
/** @type {Comment} */
|
|
8
45
|
endNode;
|
|
9
46
|
|
|
10
|
-
/** @type {Node[]} */
|
|
47
|
+
/** @type {Node[]} all sibling nodes from the start to the end marker, both included */
|
|
11
48
|
childNodes = [];
|
|
12
49
|
|
|
13
|
-
/** @type {boolean} */
|
|
14
|
-
serverSideRendered = false;
|
|
15
|
-
|
|
16
50
|
/**
|
|
17
51
|
* @param {Comment} start
|
|
18
52
|
* @param {Comment} end
|
|
19
53
|
* @param {Node[]} childNodes
|
|
20
|
-
* @return {void}
|
|
21
54
|
*/
|
|
22
55
|
constructor(start, end, childNodes) {
|
|
23
56
|
this.startNode = start;
|
|
24
57
|
this.endNode = end;
|
|
25
58
|
this.childNodes = childNodes;
|
|
59
|
+
}
|
|
26
60
|
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
61
|
+
/**
|
|
62
|
+
* The start comments of the nested parts directly between the markers, in document order. Markers inside those
|
|
63
|
+
* nested parts are not included.
|
|
64
|
+
* @param {string} partType - the marker name to search for. Defaults to 'template-part'.
|
|
65
|
+
* @return {Comment[]}
|
|
66
|
+
*/
|
|
67
|
+
findNestedStartNodes(partType = TEMPLATE_MARKER) {
|
|
68
|
+
const closing = `/${partType}`;
|
|
69
|
+
const startNodes = [];
|
|
70
|
+
let depth = 0;
|
|
71
|
+
for (const node of this.childNodes.slice(1, -1)) {
|
|
72
|
+
if (node.nodeType !== COMMENT_NODE) continue;
|
|
73
|
+
if (node.data === partType) {
|
|
74
|
+
if (depth === 0) startNodes.push(node);
|
|
75
|
+
depth++;
|
|
76
|
+
} else if (node.data === closing) {
|
|
77
|
+
depth--;
|
|
78
|
+
}
|
|
30
79
|
}
|
|
80
|
+
return startNodes;
|
|
31
81
|
}
|
|
32
82
|
|
|
33
83
|
/**
|
|
34
|
-
* @param {string} partType
|
|
35
|
-
* @return {
|
|
84
|
+
* @param {string} partType - the marker name to search for. Defaults to 'template-part'.
|
|
85
|
+
* @return {Comment|undefined} the first nested start comment, or undefined if there is none
|
|
36
86
|
*/
|
|
37
|
-
findNestedStartNode(partType =
|
|
38
|
-
return this.
|
|
87
|
+
findNestedStartNode(partType = TEMPLATE_MARKER) {
|
|
88
|
+
return this.findNestedStartNodes(partType)[0];
|
|
39
89
|
}
|
|
40
90
|
|
|
41
91
|
/**
|
|
42
|
-
* Creates a
|
|
43
|
-
*
|
|
44
|
-
* @
|
|
92
|
+
* Creates markers from a start comment by walking its siblings to the matching end comment. Nested markers with
|
|
93
|
+
* the same name, for example a nested template at the root of a template, are skipped.
|
|
94
|
+
* @param {Comment} startNode - the start comment that marks the beginning of the section
|
|
95
|
+
* @return {PartMarkers}
|
|
45
96
|
*/
|
|
46
97
|
static createFromStartNode(startNode) {
|
|
47
|
-
let endNode;
|
|
48
|
-
|
|
49
98
|
const placeholder = startNode.data;
|
|
99
|
+
const closing = `/${placeholder}`;
|
|
50
100
|
const childNodes = [startNode];
|
|
51
|
-
let
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
101
|
+
let depth = 1;
|
|
102
|
+
let node = startNode.nextSibling;
|
|
103
|
+
while (node) {
|
|
104
|
+
childNodes.push(node);
|
|
105
|
+
if (node.nodeType === COMMENT_NODE) {
|
|
106
|
+
if (node.data === placeholder) {
|
|
107
|
+
depth++;
|
|
108
|
+
} else if (node.data === closing) {
|
|
109
|
+
depth--;
|
|
110
|
+
if (depth === 0) break;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
node = node.nextSibling;
|
|
55
114
|
}
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
return new PartMarkers(startNode, endNode, childNodes);
|
|
115
|
+
if (!node) {
|
|
116
|
+
throw new Error(`PartMarkers: missing closing marker for "${placeholder}"`);
|
|
117
|
+
}
|
|
118
|
+
return new PartMarkers(startNode, node, childNodes);
|
|
61
119
|
}
|
|
62
120
|
}
|
|
@@ -1,38 +1,79 @@
|
|
|
1
1
|
import { COMMENT_NODE, convertStringToTemplate } from '../util/DOMHelper.js';
|
|
2
2
|
import { Part } from './Part.js';
|
|
3
|
-
import { TemplateResult } from './TemplateResult.js';
|
|
4
3
|
import { AttributePart } from './AttributePart.js';
|
|
5
4
|
import { ChildNodePart, getNodesBetweenComments, replaceNodesBetweenComments } from './ChildNodePart.js';
|
|
6
5
|
import { NodePart } from './NodePart.js';
|
|
7
6
|
import { RawTextNodePart } from './RawTextNodePart.js';
|
|
8
|
-
import { PartMarkers } from './PartMarkers.js';
|
|
9
|
-
|
|
10
|
-
/** @type {WeakMap<TemplateStringsArray, Part[]>} */
|
|
11
|
-
const partsCache = new WeakMap();
|
|
7
|
+
import { PartMarkers, walkPartMarkers } from './PartMarkers.js';
|
|
12
8
|
|
|
13
9
|
/** @type {WeakMap<TemplateStringsArray, DocumentFragment>} */
|
|
14
10
|
const fragmentsCache = new WeakMap();
|
|
15
11
|
|
|
12
|
+
/** @type {WeakMap<TemplateStringsArray, {descriptors: object[], markerData: string[], hasRootNodeParts: boolean}>} */
|
|
13
|
+
const partsCache = new WeakMap();
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* The parsed template for a `strings` array: the fragment to clone, the part descriptors in part order, the comment
|
|
17
|
+
* data of each part marker and whether a node part sits at the root of the template. All computed once per literal.
|
|
18
|
+
* @param {TemplateResult} templateResult
|
|
19
|
+
* @return {{fragment: DocumentFragment, descriptors: object[], markerData: string[], hasRootNodeParts: boolean}}
|
|
20
|
+
*/
|
|
21
|
+
const parseTemplate = (templateResult) => {
|
|
22
|
+
let fragment = fragmentsCache.get(templateResult.strings);
|
|
23
|
+
if (!fragment) {
|
|
24
|
+
fragment = convertStringToTemplate(templateResult.templateString);
|
|
25
|
+
fragmentsCache.set(templateResult.strings, fragment);
|
|
26
|
+
}
|
|
27
|
+
let parts = partsCache.get(templateResult.strings);
|
|
28
|
+
if (!parts) {
|
|
29
|
+
const descriptors = templateResult.parseParts(fragment.childNodes);
|
|
30
|
+
const markerData = [];
|
|
31
|
+
let hasRootNodeParts = false;
|
|
32
|
+
walkPartMarkers(fragment.firstChild, (node, index) => {
|
|
33
|
+
markerData[index] = node.data;
|
|
34
|
+
if (descriptors[index].type === 'node' && node.parentNode === fragment) hasRootNodeParts = true;
|
|
35
|
+
});
|
|
36
|
+
parts = { descriptors, markerData, hasRootNodeParts };
|
|
37
|
+
partsCache.set(templateResult.strings, parts);
|
|
38
|
+
}
|
|
39
|
+
return { fragment, ...parts };
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* One rendered instance of a template. Binds the parts of the template to DOM nodes: either to a fresh clone of the
|
|
44
|
+
* template fragment, or, when hydrating, to the markers in server rendered DOM. Both bindings walk the same comment
|
|
45
|
+
* markers, see walkPartMarkers(). Construction only binds, values flow through update().
|
|
46
|
+
*/
|
|
16
47
|
export class TemplatePart extends Part {
|
|
17
|
-
/** @type {PartMarkers
|
|
48
|
+
/** @type {PartMarkers} */
|
|
18
49
|
markers;
|
|
19
50
|
|
|
20
|
-
/** @type {Part[]} */
|
|
51
|
+
/** @type {Part[]} indexed like the values of the template */
|
|
21
52
|
parts = [];
|
|
22
53
|
|
|
23
54
|
/** @type {TemplateStringsArray} */
|
|
24
55
|
strings = undefined;
|
|
25
56
|
|
|
26
|
-
/**
|
|
57
|
+
/**
|
|
58
|
+
* The top level nodes of this instance, including its template-part markers and the content of parts at the root
|
|
59
|
+
* of the template. Refreshed after every update while the nodes are attached, kept as is while they are detached
|
|
60
|
+
* so that a removed instance can be inserted again.
|
|
61
|
+
* @type {Node[]}
|
|
62
|
+
*/
|
|
27
63
|
childNodes = [];
|
|
28
|
-
|
|
64
|
+
|
|
65
|
+
/** @type {boolean} true when the parts are bound to server rendered DOM */
|
|
66
|
+
hydrated = false;
|
|
67
|
+
|
|
68
|
+
/** @type {boolean} true when a node part at the root of the template can change the top level nodes */
|
|
69
|
+
hasRootNodeParts = false;
|
|
29
70
|
|
|
30
71
|
/**
|
|
31
|
-
* @param {
|
|
32
|
-
* @param {TemplateResult}
|
|
72
|
+
* @param {Comment|undefined} startNode - the `<!--template-part-->` comment of server rendered DOM to hydrate, or undefined to render client side
|
|
73
|
+
* @param {TemplateResult} templateResult
|
|
33
74
|
*/
|
|
34
|
-
constructor(startNode,
|
|
35
|
-
if (startNode && startNode
|
|
75
|
+
constructor(startNode, templateResult) {
|
|
76
|
+
if (startNode && startNode.nodeType !== COMMENT_NODE) {
|
|
36
77
|
throw new Error('TemplatePart: startNode is not a comment node');
|
|
37
78
|
}
|
|
38
79
|
|
|
@@ -40,86 +81,136 @@ export class TemplatePart extends Part {
|
|
|
40
81
|
|
|
41
82
|
if (startNode) {
|
|
42
83
|
startNode.__part = this; // add Part to comment node for debugging in the browser
|
|
43
|
-
this.
|
|
44
|
-
this.childNodes = this.markers.childNodes;
|
|
84
|
+
this.hydrated = this.bindLive(startNode, templateResult);
|
|
45
85
|
}
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
if (!this.markers?.serverSideRendered) {
|
|
50
|
-
// TODO: this is causing double diffing because of the this.parseValue() before, ChildNode parts will be constructed + processed
|
|
51
|
-
// TODO: I think that maybe ChildNode Parts work, but Other Parts like Attributes are not set initially otherwise...
|
|
52
|
-
this.updateParts(value.values);
|
|
86
|
+
if (!this.hydrated) {
|
|
87
|
+
this.bindClone(templateResult);
|
|
53
88
|
}
|
|
54
|
-
|
|
55
|
-
// TODO: this is somewhat strange that we are creating this twice...
|
|
56
|
-
this.markers = PartMarkers.createFromStartNode(this.childNodes[0]);
|
|
57
89
|
}
|
|
58
90
|
|
|
59
91
|
/**
|
|
60
92
|
* @param {TemplateResult} templateResult
|
|
93
|
+
* @param {boolean} [hydrate] - first update of server rendered DOM: nested parts bind to the server rendered
|
|
94
|
+
* markers instead of cloning, see ChildNodePart.parseValue(). Nodes that are in place already stay untouched.
|
|
61
95
|
*/
|
|
62
|
-
update(templateResult) {
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
this.
|
|
66
|
-
|
|
67
|
-
const
|
|
68
|
-
|
|
69
|
-
|
|
96
|
+
update(templateResult, hydrate = false) {
|
|
97
|
+
if (this.strings !== templateResult.strings) {
|
|
98
|
+
// a different template: render it fresh and swap the content between the existing markers
|
|
99
|
+
const { startNode, endNode } = this.markers;
|
|
100
|
+
this.bindClone(templateResult);
|
|
101
|
+
const innerNodes = this.childNodes.slice(1, -1);
|
|
102
|
+
replaceNodesBetweenComments(endNode, innerNodes);
|
|
103
|
+
this.markers = new PartMarkers(startNode, endNode, [startNode, ...innerNodes, endNode]);
|
|
104
|
+
this.childNodes = this.markers.childNodes;
|
|
105
|
+
this.hydrated = false;
|
|
70
106
|
}
|
|
71
|
-
|
|
107
|
+
// an instance that could not be bound to server DOM is a fresh clone, its parts clone as well
|
|
108
|
+
this.updateParts(templateResult.values, hydrate && this.hydrated);
|
|
109
|
+
if (this.hasRootNodeParts) this.refreshChildNodes();
|
|
72
110
|
}
|
|
73
111
|
|
|
74
112
|
/**
|
|
75
113
|
* @param {any[]} values
|
|
114
|
+
* @param {boolean} [hydrate]
|
|
76
115
|
*/
|
|
77
|
-
updateParts(values) {
|
|
116
|
+
updateParts(values, hydrate = false) {
|
|
78
117
|
for (let index = 0; index < values.length; index++) {
|
|
79
|
-
this.parts[index]?.update(values[index]);
|
|
118
|
+
this.parts[index]?.update(values[index], hydrate);
|
|
80
119
|
}
|
|
81
120
|
}
|
|
82
121
|
|
|
83
122
|
/**
|
|
84
|
-
*
|
|
123
|
+
* Re-reads the top level nodes between the markers. Parts at the root of the template insert and remove their
|
|
124
|
+
* content there, so the list is only known after an update. Detached nodes have no siblings to walk, the last
|
|
125
|
+
* known list is kept in that case.
|
|
85
126
|
*/
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
if (
|
|
89
|
-
|
|
90
|
-
fragmentsCache.set(templateResult.strings, fragment);
|
|
127
|
+
refreshChildNodes() {
|
|
128
|
+
const { startNode, endNode } = this.markers;
|
|
129
|
+
if (startNode.parentNode) {
|
|
130
|
+
this.childNodes = [startNode, ...getNodesBetweenComments(endNode), endNode];
|
|
91
131
|
}
|
|
92
|
-
|
|
93
|
-
this.childNodes = importedFragment.childNodes;
|
|
132
|
+
}
|
|
94
133
|
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
134
|
+
/**
|
|
135
|
+
* Clones the template fragment and binds the parts to the markers in the clone.
|
|
136
|
+
* @param {TemplateResult} templateResult
|
|
137
|
+
*/
|
|
138
|
+
bindClone(templateResult) {
|
|
139
|
+
const { fragment, descriptors, hasRootNodeParts } = parseTemplate(templateResult);
|
|
140
|
+
const clone = globalThis.document.importNode(fragment, true);
|
|
141
|
+
const startNode = clone.firstChild;
|
|
142
|
+
startNode.__part = this; // add Part to comment node for debugging in the browser
|
|
143
|
+
const parts = [];
|
|
144
|
+
const endNode = walkPartMarkers(startNode, (node, index) => {
|
|
145
|
+
parts[index] = this.createPart(descriptors[index], node, templateResult.values[index]);
|
|
146
|
+
});
|
|
147
|
+
this.parts = parts;
|
|
148
|
+
this.strings = templateResult.strings;
|
|
149
|
+
this.hasRootNodeParts = hasRootNodeParts;
|
|
150
|
+
this.childNodes = [...clone.childNodes];
|
|
151
|
+
this.markers = new PartMarkers(startNode, endNode, this.childNodes);
|
|
152
|
+
}
|
|
100
153
|
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
154
|
+
/**
|
|
155
|
+
* Binds the parts to the markers of server rendered DOM.
|
|
156
|
+
* @param {Comment} startNode - the live `<!--template-part-->` comment
|
|
157
|
+
* @param {TemplateResult} templateResult
|
|
158
|
+
* @return {boolean} false if the markers do not match the template, nothing is bound in that case
|
|
159
|
+
*/
|
|
160
|
+
bindLive(startNode, templateResult) {
|
|
161
|
+
const { descriptors, markerData, hasRootNodeParts } = parseTemplate(templateResult);
|
|
162
|
+
const values = templateResult.values;
|
|
163
|
+
const parts = [];
|
|
164
|
+
let found = 0;
|
|
165
|
+
let endNode;
|
|
166
|
+
try {
|
|
167
|
+
endNode = walkPartMarkers(startNode, (node, index) => {
|
|
168
|
+
found++;
|
|
169
|
+
// the marker must be the one the template would render at this position: same type, name and
|
|
170
|
+
// static attribute content. Differences in the static markup around the markers are not detected.
|
|
171
|
+
if (descriptors[index] && node.data === markerData[index]) {
|
|
172
|
+
parts[index] = this.createPart(descriptors[index], node, values[index]);
|
|
119
173
|
}
|
|
120
|
-
throw `cannot map part: ${part}`;
|
|
121
174
|
});
|
|
175
|
+
} catch (error) {
|
|
176
|
+
endNode = undefined;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
const complete = found === descriptors.length && descriptors.every((_, index) => parts[index] !== undefined);
|
|
180
|
+
if (!endNode || !complete) {
|
|
181
|
+
console.warn(
|
|
182
|
+
'element-js: the server rendered markers do not match the template, rendering client side instead.',
|
|
183
|
+
{ template: templateResult.templateString, startNode },
|
|
184
|
+
);
|
|
185
|
+
return false;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
this.parts = parts;
|
|
122
189
|
this.strings = templateResult.strings;
|
|
123
|
-
this.
|
|
190
|
+
this.hasRootNodeParts = hasRootNodeParts;
|
|
191
|
+
this.markers = PartMarkers.createFromStartNode(startNode);
|
|
192
|
+
this.childNodes = this.markers.childNodes;
|
|
193
|
+
return true;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* @param {{type: string, name?: string, initialValue?: string}} descriptor
|
|
198
|
+
* @param {Comment} node - the marker comment
|
|
199
|
+
* @param {any} value - the first value, only needed for directives
|
|
200
|
+
* @return {Part}
|
|
201
|
+
*/
|
|
202
|
+
createPart(descriptor, node, value) {
|
|
203
|
+
switch (descriptor.type) {
|
|
204
|
+
case 'node':
|
|
205
|
+
return new ChildNodePart(node);
|
|
206
|
+
case 'attribute':
|
|
207
|
+
return new AttributePart(node, descriptor.name, descriptor.initialValue);
|
|
208
|
+
case 'raw-text-node':
|
|
209
|
+
return new RawTextNodePart(node, descriptor.initialValue);
|
|
210
|
+
case 'directive':
|
|
211
|
+
return new NodePart(node, value);
|
|
212
|
+
default:
|
|
213
|
+
throw new Error(`TemplatePart: cannot create a part for type "${descriptor.type}"`);
|
|
214
|
+
}
|
|
124
215
|
}
|
|
125
216
|
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
|
-
import { COMMENT_NODE,
|
|
2
|
-
import { encodeAttribute, isObjectLike } from '../util/AttributeParser.js';
|
|
1
|
+
import { COMMENT_NODE, encodeHTML } from '../util/DOMHelper.js';
|
|
2
|
+
import { camelToDash, encodeAttribute, isObjectLike } from '../util/AttributeParser.js';
|
|
3
3
|
import { TemplatePart } from './TemplatePart.js';
|
|
4
|
+
import { walkPartMarkers } from './PartMarkers.js';
|
|
4
5
|
|
|
5
6
|
const voidElements = /^(?:area|base|br|col|embed|hr|img|input|link|meta|source|track|wbr)$/i;
|
|
6
7
|
const rawTextElements = /<(script|style|textarea|title)([^>]*)>(.*?)<\/\1>/i;
|
|
@@ -203,6 +204,39 @@ const ssrUpdatesCache = new WeakMap();
|
|
|
203
204
|
/** @type {Map<Element, TemplatePart>} */
|
|
204
205
|
const templatePartsCache = new WeakMap();
|
|
205
206
|
|
|
207
|
+
/**
|
|
208
|
+
* @param {Node[] | NodeList} childNodes
|
|
209
|
+
* @return {Comment|undefined} the first `<!--template-part-->` comment among the nodes
|
|
210
|
+
*/
|
|
211
|
+
const findTemplatePartStartNode = (childNodes) =>
|
|
212
|
+
Array.from(childNodes).find((node) => node.nodeType === COMMENT_NODE && node.data === 'template-part');
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* Parses the descriptor of a part from the data of its comment marker.
|
|
216
|
+
* For html`<div id="${'foo'}" class="${'bar'}">${'baz'}</div>` the markers are:
|
|
217
|
+
* <!--dom-part-0?type=attribute&name=id&initialValue=\x03--><!--dom-part-1?type=attribute&name=class&initialValue=\x03--><div><!--dom-part-2-->
|
|
218
|
+
* @param {string} data
|
|
219
|
+
* @return {{type: string, name?: string, initialValue?: string}}
|
|
220
|
+
*/
|
|
221
|
+
const parsePartDescriptor = (data) => {
|
|
222
|
+
const query = data.indexOf('?');
|
|
223
|
+
if (query === -1) {
|
|
224
|
+
return { type: 'node' };
|
|
225
|
+
}
|
|
226
|
+
const searchParams = new URLSearchParams(data.slice(query + 1));
|
|
227
|
+
const type = searchParams.get('type');
|
|
228
|
+
if (type === 'attribute') {
|
|
229
|
+
return { type, name: searchParams.get('name'), initialValue: searchParams.get('initialValue') };
|
|
230
|
+
}
|
|
231
|
+
if (type === 'raw-text-node') {
|
|
232
|
+
return { type, initialValue: searchParams.get('initialValue') };
|
|
233
|
+
}
|
|
234
|
+
if (type === 'directive') {
|
|
235
|
+
return { type };
|
|
236
|
+
}
|
|
237
|
+
throw new Error(`TemplateResult: unknown part type "${type}" in marker "${data}"`);
|
|
238
|
+
};
|
|
239
|
+
|
|
206
240
|
export class TemplateResult {
|
|
207
241
|
/**
|
|
208
242
|
* @param {TemplateStringsArray} strings
|
|
@@ -214,30 +248,26 @@ export class TemplateResult {
|
|
|
214
248
|
}
|
|
215
249
|
|
|
216
250
|
/**
|
|
251
|
+
* Renders into a DOM node. A node that already holds a rendered instance of this template is updated, a node with
|
|
252
|
+
* server rendered markup (a `<!--template-part-->` marker) is hydrated, anything else is replaced by a fresh render.
|
|
217
253
|
* @param {Element} domNode
|
|
218
254
|
*/
|
|
219
255
|
renderInto(domNode) {
|
|
220
|
-
const startNode =
|
|
221
|
-
.filter((node) => node.nodeType === COMMENT_NODE)
|
|
222
|
-
.find((node) => node.data === 'template-part');
|
|
223
|
-
|
|
224
|
-
let serverSideRendered = startNode !== undefined;
|
|
256
|
+
const startNode = findTemplatePartStartNode(domNode.childNodes);
|
|
225
257
|
|
|
226
258
|
let templatePart = templatePartsCache.get(domNode);
|
|
259
|
+
if (templatePart && startNode) {
|
|
260
|
+
templatePart.update(this);
|
|
261
|
+
return;
|
|
262
|
+
}
|
|
227
263
|
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
domNode.replaceChildren();
|
|
231
|
-
}
|
|
232
|
-
|
|
233
|
-
templatePart = new TemplatePart(startNode, this);
|
|
234
|
-
templatePartsCache.set(domNode, templatePart);
|
|
264
|
+
templatePart = new TemplatePart(startNode, this);
|
|
265
|
+
templatePartsCache.set(domNode, templatePart);
|
|
235
266
|
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
templatePart.update(this);
|
|
267
|
+
const hydrated = templatePart.hydrated;
|
|
268
|
+
templatePart.update(this, hydrated);
|
|
269
|
+
if (!hydrated) {
|
|
270
|
+
domNode.replaceChildren(...templatePart.childNodes);
|
|
241
271
|
}
|
|
242
272
|
}
|
|
243
273
|
|
|
@@ -281,7 +311,9 @@ export class TemplateResult {
|
|
|
281
311
|
}
|
|
282
312
|
|
|
283
313
|
if (name.startsWith('.')) {
|
|
284
|
-
|
|
314
|
+
// a property cannot be set in a string, the value is written as the attribute that
|
|
315
|
+
// BaseElement maps back to the property on upgrade: camelCase becomes dash-case
|
|
316
|
+
const attributeName = camelToDash(name.replace('.', ''));
|
|
285
317
|
return [
|
|
286
318
|
{
|
|
287
319
|
type: 'attribute',
|
|
@@ -291,13 +323,13 @@ export class TemplateResult {
|
|
|
291
323
|
return '';
|
|
292
324
|
}
|
|
293
325
|
// in all other cases, just escape it in quotes
|
|
294
|
-
return attribute(
|
|
326
|
+
return attribute(attributeName, value, quotes);
|
|
295
327
|
},
|
|
296
328
|
},
|
|
297
329
|
];
|
|
298
330
|
}
|
|
299
331
|
|
|
300
|
-
if (name.startsWith('@')
|
|
332
|
+
if (name.startsWith('@')) {
|
|
301
333
|
return [
|
|
302
334
|
{
|
|
303
335
|
type: 'attribute',
|
|
@@ -333,7 +365,13 @@ export class TemplateResult {
|
|
|
333
365
|
// Note: this will coarse the values into strings, but it's probably ok since there can only be multiple values in string attributes?!
|
|
334
366
|
const parsedValue = initialValue.replace(/\x03/g, () => values[replaceIndex++]);
|
|
335
367
|
values = [];
|
|
336
|
-
|
|
368
|
+
// an attribute whose sole value is null or undefined is left out, the DOM renderer
|
|
369
|
+
// removes it as well
|
|
370
|
+
if (initialValue === '\x03' && value == null) return '';
|
|
371
|
+
// a listener on an "old school" event attribute (onevent=${...}) has nothing to do on
|
|
372
|
+
// the server side, any other value of an attribute that starts with "on" is written
|
|
373
|
+
if (name.startsWith('on') && typeof value === 'function') return '';
|
|
374
|
+
return attribute(name, parsedValue, quotes);
|
|
337
375
|
},
|
|
338
376
|
});
|
|
339
377
|
return attributeUpdates;
|
|
@@ -369,72 +407,22 @@ export class TemplateResult {
|
|
|
369
407
|
}
|
|
370
408
|
|
|
371
409
|
/**
|
|
372
|
-
*
|
|
373
|
-
*
|
|
410
|
+
* Parses the part descriptors of this template from the comment markers of its rendered or cloned nodes.
|
|
411
|
+
* The descriptors are indexed like the values of the template.
|
|
412
|
+
* @param {Node[] | NodeList} childNodes - the top level nodes of a template instance, including the template-part markers
|
|
413
|
+
* @return {{type: string, name?: string, initialValue?: string}[]}
|
|
374
414
|
*/
|
|
375
415
|
parseParts(childNodes) {
|
|
376
|
-
|
|
377
|
-
const range = globalThis.document?.createRange();
|
|
378
|
-
range.setStartBefore(childNodes[0]);
|
|
379
|
-
range.setEndAfter(childNodes[childNodes.length - 1]);
|
|
380
|
-
const template = range.cloneContents();
|
|
381
|
-
|
|
382
|
-
const treeWalker = globalThis.document?.createTreeWalker(template, 128);
|
|
383
|
-
let node = treeWalker.nextNode(); //to skip the root template-part
|
|
416
|
+
const startNode = findTemplatePartStartNode(childNodes);
|
|
384
417
|
const parts = [];
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
if (/^template-part$/.test(node.data)) {
|
|
390
|
-
nestedLevel++;
|
|
391
|
-
continue;
|
|
392
|
-
}
|
|
393
|
-
if (/^\/template-part$/.test(node.data)) {
|
|
394
|
-
if (nestedLevel > 0) {
|
|
395
|
-
nestedLevel--;
|
|
396
|
-
}
|
|
397
|
-
continue;
|
|
398
|
-
}
|
|
399
|
-
if (nestedLevel > 0) {
|
|
400
|
-
continue;
|
|
401
|
-
}
|
|
402
|
-
if (/^dom-part-\d+$/.test(node.data)) {
|
|
403
|
-
parts.push({ type: 'node', path: getNodePath(node) });
|
|
404
|
-
continue;
|
|
405
|
-
}
|
|
406
|
-
if (/^dom-part-\d+\?type=attribute&(.*?)$/.test(node.data)) {
|
|
407
|
-
// For html`<div id="${'foo'}" class="${'bar'}"></div>` we will get:
|
|
408
|
-
// <!--dom-part-0?type=attribute&name=id&initialValue=\x03--><!--dom-part-1?type=attribute&name=class&initialValue=\x03-->
|
|
409
|
-
const [_, paramsString] = node.data.split('?');
|
|
410
|
-
const searchParams = new URLSearchParams(paramsString);
|
|
411
|
-
parts.push({
|
|
412
|
-
type: 'attribute',
|
|
413
|
-
path: getNodePath(node),
|
|
414
|
-
name: searchParams.get('name'),
|
|
415
|
-
initialValue: searchParams.get('initialValue'),
|
|
416
|
-
});
|
|
417
|
-
continue;
|
|
418
|
-
}
|
|
419
|
-
if (/^dom-part-\d+\?type=raw-text-node&(.*?)$/.test(node.data)) {
|
|
420
|
-
// For html`<textarea>${'foo'} bar</textarea>` we will get:
|
|
421
|
-
// <!--dom-part-0?type=raw-text-node&initialValue=\x03 bar-->
|
|
422
|
-
const [_, paramsString] = node.data.split('?');
|
|
423
|
-
const searchParams = new URLSearchParams(paramsString);
|
|
424
|
-
parts.push({
|
|
425
|
-
type: 'raw-text-node',
|
|
426
|
-
path: getNodePath(node),
|
|
427
|
-
initialValue: searchParams.get('initialValue'),
|
|
428
|
-
});
|
|
429
|
-
continue;
|
|
430
|
-
}
|
|
431
|
-
if (/^dom-part-\d+\?type=directive$/.test(node.data)) {
|
|
432
|
-
parts.push({ type: 'directive', path: getNodePath(node) });
|
|
433
|
-
}
|
|
418
|
+
if (startNode) {
|
|
419
|
+
walkPartMarkers(startNode, (node, index) => {
|
|
420
|
+
parts[index] = parsePartDescriptor(node.data);
|
|
421
|
+
});
|
|
434
422
|
}
|
|
435
423
|
|
|
436
424
|
// We couldn't correctly parse parts from the template
|
|
437
|
-
if (parts.length !== this.strings.length - 1) {
|
|
425
|
+
if (parts.length !== this.strings.length - 1 || parts.includes(undefined)) {
|
|
438
426
|
throw {
|
|
439
427
|
name: 'ParseTemplateError',
|
|
440
428
|
message:
|
|
@@ -443,7 +431,6 @@ export class TemplateResult {
|
|
|
443
431
|
strings: this.strings,
|
|
444
432
|
templateString: this.templateString,
|
|
445
433
|
childNodes,
|
|
446
|
-
template,
|
|
447
434
|
parts,
|
|
448
435
|
}),
|
|
449
436
|
};
|