solarite 0.7.1 → 0.9.0
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/dist/Solarite-debug.js +2206 -955
- package/dist/Solarite.js +2154 -909
- package/dist/Solarite.min.js +2 -2
- package/dist/jsx-dev-runtime.js +3 -0
- package/dist/jsx-dev-runtime.min.js +3 -0
- package/dist/jsx-runtime.js +86 -0
- package/dist/jsx-runtime.min.js +2 -0
- package/package.json +8 -7
- package/readme.md +9 -9
- package/src/Globals.js +22 -2
- package/src/MappedList.js +34 -0
- package/src/MultiValueMap.js +4 -4
- package/src/NodeGroup.js +205 -119
- package/src/Path.js +52 -53
- package/src/PathToAttribValue.js +259 -112
- package/src/PathToAttribs.js +10 -18
- package/src/PathToComponent.js +121 -83
- package/src/PathToEvent.js +32 -12
- package/src/PathToKey.js +0 -6
- package/src/PathToNodes.js +695 -155
- package/src/RootNodeGroup.js +45 -35
- package/src/Selector.js +207 -0
- package/src/Shell.js +313 -123
- package/src/Solarite.d.ts +91 -13
- package/src/Solarite.js +38 -25
- package/src/Template.js +20 -7
- package/src/Util.js +88 -60
- package/src/assert.js +1 -1
- package/src/assignAttributes.js +20 -19
- package/src/h.js +64 -37
- package/src/jsx.js +4 -7
- package/src/toEl.js +1 -1
- package/src/HtmlParser.js +0 -91
- package/src/PathToComment.js +0 -8
package/src/Shell.js
CHANGED
|
@@ -2,7 +2,6 @@ import assert from "./assert.js";
|
|
|
2
2
|
import Path from "./Path.js";
|
|
3
3
|
import Util from "./Util.js";
|
|
4
4
|
import Globals from "./Globals.js";
|
|
5
|
-
import HtmlParser from "./HtmlParser.js";
|
|
6
5
|
import PathToEvent from "./PathToEvent.js";
|
|
7
6
|
import PathToAttribValue from "./PathToAttribValue.js";
|
|
8
7
|
import PathToAttribs from "./PathToAttribs.js";
|
|
@@ -21,7 +20,7 @@ export default class Shell {
|
|
|
21
20
|
|
|
22
21
|
/**
|
|
23
22
|
* @type {DocumentFragment|Text} DOM parent of the shell's nodes. */
|
|
24
|
-
|
|
23
|
+
docFrag;
|
|
25
24
|
|
|
26
25
|
/** @type {Path[]} Paths to where expressions should go. */
|
|
27
26
|
paths = [];
|
|
@@ -41,10 +40,22 @@ export default class Shell {
|
|
|
41
40
|
/** @type {boolean} True if any of this Shell's own paths is a PathToComponent. */
|
|
42
41
|
hasComponentPaths = false;
|
|
43
42
|
|
|
43
|
+
/** @type {boolean} True if any path binds an attribute that's a live HTML property
|
|
44
|
+
* (checked, value, selected — Util.isHtmlProp). Users flip those underneath the template,
|
|
45
|
+
* so "expression unchanged" doesn't mean "DOM unchanged" and the skip shortcuts exempt them. */
|
|
46
|
+
hasLivePropPaths = false;
|
|
47
|
+
|
|
44
48
|
/** @type {boolean} True if every path consumes exactly one expression and none are components.
|
|
45
49
|
* Lets NodeGroup.applyExprs() use a fast loop without allocating per-path expression arrays. */
|
|
46
50
|
pathsSingleExpr = false;
|
|
47
51
|
|
|
52
|
+
/** @type {boolean} True when a NodeGroup whose values are unchanged still has work to do:
|
|
53
|
+
* components re-render so changes deeper in the tree surface, and live HTML properties are
|
|
54
|
+
* rewritten because a click can flip them underneath the cached expression. The list scans
|
|
55
|
+
* check this before calling PathToNodes.refreshSameItem(), so the overwhelmingly common
|
|
56
|
+
* unchanged row costs one field read instead of a call. */
|
|
57
|
+
needsRefresh = false;
|
|
58
|
+
|
|
48
59
|
/** @type {boolean} True if this Shell has any ids, styles, or scripts. */
|
|
49
60
|
hasEmbeds = false;
|
|
50
61
|
|
|
@@ -60,6 +71,52 @@ export default class Shell {
|
|
|
60
71
|
* with no per-instance Path objects. See the stampPaths setup in the constructor. */
|
|
61
72
|
stampable = false;
|
|
62
73
|
|
|
74
|
+
// The remaining fields are only filled in for some shells (resolve program, stampable),
|
|
75
|
+
// but they're all declared here so every Shell instance shares one hidden class.
|
|
76
|
+
// NodeGroup's per-row code (its constructor, applyStamp, resolveStampSlots) reads these
|
|
77
|
+
// off whichever shell it's given, and a single shape keeps those loads monomorphic.
|
|
78
|
+
|
|
79
|
+
/** @type {?string} The Template close key, cached here by the NodeGroup constructor so
|
|
80
|
+
* each new template row skips a WeakMap lookup. See Template.getCloseKey(). */
|
|
81
|
+
closeKey;
|
|
82
|
+
|
|
83
|
+
/** @type {?int[]} The resolve program: flat [parentSlot, childIndex] pairs in dependency
|
|
84
|
+
* order; pair i fills slot i+1, slot 0 being the fragment. Built by buildResolveProgram();
|
|
85
|
+
* undefined for shells with components. */
|
|
86
|
+
resolveOps;
|
|
87
|
+
|
|
88
|
+
/** @type {?Node[]} Reusable scratch array for resolved nodes; safe because resolution
|
|
89
|
+
* never re-enters. */
|
|
90
|
+
resolveSlots;
|
|
91
|
+
|
|
92
|
+
// The stamp program, set only when stampable is true:
|
|
93
|
+
|
|
94
|
+
/** @type {?int[]} Indexes of PathToNodes paths, checked for primitive exprs before stamping. */
|
|
95
|
+
nodesPathIdx;
|
|
96
|
+
|
|
97
|
+
/** @type {?Path[]} One shared stamper per path; nodeMarker/parentNg are set per use. */
|
|
98
|
+
stampPaths;
|
|
99
|
+
|
|
100
|
+
/** @type {?Uint8Array} Opcode per path; see the stamp-program comment in the constructor. */
|
|
101
|
+
stampOp;
|
|
102
|
+
|
|
103
|
+
/** @type {?Uint16Array} paths[i].markerSlot, in a flat array so the hot loop
|
|
104
|
+
* doesn't load the Path object to find its slot. */
|
|
105
|
+
stampSlot;
|
|
106
|
+
|
|
107
|
+
/** @type {?Path[]} Per-path extra the stamp program needs: the event stamper for op 3
|
|
108
|
+
* (it carries delegatedKey and eventName), the attribute name for op 4, null otherwise. */
|
|
109
|
+
stampAux;
|
|
110
|
+
|
|
111
|
+
/** @type {?string[]} The delegatable event names this shell binds, so a loop can register
|
|
112
|
+
* their dispatchers once for the whole run of rows instead of testing every bound node. */
|
|
113
|
+
stampEventNames;
|
|
114
|
+
|
|
115
|
+
/** @type {?Uint8Array} Per-path flags the in-place rewrite loop needs, so it reads one byte
|
|
116
|
+
* from a flat array instead of two properties from a Path object it otherwise wouldn't
|
|
117
|
+
* touch. Bit 1 = the path binds a live HTML property, bit 2 = it's a whole-parent child. */
|
|
118
|
+
stampFlags;
|
|
119
|
+
|
|
63
120
|
/**
|
|
64
121
|
* Create the nodes but without filling in the expressions.
|
|
65
122
|
* This is useful because the expression-less nodes created by a template can be cached.
|
|
@@ -69,13 +126,13 @@ export default class Shell {
|
|
|
69
126
|
if (!html)
|
|
70
127
|
return;
|
|
71
128
|
|
|
72
|
-
//#
|
|
129
|
+
//#IFDEBUG
|
|
73
130
|
this._html = html.join('');
|
|
74
131
|
//#ENDIF
|
|
75
132
|
|
|
76
133
|
// If no html tags or entities, just create a text node.
|
|
77
134
|
if (html.length === 1 && !html[0].match(/[<&]/)) {
|
|
78
|
-
this.
|
|
135
|
+
this.docFrag = Globals.doc.createTextNode(html[0]);
|
|
79
136
|
return;
|
|
80
137
|
}
|
|
81
138
|
|
|
@@ -93,29 +150,32 @@ export default class Shell {
|
|
|
93
150
|
let frag = Globals.doc.createDocumentFragment();
|
|
94
151
|
while (svgEl.firstChild)
|
|
95
152
|
frag.append(svgEl.firstChild);
|
|
96
|
-
this.
|
|
153
|
+
this.docFrag = frag;
|
|
97
154
|
}
|
|
98
155
|
else {
|
|
99
156
|
template.innerHTML = htmlWithPlaceholders;
|
|
100
|
-
this.
|
|
157
|
+
this.docFrag = template.content;
|
|
101
158
|
}
|
|
102
159
|
}
|
|
103
160
|
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
161
|
template.content.append(Globals.doc.createTextNode(''))
|
|
105
|
-
this.
|
|
162
|
+
this.docFrag = template.content;
|
|
106
163
|
}
|
|
107
164
|
|
|
108
165
|
// 1b. Remove whitespace-only text nodes inside table-structure elements.
|
|
109
166
|
// The parser foster-parents non-whitespace text out of tables, and whitespace-only
|
|
110
167
|
// text between cells/rows is never rendered, so removing it is invisible.
|
|
111
168
|
// Smaller fragments make cloning, path resolution, and insertion faster.
|
|
112
|
-
stripTableWhitespace(this.
|
|
169
|
+
stripTableWhitespace(this.docFrag);
|
|
170
|
+
|
|
171
|
+
// 1c. Neutralize `is` so the browser can't upgrade a placeholder out from under us.
|
|
172
|
+
renameIsAttribs(this.docFrag);
|
|
113
173
|
|
|
114
174
|
// 2. Find placeholders
|
|
115
175
|
let node;
|
|
116
176
|
let toRemove = [];
|
|
117
177
|
let placeholdersUsed = 0;
|
|
118
|
-
const walker = Globals.doc.createTreeWalker(this.
|
|
178
|
+
const walker = Globals.doc.createTreeWalker(this.docFrag, NodeFilter.SHOW_ELEMENT | NodeFilter.SHOW_COMMENT | NodeFilter.SHOW_TEXT);
|
|
119
179
|
while (node = walker.nextNode()) {
|
|
120
180
|
|
|
121
181
|
// Remove previous elements after each iteration, so paths will still be calculated correctly.
|
|
@@ -124,7 +184,7 @@ export default class Shell {
|
|
|
124
184
|
|
|
125
185
|
// Replace attributes
|
|
126
186
|
if (node.nodeType === 1) {
|
|
127
|
-
const hasIs = node.hasAttribute('
|
|
187
|
+
const hasIs = node.hasAttribute('_is'); // Renamed from `is` in step 1c.
|
|
128
188
|
const isComponent = (hasIs || node.tagName.includes('-'));
|
|
129
189
|
const componentAttribPaths = [];
|
|
130
190
|
|
|
@@ -133,13 +193,20 @@ export default class Shell {
|
|
|
133
193
|
// The reserved key attribute identifies this template within a keyed list.
|
|
134
194
|
// It's consumed here and never written to the DOM or passed to components.
|
|
135
195
|
if (attr.name === 'key') {
|
|
196
|
+
|
|
197
|
+
// These three are template-authoring mistakes, and every one of them fails SILENTLY if
|
|
198
|
+
// it isn't caught: the reconciler would key rows on a garbage value and reuse the wrong
|
|
199
|
+
// DOM, with nothing reported. So they ship, unlike the assertions elsewhere in this
|
|
200
|
+
// file. The cost is one regex split per unique template \u2014 never per render, never per
|
|
201
|
+
// row \u2014 which is why they are affordable to keep.
|
|
136
202
|
let parts = attr.value.split(/[\ue000-\uf8ff]/g);
|
|
137
203
|
if (parts.length !== 2 || parts[0] !== '' || parts[1] !== '')
|
|
138
|
-
throw new Error(`
|
|
139
|
-
if (node.parentNode !== this.
|
|
140
|
-
throw new Error(`
|
|
204
|
+
throw new Error(`Solarite: key must be one whole expression.`);
|
|
205
|
+
if (node.parentNode !== this.docFrag)
|
|
206
|
+
throw new Error(`Solarite: key must be on a top-level element.`);
|
|
141
207
|
if (this.keyIndex >= 0)
|
|
142
|
-
throw new Error(`
|
|
208
|
+
throw new Error(`Solarite: duplicate key attribute.`);
|
|
209
|
+
|
|
143
210
|
this.keyIndex = attr.value.charCodeAt(0) - attribPlaceholder;
|
|
144
211
|
|
|
145
212
|
let path = new PathToKey(null, node);
|
|
@@ -184,19 +251,33 @@ export default class Shell {
|
|
|
184
251
|
}
|
|
185
252
|
|
|
186
253
|
placeholdersUsed += parts.length - 1;
|
|
187
|
-
//
|
|
188
|
-
//
|
|
189
|
-
//
|
|
190
|
-
//
|
|
191
|
-
//
|
|
192
|
-
|
|
254
|
+
// An attribute whose whole value is one expression is removed from the shell:
|
|
255
|
+
// its stamped value is always the empty string, so every clone would carry a
|
|
256
|
+
// useless empty attribute that costs storage on creation and a slot in the
|
|
257
|
+
// element's attribute list forever, and apply() writes the real value anyway
|
|
258
|
+
// (a missing attribute reads back as '', so an empty expression still writes
|
|
259
|
+
// nothing). Event attributes must be removed for the same reason plus a
|
|
260
|
+
// stricter one: an empty onclick="" violates a strict CSP when the event fires.
|
|
261
|
+
// In svgMode, setting typed SVG attributes (viewBox, r, etc.) with the
|
|
262
|
+
// placeholders stripped out makes the browser log parse errors, both here and
|
|
263
|
+
// when the fragment is cloned, so those are removed whether or not they're whole.
|
|
264
|
+
if (svgMode || !nonEmptyParts)
|
|
193
265
|
node.removeAttribute(attr.name);
|
|
194
|
-
|
|
266
|
+
|
|
267
|
+
// setAttribute throws only when the template author wrote a name the browser
|
|
268
|
+
// refuses, such as one holding a space or a quote. That name comes from a tagged
|
|
269
|
+
// template literal's static text, so it is a typo that surfaces the first time the
|
|
270
|
+
// template renders and can never appear later or for only some users. Development
|
|
271
|
+
// therefore wraps the call to rethrow with the attribute name and the tag included,
|
|
272
|
+
// because the browser's own DOMException names neither and leaves the author
|
|
273
|
+
// hunting. Production ships the bare call and lets that DOMException through: the
|
|
274
|
+
// friendlier wording is only worth its bytes to whoever can still fix the template.
|
|
275
|
+
else /*#IFDEBUG*/try {/*#ENDIF*/
|
|
195
276
|
node.setAttribute(attr.name, parts.join(''));
|
|
196
|
-
}
|
|
277
|
+
/*#IFDEBUG*/}
|
|
197
278
|
catch (e) {
|
|
198
279
|
throw new Error(`Error setting attribute "${attr.name}" on node <${node.tagName}>: ${e.message}`);
|
|
199
|
-
}
|
|
280
|
+
}/*#ENDIF*/
|
|
200
281
|
}
|
|
201
282
|
}
|
|
202
283
|
}
|
|
@@ -207,10 +288,6 @@ export default class Shell {
|
|
|
207
288
|
path.attribPaths = componentAttribPaths;
|
|
208
289
|
this.paths.splice(this.paths.length - componentAttribPaths.length, 0, path); // Insert before its componentAttribPaths
|
|
209
290
|
|
|
210
|
-
if (hasIs) {
|
|
211
|
-
node.setAttribute('_is', node.getAttribute('is'));
|
|
212
|
-
node.removeAttribute('is');
|
|
213
|
-
}
|
|
214
291
|
}
|
|
215
292
|
}
|
|
216
293
|
|
|
@@ -218,7 +295,7 @@ export default class Shell {
|
|
|
218
295
|
else if (node.nodeType === 8 && node.nodeValue === '!✨!') {
|
|
219
296
|
|
|
220
297
|
if (node?.parentNode?.closest && node?.parentNode?.closest('[contenteditable]'))
|
|
221
|
-
throw new Error(`
|
|
298
|
+
throw new Error(`Solarite: no \${...} inside contenteditable; use value="\${...}".`);
|
|
222
299
|
|
|
223
300
|
let parent = node.parentNode;
|
|
224
301
|
|
|
@@ -227,7 +304,7 @@ export default class Shell {
|
|
|
227
304
|
// Components and slots are excluded because they move their children
|
|
228
305
|
// during instantiation, which would orphan the expression's region.
|
|
229
306
|
if (parent.nodeType === 1 && !node.previousSibling && !node.nextSibling
|
|
230
|
-
&& !parent.tagName.includes('-') && parent.tagName !== 'SLOT' && !parent.hasAttribute('
|
|
307
|
+
&& !parent.tagName.includes('-') && parent.tagName !== 'SLOT' && !parent.hasAttribute('_is')) {
|
|
231
308
|
let path = new PathToNodes(null, parent);
|
|
232
309
|
path.wholeParent = true;
|
|
233
310
|
this.paths.push(path);
|
|
@@ -242,7 +319,7 @@ export default class Shell {
|
|
|
242
319
|
nodeBefore = Globals.doc.createComment('Path:'+this.paths.length);
|
|
243
320
|
node.parentNode.insertBefore(nodeBefore, node)
|
|
244
321
|
}
|
|
245
|
-
/*#
|
|
322
|
+
/*#IFDEBUG*/assert(nodeBefore);/*#ENDIF*/
|
|
246
323
|
|
|
247
324
|
// Get the next node.
|
|
248
325
|
let nodeMarker;
|
|
@@ -257,7 +334,7 @@ export default class Shell {
|
|
|
257
334
|
nodeMarker = node;
|
|
258
335
|
nodeMarker.textContent = 'PathEnd:'+ this.paths.length;
|
|
259
336
|
}
|
|
260
|
-
/*#
|
|
337
|
+
/*#IFDEBUG*/assert(nodeMarker);/*#ENDIF*/
|
|
261
338
|
|
|
262
339
|
let path = new PathToNodes(nodeBefore, nodeMarker);
|
|
263
340
|
this.paths.push(path);
|
|
@@ -265,11 +342,6 @@ export default class Shell {
|
|
|
265
342
|
}
|
|
266
343
|
}
|
|
267
344
|
|
|
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
345
|
// Sometimes users will comment out a block of html code that has expressions.
|
|
274
346
|
// Here we look for expressions in comments.
|
|
275
347
|
// We don't actually update them dynamically, but we still add paths for them.
|
|
@@ -283,29 +355,39 @@ export default class Shell {
|
|
|
283
355
|
}
|
|
284
356
|
}
|
|
285
357
|
|
|
286
|
-
//
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
358
|
+
// A few elements have raw-text bodies, which the html parser reads as literal characters
|
|
359
|
+
// rather than as markup. A comment placeholder written inside one therefore never becomes
|
|
360
|
+
// a comment node; it arrives here as ordinary text. A textarea can't support expressions
|
|
361
|
+
// in its body at all, while script and style can, by splitting their text around each
|
|
362
|
+
// placeholder so that every expression gets a text node of its own to write into.
|
|
363
|
+
else if (node.nodeType === 3) { // Node.TEXT_NODE
|
|
364
|
+
let parentName = node.parentNode?.nodeName;
|
|
365
|
+
|
|
366
|
+
if (parentName === 'TEXTAREA' && node.textContent.includes(commentPlaceholder))
|
|
367
|
+
throw new Error(`Solarite: no \${...} inside textarea; use value="\${...}".`);
|
|
368
|
+
|
|
369
|
+
else if (parentName === 'SCRIPT' || parentName === 'STYLE') {
|
|
370
|
+
let parts = node.textContent.split(commentPlaceholder);
|
|
371
|
+
if (parts.length > 1) {
|
|
372
|
+
|
|
373
|
+
// Every part is inserted before the original node, in order, so from the second
|
|
374
|
+
// part onward the text node made on the previous iteration is already sitting
|
|
375
|
+
// immediately before this one and serves as the new path's nodeBefore.
|
|
376
|
+
for (let i = 0; i<parts.length; i++) {
|
|
377
|
+
let current = Globals.doc.createTextNode(parts[i]);
|
|
378
|
+
node.parentNode.insertBefore(current, node);
|
|
379
|
+
if (i > 0) {
|
|
380
|
+
let path = new PathToNodes(current.previousSibling, current);
|
|
381
|
+
this.paths.push(path);
|
|
382
|
+
placeholdersUsed ++;
|
|
383
|
+
|
|
384
|
+
/*#IFDEBUG*/path.verify();/*#ENDIF*/
|
|
385
|
+
}
|
|
386
|
+
}
|
|
303
387
|
|
|
304
|
-
|
|
388
|
+
// Removing it here will mess up the treeWalker.
|
|
389
|
+
toRemove.push(node);
|
|
305
390
|
}
|
|
306
|
-
|
|
307
|
-
// Removing them here will mess up the treeWalker.
|
|
308
|
-
toRemove.push(node);
|
|
309
391
|
}
|
|
310
392
|
}
|
|
311
393
|
}
|
|
@@ -314,31 +396,37 @@ export default class Shell {
|
|
|
314
396
|
// Less than or equal because there can be one path to multiple expressions
|
|
315
397
|
// if those expressions are in the same attribute value.
|
|
316
398
|
if (placeholdersUsed !== html.length-1)
|
|
317
|
-
throw new Error(`
|
|
399
|
+
throw new Error(`Solarite: bad html or duplicate attribute: ${html.join('${...}')}`);
|
|
318
400
|
|
|
319
401
|
for (let path of this.paths) {
|
|
320
|
-
|
|
321
|
-
|
|
402
|
+
// -1 when the path has no nodeBefore. Assigned unconditionally so every shell path
|
|
403
|
+
// of a given class takes the same property-addition order and shares one hidden class.
|
|
404
|
+
path.nodeBeforeIndex = path.nodeBefore
|
|
405
|
+
? Array.prototype.indexOf.call(path.nodeBefore.parentNode.childNodes, path.nodeBefore)
|
|
406
|
+
: -1;
|
|
322
407
|
|
|
323
408
|
// Must be calculated after we remove the toRemove nodes:
|
|
324
409
|
path.nodeMarkerPath = Path.get(path.nodeMarker)
|
|
325
|
-
|
|
326
|
-
|
|
327
410
|
}
|
|
328
411
|
|
|
329
412
|
this.findEmbeds();
|
|
330
|
-
this.buildResolveProgram();
|
|
331
413
|
|
|
414
|
+
// This scan must run before buildResolveProgram(), which skips shells with components
|
|
415
|
+
// and reads hasComponentPaths rather than walking the paths a second time.
|
|
332
416
|
this.pathsSingleExpr = true;
|
|
333
417
|
for (let path of this.paths) {
|
|
334
418
|
if (path instanceof PathToComponent) {
|
|
335
419
|
this.hasComponentPaths = true;
|
|
336
420
|
this.pathsSingleExpr = false;
|
|
337
|
-
break; // Both facts are now decided.
|
|
338
421
|
}
|
|
339
|
-
if (path.getExpressionCount() !== 1)
|
|
340
|
-
this.pathsSingleExpr = false;
|
|
422
|
+
else if (path.getExpressionCount() !== 1)
|
|
423
|
+
this.pathsSingleExpr = false;
|
|
424
|
+
if (path.isHtmlProperty) // needs the full scan — no early break
|
|
425
|
+
this.hasLivePropPaths = true;
|
|
341
426
|
}
|
|
427
|
+
this.needsRefresh = this.hasComponentPaths || (this.hasLivePropPaths && this.pathsSingleExpr);
|
|
428
|
+
|
|
429
|
+
this.buildResolveProgram();
|
|
342
430
|
|
|
343
431
|
// Stampable shells create NodeGroups without allocating any Path objects:
|
|
344
432
|
// NodeGroup.applyStamp() writes expressions through these shared stamper paths,
|
|
@@ -363,17 +451,50 @@ export default class Shell {
|
|
|
363
451
|
}
|
|
364
452
|
if (ok) {
|
|
365
453
|
this.stampable = true;
|
|
366
|
-
|
|
367
|
-
/** @type {int[]} Indexes of PathToNodes paths, checked for primitive exprs before stamping. */
|
|
368
454
|
this.nodesPathIdx = nodesIdx;
|
|
369
|
-
|
|
370
|
-
/** @type {Path[]} One shared stamper per path; nodeMarker/parentNg are set per use. */
|
|
371
455
|
this.stampPaths = this.paths.map(p => p.cloneWithNodes(null, p.nodeMarker));
|
|
372
456
|
|
|
457
|
+
// Compiled stamp program: one opcode per path lets applyStamp() write a fresh
|
|
458
|
+
// row through a flat branch chain instead of dispatching applySingle() per path.
|
|
459
|
+
// 0 = generic (shared stamper fallback), 1 = list key (no DOM), 2 = wholeParent
|
|
460
|
+
// child text, 3 = delegatable single-expression event (written as node expandos
|
|
461
|
+
// when the root delegates, the default).
|
|
462
|
+
let n = this.paths.length;
|
|
463
|
+
this.stampOp = new Uint8Array(n);
|
|
464
|
+
this.stampSlot = new Uint16Array(n);
|
|
465
|
+
this.stampAux = new Array(n).fill(null);
|
|
466
|
+
this.stampFlags = new Uint8Array(n);
|
|
467
|
+
|
|
468
|
+
let eventNames = null;
|
|
469
|
+
for (let i=0; i<n; i++) {
|
|
470
|
+
let p = this.paths[i], sp = this.stampPaths[i];
|
|
471
|
+
this.stampSlot[i] = p.markerSlot;
|
|
472
|
+
this.stampFlags[i] = (sp.isHtmlProperty ? 1 : 0) | (sp.wholeParent ? 2 : 0);
|
|
473
|
+
if (p instanceof PathToKey)
|
|
474
|
+
this.stampOp[i] = 1;
|
|
475
|
+
else if (sp.wholeParent)
|
|
476
|
+
this.stampOp[i] = 2;
|
|
477
|
+
else if (sp instanceof PathToEvent && sp.delegatedKey !== undefined && !sp.attrValue) {
|
|
478
|
+
this.stampOp[i] = 3;
|
|
479
|
+
this.stampAux[i] = sp;
|
|
480
|
+
(eventNames ??= []).push(sp.eventName);
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
// A plain attribute holding one whole expression. The shell no longer carries
|
|
484
|
+
// the attribute at all (see the placeholder handling above), so on a freshly
|
|
485
|
+
// cloned row the value is known to be absent and a string can be written
|
|
486
|
+
// without first reading back what's there.
|
|
487
|
+
else if (sp instanceof PathToAttribValue && !sp.attrValue && !sp.isHtmlProperty
|
|
488
|
+
&& !sp.isComponentAttrib) {
|
|
489
|
+
this.stampOp[i] = 4;
|
|
490
|
+
this.stampAux[i] = sp.attribName;
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
this.stampEventNames = eventNames;
|
|
373
494
|
}
|
|
374
495
|
}
|
|
375
496
|
|
|
376
|
-
/*#
|
|
497
|
+
/*#IFDEBUG*/this.verify();/*#ENDIF*/
|
|
377
498
|
}
|
|
378
499
|
|
|
379
500
|
/**
|
|
@@ -384,42 +505,64 @@ export default class Shell {
|
|
|
384
505
|
* @param htmlChunks {string[]}
|
|
385
506
|
* @returns {string} Html with the placeholders in place. */
|
|
386
507
|
static addPlaceholders(htmlChunks) {
|
|
387
|
-
let result =
|
|
508
|
+
let result = '';
|
|
509
|
+
|
|
510
|
+
// Where the tokenizer is as it walks the chunks. An expression can sit in the middle of an attribute
|
|
511
|
+
// value, so both of these have to survive from one chunk to the next. Nothing else has to: an
|
|
512
|
+
// expression anywhere inside a tag gets the same attribute placeholder, so the machine only has to
|
|
513
|
+
// know whether it is inside a tag at all, and whether a quoted value is currently open.
|
|
514
|
+
let inTag = false; // True from the '<' that opens a tag or comment through the '>' that closes it.
|
|
515
|
+
let quote = null; // The quote character that opened the attribute value we're inside of: null, '"', or "'".
|
|
388
516
|
|
|
389
|
-
let htmlParser = new HtmlParser(); // Reset the context.
|
|
390
517
|
for (let i = 0; i < htmlChunks.length; i++) {
|
|
391
|
-
let
|
|
518
|
+
let html = htmlChunks[i];
|
|
392
519
|
|
|
393
520
|
// Append -solarite-placholder to web component tags, so we can pass args to them when they're instantiated.
|
|
394
|
-
let lastIndex = 0;
|
|
395
|
-
let
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
if (
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
//
|
|
403
|
-
//
|
|
404
|
-
|
|
405
|
-
|
|
521
|
+
let lastIndex = 0; // Start of the run of this chunk not yet copied into result.
|
|
522
|
+
for (let j = 0; j < html.length; j++) {
|
|
523
|
+
const char = html[j];
|
|
524
|
+
|
|
525
|
+
if (!inTag) {
|
|
526
|
+
if (char === '<' && html[j + 1].match(/[/a-z!]/i)) { // Start of a tag or comment.
|
|
527
|
+
inTag = true;
|
|
528
|
+
|
|
529
|
+
// A component suffix can only ever be added right here, at the '<' that opens the tag, so
|
|
530
|
+
// the name is matched on the spot with a sticky regex rather than collected into a buffer
|
|
531
|
+
// and matched later. The greedy tag-name class can't run past the name, because every
|
|
532
|
+
// character that can follow a tag name is outside it.
|
|
533
|
+
isWebComponentTagName.lastIndex = j;
|
|
534
|
+
let match = isWebComponentTagName.exec(html);
|
|
535
|
+
if (match) {
|
|
536
|
+
let end = j + match[0].length;
|
|
537
|
+
result += html.slice(lastIndex, end) + '-SOLARITE-PLACEHOLDER';
|
|
538
|
+
lastIndex = end;
|
|
539
|
+
}
|
|
406
540
|
}
|
|
541
|
+
}
|
|
407
542
|
|
|
408
|
-
|
|
543
|
+
// Inside a tag, only two characters end anything: the quote that closes the value we're in, or,
|
|
544
|
+
// when we're not in one, the '>' that closes the tag. Attribute names, '=', unquoted values and
|
|
545
|
+
// whitespace all need no handling at all.
|
|
546
|
+
else if (quote) {
|
|
547
|
+
if (char === quote)
|
|
548
|
+
quote = null;
|
|
409
549
|
}
|
|
410
|
-
|
|
411
|
-
|
|
550
|
+
else if (char === '"' || char === "'")
|
|
551
|
+
quote = char;
|
|
552
|
+
else if (char === '>')
|
|
553
|
+
inTag = false;
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
result += html.slice(lastIndex);
|
|
412
557
|
|
|
413
558
|
// Insert placeholders
|
|
414
|
-
if (i < htmlChunks.length - 1)
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
result.push(String.fromCharCode(attribPlaceholder + i));
|
|
419
|
-
}
|
|
559
|
+
if (i < htmlChunks.length - 1)
|
|
560
|
+
result += inTag
|
|
561
|
+
? String.fromCharCode(attribPlaceholder + i)
|
|
562
|
+
: commentPlaceholder; // Comment Placeholder. because we can't put text in between <tr> tags for example.
|
|
420
563
|
}
|
|
421
564
|
|
|
422
|
-
return result
|
|
565
|
+
return result;
|
|
423
566
|
}
|
|
424
567
|
|
|
425
568
|
/**
|
|
@@ -431,21 +574,18 @@ export default class Shell {
|
|
|
431
574
|
* this.ids
|
|
432
575
|
* this.staticComponents */
|
|
433
576
|
findEmbeds() {
|
|
434
|
-
this.scripts = Array.prototype.map.call(this.
|
|
577
|
+
this.scripts = Array.prototype.map.call(this.docFrag.querySelectorAll('script'), el => Path.get(el))
|
|
435
578
|
|
|
436
579
|
// TODO: only find styles that have Paths in them?
|
|
437
|
-
this.styles = Array.prototype.map.call(this.
|
|
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
|
-
}
|
|
580
|
+
this.styles = Array.prototype.map.call(this.docFrag.querySelectorAll('style'), el => Path.get(el))
|
|
447
581
|
|
|
448
|
-
|
|
582
|
+
// An id that would clobber a built-in element property is reported by Util.bindId(), which
|
|
583
|
+
// asks the real component object, with `in`, at the moment the binding happens. The check
|
|
584
|
+
// that used to stand here asked Globals.div.hasOwnProperty(id) instead, and a freshly
|
|
585
|
+
// created element has no own properties at all — every DOM property an element exposes
|
|
586
|
+
// lives on its interface prototype — so that test could never be true and the error it
|
|
587
|
+
// guarded was never reachable.
|
|
588
|
+
this.ids = Array.prototype.map.call(this.docFrag.querySelectorAll('[id],[data-id]'), el => Path.get(el))
|
|
449
589
|
|
|
450
590
|
this.hasEmbeds = this.ids.length > 0 || this.styles.length > 0 || this.scripts.length > 0;
|
|
451
591
|
}
|
|
@@ -456,25 +596,37 @@ export default class Shell {
|
|
|
456
596
|
* Replaces per-path root-to-node walks in the hot NodeGroup creation path.
|
|
457
597
|
* Skipped for shells with components, whose clone() has special attribPaths behavior. */
|
|
458
598
|
buildResolveProgram() {
|
|
459
|
-
|
|
460
|
-
for (let path of this.paths)
|
|
461
|
-
if (path instanceof PathToComponent) {
|
|
462
|
-
hasComponents = true;
|
|
463
|
-
break;
|
|
464
|
-
}
|
|
465
|
-
if (hasComponents || !this.paths.length)
|
|
599
|
+
if (this.hasComponentPaths || !this.paths.length)
|
|
466
600
|
return;
|
|
467
601
|
|
|
468
602
|
let ops = [];
|
|
469
603
|
let slotOf = new Map();
|
|
470
|
-
let frag = this.
|
|
604
|
+
let frag = this.docFrag;
|
|
471
605
|
let nextSlot = 1;
|
|
472
606
|
let getSlot = node => {
|
|
473
607
|
if (node === frag)
|
|
474
608
|
return 0;
|
|
475
609
|
let s = slotOf.get(node);
|
|
476
610
|
if (s === undefined) {
|
|
477
|
-
|
|
611
|
+
// Two ways to reach a node, costing one pointer step each: walk forward from an
|
|
612
|
+
// already-resolved earlier sibling, or take the parent's firstChild and walk
|
|
613
|
+
// forward. Sibling steps win whenever they're no more numerous, and they can
|
|
614
|
+
// also spare the parent a slot of its own — in a row of cells, resolving each
|
|
615
|
+
// <td> from the previous one is one step instead of firstChild plus its index.
|
|
616
|
+
let d = 0, from = -1;
|
|
617
|
+
for (let sib = node.previousSibling; sib; sib = sib.previousSibling) {
|
|
618
|
+
d++;
|
|
619
|
+
let ss = slotOf.get(sib);
|
|
620
|
+
if (ss !== undefined) {
|
|
621
|
+
from = ss;
|
|
622
|
+
break;
|
|
623
|
+
}
|
|
624
|
+
}
|
|
625
|
+
let index = Array.prototype.indexOf.call(node.parentNode.childNodes, node);
|
|
626
|
+
if (from >= 0 && d <= index + 1)
|
|
627
|
+
ops.push(from, -d); // A negative step count means "walk nextSibling from that slot".
|
|
628
|
+
else
|
|
629
|
+
ops.push(getSlot(node.parentNode), index);
|
|
478
630
|
s = nextSlot++;
|
|
479
631
|
slotOf.set(node, s);
|
|
480
632
|
}
|
|
@@ -485,10 +637,7 @@ export default class Shell {
|
|
|
485
637
|
path.beforeSlot = path.nodeBefore ? getSlot(path.nodeBefore) : -1;
|
|
486
638
|
}
|
|
487
639
|
|
|
488
|
-
/** @type {?int[]} Flat [parentSlot, childIndex] pairs; pair i fills slot i+1. */
|
|
489
640
|
this.resolveOps = ops;
|
|
490
|
-
|
|
491
|
-
/** @type {Node[]} Reusable scratch array for resolved nodes; safe because resolution never re-enters. */
|
|
492
641
|
this.resolveSlots = new Array(nextSlot);
|
|
493
642
|
|
|
494
643
|
// A lone root element means slot 1 is always that element (the first op pair is [0, 0]),
|
|
@@ -523,15 +672,15 @@ export default class Shell {
|
|
|
523
672
|
lastSvgMode = svgMode;
|
|
524
673
|
lastShell = result;
|
|
525
674
|
|
|
526
|
-
/*#
|
|
675
|
+
/*#IFDEBUG*/result.verify();/*#ENDIF*/
|
|
527
676
|
return result;
|
|
528
677
|
}
|
|
529
678
|
|
|
530
|
-
//#
|
|
679
|
+
//#IFDEBUG
|
|
531
680
|
// For debugging only:
|
|
532
681
|
verify() {
|
|
533
682
|
for (let path of this.paths) {
|
|
534
|
-
assert(this.
|
|
683
|
+
assert(this.docFrag.contains(path.getParentNode()))
|
|
535
684
|
path.verify();
|
|
536
685
|
}
|
|
537
686
|
}
|
|
@@ -541,6 +690,15 @@ export default class Shell {
|
|
|
541
690
|
|
|
542
691
|
const commentPlaceholder = `<!--!✨!-->`;
|
|
543
692
|
|
|
693
|
+
// A tag name with a dash in the middle, which is what makes an element a web component. addPlaceholders()
|
|
694
|
+
// tests this at each '<' that opens a tag, and a match gets -solarite-placeholder appended to its tag name.
|
|
695
|
+
// That way we can gather a component's constructor arguments and its children before we call its constructor;
|
|
696
|
+
// later PathToComponent.applyAll() replaces the placeholder tag with the real component. The suffix is written in
|
|
697
|
+
// caps wherever it appears, so that the several copies of it in this project compress well. It's sticky rather
|
|
698
|
+
// than anchored so it can be tested at an offset within the chunk instead of against a sliced-out token.
|
|
699
|
+
// Ctrl+F "solarite-placeholder" in project to find all code that manages subcomponents.
|
|
700
|
+
const isWebComponentTagName = /<\/?[a-z][a-z0-9]*-[a-z0-9-]+/iy;
|
|
701
|
+
|
|
544
702
|
// Elements whose whitespace-only text children are never rendered.
|
|
545
703
|
const tableTags = ['TABLE', 'THEAD', 'TBODY', 'TFOOT', 'TR'];
|
|
546
704
|
|
|
@@ -560,6 +718,38 @@ function stripTableWhitespace(el) {
|
|
|
560
718
|
}
|
|
561
719
|
}
|
|
562
720
|
|
|
721
|
+
/**
|
|
722
|
+
* Rename every `is` attribute to `_is`, rebuilding the element to do it.
|
|
723
|
+
*
|
|
724
|
+
* A component written as a dashed tag is neutralized in the shell by renaming the TAG
|
|
725
|
+
* (`<my-tag>` becomes `<my-tag-SOLARITE-PLACEHOLDER>`), so the browser never recognizes the
|
|
726
|
+
* placeholder and never upgrades it. A customized built-in cannot be neutralized that way,
|
|
727
|
+
* because its tag has to stay real: a `<tr is="my-row">` that is not a `<tr>` is thrown out
|
|
728
|
+
* by the parser's table rules. So its ATTRIBUTE is renamed instead.
|
|
729
|
+
*
|
|
730
|
+
* Renaming the attribute in place is not enough. `is` is also recorded in an internal slot on
|
|
731
|
+
* the element, which removeAttribute() cannot clear and cloneNode() copies, so a placeholder
|
|
732
|
+
* that was parsed with `is` stays a customized built-in as far as the browser is concerned.
|
|
733
|
+
* Every clone of it is upgraded the moment it enters a document with a browsing context —
|
|
734
|
+
* running the component's constructor on the placeholder, before PathToComponent has
|
|
735
|
+
* instantiated the real element or evaluated the attribute expressions meant for it. A
|
|
736
|
+
* constructor that renders then renders the placeholder, whose children are the ones the user
|
|
737
|
+
* declared, and those get handed to the real instance as if they were slot content.
|
|
738
|
+
*
|
|
739
|
+
* Building a fresh element and moving everything across is the only way to drop that slot.
|
|
740
|
+
* It happens once per unique template, because Shells are cached, and never per render.
|
|
741
|
+
*
|
|
742
|
+
* @param docFrag {DocumentFragment} */
|
|
743
|
+
function renameIsAttribs(docFrag) {
|
|
744
|
+
for (let el of docFrag.querySelectorAll('[is]')) {
|
|
745
|
+
let clean = el.ownerDocument.createElement(el.tagName);
|
|
746
|
+
for (let attrib of el.attributes)
|
|
747
|
+
clean.setAttribute(attrib.name === 'is' ? '_is' : attrib.name, attrib.value);
|
|
748
|
+
clean.append(...el.childNodes);
|
|
749
|
+
el.replaceWith(clean);
|
|
750
|
+
}
|
|
751
|
+
}
|
|
752
|
+
|
|
563
753
|
// One-entry memo for Shell.get().
|
|
564
754
|
let lastHtmlStrings = null, lastSvgMode = false, lastShell = null;
|
|
565
755
|
|