solarite 0.5.2 → 0.7.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.
Files changed (49) hide show
  1. package/LICENSE +21 -0
  2. package/dist/Solarite-debug.js +4847 -3878
  3. package/dist/Solarite.js +4583 -3626
  4. package/dist/Solarite.min.js +2 -4
  5. package/package.json +19 -6
  6. package/readme.md +58 -11
  7. package/src/Globals.js +54 -72
  8. package/src/HtmlParser.js +90 -90
  9. package/src/MultiValueMap.js +57 -105
  10. package/src/NodeGroup.js +624 -470
  11. package/src/Path.js +224 -211
  12. package/src/PathToAttribValue.js +401 -261
  13. package/src/PathToAttribs.js +113 -80
  14. package/src/PathToComment.js +7 -7
  15. package/src/PathToComponent.js +183 -188
  16. package/src/PathToEvent.js +76 -64
  17. package/src/PathToKey.js +19 -0
  18. package/src/PathToNodes.js +1053 -566
  19. package/src/RootNodeGroup.js +120 -8
  20. package/src/Shell.js +570 -348
  21. package/src/Solarite.d.ts +134 -113
  22. package/src/Solarite.js +243 -286
  23. package/src/Template.js +195 -274
  24. package/src/Util.js +353 -351
  25. package/src/assert.js +10 -10
  26. package/src/assignAttributes.js +63 -0
  27. package/src/delve.js +55 -43
  28. package/src/h.js +220 -138
  29. package/src/jsx-dev-runtime.d.ts +1 -0
  30. package/src/jsx-dev-runtime.js +5 -0
  31. package/src/jsx-runtime.d.ts +21 -0
  32. package/src/jsx-runtime.js +84 -0
  33. package/src/jsx.js +194 -0
  34. package/src/toEl.js +77 -82
  35. package/dist/udomdiff-license.txt +0 -18
  36. package/src/getArg.js +0 -137
  37. package/src/hash.js +0 -89
  38. package/src/udomdiff.js +0 -176
  39. package/src/unused/FastLookupArray.js +0 -54
  40. package/src/unused/Hashes.js +0 -339
  41. package/src/unused/InUse.test.js +0 -92
  42. package/src/unused/InUseMap.js +0 -98
  43. package/src/unused/LinkedList.js +0 -117
  44. package/src/unused/LinkedList.test.js +0 -115
  45. package/src/unused/Misc.js +0 -13
  46. package/src/unused/Perf.js +0 -47
  47. package/src/unused/TrackedArray.js +0 -54
  48. package/src/unused/WeakArray.js +0 -33
  49. package/src/watch.js +0 -543
