@transclude/core 0.1.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/LICENSE +21 -0
- package/README.md +121 -0
- package/bin/build.js +469 -0
- package/bin/check.js +78 -0
- package/bin/dev.js +348 -0
- package/bin/release.js +176 -0
- package/bin/serve.bun.js +15 -0
- package/bin/serve.deno.js +15 -0
- package/bin/serve.js +12 -0
- package/editor/server.js +172 -0
- package/editor/vscode/extension.js +49 -0
- package/editor/vscode/package.json +32 -0
- package/editor/vscode/syntaxes/transclude.injection.json +41 -0
- package/package.json +82 -0
- package/src/address.js +183 -0
- package/src/app.js +492 -0
- package/src/cache.js +137 -0
- package/src/compiler/bind.js +496 -0
- package/src/compiler/codegen.js +1061 -0
- package/src/compiler/expr.js +221 -0
- package/src/compiler/index.js +964 -0
- package/src/compiler/interp.js +82 -0
- package/src/compiler/script.js +620 -0
- package/src/compiler/shim.js +756 -0
- package/src/compiler/sourcemap.js +140 -0
- package/src/compiler/types.js +163 -0
- package/src/compress.js +104 -0
- package/src/cookies.js +157 -0
- package/src/csp.js +192 -0
- package/src/document.js +604 -0
- package/src/extract.js +339 -0
- package/src/feed.js +194 -0
- package/src/include.js +89 -0
- package/src/lookup.js +49 -0
- package/src/negotiate.js +95 -0
- package/src/plugin.js +423 -0
- package/src/pool.js +29 -0
- package/src/precache.js +68 -0
- package/src/production.js +159 -0
- package/src/project.js +110 -0
- package/src/proxy.js +319 -0
- package/src/public-files.js +77 -0
- package/src/rewrite.js +281 -0
- package/src/routes.js +199 -0
- package/src/runtime/index.js +1345 -0
- package/src/server.js +183 -0
- package/src/sitemap.js +124 -0
- package/src/static-cache.js +170 -0
- package/src/typecheck.js +492 -0
- package/src/worker.js +87 -0
|
@@ -0,0 +1,1345 @@
|
|
|
1
|
+
// Runtime shared by the server render and the browser custom element.
|
|
2
|
+
// Nothing here touches `document` at module scope, so it imports cleanly in Node.
|
|
3
|
+
|
|
4
|
+
const ESCAPES = {
|
|
5
|
+
'&': '&',
|
|
6
|
+
'<': '<',
|
|
7
|
+
'>': '>',
|
|
8
|
+
'"': '"',
|
|
9
|
+
"'": ''',
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
class RawHtml {
|
|
13
|
+
constructor(value) {
|
|
14
|
+
this.value = value;
|
|
15
|
+
}
|
|
16
|
+
toString() {
|
|
17
|
+
return this.value;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Opt out of escaping: `${html(post.body)}`. The only way to inject markup.
|
|
23
|
+
*
|
|
24
|
+
* @param {unknown} value
|
|
25
|
+
* @returns {RawHtml} written through untouched
|
|
26
|
+
*/
|
|
27
|
+
export function html(value) {
|
|
28
|
+
return new RawHtml(value == null ? '' : String(value));
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Every `${}` in text position goes through this.
|
|
33
|
+
*
|
|
34
|
+
* @param {unknown} value
|
|
35
|
+
* @returns {string} empty for null, undefined and false
|
|
36
|
+
*/
|
|
37
|
+
export function escape(value) {
|
|
38
|
+
if (value == null || value === false) return '';
|
|
39
|
+
if (value instanceof RawHtml) return value.value;
|
|
40
|
+
return String(value).replace(/[&<>"']/g, (c) => ESCAPES[c]);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Characters that would end a `<script>` early or shift the HTML tokenizer into
|
|
45
|
+
* a state where the rest of the element is read as something else. Escaped as
|
|
46
|
+
* `\uXXXX`, which JSON and JavaScript both read back as the original character.
|
|
47
|
+
* U+2028 and U+2029 are here because JSON allows them raw in a string and
|
|
48
|
+
* JavaScript reads them as line terminators.
|
|
49
|
+
*/
|
|
50
|
+
const JSON_ESCAPES = {
|
|
51
|
+
'<': '\\u003c',
|
|
52
|
+
'>': '\\u003e',
|
|
53
|
+
'&': '\\u0026',
|
|
54
|
+
'\u2028': '\\u2028',
|
|
55
|
+
'\u2029': '\\u2029',
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Data for a `<script>`, as JSON that cannot escape the element.
|
|
60
|
+
*
|
|
61
|
+
* This is the only interpolation a script may carry, and the compiler refuses
|
|
62
|
+
* every other one. Nothing can make `${expr}` safe in a position where the
|
|
63
|
+
* result is read as code: a value ending the string it was written into runs
|
|
64
|
+
* whatever follows, and no escaping of the surrounding HTML changes that. Data
|
|
65
|
+
* is a different question and has an answer, so that is the part offered.
|
|
66
|
+
*
|
|
67
|
+
* @param {unknown} value anything `JSON.stringify` accepts
|
|
68
|
+
* @returns {RawHtml} the JSON, safe to write inside a script element
|
|
69
|
+
*/
|
|
70
|
+
export function json(value) {
|
|
71
|
+
const text = JSON.stringify(value ?? null) ?? 'null';
|
|
72
|
+
return new RawHtml(text.replace(/[<>&\u2028\u2029]/g, (c) => JSON_ESCAPES[c]));
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Interpolation inside a larger string, which is a mixed attribute value.
|
|
77
|
+
*
|
|
78
|
+
* It does not escape, because the caller does: every use is concatenated and
|
|
79
|
+
* handed to `attr`, which escapes the whole result. Do not reach for this from a
|
|
80
|
+
* position that writes straight into the document.
|
|
81
|
+
*
|
|
82
|
+
* @param {unknown} value
|
|
83
|
+
* @returns {string}
|
|
84
|
+
*/
|
|
85
|
+
export function str(value) {
|
|
86
|
+
if (value == null || value === false) return '';
|
|
87
|
+
if (value instanceof RawHtml) return value.value;
|
|
88
|
+
return String(value);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Dynamic attribute. false, null and undefined drop the attribute rather than
|
|
93
|
+
* becoming a string, or you get class="false" in the output.
|
|
94
|
+
* true emits a bare boolean attribute. Objects/arrays serialize as JSON so the
|
|
95
|
+
* client can read them back off the element.
|
|
96
|
+
*
|
|
97
|
+
* @param {string} name
|
|
98
|
+
* @param {unknown} value
|
|
99
|
+
* @returns {string} a leading space and the pair, or empty to drop it
|
|
100
|
+
*/
|
|
101
|
+
export function attr(name, value) {
|
|
102
|
+
if (value == null || value === false) return '';
|
|
103
|
+
if (value === true) return ` ${name}`;
|
|
104
|
+
const text = typeof value === 'object' ? JSON.stringify(value) : String(value);
|
|
105
|
+
return ` ${name}="${text.replace(/[&<>"]/g, (c) => ESCAPES[c])}"`;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* `pageSize` <-> `page-size`. HTML lowercases attribute names, so a camelCase
|
|
110
|
+
* prop would never match the attribute it came from. Lit has the same rule, for
|
|
111
|
+
* the same reason.
|
|
112
|
+
*
|
|
113
|
+
* @param {string} prop
|
|
114
|
+
* @returns {string}
|
|
115
|
+
*/
|
|
116
|
+
export function attrName(prop) {
|
|
117
|
+
return /[A-Z]/.test(prop) ? prop.replace(/[A-Z]/g, (c) => `-${c.toLowerCase()}`) : prop;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Attributes are strings; prop defaults tell us what they should have been.
|
|
122
|
+
*
|
|
123
|
+
* The default's own type covers string, number, boolean and anything JSON
|
|
124
|
+
* round-trips. A `from` in the block's `attributes` export takes over for
|
|
125
|
+
* everything else, such as a Date, a Set or a comma-separated list. There is no
|
|
126
|
+
* type the framework could have guessed those from.
|
|
127
|
+
*/
|
|
128
|
+
/**
|
|
129
|
+
* The part of a prop table that never changes, worked out once per table.
|
|
130
|
+
*
|
|
131
|
+
* `propDefs` is one module-level object per element, so this holds a handful of
|
|
132
|
+
* entries for the life of the process and they go when the definition does.
|
|
133
|
+
* Without it every instance rebuilt the same key list, the same attribute names
|
|
134
|
+
* and the same claimed set: measured at 0.41 us per instance, which is 40 us on
|
|
135
|
+
* a page holding a hundred of them, more than rendering a hundred table rows.
|
|
136
|
+
*/
|
|
137
|
+
const plans = new WeakMap();
|
|
138
|
+
const NO_PLAN = { entries: [], claimed: new Set() };
|
|
139
|
+
|
|
140
|
+
function planOf(defs) {
|
|
141
|
+
if (!defs || typeof defs !== 'object') return NO_PLAN;
|
|
142
|
+
|
|
143
|
+
const held = plans.get(defs);
|
|
144
|
+
if (held) return held;
|
|
145
|
+
|
|
146
|
+
const entries = [];
|
|
147
|
+
const claimed = new Set();
|
|
148
|
+
for (const key of Object.keys(defs)) {
|
|
149
|
+
const attr = attrName(key);
|
|
150
|
+
claimed.add(attr).add(key);
|
|
151
|
+
entries.push({ key, attr, fallback: defs[key] });
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const plan = { entries, claimed };
|
|
155
|
+
plans.set(defs, plan);
|
|
156
|
+
return plan;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* @param {Record<string, unknown>|null|undefined} defs the prop table
|
|
161
|
+
* @param {Record<string, unknown>|null|undefined} props either spelling
|
|
162
|
+
* @param {Record<string, { from?: Function, to?: Function }>} [specs]
|
|
163
|
+
* @returns {Record<string, unknown>} keyed by prop name, never by attribute
|
|
164
|
+
*/
|
|
165
|
+
export function coerceProps(defs, props, specs) {
|
|
166
|
+
const out = {};
|
|
167
|
+
const { entries, claimed } = planOf(defs);
|
|
168
|
+
|
|
169
|
+
for (const { key, attr, fallback } of entries) {
|
|
170
|
+
// Either spelling: the DOM reports the attribute, a template passes what
|
|
171
|
+
// the author wrote.
|
|
172
|
+
let value = props?.[attr] ?? props?.[key];
|
|
173
|
+
|
|
174
|
+
if (value === undefined || value === null) {
|
|
175
|
+
out[key] = fallback;
|
|
176
|
+
continue;
|
|
177
|
+
}
|
|
178
|
+
if (typeof value === 'string') {
|
|
179
|
+
const from = specs?.[key]?.from;
|
|
180
|
+
if (from) {
|
|
181
|
+
// A malformed attribute is the author's to see, not the page's to break
|
|
182
|
+
// on: the declared default is a defined answer.
|
|
183
|
+
try {
|
|
184
|
+
value = from(value);
|
|
185
|
+
} catch {
|
|
186
|
+
value = fallback;
|
|
187
|
+
}
|
|
188
|
+
} else if (typeof fallback === 'number') value = Number(value);
|
|
189
|
+
else if (typeof fallback === 'boolean') value = value !== 'false';
|
|
190
|
+
else if (fallback && typeof fallback === 'object') {
|
|
191
|
+
try {
|
|
192
|
+
value = JSON.parse(value);
|
|
193
|
+
} catch {
|
|
194
|
+
value = fallback;
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
out[key] = value;
|
|
199
|
+
}
|
|
200
|
+
for (const key of Object.keys(props ?? {})) {
|
|
201
|
+
if (!claimed.has(key) && !(key in out)) out[key] = props[key];
|
|
202
|
+
}
|
|
203
|
+
return out;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// ---- updates in place -----------------------------------------------------
|
|
207
|
+
//
|
|
208
|
+
// The compiler emits a `bind` that locates every node an expression owns, and
|
|
209
|
+
// an `update` that writes to them. Nothing is destroyed, so focus, selection,
|
|
210
|
+
// scroll position, input values, media playback and running animations all
|
|
211
|
+
// survive a change that used to replace the whole shadow root.
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* The text node a ${} owns, carved out of the one the parser actually built.
|
|
215
|
+
*
|
|
216
|
+
* `Hello ${name}!` is a single text node reading "Hello Ada!". Both static
|
|
217
|
+
* sides have lengths known at compile time, so the dynamic middle splits out
|
|
218
|
+
* exactly. No marker comments in the served HTML, and nothing evaluated.
|
|
219
|
+
*
|
|
220
|
+
* @param {Node} parent
|
|
221
|
+
* @param {Text|null} node the text node the parser built, if any
|
|
222
|
+
* @param {number} prefix static characters before the expression
|
|
223
|
+
* @param {number} suffix static characters after it
|
|
224
|
+
* @returns {Text}
|
|
225
|
+
*/
|
|
226
|
+
export function textAt(parent, node, prefix, suffix) {
|
|
227
|
+
// An expression that rendered to nothing left no text node behind, and every
|
|
228
|
+
// index after it would be short by one. Putting an empty one back is what
|
|
229
|
+
// keeps every path after it correct.
|
|
230
|
+
if (!node || node.nodeType !== 3) {
|
|
231
|
+
const text = (parent.ownerDocument ?? document).createTextNode('');
|
|
232
|
+
parent.insertBefore(text, node ?? null);
|
|
233
|
+
node = text;
|
|
234
|
+
}
|
|
235
|
+
if (prefix) node = node.splitText(prefix);
|
|
236
|
+
if (suffix) node.splitText(node.length - suffix);
|
|
237
|
+
return node;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* False means the value cannot live in a text node, so the caller repaints.
|
|
242
|
+
*
|
|
243
|
+
* @param {Text} node
|
|
244
|
+
* @param {unknown} value
|
|
245
|
+
* @returns {boolean} false when the value cannot live in a text node
|
|
246
|
+
*/
|
|
247
|
+
export function setText(node, value) {
|
|
248
|
+
if (value instanceof RawHtml) return false;
|
|
249
|
+
const text = str(value);
|
|
250
|
+
if (node.data !== text) node.data = text;
|
|
251
|
+
return true;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* The update-time counterpart of attr(), with the same rules.
|
|
256
|
+
*
|
|
257
|
+
* @param {Element} element
|
|
258
|
+
* @param {string} name
|
|
259
|
+
* @param {unknown} value
|
|
260
|
+
* @returns {void}
|
|
261
|
+
*/
|
|
262
|
+
export function setAttr(element, name, value) {
|
|
263
|
+
if (value === null || value === undefined || value === false) {
|
|
264
|
+
element.removeAttribute(name);
|
|
265
|
+
return;
|
|
266
|
+
}
|
|
267
|
+
if (value === true) {
|
|
268
|
+
if (!element.hasAttribute(name)) element.setAttribute(name, '');
|
|
269
|
+
return;
|
|
270
|
+
}
|
|
271
|
+
const text = typeof value === 'object' ? JSON.stringify(value) : String(value);
|
|
272
|
+
if (element.getAttribute(name) !== text) element.setAttribute(name, text);
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/**
|
|
276
|
+
* Several ${} in one text node: rewrite the whole thing rather than split it.
|
|
277
|
+
*
|
|
278
|
+
* @param {Text} node
|
|
279
|
+
* @param {unknown[]} parts
|
|
280
|
+
* @returns {boolean} false when any part has to be markup
|
|
281
|
+
*/
|
|
282
|
+
export function setParts(node, parts) {
|
|
283
|
+
let text = '';
|
|
284
|
+
for (const part of parts) {
|
|
285
|
+
if (part instanceof RawHtml) return false;
|
|
286
|
+
text += str(part);
|
|
287
|
+
}
|
|
288
|
+
if (node.data !== text) node.data = text;
|
|
289
|
+
return true;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
// ---- structural blocks ----------------------------------------------------
|
|
293
|
+
//
|
|
294
|
+
// `if` and `each` change how many nodes there are, so they cannot be addressed
|
|
295
|
+
// by a compile-time index. Each one is wrapped in a pair of comment anchors at
|
|
296
|
+
// render time, and owns everything between them. That is the only thing in the
|
|
297
|
+
// served HTML that exists for the client's benefit. 14 bytes per block, and only
|
|
298
|
+
// in components.
|
|
299
|
+
|
|
300
|
+
/** The matching closing anchor, counting nested ones on the way. */
|
|
301
|
+
function closingAnchor(open) {
|
|
302
|
+
let depth = 0;
|
|
303
|
+
for (let node = open; node; node = node.nextSibling) {
|
|
304
|
+
if (node.nodeType !== 8) continue;
|
|
305
|
+
if (node.data === '[') depth++;
|
|
306
|
+
else if (node.data === ']' && --depth === 0) return node;
|
|
307
|
+
}
|
|
308
|
+
return null;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/**
|
|
312
|
+
* An item spans one node or, where it renders several, the region between its
|
|
313
|
+
* own anchors. Everything downstream works on the range, so a single-element
|
|
314
|
+
* item is just the case where first and last are the same node.
|
|
315
|
+
*/
|
|
316
|
+
function entryAt(block, first) {
|
|
317
|
+
const last = block.ranged ? closingAnchor(first) : first;
|
|
318
|
+
return last && { first, last, bindings: null, html: null };
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
const bindsFrom = (block, entry) => (block.ranged ? entry.first.nextSibling : entry.first);
|
|
322
|
+
|
|
323
|
+
/**
|
|
324
|
+
* @param {Comment} open the opening anchor
|
|
325
|
+
* @param {object} block the compiled block
|
|
326
|
+
* @param {object} props
|
|
327
|
+
* @param {unknown[]} [args] enclosing loop variables
|
|
328
|
+
* @returns {object} the state `updateBlock` writes through
|
|
329
|
+
*/
|
|
330
|
+
export function blockAt(open, block, props, args = []) {
|
|
331
|
+
const end = closingAnchor(open);
|
|
332
|
+
const state = { start: open, end, html: null, keyed: null, branch: -1, bindings: null };
|
|
333
|
+
if (block.keyed) {
|
|
334
|
+
state.keyed = adoptKeyed(state, block, props, args);
|
|
335
|
+
return state;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
// Which branch the server rendered, so the first update can tell a change of
|
|
339
|
+
// structure from a change of content.
|
|
340
|
+
state.branch = block.pick ? block.pick(props, ...args) : 0;
|
|
341
|
+
const part = block.parts?.[state.branch];
|
|
342
|
+
if (part) state.bindings = part.bind(state.start.nextSibling, props, ...args);
|
|
343
|
+
// Without a part there is nothing to write into, so the rendered markup is
|
|
344
|
+
// the only thing left to compare against.
|
|
345
|
+
else state.html = block.html(props, ...args);
|
|
346
|
+
return state;
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
/** Pairs the nodes already in the document with the list that produced them. */
|
|
350
|
+
function adoptKeyed(state, block, props, args) {
|
|
351
|
+
const owned = new Map();
|
|
352
|
+
const part = block.parts?.[0];
|
|
353
|
+
let node = state.start.nextSibling;
|
|
354
|
+
let index = 0;
|
|
355
|
+
|
|
356
|
+
for (const item of block.list(props, ...args)) {
|
|
357
|
+
if (!node || node === state.end) break;
|
|
358
|
+
const entry = entryAt(block, node);
|
|
359
|
+
if (!entry) break;
|
|
360
|
+
|
|
361
|
+
if (part) entry.bindings = part.bind(bindsFrom(block, entry), props, ...args, item, index);
|
|
362
|
+
else entry.html = block.item(props, ...args, item, index);
|
|
363
|
+
|
|
364
|
+
owned.set(block.key(props, ...args, item, index), entry);
|
|
365
|
+
node = entry.last.nextSibling;
|
|
366
|
+
index++;
|
|
367
|
+
}
|
|
368
|
+
return owned;
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
/**
|
|
372
|
+
* @param {object} state from `blockAt`
|
|
373
|
+
* @param {object} block
|
|
374
|
+
* @param {object} props
|
|
375
|
+
* @param {unknown[]} [args]
|
|
376
|
+
* @returns {boolean} false when the caller has to repaint instead
|
|
377
|
+
*/
|
|
378
|
+
export function updateBlock(state, block, props, args = []) {
|
|
379
|
+
if (!state.end) return false;
|
|
380
|
+
if (block.keyed) return updateKeyed(state, block, props, args);
|
|
381
|
+
|
|
382
|
+
const branch = block.pick ? block.pick(props, ...args) : 0;
|
|
383
|
+
const part = block.parts?.[branch];
|
|
384
|
+
|
|
385
|
+
// Same branch: its contents are bindings like any others, so nothing here is
|
|
386
|
+
// destroyed and a focused field inside it stays focused.
|
|
387
|
+
if (branch === state.branch && part && state.bindings) {
|
|
388
|
+
if (part.update(state.bindings, props, ...args)) return true;
|
|
389
|
+
} else if (branch === state.branch && !part) {
|
|
390
|
+
const html = block.html(props, ...args);
|
|
391
|
+
if (html === state.html) return true;
|
|
392
|
+
state.html = html;
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
const html = block.html(props, ...args);
|
|
396
|
+
render(state, html);
|
|
397
|
+
state.branch = branch;
|
|
398
|
+
state.bindings = part ? part.bind(state.start.nextSibling, props, ...args) : null;
|
|
399
|
+
if (!part) state.html = html;
|
|
400
|
+
return true;
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
/** Replaces everything between the anchors. */
|
|
404
|
+
function render(state, html) {
|
|
405
|
+
const parent = state.start.parentNode;
|
|
406
|
+
while (state.start.nextSibling && state.start.nextSibling !== state.end) {
|
|
407
|
+
parent.removeChild(state.start.nextSibling);
|
|
408
|
+
}
|
|
409
|
+
const holder = parseInContext(parent, html);
|
|
410
|
+
while (holder.firstChild) parent.insertBefore(holder.firstChild, state.end);
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
/**
|
|
414
|
+
* Reordering reuses the nodes a key already owns. `moveBefore` moves them without
|
|
415
|
+
* resetting anything. An iframe keeps loading, focus stays, and a running
|
|
416
|
+
* animation keeps running. Plain insertBefore cannot promise that.
|
|
417
|
+
*/
|
|
418
|
+
function updateKeyed(state, block, props, args) {
|
|
419
|
+
const parent = state.start.parentNode;
|
|
420
|
+
const previous = state.keyed;
|
|
421
|
+
const part = block.parts?.[0];
|
|
422
|
+
const owned = new Map();
|
|
423
|
+
const restoreFocus = captureFocus(parent);
|
|
424
|
+
|
|
425
|
+
/**
|
|
426
|
+
* Inserted before it is bound, on purpose. A part reads `__n.parentNode` to find
|
|
427
|
+
* where its own top-level nodes live, and before insertion that is the scratch
|
|
428
|
+
* element the markup was parsed in.
|
|
429
|
+
*/
|
|
430
|
+
const build = (item, index, reference) => {
|
|
431
|
+
const html = block.item(props, ...args, item, index);
|
|
432
|
+
const nodes = [...parseInContext(parent, html).childNodes];
|
|
433
|
+
for (const node of nodes) parent.insertBefore(node, reference);
|
|
434
|
+
|
|
435
|
+
const entry = { first: nodes[0], last: nodes[nodes.length - 1], bindings: null, html: null };
|
|
436
|
+
if (part) entry.bindings = part.bind(bindsFrom(block, entry), props, ...args, item, index);
|
|
437
|
+
else entry.html = html;
|
|
438
|
+
return entry;
|
|
439
|
+
};
|
|
440
|
+
|
|
441
|
+
let reference = state.start.nextSibling;
|
|
442
|
+
let index = 0;
|
|
443
|
+
|
|
444
|
+
for (const item of block.list(props, ...args)) {
|
|
445
|
+
const key = block.key(props, ...args, item, index);
|
|
446
|
+
// A repeated key owns one row; the second claim on it has to build its own.
|
|
447
|
+
let entry = owned.has(key) ? undefined : previous.get(key);
|
|
448
|
+
|
|
449
|
+
if (!entry) {
|
|
450
|
+
// Built straight into position, so it needs no move afterwards.
|
|
451
|
+
entry = build(item, index, reference);
|
|
452
|
+
owned.set(key, entry);
|
|
453
|
+
index++;
|
|
454
|
+
continue;
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
// An item whose content changed is written into, not replaced. The row keeps
|
|
458
|
+
// its identity, and with it anything the user was doing in it.
|
|
459
|
+
const wrote = part
|
|
460
|
+
? part.update(entry.bindings, props, ...args, item, index)
|
|
461
|
+
: entry.html === block.item(props, ...args, item, index);
|
|
462
|
+
|
|
463
|
+
if (!wrote) {
|
|
464
|
+
const inPlace = reference === entry.first;
|
|
465
|
+
const replacement = build(item, index, entry.first);
|
|
466
|
+
removeRange(entry);
|
|
467
|
+
entry = replacement;
|
|
468
|
+
if (inPlace) reference = entry.last.nextSibling;
|
|
469
|
+
else moveRange(parent, entry, reference);
|
|
470
|
+
} else if (entry.first === reference) {
|
|
471
|
+
reference = entry.last.nextSibling;
|
|
472
|
+
} else {
|
|
473
|
+
moveRange(parent, entry, reference);
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
owned.set(key, entry);
|
|
477
|
+
index++;
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
for (const [key, entry] of previous) {
|
|
481
|
+
if (owned.get(key) !== entry) removeRange(entry);
|
|
482
|
+
}
|
|
483
|
+
state.keyed = owned;
|
|
484
|
+
restoreFocus?.();
|
|
485
|
+
return true;
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
function moveRange(parent, entry, reference) {
|
|
489
|
+
const stop = entry.last.nextSibling;
|
|
490
|
+
let node = entry.first;
|
|
491
|
+
while (node && node !== stop) {
|
|
492
|
+
// Captured before the move, which is what changes it.
|
|
493
|
+
const next = node.nextSibling;
|
|
494
|
+
place(parent, node, reference);
|
|
495
|
+
node = next;
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
function removeRange(entry) {
|
|
500
|
+
const stop = entry.last.nextSibling;
|
|
501
|
+
let node = entry.first;
|
|
502
|
+
while (node && node !== stop) {
|
|
503
|
+
const next = node.nextSibling;
|
|
504
|
+
node.remove();
|
|
505
|
+
node = next;
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
/**
|
|
510
|
+
* The focused element, following shadow roots down. `document.activeElement`
|
|
511
|
+
* only ever names the outermost host.
|
|
512
|
+
*/
|
|
513
|
+
function deepActiveElement() {
|
|
514
|
+
if (typeof document === 'undefined') return null;
|
|
515
|
+
let active = document.activeElement;
|
|
516
|
+
while (active?.shadowRoot?.activeElement) active = active.shadowRoot.activeElement;
|
|
517
|
+
return active ?? null;
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
/**
|
|
521
|
+
* Where `moveBefore` is unavailable, a move is a removal followed by an insert,
|
|
522
|
+
* and removing a node blurs whatever was focused inside it.
|
|
523
|
+
*
|
|
524
|
+
* Measured, focus is the only thing lost. The node itself is reused, and its
|
|
525
|
+
* value, its selection, its shadow root and its component state all come through.
|
|
526
|
+
* So focus is the one thing worth carrying across by hand. Where `moveBefore`
|
|
527
|
+
* exists, none of this runs.
|
|
528
|
+
*/
|
|
529
|
+
function captureFocus(parent) {
|
|
530
|
+
if (parent.moveBefore) return null;
|
|
531
|
+
|
|
532
|
+
const active = deepActiveElement();
|
|
533
|
+
if (!active) return null;
|
|
534
|
+
|
|
535
|
+
let start = null;
|
|
536
|
+
let end = null;
|
|
537
|
+
try {
|
|
538
|
+
start = active.selectionStart;
|
|
539
|
+
end = active.selectionEnd;
|
|
540
|
+
} catch {
|
|
541
|
+
// Not a field with a selection; focus alone is what there is to restore.
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
return () => {
|
|
545
|
+
if (!active.isConnected || deepActiveElement() === active) return;
|
|
546
|
+
active.focus({ preventScroll: true });
|
|
547
|
+
if (typeof start === 'number') {
|
|
548
|
+
try {
|
|
549
|
+
active.setSelectionRange(start, end);
|
|
550
|
+
} catch {
|
|
551
|
+
// Same as above.
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
};
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
function place(parent, node, reference) {
|
|
558
|
+
if (parent.moveBefore && node.isConnected) {
|
|
559
|
+
try {
|
|
560
|
+
parent.moveBefore(node, reference);
|
|
561
|
+
return;
|
|
562
|
+
} catch {
|
|
563
|
+
// Not movable in this position; falling back loses state but stays correct.
|
|
564
|
+
}
|
|
565
|
+
}
|
|
566
|
+
parent.insertBefore(node, reference);
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
/**
|
|
570
|
+
* Parsing has to happen inside an element of the same kind as the destination,
|
|
571
|
+
* or the parser drops what cannot live there. A bare <tr> in a <div> is thrown
|
|
572
|
+
* away.
|
|
573
|
+
*/
|
|
574
|
+
function parseInContext(parent, html) {
|
|
575
|
+
const holder = document.createElement(parent.nodeType === 1 ? parent.tagName : 'div');
|
|
576
|
+
if (holder.setHTMLUnsafe) holder.setHTMLUnsafe(html);
|
|
577
|
+
else holder.innerHTML = html;
|
|
578
|
+
return holder;
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
// ---- internal state -------------------------------------------------------
|
|
582
|
+
//
|
|
583
|
+
// A prop is an attribute, and the attribute is the only copy of it. State is
|
|
584
|
+
// the other half: a field that lives on the instance, is not in the document,
|
|
585
|
+
// and is nobody's business but the component's. Assigning one schedules a
|
|
586
|
+
// render the same way an attribute change does.
|
|
587
|
+
//
|
|
588
|
+
// Kept in a WeakMap rather than a private field so the accessors can be
|
|
589
|
+
// generated from outside the class body, like the prop ones are.
|
|
590
|
+
|
|
591
|
+
const STATE = new WeakMap();
|
|
592
|
+
|
|
593
|
+
/**
|
|
594
|
+
* @param {Element} element
|
|
595
|
+
* @param {Record<string, unknown>} defs the declared defaults
|
|
596
|
+
* @returns {Record<string, unknown>} the same object for the life of the element
|
|
597
|
+
*/
|
|
598
|
+
export function stateOf(element, defs) {
|
|
599
|
+
let state = STATE.get(element);
|
|
600
|
+
if (!state) {
|
|
601
|
+
state = { ...defs };
|
|
602
|
+
STATE.set(element, state);
|
|
603
|
+
}
|
|
604
|
+
return state;
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
function defineState(Class, defs, schedule) {
|
|
608
|
+
for (const key of Object.keys(defs ?? {})) {
|
|
609
|
+
if (key in Class.prototype) {
|
|
610
|
+
throw new Error(
|
|
611
|
+
`<script state>: \`${key}\` already exists on the element. Pick another name.`,
|
|
612
|
+
);
|
|
613
|
+
}
|
|
614
|
+
Object.defineProperty(Class.prototype, key, {
|
|
615
|
+
get() {
|
|
616
|
+
return stateOf(this, defs)[key];
|
|
617
|
+
},
|
|
618
|
+
set(value) {
|
|
619
|
+
const state = stateOf(this, defs);
|
|
620
|
+
if (Object.is(state[key], value)) return;
|
|
621
|
+
state[key] = value;
|
|
622
|
+
schedule(this);
|
|
623
|
+
},
|
|
624
|
+
enumerable: true,
|
|
625
|
+
configurable: true,
|
|
626
|
+
});
|
|
627
|
+
}
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
/**
|
|
631
|
+
* Whether anything the compiler could not bind has changed. Compared as raw
|
|
632
|
+
* attribute strings rather than coerced values: an object prop coerces to a
|
|
633
|
+
* fresh object every time, which would read as a change on every update.
|
|
634
|
+
*/
|
|
635
|
+
function volatileChanged(names, next, prev, state, prevState) {
|
|
636
|
+
for (const name of names ?? []) {
|
|
637
|
+
// A volatile name is whatever the template read. That may be state, and state
|
|
638
|
+
// has no attribute to compare.
|
|
639
|
+
if (name in state) {
|
|
640
|
+
if (!Object.is(state[name], prevState[name])) return true;
|
|
641
|
+
} else if (next[attrName(name)] !== prev[attrName(name)]) {
|
|
642
|
+
return true;
|
|
643
|
+
}
|
|
644
|
+
}
|
|
645
|
+
return false;
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
/**
|
|
649
|
+
* The inverse: writing a property back to the attribute that backs it.
|
|
650
|
+
*
|
|
651
|
+
* @param {Element} element
|
|
652
|
+
* @param {string} prop
|
|
653
|
+
* @param {unknown} value
|
|
654
|
+
* @param {unknown} fallback the declared default
|
|
655
|
+
* @param {object} [specs]
|
|
656
|
+
* @returns {void}
|
|
657
|
+
*/
|
|
658
|
+
export function writeProp(element, prop, value, fallback, specs) {
|
|
659
|
+
const attr = attrName(prop);
|
|
660
|
+
|
|
661
|
+
const to = specs?.[prop]?.to;
|
|
662
|
+
if (to) {
|
|
663
|
+
// setAttr already knows what false, null and true mean for an attribute.
|
|
664
|
+
setAttr(element, attr, to(value));
|
|
665
|
+
return;
|
|
666
|
+
}
|
|
667
|
+
if (typeof fallback === 'boolean') {
|
|
668
|
+
element.toggleAttribute(attr, Boolean(value));
|
|
669
|
+
return;
|
|
670
|
+
}
|
|
671
|
+
if (value === null || value === undefined) {
|
|
672
|
+
element.removeAttribute(attr);
|
|
673
|
+
return;
|
|
674
|
+
}
|
|
675
|
+
element.setAttribute(attr, typeof value === 'object' ? JSON.stringify(value) : String(value));
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
/**
|
|
679
|
+
* An accessor per declared prop, generated from `<script props>`. The shape is
|
|
680
|
+
* already stated there, so writing a getter and setter for each would say the
|
|
681
|
+
* same thing twice.
|
|
682
|
+
*
|
|
683
|
+
* The attribute is the only state. A getter reads and coerces it; a setter
|
|
684
|
+
* writes it, which for a shadow element triggers attributeChangedCallback and a
|
|
685
|
+
* re-render, and for a light one drives attribute selectors in CSS. Nothing is
|
|
686
|
+
* mirrored, so nothing can drift.
|
|
687
|
+
*/
|
|
688
|
+
function defineProps(Class, defs, specs) {
|
|
689
|
+
for (const [prop, fallback] of Object.entries(defs ?? {})) {
|
|
690
|
+
const attr = attrName(prop);
|
|
691
|
+
const single = { [prop]: fallback };
|
|
692
|
+
|
|
693
|
+
Object.defineProperty(Class.prototype, prop, {
|
|
694
|
+
get() {
|
|
695
|
+
return coerceProps(single, { [attr]: this.getAttribute(attr) }, specs)[prop];
|
|
696
|
+
},
|
|
697
|
+
set(value) {
|
|
698
|
+
writeProp(this, prop, value, fallback, specs);
|
|
699
|
+
},
|
|
700
|
+
enumerable: true,
|
|
701
|
+
configurable: true,
|
|
702
|
+
});
|
|
703
|
+
}
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
/**
|
|
707
|
+
* `export const prototype` is an object; its members land on the class prototype
|
|
708
|
+
* so they are shared, inherited, and visible to `in`.
|
|
709
|
+
*
|
|
710
|
+
* Descriptors, not `Object.assign`. Assigning would call every getter once at
|
|
711
|
+
* define time and copy the results as plain values.
|
|
712
|
+
*
|
|
713
|
+
* The collision check is `in`, which walks the whole chain up through
|
|
714
|
+
* HTMLElement. That is exhaustive and needs no table of DOM member names to
|
|
715
|
+
* fall out of date. It catches a clash with a declared prop too, since
|
|
716
|
+
* defineProps has already run.
|
|
717
|
+
*/
|
|
718
|
+
function defineMembers(Class, members, tag) {
|
|
719
|
+
const descriptors = Object.getOwnPropertyDescriptors(members ?? {});
|
|
720
|
+
|
|
721
|
+
for (const name of Object.keys(descriptors)) {
|
|
722
|
+
if (name in Class.prototype) {
|
|
723
|
+
throw new Error(
|
|
724
|
+
`<${tag}>: \`${name}\` in \`export const prototype\` would shadow ` +
|
|
725
|
+
`something the element already has. Pick another name.`,
|
|
726
|
+
);
|
|
727
|
+
}
|
|
728
|
+
Object.defineProperty(Class.prototype, name, {
|
|
729
|
+
...descriptors[name],
|
|
730
|
+
enumerable: false,
|
|
731
|
+
configurable: true,
|
|
732
|
+
});
|
|
733
|
+
}
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
/**
|
|
737
|
+
* The prop an attribute name belongs to. Without a rename the mapping is just
|
|
738
|
+
* dash-casing, so this reverses it, over a handful of declared names.
|
|
739
|
+
*/
|
|
740
|
+
function propFor(def, attr) {
|
|
741
|
+
return Object.keys(def.propDefs ?? {}).find((key) => attrName(key) === attr);
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
/**
|
|
745
|
+
* A component's attribute, serialized the way that component reads it back.
|
|
746
|
+
* The parent's template cannot know that a Date crosses the boundary as an ISO
|
|
747
|
+
* string rather than as JSON. The child's `to` does.
|
|
748
|
+
*
|
|
749
|
+
* @param {object} def the compiled element module
|
|
750
|
+
* @param {string} name
|
|
751
|
+
* @param {unknown} value
|
|
752
|
+
* @returns {string}
|
|
753
|
+
*/
|
|
754
|
+
export function attrProp(def, name, value) {
|
|
755
|
+
const to = def.propAttrs?.[propFor(def, name)]?.to;
|
|
756
|
+
return attr(name, to ? to(value) : value);
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
/**
|
|
760
|
+
* The same, for an update writing into an already-rendered child.
|
|
761
|
+
*
|
|
762
|
+
* @param {object} def
|
|
763
|
+
* @param {Element} element
|
|
764
|
+
* @param {string} name
|
|
765
|
+
* @param {unknown} value
|
|
766
|
+
* @returns {void}
|
|
767
|
+
*/
|
|
768
|
+
export function setAttrProp(def, element, name, value) {
|
|
769
|
+
const to = def.propAttrs?.[propFor(def, name)]?.to;
|
|
770
|
+
setAttr(element, name, to ? to(value) : value);
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
/**
|
|
774
|
+
* Server side of the component: the shadow root, inline, so the page is correct
|
|
775
|
+
* before any JS runs. Nested DSD works because the HTML parser handles it.
|
|
776
|
+
*
|
|
777
|
+
* Except in a fragment. A fragment is swapped into a document that is already
|
|
778
|
+
* live, and nothing that does the swapping processes a declarative shadow root.
|
|
779
|
+
* Not innerHTML, not DOMParser, and none of the libraries built on them. The
|
|
780
|
+
* template would land dead and the component would never exist.
|
|
781
|
+
*
|
|
782
|
+
* So a fragment ships the element bare: the tag and its attributes, nothing
|
|
783
|
+
* inside. `connectedCallback` finds no shadow root, attaches one and paints,
|
|
784
|
+
* and that paint goes through setHTMLUnsafe, which does process the nested
|
|
785
|
+
* declarative roots underneath it. Nothing is lost by leaving it out. Server
|
|
786
|
+
* rendering buys a correct first paint, and a fragment arrives long after first
|
|
787
|
+
* paint.
|
|
788
|
+
*
|
|
789
|
+
* @param {object} def
|
|
790
|
+
* @param {object} props
|
|
791
|
+
* @param {boolean} [fragment] true returns empty: nothing that swaps HTML
|
|
792
|
+
* processes a declarative shadow root, so the element paints itself
|
|
793
|
+
* @returns {string}
|
|
794
|
+
*/
|
|
795
|
+
export function shadow(def, props, fragment = false) {
|
|
796
|
+
if (fragment) return '';
|
|
797
|
+
const styles = def.css ? `<style>${def.css}</style>` : '';
|
|
798
|
+
return `<template shadowrootmode="open">${styles}${def.render(data(def, props))}</template>`;
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
/**
|
|
802
|
+
* What a template sees, on the server: state defaults under the props.
|
|
803
|
+
*
|
|
804
|
+
* The same order the element uses once it is live, so the first paint and every
|
|
805
|
+
* later one read the same shape. Rendering props alone wrote `undefined` wherever
|
|
806
|
+
* a template named state.
|
|
807
|
+
*
|
|
808
|
+
* @param {object} def
|
|
809
|
+
* @param {object} props
|
|
810
|
+
* @returns {Record<string, unknown>} state underneath, props on top
|
|
811
|
+
*/
|
|
812
|
+
export function data(def, props) {
|
|
813
|
+
return { ...def.stateDefs, ...def.coerce(props) };
|
|
814
|
+
}
|
|
815
|
+
|
|
816
|
+
/**
|
|
817
|
+
* A light element rendered for insertion into a live document: its own markup,
|
|
818
|
+
* with any shadow element inside it left bare for the client to paint.
|
|
819
|
+
*
|
|
820
|
+
* Its styles are left out on purpose. They are one `<style>` per tag in <head>,
|
|
821
|
+
* not one per use, so putting them here would ship a copy on every swap. When
|
|
822
|
+
* the swapped markup names a tag the document has never rendered, `watch`
|
|
823
|
+
* notices it and `adoptStyles` adds them once.
|
|
824
|
+
*/
|
|
825
|
+
/**
|
|
826
|
+
* What an external include renders: the fragment the server fetched, the
|
|
827
|
+
* element's own children if it could not be read, or a throw if there are
|
|
828
|
+
* neither.
|
|
829
|
+
*
|
|
830
|
+
* A page with no fallback that silently rendered a hole would be worse than one
|
|
831
|
+
* that fails: the hole looks like content nobody wrote.
|
|
832
|
+
*
|
|
833
|
+
* @param {Record<string, unknown>|null|undefined} data
|
|
834
|
+
* @param {string} key the src exactly as written
|
|
835
|
+
* @param {string|null} fallback the element's children, or null if it had none
|
|
836
|
+
* @returns {string}
|
|
837
|
+
* @throws when the source failed and there is no fallback
|
|
838
|
+
*/
|
|
839
|
+
export function included(data, key, fallback) {
|
|
840
|
+
const html = data?.__included?.[key];
|
|
841
|
+
if (html != null) return html;
|
|
842
|
+
if (fallback !== null) return fallback;
|
|
843
|
+
|
|
844
|
+
throw new Error(
|
|
845
|
+
`[transclude] <transclude src="${key}"> could not be read, and the ` +
|
|
846
|
+
`element has no children to fall back to.`,
|
|
847
|
+
);
|
|
848
|
+
}
|
|
849
|
+
|
|
850
|
+
/**
|
|
851
|
+
* @param {object} def
|
|
852
|
+
* @param {object} [props]
|
|
853
|
+
* @param {object} [slots]
|
|
854
|
+
* @returns {string}
|
|
855
|
+
*/
|
|
856
|
+
export function fragment(def, props = {}, slots = {}) {
|
|
857
|
+
return def.render(data(def, props), slots, true);
|
|
858
|
+
}
|
|
859
|
+
|
|
860
|
+
/**
|
|
861
|
+
* A light element's styles, in <head>, at most once per tag.
|
|
862
|
+
*
|
|
863
|
+
* The server writes the same `<style data-transclude="tag">` for every light element the
|
|
864
|
+
* document rendered, so the marker is the whole agreement. If one is already
|
|
865
|
+
* there, these styles are applied and this does nothing. That is why the
|
|
866
|
+
* attribute is on the server's output too. A page that renders <site-note> and a
|
|
867
|
+
* swap that brings one in must not end up with two copies.
|
|
868
|
+
*
|
|
869
|
+
* Inserted *before* the document's own <style>, not appended, because that is
|
|
870
|
+
* where the server would have put it: a page's rules override an element's.
|
|
871
|
+
*
|
|
872
|
+
* @param {object} def
|
|
873
|
+
* @returns {void}
|
|
874
|
+
*/
|
|
875
|
+
export function adoptStyles(def) {
|
|
876
|
+
if (typeof document === 'undefined') return;
|
|
877
|
+
if (!def.light || !def.css) return;
|
|
878
|
+
if (document.querySelector(`style[data-transclude="${def.tag}"]`)) return;
|
|
879
|
+
|
|
880
|
+
const style = document.createElement('style');
|
|
881
|
+
style.setAttribute('data-transclude', def.tag);
|
|
882
|
+
style.textContent = def.css;
|
|
883
|
+
document.head.insertBefore(style, document.querySelector('style[data-transclude-page]'));
|
|
884
|
+
}
|
|
885
|
+
|
|
886
|
+
/**
|
|
887
|
+
* Loads element definitions for tags that arrive after the page did.
|
|
888
|
+
*
|
|
889
|
+
* A page's client entry defines what the page can render. A fragment swapped in
|
|
890
|
+
* from another route can contain anything, and it arrives as plain markup. A
|
|
891
|
+
* light element arrives with no styles, a shadow one with no definition.
|
|
892
|
+
*
|
|
893
|
+
* The framework does not do the swapping. Whoever does, whether htmx, Turbo or a
|
|
894
|
+
* short fetch, cannot be counted on to announce it, and half of them use plain
|
|
895
|
+
* innerHTML. So this watches the result rather than the cause: whatever put the
|
|
896
|
+
* tag in the document, it is in the document, and that is the signal.
|
|
897
|
+
*
|
|
898
|
+
* `loaders` is tag -> dynamic import, so a tag that never appears costs one
|
|
899
|
+
* string. The observer disconnects once every tag it knows about has been seen.
|
|
900
|
+
*
|
|
901
|
+
* It does not look inside shadow roots. It does not need to: a component's own
|
|
902
|
+
* `define` brings the elements it renders with it.
|
|
903
|
+
*
|
|
904
|
+
* @param {Record<string, () => Promise<unknown>>} loaders tag to dynamic import
|
|
905
|
+
* @param {Document} [root]
|
|
906
|
+
* @returns {() => void} stops the observer
|
|
907
|
+
*/
|
|
908
|
+
export function watch(loaders, root = globalThis.document) {
|
|
909
|
+
if (!root || typeof MutationObserver === 'undefined') return () => {};
|
|
910
|
+
|
|
911
|
+
const pending = new Set(Object.keys(loaders));
|
|
912
|
+
if (!pending.size) return () => {};
|
|
913
|
+
|
|
914
|
+
const observer = new MutationObserver(() => sweep());
|
|
915
|
+
const stop = () => observer.disconnect();
|
|
916
|
+
|
|
917
|
+
function sweep() {
|
|
918
|
+
for (const tag of pending) {
|
|
919
|
+
if (!root.querySelector(tag)) continue;
|
|
920
|
+
pending.delete(tag);
|
|
921
|
+
// A failed chunk should say so and not take the sweep down with it. Every
|
|
922
|
+
// other tag on the page is independent of this one.
|
|
923
|
+
Promise.resolve()
|
|
924
|
+
.then(loaders[tag])
|
|
925
|
+
.then((mod) => mod?.define?.())
|
|
926
|
+
.catch((err) => console.error(`[transclude] could not define <${tag}>`, err));
|
|
927
|
+
}
|
|
928
|
+
if (!pending.size) stop();
|
|
929
|
+
}
|
|
930
|
+
|
|
931
|
+
observer.observe(root.documentElement ?? root, { childList: true, subtree: true });
|
|
932
|
+
sweep();
|
|
933
|
+
return stop;
|
|
934
|
+
}
|
|
935
|
+
|
|
936
|
+
/**
|
|
937
|
+
* Client side of the same component. On first connect the shadow root already
|
|
938
|
+
* exists, because the parser attached it from the DSD template, so nothing
|
|
939
|
+
* repaints. Rendering it on the server is what makes that possible.
|
|
940
|
+
*/
|
|
941
|
+
/**
|
|
942
|
+
* A light element has no shadow root to repaint, and repainting would destroy
|
|
943
|
+
* the children the page put inside it. So it upgrades for behaviour only: the
|
|
944
|
+
* markup it was served is the markup it keeps.
|
|
945
|
+
*
|
|
946
|
+
* @param {object} def
|
|
947
|
+
* @param {Function|null} [init] the `<script>` block, once per element
|
|
948
|
+
* @returns {void}
|
|
949
|
+
*/
|
|
950
|
+
export function defineLight(def, init) {
|
|
951
|
+
// Before every other exit below: styles are the half of this that an element
|
|
952
|
+
// with no behaviour still has, and the half a swapped-in one arrives without.
|
|
953
|
+
adoptStyles(def);
|
|
954
|
+
|
|
955
|
+
if (typeof customElements === 'undefined') return;
|
|
956
|
+
if (customElements.get(def.tag)) return;
|
|
957
|
+
// No behaviour to attach means nothing to register. A light element with no
|
|
958
|
+
// <script> is markup that was already rendered, and it ships no JavaScript at
|
|
959
|
+
// all, accessors included. That is the trade the
|
|
960
|
+
// zero-JS default makes.
|
|
961
|
+
//
|
|
962
|
+
// Being a form control counts as behaviour: a shadow root is not required to be
|
|
963
|
+
// one, and an element that submits a value has to exist to do it.
|
|
964
|
+
if (!init && !hasMembers(def) && !def.formAssociated && !hasState(def)) return;
|
|
965
|
+
|
|
966
|
+
class Light extends HTMLElement {
|
|
967
|
+
// Every declared prop, so a change reaches the template. A light element
|
|
968
|
+
// that ships no JavaScript is never registered at all, so nothing here costs
|
|
969
|
+
// a page that does not already have a script on it.
|
|
970
|
+
static observedAttributes = Object.keys(def.propDefs ?? {}).map(attrName);
|
|
971
|
+
static formAssociated = def.formAssociated === true;
|
|
972
|
+
|
|
973
|
+
#internals = null;
|
|
974
|
+
#cleanup = null;
|
|
975
|
+
#abort = null;
|
|
976
|
+
#bindings = null;
|
|
977
|
+
#bound = false;
|
|
978
|
+
#ready = false;
|
|
979
|
+
#raw = null;
|
|
980
|
+
#was = null;
|
|
981
|
+
#pending = null;
|
|
982
|
+
#settle = null;
|
|
983
|
+
|
|
984
|
+
constructor() {
|
|
985
|
+
super();
|
|
986
|
+
if (def.formAssociated) this.#internals = this.attachInternals();
|
|
987
|
+
}
|
|
988
|
+
|
|
989
|
+
get internals() {
|
|
990
|
+
return this.#internals;
|
|
991
|
+
}
|
|
992
|
+
|
|
993
|
+
reportFormValue() {
|
|
994
|
+
if (!this.#internals) return;
|
|
995
|
+
const value = formValueOf(def, this);
|
|
996
|
+
if (value !== undefined) this.#internals.setFormValue(value);
|
|
997
|
+
}
|
|
998
|
+
|
|
999
|
+
/** Resolves once the changes made so far have been written. */
|
|
1000
|
+
get updateComplete() {
|
|
1001
|
+
return this.#pending ?? Promise.resolve();
|
|
1002
|
+
}
|
|
1003
|
+
|
|
1004
|
+
/** One render per microtask, so `a = 1; b = 2` writes once. */
|
|
1005
|
+
schedule() {
|
|
1006
|
+
if (this.#pending) return;
|
|
1007
|
+
this.#pending = new Promise((resolve) => {
|
|
1008
|
+
this.#settle = resolve;
|
|
1009
|
+
});
|
|
1010
|
+
queueMicrotask(() => {
|
|
1011
|
+
const settle = this.#settle;
|
|
1012
|
+
this.#pending = null;
|
|
1013
|
+
this.#settle = null;
|
|
1014
|
+
if (this.#ready) this.#apply();
|
|
1015
|
+
settle();
|
|
1016
|
+
});
|
|
1017
|
+
}
|
|
1018
|
+
|
|
1019
|
+
attributeChangedCallback() {
|
|
1020
|
+
// Before the render, the same as a component: a form can be submitted
|
|
1021
|
+
// between the change and the microtask that writes it.
|
|
1022
|
+
this.reportFormValue();
|
|
1023
|
+
if (this.#ready) this.schedule();
|
|
1024
|
+
}
|
|
1025
|
+
|
|
1026
|
+
connectedCallback() {
|
|
1027
|
+
// The markup is already right, so this only finds the nodes each
|
|
1028
|
+
// expression owns. Once: moving an element reconnects it, and binding a
|
|
1029
|
+
// second time would split an already split text node.
|
|
1030
|
+
if (!this.#bound) {
|
|
1031
|
+
this.#raw = this.#snapshot();
|
|
1032
|
+
this.#bindings = def.bind ? def.bind(this, this.#data(this.#raw)) : null;
|
|
1033
|
+
this.#bound = true;
|
|
1034
|
+
}
|
|
1035
|
+
|
|
1036
|
+
this.#ready = true;
|
|
1037
|
+
this.#abort = new AbortController();
|
|
1038
|
+
this.#cleanup = init?.(this, null, this.#abort.signal, this.#internals);
|
|
1039
|
+
this.reportFormValue();
|
|
1040
|
+
this.updated?.();
|
|
1041
|
+
}
|
|
1042
|
+
|
|
1043
|
+
/**
|
|
1044
|
+
* Writes to the nodes that are already there, and only those.
|
|
1045
|
+
*
|
|
1046
|
+
* There is no repaint here. Replacing the children would throw away what the
|
|
1047
|
+
* caller slotted in and anything the page did to them, and a light element
|
|
1048
|
+
* does not own its children. The compiler refuses a template that would need
|
|
1049
|
+
* one, so reaching that case means a binding the compiler could not make.
|
|
1050
|
+
*/
|
|
1051
|
+
#apply() {
|
|
1052
|
+
const raw = this.#snapshot();
|
|
1053
|
+
if (this.#bindings) def.update(this.#bindings, this.#data(raw));
|
|
1054
|
+
this.updated?.();
|
|
1055
|
+
}
|
|
1056
|
+
|
|
1057
|
+
#data(raw) {
|
|
1058
|
+
this.#was = { ...stateOf(this, def.stateDefs) };
|
|
1059
|
+
return { ...this.#was, ...def.coerce(raw) };
|
|
1060
|
+
}
|
|
1061
|
+
|
|
1062
|
+
#snapshot() {
|
|
1063
|
+
const raw = {};
|
|
1064
|
+
for (const { name, value } of this.attributes) raw[name] = value;
|
|
1065
|
+
this.#raw = raw;
|
|
1066
|
+
return raw;
|
|
1067
|
+
}
|
|
1068
|
+
|
|
1069
|
+
disconnectedCallback() {
|
|
1070
|
+
this.#abort?.abort();
|
|
1071
|
+
this.#abort = null;
|
|
1072
|
+
release(this.#cleanup);
|
|
1073
|
+
this.#cleanup = null;
|
|
1074
|
+
}
|
|
1075
|
+
}
|
|
1076
|
+
|
|
1077
|
+
defineProps(Light, def.propDefs, def.propAttrs);
|
|
1078
|
+
defineState(Light, def.stateDefs, (element) => element.schedule());
|
|
1079
|
+
if (def.formAssociated) defineFormMembers(Light, def);
|
|
1080
|
+
defineMembers(Light, def.members, def.tag);
|
|
1081
|
+
customElements.define(def.tag, Light);
|
|
1082
|
+
}
|
|
1083
|
+
|
|
1084
|
+
function hasMembers(def) {
|
|
1085
|
+
return Object.keys(def.members ?? {}).length > 0;
|
|
1086
|
+
}
|
|
1087
|
+
|
|
1088
|
+
/** State counts as behavior: its accessors are the only way to change it. */
|
|
1089
|
+
function hasState(def) {
|
|
1090
|
+
return Object.keys(def.stateDefs ?? {}).length > 0;
|
|
1091
|
+
}
|
|
1092
|
+
|
|
1093
|
+
/**
|
|
1094
|
+
* A `<script>` block may end by returning a function. That function runs when
|
|
1095
|
+
* the element leaves the document. Use it for cleanup that has no signal of its
|
|
1096
|
+
* own.
|
|
1097
|
+
* Listeners do not need it: they get `signal` as the third argument.
|
|
1098
|
+
*
|
|
1099
|
+
* It is a promise because the block is compiled to an async function, so a
|
|
1100
|
+
* top-level `await` works inside it.
|
|
1101
|
+
*/
|
|
1102
|
+
function release(cleanup) {
|
|
1103
|
+
Promise.resolve(cleanup).then((fn) => {
|
|
1104
|
+
if (typeof fn === 'function') fn();
|
|
1105
|
+
});
|
|
1106
|
+
}
|
|
1107
|
+
|
|
1108
|
+
/**
|
|
1109
|
+
* Form association, for an element that opted in with `export const
|
|
1110
|
+
* formAssociated = true`.
|
|
1111
|
+
*
|
|
1112
|
+
* A custom element in a `<form>` sends nothing by default, because the browser
|
|
1113
|
+
* has no reason to think it is a control. The platform's answer is a static flag,
|
|
1114
|
+
* `attachInternals()` for the handle, and `setFormValue` to say what would be
|
|
1115
|
+
* submitted.
|
|
1116
|
+
*
|
|
1117
|
+
* The value comes from a `value` prop, serialized exactly the way its attribute
|
|
1118
|
+
* is, so what is submitted is what the DOM says. A form-associated element with
|
|
1119
|
+
* no `value` prop can still report validity, so this reports nothing rather than
|
|
1120
|
+
* complaining.
|
|
1121
|
+
*/
|
|
1122
|
+
function formValueOf(def, element) {
|
|
1123
|
+
if (!('value' in (def.propDefs ?? {}))) return undefined;
|
|
1124
|
+
|
|
1125
|
+
const value = element.value;
|
|
1126
|
+
if (value === null || value === undefined || value === false) return null;
|
|
1127
|
+
if (typeof value === 'string') return value;
|
|
1128
|
+
return typeof value === 'object' ? JSON.stringify(value) : String(value);
|
|
1129
|
+
}
|
|
1130
|
+
|
|
1131
|
+
/** The callbacks a form calls on its controls. Identical for both kinds. */
|
|
1132
|
+
function defineFormMembers(Class, def) {
|
|
1133
|
+
Object.defineProperties(Class.prototype, {
|
|
1134
|
+
/**
|
|
1135
|
+
* Reset puts the prop back to the default its `<script properties>` block
|
|
1136
|
+
* declared. That is the same thing `value` returns when the attribute is
|
|
1137
|
+
* absent, so removing it is the whole job.
|
|
1138
|
+
*/
|
|
1139
|
+
formResetCallback: {
|
|
1140
|
+
value() {
|
|
1141
|
+
if ('value' in (def.propDefs ?? {})) this.removeAttribute(attrName('value'));
|
|
1142
|
+
this.reportFormValue?.();
|
|
1143
|
+
},
|
|
1144
|
+
configurable: true,
|
|
1145
|
+
},
|
|
1146
|
+
|
|
1147
|
+
/** A fieldset or form went disabled. Mirrored to an attribute if declared. */
|
|
1148
|
+
formDisabledCallback: {
|
|
1149
|
+
value(disabled) {
|
|
1150
|
+
if (!('disabled' in (def.propDefs ?? {}))) return;
|
|
1151
|
+
if (disabled) this.setAttribute('disabled', '');
|
|
1152
|
+
else this.removeAttribute('disabled');
|
|
1153
|
+
},
|
|
1154
|
+
configurable: true,
|
|
1155
|
+
},
|
|
1156
|
+
|
|
1157
|
+
/**
|
|
1158
|
+
* The browser restoring a value after a back-navigation or a crash. Same
|
|
1159
|
+
* shape as a submit, so writing the attribute is enough.
|
|
1160
|
+
*/
|
|
1161
|
+
formStateRestoreCallback: {
|
|
1162
|
+
value(state) {
|
|
1163
|
+
if (!('value' in (def.propDefs ?? {}))) return;
|
|
1164
|
+
if (typeof state === 'string') this.setAttribute(attrName('value'), state);
|
|
1165
|
+
this.reportFormValue?.();
|
|
1166
|
+
},
|
|
1167
|
+
configurable: true,
|
|
1168
|
+
},
|
|
1169
|
+
});
|
|
1170
|
+
}
|
|
1171
|
+
|
|
1172
|
+
/**
|
|
1173
|
+
* @param {object} def
|
|
1174
|
+
* @param {Function|null} [init]
|
|
1175
|
+
* @returns {void}
|
|
1176
|
+
*/
|
|
1177
|
+
export function defineComponent(def, init) {
|
|
1178
|
+
if (typeof customElements === 'undefined') return;
|
|
1179
|
+
if (customElements.get(def.tag)) return;
|
|
1180
|
+
|
|
1181
|
+
class Component extends HTMLElement {
|
|
1182
|
+
static observedAttributes = Object.keys(def.propDefs ?? {}).map(attrName);
|
|
1183
|
+
// The platform's own switch. Without it a `<form>` has no reason to think
|
|
1184
|
+
// this element is a control, and nothing it holds is ever submitted.
|
|
1185
|
+
static formAssociated = def.formAssociated === true;
|
|
1186
|
+
|
|
1187
|
+
#internals = null;
|
|
1188
|
+
#ready = false;
|
|
1189
|
+
#cleanup = null;
|
|
1190
|
+
#abort = null;
|
|
1191
|
+
#bindings = null;
|
|
1192
|
+
#bound = false;
|
|
1193
|
+
#raw = null;
|
|
1194
|
+
#was = null;
|
|
1195
|
+
#pending = null;
|
|
1196
|
+
#settle = null;
|
|
1197
|
+
|
|
1198
|
+
constructor() {
|
|
1199
|
+
super();
|
|
1200
|
+
// In the constructor, which is where it belongs and where it can only
|
|
1201
|
+
// happen once. For a server-rendered element that is upgrade time.
|
|
1202
|
+
if (def.formAssociated) this.#internals = this.attachInternals();
|
|
1203
|
+
}
|
|
1204
|
+
|
|
1205
|
+
/** What this element would submit, if it is a control. */
|
|
1206
|
+
reportFormValue() {
|
|
1207
|
+
if (!this.#internals) return;
|
|
1208
|
+
const value = formValueOf(def, this);
|
|
1209
|
+
if (value !== undefined) this.#internals.setFormValue(value);
|
|
1210
|
+
}
|
|
1211
|
+
|
|
1212
|
+
/** For `setValidity` and the rest. The same handle the platform hands out. */
|
|
1213
|
+
get internals() {
|
|
1214
|
+
return this.#internals;
|
|
1215
|
+
}
|
|
1216
|
+
|
|
1217
|
+
/**
|
|
1218
|
+
* Resolves once the render for the changes made so far has happened.
|
|
1219
|
+
* Reading it when nothing is pending is not an error. It is already done.
|
|
1220
|
+
*/
|
|
1221
|
+
get updateComplete() {
|
|
1222
|
+
return this.#pending ?? Promise.resolve();
|
|
1223
|
+
}
|
|
1224
|
+
|
|
1225
|
+
/**
|
|
1226
|
+
* Renders are collected into one microtask, so `a = 1; b = 2` is one render
|
|
1227
|
+
* and not two. Setting a prop still writes its attribute right away. Only the
|
|
1228
|
+
* rendering waits.
|
|
1229
|
+
*/
|
|
1230
|
+
schedule() {
|
|
1231
|
+
if (this.#pending) return;
|
|
1232
|
+
this.#pending = new Promise((resolve) => {
|
|
1233
|
+
this.#settle = resolve;
|
|
1234
|
+
});
|
|
1235
|
+
queueMicrotask(() => {
|
|
1236
|
+
const settle = this.#settle;
|
|
1237
|
+
this.#pending = null;
|
|
1238
|
+
this.#settle = null;
|
|
1239
|
+
if (this.#ready) this.#apply();
|
|
1240
|
+
settle();
|
|
1241
|
+
});
|
|
1242
|
+
}
|
|
1243
|
+
|
|
1244
|
+
connectedCallback() {
|
|
1245
|
+
if (!this.shadowRoot) {
|
|
1246
|
+
this.attachShadow({ mode: 'open' });
|
|
1247
|
+
this.#paint();
|
|
1248
|
+
} else if (!this.#bound) {
|
|
1249
|
+
// Server-rendered: the markup is already right, so this only has to
|
|
1250
|
+
// find the nodes each expression owns. Once only. Moving an element in
|
|
1251
|
+
// the document reconnects it, and binding a second time would split an
|
|
1252
|
+
// already split text node down the middle.
|
|
1253
|
+
this.#adopt();
|
|
1254
|
+
}
|
|
1255
|
+
// Runs on every connect, not just the first: moving an element in the DOM
|
|
1256
|
+
// disconnects and reconnects it, and behaviour that was torn down on the
|
|
1257
|
+
// way out has to come back on the way in.
|
|
1258
|
+
this.#ready = true;
|
|
1259
|
+
this.#abort = new AbortController();
|
|
1260
|
+
this.#cleanup = init?.(this, this.shadowRoot, this.#abort.signal, this.#internals);
|
|
1261
|
+
this.reportFormValue();
|
|
1262
|
+
this.updated?.();
|
|
1263
|
+
}
|
|
1264
|
+
|
|
1265
|
+
disconnectedCallback() {
|
|
1266
|
+
// Every listener that was passed this signal is now removed, without the
|
|
1267
|
+
// block having said anything about removing it.
|
|
1268
|
+
this.#abort?.abort();
|
|
1269
|
+
this.#abort = null;
|
|
1270
|
+
release(this.#cleanup);
|
|
1271
|
+
this.#cleanup = null;
|
|
1272
|
+
}
|
|
1273
|
+
|
|
1274
|
+
attributeChangedCallback() {
|
|
1275
|
+
// Before the render, and not only when ready: a form can be submitted
|
|
1276
|
+
// between an attribute change and the microtask that repaints, and what it
|
|
1277
|
+
// submits should be what the attribute already says.
|
|
1278
|
+
this.reportFormValue();
|
|
1279
|
+
if (this.#ready) this.schedule();
|
|
1280
|
+
}
|
|
1281
|
+
|
|
1282
|
+
#apply() {
|
|
1283
|
+
const prev = this.#raw;
|
|
1284
|
+
const prevState = this.#was;
|
|
1285
|
+
const raw = this.#snapshot();
|
|
1286
|
+
const state = stateOf(this, def.stateDefs);
|
|
1287
|
+
|
|
1288
|
+
// Structure the compiler could not bind is the only reason to rebuild.
|
|
1289
|
+
// Everything else is a write to a node that already exists.
|
|
1290
|
+
if (
|
|
1291
|
+
this.#bindings &&
|
|
1292
|
+
prev &&
|
|
1293
|
+
!volatileChanged(def.volatile, raw, prev, state, prevState ?? state)
|
|
1294
|
+
) {
|
|
1295
|
+
if (def.update(this.#bindings, this.#data(raw))) {
|
|
1296
|
+
this.updated?.();
|
|
1297
|
+
return;
|
|
1298
|
+
}
|
|
1299
|
+
}
|
|
1300
|
+
this.#paint();
|
|
1301
|
+
this.updated?.();
|
|
1302
|
+
}
|
|
1303
|
+
|
|
1304
|
+
/** What the template sees: state first, so a prop of the same name cannot
|
|
1305
|
+
* be hidden by one. The compiler rejects that clash anyway. */
|
|
1306
|
+
#data(raw) {
|
|
1307
|
+
this.#was = { ...stateOf(this, def.stateDefs) };
|
|
1308
|
+
return { ...this.#was, ...def.coerce(raw) };
|
|
1309
|
+
}
|
|
1310
|
+
|
|
1311
|
+
#snapshot() {
|
|
1312
|
+
const raw = {};
|
|
1313
|
+
for (const { name, value } of this.attributes) raw[name] = value;
|
|
1314
|
+
this.#raw = raw;
|
|
1315
|
+
return raw;
|
|
1316
|
+
}
|
|
1317
|
+
|
|
1318
|
+
#adopt() {
|
|
1319
|
+
const raw = this.#snapshot();
|
|
1320
|
+
this.#bindings = def.bind ? def.bind(this.shadowRoot, this.#data(raw)) : null;
|
|
1321
|
+
this.#bound = true;
|
|
1322
|
+
}
|
|
1323
|
+
|
|
1324
|
+
#paint() {
|
|
1325
|
+
const raw = this.#snapshot();
|
|
1326
|
+
const data = this.#data(raw);
|
|
1327
|
+
const markup = (def.css ? `<style>${def.css}</style>` : '') + def.render(data);
|
|
1328
|
+
|
|
1329
|
+
// innerHTML does NOT process nested shadow root templates. Anything with
|
|
1330
|
+
// a child component would end up with inert <template> nodes in the DOM.
|
|
1331
|
+
if (this.shadowRoot.setHTMLUnsafe) this.shadowRoot.setHTMLUnsafe(markup);
|
|
1332
|
+
else this.shadowRoot.innerHTML = markup;
|
|
1333
|
+
|
|
1334
|
+
// The nodes the old bindings pointed at are gone.
|
|
1335
|
+
this.#bindings = def.bind ? def.bind(this.shadowRoot, data) : null;
|
|
1336
|
+
this.#bound = true;
|
|
1337
|
+
}
|
|
1338
|
+
}
|
|
1339
|
+
|
|
1340
|
+
defineProps(Component, def.propDefs, def.propAttrs);
|
|
1341
|
+
defineState(Component, def.stateDefs, (element) => element.schedule());
|
|
1342
|
+
if (def.formAssociated) defineFormMembers(Component, def);
|
|
1343
|
+
defineMembers(Component, def.members, def.tag);
|
|
1344
|
+
customElements.define(def.tag, Component);
|
|
1345
|
+
}
|