package/src/Shell.js CHANGED
@@ -1,348 +1,570 @@
1
- import assert from "./assert.js";
2
- import Path from "./Path.js";
3
- import Util from "./Util.js";
4
- import Globals from "./Globals.js";
5
- import HtmlParser from "./HtmlParser.js";
6
- import PathToEvent from "./PathToEvent.js";
7
- import PathToAttribValue from "./PathToAttribValue.js";
8
- import PathToAttribs from "./PathToAttribs.js";
9
- import PathToNodes from "./PathToNodes.js";
10
- import PathToComponent from "./PathToComponent.js";
11
-
12
- /**
13
- * A Shell is created from a tagged template expression instantiated as Nodes,
14
- * but without any expressions filled in.
15
- * Only one Shell is created for all the items in a loop.
16
- *
17
- * When a NodeGroup is created from a Template's html strings,
18
- * the NodeGroup then clones the Shell's fragment to be its nodes. */
19
- export default class Shell {
20
-
21
- /**
22
- * @type {DocumentFragment|Text} DOM parent of the shell's nodes. */
23
- fragment;
24
-
25
- /** @type {Path[]} Paths to where expressions should go. */
26
- paths = [];
27
-
28
- // Elements with events. Is there a reason to use this? We already mark event Exprs in Shell.js.
29
- // events = [];
30
-
31
- /** @type {int[][]} Array of paths */
32
- ids = [];
33
-
34
- /** @type {int[][]} Array of paths */
35
- scripts = [];
36
-
37
- /** @type {int[][]} Array of paths */
38
- styles = [];
39
-
40
- /**
41
- * Create the nodes but without filling in the expressions.
42
- * This is useful because the expression-less nodes created by a template can be cached.
43
- * @param html {string[]} Html strings, split on places where an expression exists. */
44
- constructor(html=null) {
45
- if (!html)
46
- return;
47
-
48
- //#IFDEV
49
- this._html = html.join('');
50
- //#ENDIF
51
-
52
- // If no html tags or entities, just create a text node.
53
- if (html.length === 1 && !html[0].match(/[<&]/)) {
54
- this.fragment = Globals.doc.createTextNode(html[0]);
55
- return;
56
- }
57
-
58
-
59
- // 1. Add placeholders
60
- let htmlWithPlaceholders = Shell.addPlaceholders(html);
61
-
62
- let template = Globals.doc.createElement('template'); // Using a single global template won't keep the nodes as children of the DocumentFragment.
63
- if (htmlWithPlaceholders)
64
- template.innerHTML = htmlWithPlaceholders;
65
- else // Create one text node, so shell isn't empty and NodeGroups created from it have something to point the startNode and endNode at.
66
- template.content.append(Globals.doc.createTextNode(''))
67
- this.fragment = template.content;
68
-
69
- // 2. Find placeholders
70
- let node;
71
- let toRemove = [];
72
- let placeholdersUsed = 0;
73
- const walker = Globals.doc.createTreeWalker(this.fragment, NodeFilter.SHOW_ELEMENT | NodeFilter.SHOW_COMMENT | NodeFilter.SHOW_TEXT);
74
- while (node = walker.nextNode()) {
75
-
76
- // Remove previous elements after each iteration, so paths will still be calculated correctly.
77
- toRemove.map(el => el.remove());
78
- toRemove = [];
79
-
80
- // Replace attributes
81
- if (node.nodeType === 1) {
82
- const hasIs = node.hasAttribute('is');
83
- const isComponent = (hasIs || node.tagName.includes('-'));
84
- const componentAttribPaths = [];
85
-
86
- for (let attr of [...node.attributes]) { // Copy the attributes array b/c we remove attributes with placeholders as we go.
87
-
88
- // One or more whole attributes
89
- let matches = attr.name.match(/^[\ue000-\uf8ff]$/)
90
- if (matches) {
91
- let path = new PathToAttribs(null, node);
92
- this.paths.push(path);
93
- if (isComponent) {
94
- path.isComponentAttrib = true;
95
- componentAttribPaths.push(path);
96
- }
97
-
98
- placeholdersUsed ++;
99
- node.removeAttribute(matches[0]); // TODO: Is this necessary?
100
- }
101
-
102
- // Just the attribute value.
103
- else {
104
- let parts = attr.value.split(/[\ue000-\uf8ff]/g);
105
- if (parts.length > 1) {
106
- let nonEmptyParts = (parts.length === 2 && !parts[0].length && !parts[1].length) ? null : parts;
107
-
108
- let path = Util.isEvent(attr.name)
109
- ? new PathToEvent(null, node, attr.name, nonEmptyParts)
110
- : new PathToAttribValue(null, node, attr.name, nonEmptyParts);
111
- path.isHtmlProperty = Util.isHtmlProp(node, attr.name);
112
- this.paths.push(path);
113
- if (isComponent) {
114
- path.isComponentAttrib = true;
115
- componentAttribPaths.push(path);
116
- }
117
-
118
- placeholdersUsed += parts.length - 1;
119
- try {
120
- node.setAttribute(attr.name, parts.join(''));
121
- }
122
- catch (e) {
123
- throw new Error(`Error setting attribute "${attr.name}" on node <${node.tagName}>: ${e.message}`);
124
- }
125
- }
126
- }
127
- }
128
-
129
- // Web components
130
- if (isComponent) {
131
- let path = new PathToComponent(null, node);
132
- path.attribPaths = componentAttribPaths;
133
- this.paths.splice(this.paths.length - componentAttribPaths.length, 0, path); // Insert before its componentAttribPaths
134
-
135
- if (hasIs) {
136
- node.setAttribute('_is', node.getAttribute('is'));
137
- node.removeAttribute('is');
138
- }
139
- }
140
- }
141
-
142
- // Replace comment placeholders
143
- else if (node.nodeType === 8 && node.nodeValue === '!✨!') {
144
-
145
- if (node?.parentNode?.closest && node?.parentNode?.closest('[contenteditable]'))
146
- throw new Error(`Contenteditable can't have expressions inside them. Use <div contenteditable value="\${...}"> instead.`);
147
-
148
- // Get or create nodeBefore.
149
- let nodeBefore = node.previousSibling; // Can be the same as another Path's nodeMarker.
150
- if (!nodeBefore) {
151
- nodeBefore = Globals.doc.createComment('Path:'+this.paths.length);
152
- node.parentNode.insertBefore(nodeBefore, node)
153
- }
154
- /*#IFDEV*/assert(nodeBefore);/*#ENDIF*/
155
-
156
- // Get the next node.
157
- let nodeMarker;
158
-
159
- // A subsequent node is available to be a nodeMarker.
160
- if (node.nextSibling && (node.nextSibling.nodeType !== 8 || node.nextSibling.textContent !== '!✨!')) {
161
- nodeMarker = node.nextSibling;
162
- toRemove.push(node); // Removing them here will mess up the treeWalker.
163
- }
164
- // Re-use existing comment placeholder.
165
- else {
166
- nodeMarker = node;
167
- nodeMarker.textContent = 'PathEnd:'+ this.paths.length;
168
- }
169
- /*#IFDEV*/assert(nodeMarker);/*#ENDIF*/
170
-
171
- let path = new PathToNodes(nodeBefore, nodeMarker);
172
- this.paths.push(path);
173
- placeholdersUsed ++;
174
- }
175
-
176
- // Comments become text nodes when inside textareas.
177
- else if (node.nodeType === 3 && node.parentNode?.tagName === 'TEXTAREA' && node.textContent.includes('<!--!✨!-->'))
178
- throw new Error(`Textarea can't have expressions inside them. Use <textarea value="\${...}"> instead.`);
179
-
180
-
181
- // Sometimes users will comment out a block of html code that has expressions.
182
- // Here we look for expressions in comments.
183
- // We don't actually update them dynamically, but we still add paths for them.
184
- // That way the expression count still matches.
185
- else if (node.nodeType === 8) { // Node.COMMENT_NODE
186
- let parts = node.textContent.split(/[\ue000-\uf8ff]/g);
187
- for (let i=0; i<parts.length-1; i++) {
188
- let path = new Path(node.previousSibling, node)
189
- this.paths.push(path);
190
- placeholdersUsed ++;
191
- }
192
- }
193
-
194
- // Replace comment placeholders inside script and style tags, which have become text nodes.
195
- else if (node.nodeType === 3 && ['SCRIPT', 'STYLE'].includes(node.parentNode?.nodeName)) { // Node.TEXT_NODE
196
- let parts = node.textContent.split(commentPlaceholder);
197
- if (parts.length > 1) {
198
-
199
- let placeholders = [];
200
- for (let i = 0; i<parts.length; i++) {
201
- let current = Globals.doc.createTextNode(parts[i]);
202
- node.parentNode.insertBefore(current, node);
203
- if (i > 0)
204
- placeholders.push(current)
205
- }
206
-
207
- for (let i=0, node; node=placeholders[i]; i++) {
208
- let path = new PathToNodes(node.previousSibling, node);
209
- this.paths.push(path);
210
- placeholdersUsed ++;
211
-
212
- /*#IFDEV*/path.verify();/*#ENDIF*/
213
- }
214
-
215
- // Removing them here will mess up the treeWalker.
216
- toRemove.push(node);
217
- }
218
- }
219
- }
220
- toRemove.map(el => el.remove());
221
-
222
- // Less than or equal because there can be one path to multiple expressions
223
- // if those expressions are in the same attribute value.
224
- if (placeholdersUsed !== html.length-1)
225
- throw new Error(`Could not parse expressions in template. Check for duplicate attributes or malformed html: ${html.join('${...}')}`);
226
-
227
- for (let path of this.paths) {
228
- if (path.nodeBefore)
229
- path.nodeBeforeIndex = Array.prototype.indexOf.call(path.nodeBefore.parentNode.childNodes, path.nodeBefore)
230
-
231
- // Must be calculated after we remove the toRemove nodes:
232
- path.nodeMarkerPath = Path.get(path.nodeMarker)
233
-
234
-
235
- }
236
-
237
- this.findEmbeds();
238
-
239
-
240
- /*#IFDEV*/this.verify();/*#ENDIF*/
241
- }
242
-
243
- /**
244
- * 1. Add a Unicode placeholder char for where expressions go within attributes.
245
- * 2. Add a comment placeholder for where expressions are children of other nodes.
246
- * 3. Append -solarite-placeholder to the tag names of custom components so that we can instantiate them later
247
- * when we can manually call their constructors with the proper attribute and children arguments from evaluated expressions.
248
- * @param htmlChunks {string[]}
249
- * @returns {string} Html with the placeholders in place. */
250
- static addPlaceholders(htmlChunks) {
251
- let result = [];
252
-
253
- let htmlParser = new HtmlParser(); // Reset the context.
254
- for (let i = 0; i < htmlChunks.length; i++) {
255
- let lastHtml = htmlChunks[i];
256
-
257
- // Append -solarite-placholder to web component tags, so we can pass args to them when they're instantiated.
258
- let lastIndex = 0;
259
- let context = htmlParser.parse(lastHtml, (html, index, prevContext/*, nextContext*/) => { // This function is called every time the html context changes.
260
- if (lastIndex !== index) {
261
- let token = html.slice(lastIndex, index);
262
-
263
- if (prevContext === HtmlParser.Tag) {
264
- // Find Web Component tags and append -solarite-placeholder to their tag names
265
- // This way we can gather their constructor arguments and their children before we call their constructor.
266
- // Later, PathToComponent.apply() will replace them with the real components.
267
- // Ctrl+F "solarite-placeholder" in project to find all code that manages subcomponents.
268
- const isWebComponentTagName = /^<\/?[a-z][a-z0-9]*-[a-z0-9-]+/i; // a dash in the middle
269
- token = token.replace(isWebComponentTagName, match => match + '-SOLARITE-PLACEHOLDER'); // caps to match other instances of this string, for better compression.
270
- }
271
-
272
- result.push(token);
273
- }
274
- lastIndex = index;
275
- });
276
-
277
- // Insert placeholders
278
- if (i < htmlChunks.length - 1) {
279
- if (context === HtmlParser.Text)
280
- result.push(commentPlaceholder) // Comment Placeholder. because we can't put text in between <tr> tags for example.
281
- else
282
- result.push(String.fromCharCode(attribPlaceholder + i));
283
- }
284
- }
285
-
286
- return result.join('');
287
- }
288
-
289
- /**
290
- * We find the path to every embed here once in the Shell, instead of every time a NodeGroup is instantiated.
291
- * When a Nodegroup is created, it calls NodeGroup.activateEmbeds() that uses these paths.
292
- * Populates:
293
- * this.scripts
294
- * this.styles
295
- * this.ids
296
- * this.staticComponents */
297
- findEmbeds() {
298
- this.scripts = Array.prototype.map.call(this.fragment.querySelectorAll('scripts'), el => Path.get(el))
299
-
300
- // TODO: only find styles that have Paths in them?
301
- this.styles = Array.prototype.map.call(this.fragment.querySelectorAll('style'), el => Path.get(el))
302
-
303
- let idEls = this.fragment.querySelectorAll('[id],[data-id]');
304
-
305
- // Check for valid id names.
306
- for (let el of idEls) {
307
- let id = el.getAttribute('data-id') || el.getAttribute('id')
308
- if (Globals.div.hasOwnProperty(id))
309
- throw new Error(`<${el.tagName.toLowerCase()} id="${id}"> can't override existing HTMLElement id property.`)
310
- }
311
-
312
- this.ids = Array.prototype.map.call(idEls, el => Path.get(el))
313
- }
314
-
315
- /**
316
- * Get the shell for the html strings.
317
- * @param htmlStrings {string[]} Typically comes from a Template.
318
- * @returns {Shell} */
319
- static get(htmlStrings) {
320
- let result = Globals.shells.get(htmlStrings);
321
- if (!result) {
322
- result = new Shell(htmlStrings);
323
- Globals.shells.set(htmlStrings, result); // cache
324
- }
325
-
326
- /*#IFDEV*/result.verify();/*#ENDIF*/
327
- return result;
328
- }
329
-
330
- //#IFDEV
331
- // For debugging only:
332
- verify() {
333
- for (let path of this.paths) {
334
- assert(this.fragment.contains(path.getParentNode()))
335
- path.verify();
336
- }
337
- }
338
- //#ENDIF
339
- }
340
-
341
-
342
- const commentPlaceholder = `<!--!✨!-->`;
343
-
344
-
345
- // We increment the placeholder char as we go because nodes can't have the same attribute more than once.
346
- const attribPlaceholder = 0xe000; // https://en.wikipedia.org/wiki/Private_Use_Areas 6400.
347
-
348
-
1
+ import assert from "./assert.js";
2
+ import Path from "./Path.js";
3
+ import Util from "./Util.js";
4
+ import Globals from "./Globals.js";
5
+ import HtmlParser from "./HtmlParser.js";
6
+ import PathToEvent from "./PathToEvent.js";
7
+ import PathToAttribValue from "./PathToAttribValue.js";
8
+ import PathToAttribs from "./PathToAttribs.js";
9
+ import PathToNodes from "./PathToNodes.js";
10
+ import PathToComponent from "./PathToComponent.js";
11
+ import PathToKey from "./PathToKey.js";
12
+
13
+ /**
14
+ * A Shell is created from a tagged template expression instantiated as Nodes,
15
+ * but without any expressions filled in.
16
+ * Only one Shell is created for all the items in a loop.
17
+ *
18
+ * When a NodeGroup is created from a Template's html strings,
19
+ * the NodeGroup then clones the Shell's fragment to be its nodes. */
20
+ export default class Shell {
21
+
22
+ /**
23
+ * @type {DocumentFragment|Text} DOM parent of the shell's nodes. */
24
+ fragment;
25
+
26
+ /** @type {Path[]} Paths to where expressions should go. */
27
+ paths = [];
28
+
29
+ // Elements with events. Is there a reason to use this? We already mark event Exprs in Shell.js.
30
+ // events = [];
31
+
32
+ /** @type {int[][]} Array of paths */
33
+ ids = [];
34
+
35
+ /** @type {int[][]} Array of paths */
36
+ styles = [];
37
+
38
+ /** @type {int[][]} Array of paths */
39
+ scripts = [];
40
+
41
+ /** @type {boolean} True if any of this Shell's own paths is a PathToComponent. */
42
+ hasComponentPaths = false;
43
+
44
+ /** @type {boolean} True if every path consumes exactly one expression and none are components.
45
+ * Lets NodeGroup.applyExprs() use a fast loop without allocating per-path expression arrays. */
46
+ pathsSingleExpr = false;
47
+
48
+ /** @type {boolean} True if this Shell has any ids, styles, or scripts. */
49
+ hasEmbeds = false;
50
+
51
+ /** @type {int} Index of the key=${} expression, or -1 when the template isn't keyed. */
52
+ keyIndex = -1;
53
+
54
+ /** @type {boolean} True when the fragment holds exactly one root element and the resolve
55
+ * program exists. NodeGroups then clone that element directly, skipping a throwaway
56
+ * DocumentFragment wrapper per clone. See setPathsFromFragment(). */
57
+ singleRoot = false;
58
+
59
+ /** @type {boolean} True when NodeGroups can be created via NodeGroup.applyStamp()
60
+ * with no per-instance Path objects. See the stampPaths setup in the constructor. */
61
+ stampable = false;
62
+
63
+ /**
64
+ * Create the nodes but without filling in the expressions.
65
+ * This is useful because the expression-less nodes created by a template can be cached.
66
+ * @param html {string[]} Html strings, split on places where an expression exists.
67
+ * @param svgMode {boolean} Parse the html in the SVG namespace. */
68
+ constructor(html=null, svgMode=false) {
69
+ if (!html)
70
+ return;
71
+
72
+ //#IFDEV
73
+ this._html = html.join('');
74
+ //#ENDIF
75
+
76
+ // If no html tags or entities, just create a text node.
77
+ if (html.length === 1 && !html[0].match(/[<&]/)) {
78
+ this.fragment = Globals.doc.createTextNode(html[0]);
79
+ return;
80
+ }
81
+
82
+
83
+ // 1. Add placeholders
84
+ let htmlWithPlaceholders = Shell.addPlaceholders(html);
85
+
86
+ let template = Globals.doc.createElement('template'); // Using a single global template won't keep the nodes as children of the DocumentFragment.
87
+ if (htmlWithPlaceholders) {
88
+ // Wrap in <svg> so the parser's foreign-content rules create the nodes in the SVG namespace,
89
+ // then lift the children back out so the fragment has no wrapper.
90
+ if (svgMode) {
91
+ template.innerHTML = '<svg>' + htmlWithPlaceholders + '</svg>';
92
+ let svgEl = template.content.firstChild;
93
+ let frag = Globals.doc.createDocumentFragment();
94
+ while (svgEl.firstChild)
95
+ frag.append(svgEl.firstChild);
96
+ this.fragment = frag;
97
+ }
98
+ else {
99
+ template.innerHTML = htmlWithPlaceholders;
100
+ this.fragment = template.content;
101
+ }
102
+ }
103
+ else { // Create one text node, so shell isn't empty and NodeGroups created from it have something to point the startNode and endNode at.
104
+ template.content.append(Globals.doc.createTextNode(''))
105
+ this.fragment = template.content;
106
+ }
107
+
108
+ // 1b. Remove whitespace-only text nodes inside table-structure elements.
109
+ // The parser foster-parents non-whitespace text out of tables, and whitespace-only
110
+ // text between cells/rows is never rendered, so removing it is invisible.
111
+ // Smaller fragments make cloning, path resolution, and insertion faster.
112
+ stripTableWhitespace(this.fragment);
113
+
114
+ // 2. Find placeholders
115
+ let node;
116
+ let toRemove = [];
117
+ let placeholdersUsed = 0;
118
+ const walker = Globals.doc.createTreeWalker(this.fragment, NodeFilter.SHOW_ELEMENT | NodeFilter.SHOW_COMMENT | NodeFilter.SHOW_TEXT);
119
+ while (node = walker.nextNode()) {
120
+
121
+ // Remove previous elements after each iteration, so paths will still be calculated correctly.
122
+ toRemove.map(el => el.remove());
123
+ toRemove = [];
124
+
125
+ // Replace attributes
126
+ if (node.nodeType === 1) {
127
+ const hasIs = node.hasAttribute('is');
128
+ const isComponent = (hasIs || node.tagName.includes('-'));
129
+ const componentAttribPaths = [];
130
+
131
+ for (let attr of [...node.attributes]) { // Copy the attributes array b/c we remove attributes with placeholders as we go.
132
+
133
+ // The reserved key attribute identifies this template within a keyed list.
134
+ // It's consumed here and never written to the DOM or passed to components.
135
+ if (attr.name === 'key') {
136
+ let parts = attr.value.split(/[\ue000-\uf8ff]/g);
137
+ if (parts.length !== 2 || parts[0] !== '' || parts[1] !== '')
138
+ throw new Error(`The key attribute is reserved and must be a single expression: key=\${...}`);
139
+ if (node.parentNode !== this.fragment)
140
+ throw new Error(`The key attribute must be on a top-level element of its template.`);
141
+ if (this.keyIndex >= 0)
142
+ throw new Error(`A template can have only one key attribute.`);
143
+ this.keyIndex = attr.value.charCodeAt(0) - attribPlaceholder;
144
+
145
+ let path = new PathToKey(null, node);
146
+ this.paths.push(path);
147
+ if (isComponent)
148
+ componentAttribPaths.push(path); // Keeps PathToComponent's contiguous expression slices aligned; it skips PathToKey when building args.
149
+
150
+ placeholdersUsed++;
151
+ node.removeAttribute('key');
152
+ continue;
153
+ }
154
+
155
+ // One or more whole attributes
156
+ let matches = attr.name.match(/^[\ue000-\uf8ff]$/)
157
+ if (matches) {
158
+ let path = new PathToAttribs(null, node);
159
+ this.paths.push(path);
160
+ if (isComponent) {
161
+ path.isComponentAttrib = true;
162
+ componentAttribPaths.push(path);
163
+ }
164
+
165
+ placeholdersUsed ++;
166
+ node.removeAttribute(matches[0]); // TODO: Is this necessary?
167
+ }
168
+
169
+ // Just the attribute value.
170
+ else {
171
+ let parts = attr.value.split(/[\ue000-\uf8ff]/g);
172
+ if (parts.length > 1) {
173
+ let nonEmptyParts = (parts.length === 2 && !parts[0].length && !parts[1].length) ? null : parts;
174
+
175
+ let isEvent = Util.isEvent(attr.name);
176
+ let path = isEvent
177
+ ? new PathToEvent(null, node, attr.name, nonEmptyParts)
178
+ : new PathToAttribValue(null, node, attr.name, nonEmptyParts);
179
+ path.isHtmlProperty = Util.isHtmlProp(node, attr.name);
180
+ this.paths.push(path);
181
+ if (isComponent) {
182
+ path.isComponentAttrib = true;
183
+ componentAttribPaths.push(path);
184
+ }
185
+
186
+ placeholdersUsed += parts.length - 1;
187
+ // In svgMode, setting typed SVG attributes (viewBox, r, etc.) with the placeholders
188
+ // stripped out makes the browser log parse errors, both here and when the fragment is cloned.
189
+ // Remove the attribute instead; apply() recreates it with the real values.
190
+ // Event attributes bound to a single expression are removed because they bind via
191
+ // addEventListener; leaving an empty onclick="" attribute violates a strict CSP when the event fires.
192
+ if (svgMode || (isEvent && !nonEmptyParts))
193
+ node.removeAttribute(attr.name);
194
+ else try {
195
+ node.setAttribute(attr.name, parts.join(''));
196
+ }
197
+ catch (e) {
198
+ throw new Error(`Error setting attribute "${attr.name}" on node <${node.tagName}>: ${e.message}`);
199
+ }
200
+ }
201
+ }
202
+ }
203
+
204
+ // Web components
205
+ if (isComponent) {
206
+ let path = new PathToComponent(null, node);
207
+ path.attribPaths = componentAttribPaths;
208
+ this.paths.splice(this.paths.length - componentAttribPaths.length, 0, path); // Insert before its componentAttribPaths
209
+
210
+ if (hasIs) {
211
+ node.setAttribute('_is', node.getAttribute('is'));
212
+ node.removeAttribute('is');
213
+ }
214
+ }
215
+ }
216
+
217
+ // Replace comment placeholders
218
+ else if (node.nodeType === 8 && node.nodeValue === '!✨!') {
219
+
220
+ if (node?.parentNode?.closest && node?.parentNode?.closest('[contenteditable]'))
221
+ throw new Error(`Contenteditable can't have expressions inside them. Use <div contenteditable value="\${...}"> instead.`);
222
+
223
+ let parent = node.parentNode;
224
+
225
+ // The expression is the only child of an element, so the element itself
226
+ // can delimit the expression's nodes and no marker comments are needed.
227
+ // Components and slots are excluded because they move their children
228
+ // during instantiation, which would orphan the expression's region.
229
+ if (parent.nodeType === 1 && !node.previousSibling && !node.nextSibling
230
+ && !parent.tagName.includes('-') && parent.tagName !== 'SLOT' && !parent.hasAttribute('is')) {
231
+ let path = new PathToNodes(null, parent);
232
+ path.wholeParent = true;
233
+ this.paths.push(path);
234
+ placeholdersUsed ++;
235
+ toRemove.push(node); // Removing it here would mess up the treeWalker.
236
+ }
237
+
238
+ else {
239
+ // Get or create nodeBefore.
240
+ let nodeBefore = node.previousSibling; // Can be the same as another Path's nodeMarker.
241
+ if (!nodeBefore) {
242
+ nodeBefore = Globals.doc.createComment('Path:'+this.paths.length);
243
+ node.parentNode.insertBefore(nodeBefore, node)
244
+ }
245
+ /*#IFDEV*/assert(nodeBefore);/*#ENDIF*/
246
+
247
+ // Get the next node.
248
+ let nodeMarker;
249
+
250
+ // A subsequent node is available to be a nodeMarker.
251
+ if (node.nextSibling && (node.nextSibling.nodeType !== 8 || node.nextSibling.textContent !== '!✨!')) {
252
+ nodeMarker = node.nextSibling;
253
+ toRemove.push(node); // Removing them here will mess up the treeWalker.
254
+ }
255
+ // Re-use existing comment placeholder.
256
+ else {
257
+ nodeMarker = node;
258
+ nodeMarker.textContent = 'PathEnd:'+ this.paths.length;
259
+ }
260
+ /*#IFDEV*/assert(nodeMarker);/*#ENDIF*/
261
+
262
+ let path = new PathToNodes(nodeBefore, nodeMarker);
263
+ this.paths.push(path);
264
+ placeholdersUsed ++;
265
+ }
266
+ }
267
+
268
+ // Comments become text nodes when inside textareas.
269
+ else if (node.nodeType === 3 && node.parentNode?.tagName === 'TEXTAREA' && node.textContent.includes('<!--!✨!-->'))
270
+ throw new Error(`Textarea can't have expressions inside them. Use <textarea value="\${...}"> instead.`);
271
+
272
+
273
+ // Sometimes users will comment out a block of html code that has expressions.
274
+ // Here we look for expressions in comments.
275
+ // We don't actually update them dynamically, but we still add paths for them.
276
+ // That way the expression count still matches.
277
+ else if (node.nodeType === 8) { // Node.COMMENT_NODE
278
+ let parts = node.textContent.split(/[\ue000-\uf8ff]/g);
279
+ for (let i=0; i<parts.length-1; i++) {
280
+ let path = new Path(node.previousSibling, node)
281
+ this.paths.push(path);
282
+ placeholdersUsed ++;
283
+ }
284
+ }
285
+
286
+ // Replace comment placeholders inside script and style tags, which have become text nodes.
287
+ else if (node.nodeType === 3 && ['SCRIPT', 'STYLE'].includes(node.parentNode?.nodeName)) { // Node.TEXT_NODE
288
+ let parts = node.textContent.split(commentPlaceholder);
289
+ if (parts.length > 1) {
290
+
291
+ let placeholders = [];
292
+ for (let i = 0; i<parts.length; i++) {
293
+ let current = Globals.doc.createTextNode(parts[i]);
294
+ node.parentNode.insertBefore(current, node);
295
+ if (i > 0)
296
+ placeholders.push(current)
297
+ }
298
+
299
+ for (let i=0, node; node=placeholders[i]; i++) {
300
+ let path = new PathToNodes(node.previousSibling, node);
301
+ this.paths.push(path);
302
+ placeholdersUsed ++;
303
+
304
+ /*#IFDEV*/path.verify();/*#ENDIF*/
305
+ }
306
+
307
+ // Removing them here will mess up the treeWalker.
308
+ toRemove.push(node);
309
+ }
310
+ }
311
+ }
312
+ toRemove.map(el => el.remove());
313
+
314
+ // Less than or equal because there can be one path to multiple expressions
315
+ // if those expressions are in the same attribute value.
316
+ if (placeholdersUsed !== html.length-1)
317
+ throw new Error(`Could not parse expressions in template. Check for duplicate attributes or malformed html: ${html.join('${...}')}`);
318
+
319
+ for (let path of this.paths) {
320
+ if (path.nodeBefore)
321
+ path.nodeBeforeIndex = Array.prototype.indexOf.call(path.nodeBefore.parentNode.childNodes, path.nodeBefore)
322
+
323
+ // Must be calculated after we remove the toRemove nodes:
324
+ path.nodeMarkerPath = Path.get(path.nodeMarker)
325
+
326
+
327
+ }
328
+
329
+ this.findEmbeds();
330
+ this.buildResolveProgram();
331
+
332
+ this.pathsSingleExpr = true;
333
+ for (let path of this.paths) {
334
+ if (path instanceof PathToComponent) {
335
+ this.hasComponentPaths = true;
336
+ this.pathsSingleExpr = false;
337
+ break; // Both facts are now decided.
338
+ }
339
+ if (path.getExpressionCount() !== 1)
340
+ this.pathsSingleExpr = false; // Keep scanning for components.
341
+ }
342
+
343
+ // Stampable shells create NodeGroups without allocating any Path objects:
344
+ // NodeGroup.applyStamp() writes expressions through these shared stamper paths,
345
+ // and real paths are materialized only if a NodeGroup is later rewritten in place.
346
+ // Child-node paths must be wholeParent so their bare-text state can be recovered.
347
+ if (this.singleRoot && this.pathsSingleExpr) {
348
+ let nodesIdx = [];
349
+ let ok = true;
350
+ for (let i=0; i<this.paths.length; i++) {
351
+ let path = this.paths[i];
352
+ if (path instanceof PathToNodes) {
353
+ if (!path.wholeParent) {
354
+ ok = false;
355
+ break;
356
+ }
357
+ nodesIdx.push(i);
358
+ }
359
+ else if (!(path instanceof PathToAttribValue || path instanceof PathToKey)) {
360
+ ok = false; // Base Paths from commented-out expressions, etc.
361
+ break;
362
+ }
363
+ }
364
+ if (ok) {
365
+ this.stampable = true;
366
+
367
+ /** @type {int[]} Indexes of PathToNodes paths, checked for primitive exprs before stamping. */
368
+ this.nodesPathIdx = nodesIdx;
369
+
370
+ /** @type {Path[]} One shared stamper per path; nodeMarker/parentNg are set per use. */
371
+ this.stampPaths = this.paths.map(p => p.cloneWithNodes(null, p.nodeMarker));
372
+
373
+ }
374
+ }
375
+
376
+ /*#IFDEV*/this.verify();/*#ENDIF*/
377
+ }
378
+
379
+ /**
380
+ * 1. Add a Unicode placeholder char for where expressions go within attributes.
381
+ * 2. Add a comment placeholder for where expressions are children of other nodes.
382
+ * 3. Append -solarite-placeholder to the tag names of custom components so that we can instantiate them later
383
+ * when we can manually call their constructors with the proper attribute and children arguments from evaluated expressions.
384
+ * @param htmlChunks {string[]}
385
+ * @returns {string} Html with the placeholders in place. */
386
+ static addPlaceholders(htmlChunks) {
387
+ let result = [];
388
+
389
+ let htmlParser = new HtmlParser(); // Reset the context.
390
+ for (let i = 0; i < htmlChunks.length; i++) {
391
+ let lastHtml = htmlChunks[i];
392
+
393
+ // Append -solarite-placholder to web component tags, so we can pass args to them when they're instantiated.
394
+ let lastIndex = 0;
395
+ let context = htmlParser.parse(lastHtml, (html, index, prevContext/*, nextContext*/) => { // This function is called every time the html context changes.
396
+ if (lastIndex !== index) {
397
+ let token = html.slice(lastIndex, index);
398
+
399
+ if (prevContext === HtmlParser.Tag) {
400
+ // Find Web Component tags and append -solarite-placeholder to their tag names
401
+ // This way we can gather their constructor arguments and their children before we call their constructor.
402
+ // Later, PathToComponent.apply() will replace them with the real components.
403
+ // Ctrl+F "solarite-placeholder" in project to find all code that manages subcomponents.
404
+ const isWebComponentTagName = /^<\/?[a-z][a-z0-9]*-[a-z0-9-]+/i; // a dash in the middle
405
+ token = token.replace(isWebComponentTagName, match => match + '-SOLARITE-PLACEHOLDER'); // caps to match other instances of this string, for better compression.
406
+ }
407
+
408
+ result.push(token);
409
+ }
410
+ lastIndex = index;
411
+ });
412
+
413
+ // Insert placeholders
414
+ if (i < htmlChunks.length - 1) {
415
+ if (context === HtmlParser.Text)
416
+ result.push(commentPlaceholder) // Comment Placeholder. because we can't put text in between <tr> tags for example.
417
+ else
418
+ result.push(String.fromCharCode(attribPlaceholder + i));
419
+ }
420
+ }
421
+
422
+ return result.join('');
423
+ }
424
+
425
+ /**
426
+ * We find the path to every embed here once in the Shell, instead of every time a NodeGroup is instantiated.
427
+ * When a Nodegroup is created, it calls NodeGroup.activateEmbeds() that uses these paths.
428
+ * Populates:
429
+ * this.scripts
430
+ * this.styles
431
+ * this.ids
432
+ * this.staticComponents */
433
+ findEmbeds() {
434
+ this.scripts = Array.prototype.map.call(this.fragment.querySelectorAll('script'), el => Path.get(el))
435
+
436
+ // TODO: only find styles that have Paths in them?
437
+ this.styles = Array.prototype.map.call(this.fragment.querySelectorAll('style'), el => Path.get(el))
438
+
439
+ let idEls = this.fragment.querySelectorAll('[id],[data-id]');
440
+
441
+ // Check for valid id names.
442
+ for (let el of idEls) {
443
+ let id = el.getAttribute('data-id') || el.getAttribute('id')
444
+ if (Globals.div.hasOwnProperty(id))
445
+ throw new Error(`<${el.tagName.toLowerCase()} id="${id}"> can't override existing HTMLElement id property.`)
446
+ }
447
+
448
+ this.ids = Array.prototype.map.call(idEls, el => Path.get(el))
449
+
450
+ this.hasEmbeds = this.ids.length > 0 || this.styles.length > 0 || this.scripts.length > 0;
451
+ }
452
+
453
+ /**
454
+ * Precompute a flat program that resolves every path's nodeMarker/nodeBefore in a cloned
455
+ * fragment with one childNodes access per unique node, sharing ancestor lookups between paths.
456
+ * Replaces per-path root-to-node walks in the hot NodeGroup creation path.
457
+ * Skipped for shells with components, whose clone() has special attribPaths behavior. */
458
+ buildResolveProgram() {
459
+ let hasComponents = false;
460
+ for (let path of this.paths)
461
+ if (path instanceof PathToComponent) {
462
+ hasComponents = true;
463
+ break;
464
+ }
465
+ if (hasComponents || !this.paths.length)
466
+ return;
467
+
468
+ let ops = [];
469
+ let slotOf = new Map();
470
+ let frag = this.fragment;
471
+ let nextSlot = 1;
472
+ let getSlot = node => {
473
+ if (node === frag)
474
+ return 0;
475
+ let s = slotOf.get(node);
476
+ if (s === undefined) {
477
+ ops.push(getSlot(node.parentNode), Array.prototype.indexOf.call(node.parentNode.childNodes, node));
478
+ s = nextSlot++;
479
+ slotOf.set(node, s);
480
+ }
481
+ return s;
482
+ };
483
+ for (let path of this.paths) {
484
+ path.markerSlot = path.nodeMarker === frag ? 0 : getSlot(path.nodeMarker);
485
+ path.beforeSlot = path.nodeBefore ? getSlot(path.nodeBefore) : -1;
486
+ }
487
+
488
+ /** @type {?int[]} Flat [parentSlot, childIndex] pairs; pair i fills slot i+1. */
489
+ this.resolveOps = ops;
490
+
491
+ /** @type {Node[]} Reusable scratch array for resolved nodes; safe because resolution never re-enters. */
492
+ this.resolveSlots = new Array(nextSlot);
493
+
494
+ // A lone root element means slot 1 is always that element (the first op pair is [0, 0]),
495
+ // so a NodeGroup can clone the element directly and seed slot 1 with it.
496
+ // Embeds are excluded because their paths are fragment-relative.
497
+ if (!this.hasEmbeds && frag.childNodes.length === 1 && frag.firstChild.nodeType === 1
498
+ && ops.length >= 2 && ops[0] === 0 && ops[1] === 0)
499
+ this.singleRoot = true;
500
+ }
501
+
502
+ /**
503
+ * Get the shell for the html strings.
504
+ * @param htmlStrings {string[]} Typically comes from a Template.
505
+ * @param svgMode {boolean} Parse the html in the SVG namespace.
506
+ * @returns {Shell} */
507
+ static get(htmlStrings, svgMode=false) {
508
+ // One-entry memo, since loops request the same shell for every item.
509
+ if (htmlStrings === lastHtmlStrings && svgMode === lastSvgMode)
510
+ return lastShell;
511
+
512
+ let entry = Globals.shells.get(htmlStrings);
513
+ if (!entry) {
514
+ entry = {};
515
+ Globals.shells.set(htmlStrings, entry); // cache
516
+ }
517
+ let key = svgMode ? 'svg' : 'html';
518
+ let result = entry[key];
519
+ if (!result)
520
+ result = entry[key] = new Shell(htmlStrings, svgMode);
521
+
522
+ lastHtmlStrings = htmlStrings;
523
+ lastSvgMode = svgMode;
524
+ lastShell = result;
525
+
526
+ /*#IFDEV*/result.verify();/*#ENDIF*/
527
+ return result;
528
+ }
529
+
530
+ //#IFDEV
531
+ // For debugging only:
532
+ verify() {
533
+ for (let path of this.paths) {
534
+ assert(this.fragment.contains(path.getParentNode()))
535
+ path.verify();
536
+ }
537
+ }
538
+ //#ENDIF
539
+ }
540
+
541
+
542
+ const commentPlaceholder = `<!--!✨!-->`;
543
+
544
+ // Elements whose whitespace-only text children are never rendered.
545
+ const tableTags = ['TABLE', 'THEAD', 'TBODY', 'TFOOT', 'TR'];
546
+
547
+ /**
548
+ * Recursively remove whitespace-only text children of table-structure elements.
549
+ * @param el {DocumentFragment|HTMLElement} */
550
+ function stripTableWhitespace(el) {
551
+ let isTable = el.nodeType === 1 && tableTags.includes(el.tagName);
552
+ let child = el.firstChild;
553
+ while (child) {
554
+ let next = child.nextSibling;
555
+ if (child.nodeType === 1)
556
+ stripTableWhitespace(child);
557
+ else if (isTable && child.nodeType === 3 && !child.nodeValue.trim())
558
+ child.remove();
559
+ child = next;
560
+ }
561
+ }
562
+
563
+ // One-entry memo for Shell.get().
564
+ let lastHtmlStrings = null, lastSvgMode = false, lastShell = null;
565
+
566
+
567
+ // We increment the placeholder char as we go because nodes can't have the same attribute more than once.
568
+ const attribPlaceholder = 0xe000; // https://en.wikipedia.org/wiki/Private_Use_Areas 6400.
569
+
570
+