@jxsuite/runtime 4.0.3 → 5.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/dist/runtime.js +749 -473
- package/dist/runtime.js.map +12 -12
- package/package.json +5 -4
- package/src/cookie.ts +4 -2
- package/src/css.ts +31 -18
- package/src/expression.ts +321 -232
- package/src/pointer.ts +10 -4
- package/src/runtime.ts +1399 -611
- package/src/statements.ts +58 -48
- package/src/types.ts +114 -13
package/src/runtime.ts
CHANGED
|
@@ -1,16 +1,17 @@
|
|
|
1
1
|
/// <reference lib="dom" />
|
|
2
2
|
/**
|
|
3
3
|
* Jx — JSON-native reactive web component runtime
|
|
4
|
-
* @version 3.0.0
|
|
5
|
-
* @license MIT
|
|
6
4
|
*
|
|
7
5
|
* Four-step pipeline:
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
6
|
+
*
|
|
7
|
+
* 1. `resolve` — fetch JSON source (or accept raw object)
|
|
8
|
+
* 2. `buildScope` — state detection + reactive proxy construction
|
|
9
|
+
* 3. `render` — walk resolved tree, build DOM, wire reactive effects
|
|
10
|
+
* 4. `output` — append to target
|
|
12
11
|
*
|
|
13
12
|
* @module jx
|
|
13
|
+
* @license MIT
|
|
14
|
+
* @version 3.0.0
|
|
14
15
|
*/
|
|
15
16
|
|
|
16
17
|
import {
|
|
@@ -38,38 +39,52 @@ import {
|
|
|
38
39
|
import { evaluateExpression, evaluateOperand, isMutating } from "./expression.ts";
|
|
39
40
|
import { readPath } from "./pointer.ts";
|
|
40
41
|
import type {
|
|
41
|
-
DynamicClass,
|
|
42
42
|
JxContext,
|
|
43
43
|
JxEventHandler,
|
|
44
|
+
JxHostScope,
|
|
44
45
|
JxMount,
|
|
45
46
|
JxMountOptions,
|
|
46
47
|
JxPath,
|
|
47
48
|
JxRenderOptions,
|
|
49
|
+
JxRowContext,
|
|
48
50
|
JxScope,
|
|
51
|
+
JxScopeFunction,
|
|
52
|
+
JxScopeValue,
|
|
49
53
|
} from "./types.ts";
|
|
50
54
|
import {
|
|
51
55
|
bodyReturnsValue,
|
|
52
56
|
hasStructuredBody,
|
|
57
|
+
isBoolean,
|
|
53
58
|
isExpressionDef,
|
|
59
|
+
isFunction,
|
|
54
60
|
isFunctionDef,
|
|
55
61
|
isJsonObject,
|
|
56
62
|
isMappedArray,
|
|
57
63
|
isNamedFormulaDef,
|
|
64
|
+
isNestedStyle,
|
|
65
|
+
isNonNullObject,
|
|
66
|
+
isNumber,
|
|
58
67
|
isPrivateStateKey,
|
|
59
68
|
isPrototypeDef,
|
|
60
69
|
isRef as isRefValue,
|
|
61
70
|
isServerFnDef,
|
|
71
|
+
isString,
|
|
62
72
|
isTagExpression,
|
|
63
73
|
isTemplateString,
|
|
64
74
|
paramNames,
|
|
65
75
|
} from "@jxsuite/schema/guards";
|
|
76
|
+
import type { TagNameSlot } from "@jxsuite/schema/guards";
|
|
66
77
|
import { formatSrcset, parseSrcset } from "@jxsuite/schema/asset-paths";
|
|
67
78
|
import { runStatements } from "./statements.ts";
|
|
68
79
|
import { readCookie, serializeCookie } from "./cookie.ts";
|
|
69
80
|
import type {
|
|
81
|
+
CemParameter,
|
|
82
|
+
JsonValue,
|
|
70
83
|
JxAttributeValue,
|
|
84
|
+
JxClassCtorDef,
|
|
71
85
|
JxClassDef,
|
|
72
86
|
JxDocument,
|
|
87
|
+
JxDocumentValue,
|
|
73
88
|
JxElement,
|
|
74
89
|
JxFunctionDef,
|
|
75
90
|
JxHeadEntry,
|
|
@@ -105,8 +120,8 @@ let _schemaVersionWarned = false;
|
|
|
105
120
|
* runtime supports. A relative or non-hosted `$schema` (local dev schema paths) is ignored. This is
|
|
106
121
|
* a diagnostic only — there is no document-format migration story yet (see spec §3.2 / §21.4).
|
|
107
122
|
*/
|
|
108
|
-
function checkSchemaVersion($schema:
|
|
109
|
-
if (_schemaVersionWarned ||
|
|
123
|
+
function checkSchemaVersion($schema: JxDocumentValue | undefined): void {
|
|
124
|
+
if (_schemaVersionWarned || !isString($schema)) {
|
|
110
125
|
return;
|
|
111
126
|
}
|
|
112
127
|
const match = $schema.match(/\/schema\/v(\d+)\b/);
|
|
@@ -126,7 +141,7 @@ export async function Jx(
|
|
|
126
141
|
) {
|
|
127
142
|
const base = options?.base
|
|
128
143
|
? new URL(options.base, location.href).href
|
|
129
|
-
:
|
|
144
|
+
: isString(source)
|
|
130
145
|
? new URL(source, location.href).href
|
|
131
146
|
: location.href;
|
|
132
147
|
const doc = await resolve(source);
|
|
@@ -138,18 +153,57 @@ export async function Jx(
|
|
|
138
153
|
_rootMedia = doc.$media;
|
|
139
154
|
}
|
|
140
155
|
|
|
141
|
-
const
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
156
|
+
const mountOptions: JxMountOptions = { base };
|
|
157
|
+
if (options?.onNodeCreated) {
|
|
158
|
+
mountOptions.onNodeCreated = options.onNodeCreated;
|
|
159
|
+
}
|
|
160
|
+
if (options?.signal) {
|
|
161
|
+
mountOptions.signal = options.signal;
|
|
162
|
+
}
|
|
163
|
+
const mounted = await mount(doc, target, mountOptions);
|
|
146
164
|
return mounted.scope;
|
|
147
165
|
}
|
|
148
166
|
|
|
167
|
+
/**
|
|
168
|
+
* A lifecycle hook a document's state supplies (`onMount`, `onUnmount`, `onAdopted`) as the runtime
|
|
169
|
+
* calls it: with the scope, plus the host element for a defined element's `onMount`, which also
|
|
170
|
+
* receives the host as `this`.
|
|
171
|
+
*/
|
|
172
|
+
type ScopeHook = (
|
|
173
|
+
this: HTMLElement | JxScope | undefined,
|
|
174
|
+
scope: JxScope,
|
|
175
|
+
host?: HTMLElement,
|
|
176
|
+
) => void;
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Whether a scope member can be called as a {@link ScopeHook}. Being a function is the whole
|
|
180
|
+
* contract: JavaScript checks neither the arguments a call passes nor what it returns, so every
|
|
181
|
+
* function accepts this call, and the runtime discards whatever it returns.
|
|
182
|
+
*/
|
|
183
|
+
function isScopeHook(value: unknown): value is ScopeHook {
|
|
184
|
+
return isFunction(value);
|
|
185
|
+
}
|
|
186
|
+
|
|
149
187
|
/** Host-scope keys the runtime owns: `$media`, `$map`, the `$`-locals and `#`-private entries. */
|
|
150
188
|
const HOST_SCOPE_RESERVED = /^[$#]/;
|
|
151
189
|
|
|
152
|
-
|
|
190
|
+
/**
|
|
191
|
+
* A host's scope as the runtime merges it: the host's own record, typed as the {@link JxScope} it
|
|
192
|
+
* joins.
|
|
193
|
+
*
|
|
194
|
+
* @param {S} scope - The host's scope, held to `JxScopeValue` member by member by its type
|
|
195
|
+
* @returns {JxScope}
|
|
196
|
+
*/
|
|
197
|
+
function hostScopeOf<S extends JxHostScope<S>>(scope: S): JxScope {
|
|
198
|
+
/* SAFETY: `JxHostScope<S>` has already held every member of `S` to `JxScopeValue`, recursively
|
|
199
|
+
through arrays, records and function results. What TypeScript cannot conclude from that is an
|
|
200
|
+
index signature, which an interface never has implicitly, so a host's own interface is not
|
|
201
|
+
assignable to `JxScope` however its members are typed. Nothing is copied or converted: this is
|
|
202
|
+
the host's record, read by member name like every other scope. */
|
|
203
|
+
return scope as JxScope;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function assertHostScope(scope: JxScope): void {
|
|
153
207
|
for (const key of Object.keys(scope)) {
|
|
154
208
|
if (HOST_SCOPE_RESERVED.test(key)) {
|
|
155
209
|
throw new TypeError(
|
|
@@ -175,26 +229,28 @@ function assertHostScope(scope: Record<string, unknown>): void {
|
|
|
175
229
|
* @returns {Promise<JxMount>}
|
|
176
230
|
* @docs extending/embedding/runtime-host
|
|
177
231
|
*/
|
|
178
|
-
export async function mount(
|
|
232
|
+
export async function mount<S extends JxHostScope<S> = JxScope>(
|
|
179
233
|
doc: JxDocument,
|
|
180
234
|
target: ParentNode,
|
|
181
|
-
options: JxMountOptions = {},
|
|
235
|
+
options: JxMountOptions<S> = {},
|
|
182
236
|
): Promise<JxMount> {
|
|
183
|
-
const hostScope = options.scope
|
|
237
|
+
const hostScope: JxScope = options.scope ? hostScopeOf(options.scope) : {};
|
|
184
238
|
assertHostScope(hostScope);
|
|
185
239
|
const base = options.base ? new URL(options.base, location.href).href : location.href;
|
|
186
240
|
const ctx: JxContext = {
|
|
187
241
|
base,
|
|
188
242
|
media: options.media ?? doc.$media ?? null,
|
|
189
243
|
root: null,
|
|
190
|
-
...(options.resolver ? { resolver: options.resolver } : {}),
|
|
191
|
-
...(options.skipServerFunctions === undefined
|
|
192
|
-
? {}
|
|
193
|
-
: { skipServerFunctions: options.skipServerFunctions }),
|
|
194
|
-
...(options.skipAutoRequests === undefined
|
|
195
|
-
? {}
|
|
196
|
-
: { skipAutoRequests: options.skipAutoRequests }),
|
|
197
244
|
};
|
|
245
|
+
if (options.resolver) {
|
|
246
|
+
ctx.resolver = options.resolver;
|
|
247
|
+
}
|
|
248
|
+
if (options.skipServerFunctions !== undefined) {
|
|
249
|
+
ctx.skipServerFunctions = options.skipServerFunctions;
|
|
250
|
+
}
|
|
251
|
+
if (options.skipAutoRequests !== undefined) {
|
|
252
|
+
ctx.skipAutoRequests = options.skipAutoRequests;
|
|
253
|
+
}
|
|
198
254
|
checkSchemaVersion(doc.$schema);
|
|
199
255
|
|
|
200
256
|
// Register custom elements declared in $elements (depth-first)
|
|
@@ -218,8 +274,10 @@ export async function mount(
|
|
|
218
274
|
}
|
|
219
275
|
options.onNodeCreated?.(el, path, def, state);
|
|
220
276
|
},
|
|
221
|
-
...(options.onNodeMoved ? { onNodeMoved: options.onNodeMoved } : {}),
|
|
222
277
|
};
|
|
278
|
+
if (options.onNodeMoved) {
|
|
279
|
+
renderOptions.onNodeMoved = options.onNodeMoved;
|
|
280
|
+
}
|
|
223
281
|
const { result: root, stop } = runScoped(() => renderNode(doc, scope, renderOptions));
|
|
224
282
|
ctx.root = root;
|
|
225
283
|
|
|
@@ -231,11 +289,18 @@ export async function mount(
|
|
|
231
289
|
}
|
|
232
290
|
disposed = true;
|
|
233
291
|
options.signal?.removeEventListener("abort", dispose);
|
|
292
|
+
/* The root leaves the document BEFORE its effects stop (embedding.md §2.2). Stopping releases
|
|
293
|
+
each element's styles, and on a connected subtree every one of those inline-property and
|
|
294
|
+
`data-jx` writes schedules a style invalidation for a node that is leaving anyway: a
|
|
295
|
+
Publish tab switch spent 22 ms there, and a dialog's close forced a recalculation of the
|
|
296
|
+
subtree it was removing. It is also the order a custom element's own `onUnmount` sees,
|
|
297
|
+
which runs from `disconnectedCallback` (spec.md §16.4). */
|
|
298
|
+
root.remove();
|
|
234
299
|
stop();
|
|
235
|
-
|
|
236
|
-
|
|
300
|
+
// Called as a method, so a hook that reads `this` sees the scope, as it always has.
|
|
301
|
+
if (mounted && isScopeHook(scope.onUnmount)) {
|
|
302
|
+
scope.onUnmount(scope);
|
|
237
303
|
}
|
|
238
|
-
root.remove();
|
|
239
304
|
};
|
|
240
305
|
|
|
241
306
|
if (options.signal?.aborted) {
|
|
@@ -245,8 +310,8 @@ export async function mount(
|
|
|
245
310
|
options.signal?.addEventListener("abort", dispose, { once: true });
|
|
246
311
|
target.append(root);
|
|
247
312
|
mounted = true;
|
|
248
|
-
if (
|
|
249
|
-
|
|
313
|
+
if (isScopeHook(scope.onMount)) {
|
|
314
|
+
scope.onMount(scope);
|
|
250
315
|
}
|
|
251
316
|
}
|
|
252
317
|
return { scope, root, elements, dispose };
|
|
@@ -278,8 +343,20 @@ export function preloadDocument(url: string, doc: JxDocument): void {
|
|
|
278
343
|
}
|
|
279
344
|
}
|
|
280
345
|
|
|
346
|
+
/**
|
|
347
|
+
* A response's Content-Type, or `""` when it carries no headers at all. A response we have no
|
|
348
|
+
* EVIDENCE about is one we proceed with rather than guess. Every real `Response` has headers; a
|
|
349
|
+
* host's or a test's `fetch` stand-in may not, which is what the parameter type admits.
|
|
350
|
+
*
|
|
351
|
+
* @param res The response, or a stand-in for one
|
|
352
|
+
* @returns {string}
|
|
353
|
+
*/
|
|
354
|
+
function responseContentType(res: Partial<Pick<Response, "headers">>): string {
|
|
355
|
+
return res.headers?.get("Content-Type") ?? "";
|
|
356
|
+
}
|
|
357
|
+
|
|
281
358
|
export async function resolve(source: string | JxDocument, ctx?: JxContext): Promise<JxDocument> {
|
|
282
|
-
if (
|
|
359
|
+
if (!isString(source)) {
|
|
283
360
|
return source;
|
|
284
361
|
}
|
|
285
362
|
// A mount's resolver answers for THIS mount and its hit never enters the shared cache.
|
|
@@ -304,16 +381,16 @@ export async function resolve(source: string | JxDocument, ctx?: JxContext): Pro
|
|
|
304
381
|
* Checked on the content type rather than by sniffing the body: a host that says `text/html`
|
|
305
382
|
* has told us plainly, and saying so costs nothing.
|
|
306
383
|
*/
|
|
307
|
-
|
|
308
|
-
// About, and guessing is worse than proceeding. Every real `Response` has them.
|
|
309
|
-
const contentType = res.headers?.get("Content-Type") ?? "";
|
|
384
|
+
const contentType = responseContentType(res);
|
|
310
385
|
if (contentType.includes("text/html")) {
|
|
311
386
|
throw new Error(
|
|
312
387
|
`Jx: ${source} returned HTML, not a document — the host answered a missing file with its ` +
|
|
313
388
|
`app shell (a single-page fallback), so this path is not served.`,
|
|
314
389
|
);
|
|
315
390
|
}
|
|
316
|
-
|
|
391
|
+
/* SAFETY: trust boundary. `resolve` fetches only URLs that name a Jx document (a `Jx()` source,
|
|
392
|
+
an `$elements` entry, a `$switch` case), and the host answered 2xx with a non-HTML body, so
|
|
393
|
+
the JSON is that document; the runtime does not schema-validate documents by design. */
|
|
317
394
|
return (await res.json()) as JxDocument;
|
|
318
395
|
});
|
|
319
396
|
_resolveCache.set(source, p);
|
|
@@ -377,11 +454,6 @@ function resolveProxyPath(path: string): string {
|
|
|
377
454
|
return _resolveToken ? `${path}?token=${encodeURIComponent(_resolveToken)}` : path;
|
|
378
455
|
}
|
|
379
456
|
|
|
380
|
-
/** @deprecated No longer needed — ContentCollection/ContentEntry resolve via generic class path */
|
|
381
|
-
export function setSkipContentResolution(_v: boolean) {
|
|
382
|
-
// No-op retained for API compatibility
|
|
383
|
-
}
|
|
384
|
-
|
|
385
457
|
/**
|
|
386
458
|
* Observe runtime-created reactive values: runs `fn` immediately inside a reactive effect and
|
|
387
459
|
* re-runs it whenever a tracked value changes; returns a disposer. Dep tracking in @vue/reactivity
|
|
@@ -400,6 +472,12 @@ export function observeScope(fn: () => void): () => void {
|
|
|
400
472
|
return () => scope.stop();
|
|
401
473
|
}
|
|
402
474
|
|
|
475
|
+
/** What {@link runScoped} hands back: the callback's result, and the disposer for its effects. */
|
|
476
|
+
export interface ScopedRun<T> {
|
|
477
|
+
result: T;
|
|
478
|
+
stop: () => void;
|
|
479
|
+
}
|
|
480
|
+
|
|
403
481
|
/**
|
|
404
482
|
* Run `fn` inside a detached reactive scope of THIS module's reactivity instance, returning its
|
|
405
483
|
* result plus a disposer that stops every effect `fn` created. Callers that render via
|
|
@@ -409,11 +487,13 @@ export function observeScope(fn: () => void): () => void {
|
|
|
409
487
|
* its stop() silently leaks every binding effect of the superseded render.
|
|
410
488
|
*
|
|
411
489
|
* @param {() => T} fn - Work that may create reactive effects (typically a renderNode call).
|
|
412
|
-
* @returns {
|
|
490
|
+
* @returns {ScopedRun<T>} The callback result and the scope disposer.
|
|
413
491
|
*/
|
|
414
|
-
export function runScoped<T>(fn: () => T):
|
|
492
|
+
export function runScoped<T>(fn: () => T): ScopedRun<T> {
|
|
415
493
|
const scope = effectScope(true);
|
|
416
494
|
try {
|
|
495
|
+
/* SAFETY: `run` answers undefined only for a scope that was already stopped, and this one was
|
|
496
|
+
created on the line above, so it is active and `run` returns what `fn` returned. */
|
|
417
497
|
const result = scope.run(fn) as T;
|
|
418
498
|
return { result, stop: () => scope.stop() };
|
|
419
499
|
} catch (error) {
|
|
@@ -437,14 +517,14 @@ export function setCanvasViewportTranspose(on: boolean) {
|
|
|
437
517
|
}
|
|
438
518
|
|
|
439
519
|
const VIEWPORT_UNIT_RE = /(-?\d*\.?\d+)(?:s|l|d)?v(h|w|min|max|i|b)\b/gi;
|
|
440
|
-
const VIEWPORT_UNIT_MAP
|
|
441
|
-
b
|
|
442
|
-
h
|
|
443
|
-
i
|
|
444
|
-
max
|
|
445
|
-
min
|
|
446
|
-
w
|
|
447
|
-
|
|
520
|
+
const VIEWPORT_UNIT_MAP = new Map([
|
|
521
|
+
["b", "cqb"],
|
|
522
|
+
["h", "cqh"],
|
|
523
|
+
["i", "cqi"],
|
|
524
|
+
["max", "cqmax"],
|
|
525
|
+
["min", "cqmin"],
|
|
526
|
+
["w", "cqw"],
|
|
527
|
+
]);
|
|
448
528
|
|
|
449
529
|
/**
|
|
450
530
|
* Transpose CSS viewport units → container-query units in a value string, but only when the
|
|
@@ -457,7 +537,8 @@ export function transposeCanvasUnits(value: string): string {
|
|
|
457
537
|
}
|
|
458
538
|
return value.replace(
|
|
459
539
|
VIEWPORT_UNIT_RE,
|
|
460
|
-
(_m, num: string, dim: string) =>
|
|
540
|
+
(_m, num: string, dim: string) =>
|
|
541
|
+
`${num}${VIEWPORT_UNIT_MAP.get(dim.toLowerCase()) ?? `cq${dim}`}`,
|
|
461
542
|
);
|
|
462
543
|
}
|
|
463
544
|
|
|
@@ -656,15 +737,15 @@ function canvasAssetValue(tagName: string, key: string, value: string): string {
|
|
|
656
737
|
const name = key.toLowerCase();
|
|
657
738
|
if (SRCSET_KEYS.has(name)) {
|
|
658
739
|
const candidates = parseSrcset(value);
|
|
659
|
-
let changed = false;
|
|
660
740
|
const next = candidates.map((candidate) => {
|
|
661
741
|
const resolved = _canvasAssetResolver?.(candidate.url, name) ?? null;
|
|
662
742
|
if (resolved === null || resolved === candidate.url) {
|
|
663
743
|
return candidate;
|
|
664
744
|
}
|
|
665
|
-
changed = true;
|
|
666
745
|
return { descriptor: candidate.descriptor, url: resolved };
|
|
667
746
|
});
|
|
747
|
+
// A rewritten candidate is a new object, so identity says whether anything changed.
|
|
748
|
+
const changed = next.some((candidate, index) => candidate !== candidates[index]);
|
|
668
749
|
return changed ? formatSrcset(next) : value;
|
|
669
750
|
}
|
|
670
751
|
if (!isAssetKey(tagName, name)) {
|
|
@@ -726,18 +807,22 @@ export function setStampPropBindings(on: boolean) {
|
|
|
726
807
|
const PURE_STATE_TEMPLATE = /^\s*\$\{\s*state\.(\w+)\s*\}\s*$/;
|
|
727
808
|
|
|
728
809
|
/** The state key of an invertible `textContent` binding, or null (mixed template, deep path). */
|
|
729
|
-
function boundPropKey(
|
|
810
|
+
function boundPropKey(
|
|
811
|
+
key: string,
|
|
812
|
+
val: JxDocumentValue | undefined,
|
|
813
|
+
state: JxScope,
|
|
814
|
+
): string | null {
|
|
730
815
|
if (!_stampPropBindings || key !== "textContent") {
|
|
731
816
|
return null;
|
|
732
817
|
}
|
|
733
818
|
let sub: string | null = null;
|
|
734
|
-
if (
|
|
819
|
+
if (isRefValue(val)) {
|
|
735
820
|
if (!val.$ref.startsWith("#/state/")) {
|
|
736
821
|
return null;
|
|
737
822
|
}
|
|
738
823
|
const rest = val.$ref.slice("#/state/".length);
|
|
739
824
|
sub = rest && !rest.includes("/") ? rest : null;
|
|
740
|
-
} else if (
|
|
825
|
+
} else if (isString(val)) {
|
|
741
826
|
sub = PURE_STATE_TEMPLATE.exec(val)?.[1] ?? null;
|
|
742
827
|
}
|
|
743
828
|
if (!sub) {
|
|
@@ -747,11 +832,11 @@ function boundPropKey(key: string, val: unknown, state: JxScope): string | null
|
|
|
747
832
|
// In the raw scope (auto-unwrapped by the reactive proxy), and function/object entries (handlers,
|
|
748
833
|
// $prototype instances) are not prop-overridable either — an instance $props write against any of
|
|
749
834
|
// Them would clobber the definition's behavior, so their bindings are never marked editable.
|
|
750
|
-
const stored =
|
|
751
|
-
if (isRef(stored) ||
|
|
835
|
+
const stored = toRaw(state)[sub];
|
|
836
|
+
if (isRef(stored) || isFunction(stored)) {
|
|
752
837
|
return null;
|
|
753
838
|
}
|
|
754
|
-
if (
|
|
839
|
+
if (isNonNullObject(stored)) {
|
|
755
840
|
return null;
|
|
756
841
|
}
|
|
757
842
|
return sub;
|
|
@@ -789,7 +874,7 @@ export async function buildScope(
|
|
|
789
874
|
configurable: true,
|
|
790
875
|
enumerable: true,
|
|
791
876
|
get: () => parentScope[key],
|
|
792
|
-
set: (value:
|
|
877
|
+
set: (value: JxScopeValue) => {
|
|
793
878
|
parentScope[key] = value;
|
|
794
879
|
},
|
|
795
880
|
});
|
|
@@ -818,7 +903,7 @@ export async function buildScope(
|
|
|
818
903
|
// First pass: collect naked values, expanded defaults, plain objects
|
|
819
904
|
for (const [key, def] of Object.entries(defs)) {
|
|
820
905
|
// 1. String value
|
|
821
|
-
if (
|
|
906
|
+
if (isString(def)) {
|
|
822
907
|
if (!def.includes("${")) {
|
|
823
908
|
raw[key] = def;
|
|
824
909
|
} // Shape 1: naked string
|
|
@@ -826,7 +911,7 @@ export async function buildScope(
|
|
|
826
911
|
}
|
|
827
912
|
|
|
828
913
|
// 2. Number, boolean, null
|
|
829
|
-
if (
|
|
914
|
+
if (isNumber(def) || isBoolean(def) || def === null) {
|
|
830
915
|
raw[key] = def;
|
|
831
916
|
continue;
|
|
832
917
|
}
|
|
@@ -838,7 +923,7 @@ export async function buildScope(
|
|
|
838
923
|
}
|
|
839
924
|
|
|
840
925
|
// 4. Object
|
|
841
|
-
if (
|
|
926
|
+
if (isNonNullObject(def)) {
|
|
842
927
|
if (def.$prototype) {
|
|
843
928
|
continue;
|
|
844
929
|
} // Handled in later passes
|
|
@@ -862,10 +947,10 @@ export async function buildScope(
|
|
|
862
947
|
// Wrap in Vue reactive proxy — deep reactivity from this point on
|
|
863
948
|
const state = reactive(raw);
|
|
864
949
|
|
|
865
|
-
// Second pass: template strings → computed
|
|
950
|
+
// Second pass: template strings → computed, a single expression keeping its own type (§5.3)
|
|
866
951
|
for (const [key, def] of Object.entries(defs)) {
|
|
867
|
-
if (
|
|
868
|
-
state[key] = computed(() =>
|
|
952
|
+
if (isString(def) && def.includes("${")) {
|
|
953
|
+
state[key] = computed(() => evaluateValueTemplate(def, state));
|
|
869
954
|
}
|
|
870
955
|
}
|
|
871
956
|
|
|
@@ -875,21 +960,9 @@ export async function buildScope(
|
|
|
875
960
|
const node = def.$expression;
|
|
876
961
|
if (isNamedFormulaDef(def)) {
|
|
877
962
|
// A named formula: callable with positional args mapped onto its declared parameters.
|
|
878
|
-
const params = def.parameters
|
|
879
|
-
state[key] = (...argValues:
|
|
880
|
-
|
|
881
|
-
for (const [i, p] of params.entries()) {
|
|
882
|
-
const name = typeof p === "string" ? p : (p?.name ?? "");
|
|
883
|
-
if (!name) {
|
|
884
|
-
continue;
|
|
885
|
-
}
|
|
886
|
-
args[name] =
|
|
887
|
-
argValues[i] === undefined && typeof p === "object" && p !== null && "default" in p
|
|
888
|
-
? p.default
|
|
889
|
-
: argValues[i];
|
|
890
|
-
}
|
|
891
|
-
return evaluateExpression(node, state, null, { args });
|
|
892
|
-
};
|
|
963
|
+
const params = def.parameters;
|
|
964
|
+
state[key] = (...argValues: JxScopeValue[]) =>
|
|
965
|
+
evaluateExpression(node, state, null, { args: bindPositionalArgs(params, argValues) });
|
|
893
966
|
} else if (isMutating(node.operator)) {
|
|
894
967
|
const handler: JxEventHandler = (s, event) => evaluateExpression(node, s, event);
|
|
895
968
|
state[key] = handler;
|
|
@@ -906,23 +979,16 @@ export async function buildScope(
|
|
|
906
979
|
// $args names (invoked via the call operator); without → an event handler.
|
|
907
980
|
const { body } = def;
|
|
908
981
|
if (Array.isArray(def.parameters) && def.parameters.length > 0) {
|
|
909
|
-
const params = def.parameters
|
|
910
|
-
state[key] = (...argValues:
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
continue;
|
|
916
|
-
}
|
|
917
|
-
args[name] =
|
|
918
|
-
argValues[i] === undefined && typeof p === "object" && p !== null && "default" in p
|
|
919
|
-
? p.default
|
|
920
|
-
: argValues[i];
|
|
921
|
-
}
|
|
922
|
-
return runStatements(body, state, null, { args, target: dispatchRoot });
|
|
923
|
-
};
|
|
982
|
+
const params = def.parameters;
|
|
983
|
+
state[key] = (...argValues: JxScopeValue[]) =>
|
|
984
|
+
runStatements(body, state, null, {
|
|
985
|
+
args: bindPositionalArgs(params, argValues),
|
|
986
|
+
target: dispatchRoot,
|
|
987
|
+
});
|
|
924
988
|
} else {
|
|
925
|
-
|
|
989
|
+
/* The event is optional in fact: a `call` operator or a host invokes a handler with the
|
|
990
|
+
scope alone, and the body then runs with no event rather than an undefined one. */
|
|
991
|
+
const handler = (s: JxScope, event?: Event | null): void => {
|
|
926
992
|
void runStatements(body, s, event ?? null, { target: dispatchRoot });
|
|
927
993
|
};
|
|
928
994
|
state[key] = handler;
|
|
@@ -939,7 +1005,8 @@ export async function buildScope(
|
|
|
939
1005
|
}
|
|
940
1006
|
}
|
|
941
1007
|
|
|
942
|
-
|
|
1008
|
+
/* Fifth pass: timing: "server" entries, each called through the host's /__jx_server__ proxy, or
|
|
1009
|
+
in process only when the host registered the module itself (spec.md §11.4) */
|
|
943
1010
|
if (!(ctx?.skipServerFunctions ?? _serverFnConfig.skip)) {
|
|
944
1011
|
for (const [key, def] of Object.entries(defs)) {
|
|
945
1012
|
if (isServerFnDef(def)) {
|
|
@@ -958,14 +1025,42 @@ export async function buildScope(
|
|
|
958
1025
|
return state;
|
|
959
1026
|
}
|
|
960
1027
|
|
|
1028
|
+
/**
|
|
1029
|
+
* Map a call's positional arguments onto a definition's declared parameter names, as `$args/<name>`
|
|
1030
|
+
* refs read them. A parameter given as an object supplies its `default` for a position the call
|
|
1031
|
+
* left undefined. Parameters come from a document that is not schema-validated at runtime, so an
|
|
1032
|
+
* entry that names nothing (null, or an object without a `name`) binds nothing.
|
|
1033
|
+
*
|
|
1034
|
+
* @param parameters The declared parameters, in call order
|
|
1035
|
+
* @param argValues The values the call passed, in the same order
|
|
1036
|
+
* @returns {JxScope} Name to value, for the body's `$args` refs
|
|
1037
|
+
*/
|
|
1038
|
+
function bindPositionalArgs(
|
|
1039
|
+
parameters: readonly (string | CemParameter | null)[],
|
|
1040
|
+
argValues: readonly JxScopeValue[],
|
|
1041
|
+
): JxScope {
|
|
1042
|
+
const args: JxScope = {};
|
|
1043
|
+
for (const [i, p] of parameters.entries()) {
|
|
1044
|
+
const name = isString(p) ? p : (p?.name ?? "");
|
|
1045
|
+
if (!name) {
|
|
1046
|
+
continue;
|
|
1047
|
+
}
|
|
1048
|
+
args[name] =
|
|
1049
|
+
argValues[i] === undefined && p !== null && !isString(p) && "default" in p
|
|
1050
|
+
? p.default
|
|
1051
|
+
: argValues[i];
|
|
1052
|
+
}
|
|
1053
|
+
return args;
|
|
1054
|
+
}
|
|
1055
|
+
|
|
961
1056
|
/**
|
|
962
1057
|
* Check whether an object contains any JSON Schema keywords. Used to discriminate Shape 2b (pure
|
|
963
1058
|
* type definition) from Shape 1 (naked object).
|
|
964
1059
|
*
|
|
965
|
-
* @param
|
|
1060
|
+
* @param obj A state entry object, as the document wrote it
|
|
966
1061
|
* @returns {boolean}
|
|
967
1062
|
*/
|
|
968
|
-
function hasSchemaKeywords(obj:
|
|
1063
|
+
function hasSchemaKeywords(obj: Readonly<Record<string, JxDocumentValue | undefined>>) {
|
|
969
1064
|
for (const k of Object.keys(obj)) {
|
|
970
1065
|
if (SCHEMA_KEYWORDS.has(k)) {
|
|
971
1066
|
return true;
|
|
@@ -975,23 +1070,151 @@ function hasSchemaKeywords(obj: object) {
|
|
|
975
1070
|
}
|
|
976
1071
|
export { hasSchemaKeywords };
|
|
977
1072
|
|
|
1073
|
+
/**
|
|
1074
|
+
* The names a template may read without a `state.` prefix, in the order {@link templateArgs}
|
|
1075
|
+
* supplies them.
|
|
1076
|
+
*
|
|
1077
|
+
* `$site` and `$page` are here for the reason `evaluateStaticTemplate` in the compiler binds them:
|
|
1078
|
+
* every example in site-architecture.md §8.2 and §13.4 writes the bare `${$page.locale}`, and
|
|
1079
|
+
* `injectContext` (or the Studio canvas) puts the two on the document's state, not in the
|
|
1080
|
+
* template's lexical scope. Without them the live runtime threw `ReferenceError: $page is not
|
|
1081
|
+
* defined` on a template the build renders, so a page that prerendered correctly drew nothing where
|
|
1082
|
+
* it hydrated or rendered live.
|
|
1083
|
+
*/
|
|
1084
|
+
const TEMPLATE_PARAMS = ["state", "$map", "item", "index", "$site", "$page"] as const;
|
|
1085
|
+
|
|
1086
|
+
/** The values {@link TEMPLATE_PARAMS} name, read from one scope. */
|
|
1087
|
+
function templateArgs(state: JxScope): Parameters<TemplateFn<unknown>> {
|
|
1088
|
+
const $map = rowContext(state);
|
|
1089
|
+
return [state, $map, $map?.item, $map?.index, state.$site, state.$page];
|
|
1090
|
+
}
|
|
1091
|
+
|
|
978
1092
|
/**
|
|
979
1093
|
* Evaluate a template string in the context of state and optional $map. Templates use
|
|
980
|
-
* `state.varName
|
|
1094
|
+
* `state.varName`, `$map.item` (or `item`) and the page context `$page` / `$site`.
|
|
981
1095
|
*
|
|
982
1096
|
* @param {string} str
|
|
983
1097
|
* @param {JxScope} state
|
|
984
1098
|
* @returns {string}
|
|
985
1099
|
*/
|
|
986
1100
|
function evaluateTemplate(str: string, state: JxScope): string {
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
return fn(state
|
|
1101
|
+
let fn = textTemplates.get(str);
|
|
1102
|
+
if (!fn) {
|
|
1103
|
+
/* SAFETY: `new Function` compiles exactly the TEMPLATE_PARAMS, and its body returns a template
|
|
1104
|
+
literal, so every call answers a string (or throws, which the caller sees). */
|
|
1105
|
+
fn = new Function(...TEMPLATE_PARAMS, `return \`${str}\``) as TemplateFn<string>;
|
|
1106
|
+
remember(textTemplates, str, fn);
|
|
1107
|
+
}
|
|
1108
|
+
return fn(...templateArgs(state));
|
|
1109
|
+
}
|
|
1110
|
+
|
|
1111
|
+
/**
|
|
1112
|
+
* Compiled templates, by source. A compiled template closes over nothing — everything it reads
|
|
1113
|
+
* arrives as one of its TEMPLATE_PARAMS — so one function per distinct source serves every binding
|
|
1114
|
+
* that spells it. Compiling is the expensive half: a `$map` row re-bound to a fresh item re-runs
|
|
1115
|
+
* each of its template effects, and a 40-row window re-binding on every scroll step spent seconds
|
|
1116
|
+
* in `new Function` alone. A source that fails to compile throws before it is stored, so it throws
|
|
1117
|
+
* again on the next evaluation exactly as it did before the cache existed.
|
|
1118
|
+
*/
|
|
1119
|
+
const textTemplates = new Map<string, TemplateFn<string>>();
|
|
1120
|
+
|
|
1121
|
+
/** {@link textTemplates}, for single-expression templates compiled to keep their own type. */
|
|
1122
|
+
const valueTemplates = new Map<string, TemplateFn<JxScopeValue>>();
|
|
1123
|
+
|
|
1124
|
+
/**
|
|
1125
|
+
* How many sources a template cache holds before it starts over. Editing mints a new source per
|
|
1126
|
+
* keystroke, so the cache is bounded; clearing it wholesale costs one recompile per live binding,
|
|
1127
|
+
* which is what every evaluation cost before there was a cache.
|
|
1128
|
+
*/
|
|
1129
|
+
const TEMPLATE_CACHE_MAX = 4096;
|
|
1130
|
+
|
|
1131
|
+
/**
|
|
1132
|
+
* Store a compiled template, emptying the cache first when it is full.
|
|
1133
|
+
*
|
|
1134
|
+
* @param {Map<string, F>} cache
|
|
1135
|
+
* @param {string} source
|
|
1136
|
+
* @param {F} fn
|
|
1137
|
+
*/
|
|
1138
|
+
function remember<F>(cache: Map<string, F>, source: string, fn: F): void {
|
|
1139
|
+
if (cache.size >= TEMPLATE_CACHE_MAX) {
|
|
1140
|
+
cache.clear();
|
|
1141
|
+
}
|
|
1142
|
+
cache.set(source, fn);
|
|
1143
|
+
}
|
|
1144
|
+
|
|
1145
|
+
/** Template sources already reported as throwing, so a binding re-run per keystroke warns once. */
|
|
1146
|
+
const reportedTemplates = new Set<string>();
|
|
1147
|
+
|
|
1148
|
+
/** Who hears a throwing template binding, when a host asked to; otherwise the console does. */
|
|
1149
|
+
let _templateErrorHandler: ((error: Error, source: string) => void) | null = null;
|
|
1150
|
+
|
|
1151
|
+
/**
|
|
1152
|
+
* Route throwing template bindings to `handler` instead of a console warning, or back with `null`.
|
|
1153
|
+
*
|
|
1154
|
+
* A binding survives its expression throwing (§6.1), which is right for a page and wrong for a host
|
|
1155
|
+
* whose question IS whether the document renders: Studio's render critic must still refuse a page
|
|
1156
|
+
* whose `${nonExistent}` can never evaluate. Every throw is delivered, not only the first.
|
|
1157
|
+
*
|
|
1158
|
+
* @param {((error: Error, source: string) => void) | null} handler
|
|
1159
|
+
*/
|
|
1160
|
+
export function setTemplateErrorHandler(
|
|
1161
|
+
handler: ((error: Error, source: string) => void) | null,
|
|
1162
|
+
): void {
|
|
1163
|
+
_templateErrorHandler = handler;
|
|
1164
|
+
}
|
|
1165
|
+
|
|
1166
|
+
/**
|
|
1167
|
+
* Run a template binding as an effect that survives its expression throwing.
|
|
1168
|
+
*
|
|
1169
|
+
* A binding is live: `${state.posts.length}` over a collection that resolves asynchronously reads
|
|
1170
|
+
* `null` first and an array a moment later. Unguarded, the first run's TypeError escaped `effect()`
|
|
1171
|
+
* — which stops the effect before rethrowing — so the binding never re-ran when the data arrived,
|
|
1172
|
+
* and the throw unwound the whole `renderNode` above it: one early read blanked the page. Caught
|
|
1173
|
+
* INSIDE the effect, every dependency read before the throw stays tracked, so the binding re-runs
|
|
1174
|
+
* on the change that makes it valid. Until then it shows `fallback`'s answer (nothing written, by
|
|
1175
|
+
* default), and the error is reported once per source.
|
|
1176
|
+
*
|
|
1177
|
+
* @param {string} source - The template, for the report
|
|
1178
|
+
* @param {() => void} run - Evaluates the template and writes it
|
|
1179
|
+
* @param {() => void} [fallback] - What a throwing evaluation writes instead
|
|
1180
|
+
*/
|
|
1181
|
+
function bindTemplate(source: string, run: () => void, fallback?: () => void): void {
|
|
1182
|
+
effect(() => {
|
|
1183
|
+
try {
|
|
1184
|
+
run();
|
|
1185
|
+
} catch (error) {
|
|
1186
|
+
if (_templateErrorHandler) {
|
|
1187
|
+
_templateErrorHandler(error instanceof Error ? error : new Error(String(error)), source);
|
|
1188
|
+
} else if (!reportedTemplates.has(source)) {
|
|
1189
|
+
reportedTemplates.add(source);
|
|
1190
|
+
console.warn(`jx: template ${JSON.stringify(source)} threw:`, error);
|
|
1191
|
+
}
|
|
1192
|
+
fallback?.();
|
|
1193
|
+
}
|
|
1194
|
+
});
|
|
1195
|
+
}
|
|
1196
|
+
|
|
1197
|
+
/** A compiled template: the scope, the row context, its item and index, and the page context. */
|
|
1198
|
+
type TemplateFn<R> = (
|
|
1199
|
+
state: JxScope,
|
|
1200
|
+
$map: JxRowContext | undefined,
|
|
1201
|
+
item: JxRowContext["item"],
|
|
1202
|
+
index: JxRowContext["index"] | undefined,
|
|
1203
|
+
$site: JxScopeValue,
|
|
1204
|
+
$page: JxScopeValue,
|
|
1205
|
+
) => R;
|
|
1206
|
+
|
|
1207
|
+
/**
|
|
1208
|
+
* The row context a scope carries, or undefined outside a mapped row.
|
|
1209
|
+
*
|
|
1210
|
+
* @param {JxScope} state
|
|
1211
|
+
* @returns {JxRowContext | undefined}
|
|
1212
|
+
*/
|
|
1213
|
+
function rowContext(state: JxScope): JxRowContext | undefined {
|
|
1214
|
+
/* SAFETY: `$map` is runtime vocabulary. A host scope may not supply it (`assertHostScope`), a
|
|
1215
|
+
node never binds it as a `$`-local (it is in RESERVED_KEYS), and the one writer is
|
|
1216
|
+
`renderMappedArrayInto`, which binds the `{ index, item }` row record; elsewhere it is absent. */
|
|
1217
|
+
return state.$map as JxRowContext | undefined;
|
|
995
1218
|
}
|
|
996
1219
|
|
|
997
1220
|
/**
|
|
@@ -1032,50 +1255,137 @@ export function isSingleExpression(str: string): boolean {
|
|
|
1032
1255
|
}
|
|
1033
1256
|
|
|
1034
1257
|
/**
|
|
1035
|
-
* Evaluate a template
|
|
1036
|
-
*
|
|
1037
|
-
*
|
|
1038
|
-
*
|
|
1039
|
-
*
|
|
1040
|
-
*
|
|
1258
|
+
* Evaluate a template as a VALUE — a state entry (Shape 3) or an attribute — keeping the
|
|
1259
|
+
* expression's own type.
|
|
1260
|
+
*
|
|
1261
|
+
* `evaluateTemplate` interpolates into a string, which is what text wants and what a value cannot
|
|
1262
|
+
* afford: `"${state.expanded}"` has to arrive as the boolean `false`, or `booleanAttrValue` never
|
|
1263
|
+
* sees a boolean, `open="false"` is written, and HTML reads that as open. A state entry is the same
|
|
1264
|
+
* case one step removed: `"isBrand": "${state.name === 'github'}"` held the string `"false"`, which
|
|
1265
|
+
* is truthy, so every `state.isBrand ? … : …` took the wrong branch in Studio and in a hydrated
|
|
1266
|
+
* component while the prerendered page took the right one. Only a single expression can carry a
|
|
1267
|
+
* type at all — `"a ${b}"` is text by construction — which is the same line
|
|
1041
1268
|
* `evaluateStaticTemplate` draws in the compiler, and drawing it identically is what keeps a
|
|
1042
1269
|
* prerendered page and the same document rendered live in agreement.
|
|
1043
1270
|
*
|
|
1044
1271
|
* @param {string} str - Template source
|
|
1045
1272
|
* @param {JxScope} state - Reactive scope
|
|
1046
|
-
* @returns {
|
|
1273
|
+
* @returns {JxScopeValue} The expression's value, or the interpolated string
|
|
1047
1274
|
*/
|
|
1048
|
-
function
|
|
1275
|
+
function evaluateValueTemplate(str: string, state: JxScope): JxScopeValue {
|
|
1049
1276
|
if (!isSingleExpression(str)) {
|
|
1050
1277
|
return evaluateTemplate(str, state);
|
|
1051
1278
|
}
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1279
|
+
let fn = valueTemplates.get(str);
|
|
1280
|
+
if (!fn) {
|
|
1281
|
+
/* SAFETY: `new Function` compiles exactly the TEMPLATE_PARAMS; the body returns one
|
|
1282
|
+
expression's value, which is whatever the scope it reads holds, so it is typed as a scope
|
|
1283
|
+
member and narrowed by the attribute writer. */
|
|
1284
|
+
fn = new Function(
|
|
1285
|
+
...TEMPLATE_PARAMS,
|
|
1286
|
+
`return (${str.slice(2, -1)})`,
|
|
1287
|
+
) as TemplateFn<JxScopeValue>;
|
|
1288
|
+
remember(valueTemplates, str, fn);
|
|
1289
|
+
}
|
|
1290
|
+
return fn(...templateArgs(state));
|
|
1060
1291
|
}
|
|
1061
1292
|
|
|
1062
1293
|
// ─── Step 2b: Function resolution (Shape 4) ─────────────────────────────────
|
|
1063
1294
|
|
|
1064
|
-
/**
|
|
1065
|
-
|
|
1295
|
+
/**
|
|
1296
|
+
* A module namespace as the runtime reads it: exports looked up by name, then under `default` (the
|
|
1297
|
+
* shape a CommonJS module takes when imported). An export is whatever the module exported, so each
|
|
1298
|
+
* lookup checks what it found before using it.
|
|
1299
|
+
*/
|
|
1300
|
+
interface ImportedModule {
|
|
1301
|
+
readonly [exportName: string]: JxScopeValue;
|
|
1302
|
+
readonly default?: ImportedModule;
|
|
1303
|
+
}
|
|
1304
|
+
|
|
1305
|
+
/**
|
|
1306
|
+
* Import a module namespace by specifier.
|
|
1307
|
+
*
|
|
1308
|
+
* Not `async`: the import's own promise is handed back, so awaiting this costs exactly the ticks
|
|
1309
|
+
* awaiting `import()` did, and scope construction keeps its order against its siblings'.
|
|
1310
|
+
*
|
|
1311
|
+
* @param {string} specifier
|
|
1312
|
+
* @returns {Promise<ImportedModule>}
|
|
1313
|
+
*/
|
|
1314
|
+
/**
|
|
1315
|
+
* How the host serves the package an `npm:` specifier names, or null for none.
|
|
1316
|
+
*
|
|
1317
|
+
* `npm:@jxsuite/search/client` is a package specifier (spec.md §5.3, 4c), and the build bundles it
|
|
1318
|
+
* (`compiler/src/site/bundler.ts`). The interpreter had no answer for it: `new URL()` reads `npm:`
|
|
1319
|
+
* as a URL scheme, so it was handed to `import()` verbatim and the browser refused it as a
|
|
1320
|
+
* cross-origin request to a scheme it does not fetch. A host that renders documents live knows
|
|
1321
|
+
* where its packages are served (the dev server bundles `/node_modules/<package>/<subpath>` on
|
|
1322
|
+
* demand), so it says so here. With no resolver the bare specifier is imported, which an import map
|
|
1323
|
+
* can answer.
|
|
1324
|
+
*/
|
|
1325
|
+
let _npmModuleResolver: ((specifier: string) => string) | null = null;
|
|
1326
|
+
|
|
1327
|
+
/**
|
|
1328
|
+
* Install (or with `null`, remove) the host's resolver for `npm:` specifiers.
|
|
1329
|
+
*
|
|
1330
|
+
* @param {((specifier: string) => string) | null} resolver - Package specifier → importable URL
|
|
1331
|
+
*/
|
|
1332
|
+
export function setNpmModuleResolver(resolver: ((specifier: string) => string) | null): void {
|
|
1333
|
+
_npmModuleResolver = resolver;
|
|
1334
|
+
}
|
|
1335
|
+
|
|
1336
|
+
/** The scheme a package specifier is written with. */
|
|
1337
|
+
const NPM_SCHEME = "npm:";
|
|
1338
|
+
|
|
1339
|
+
/**
|
|
1340
|
+
* Import a module a document names by `$src`: an `npm:` package through the host's resolver, and
|
|
1341
|
+
* anything else from the document's base (then as written).
|
|
1342
|
+
*
|
|
1343
|
+
* @param {string} src - The `$src` as the document spells it
|
|
1344
|
+
* @param {string} [base] - The document's URL
|
|
1345
|
+
* @returns {Promise<ImportedModule>}
|
|
1346
|
+
*/
|
|
1347
|
+
async function importSource(src: string, base?: string): Promise<ImportedModule> {
|
|
1348
|
+
if (src.startsWith(NPM_SCHEME)) {
|
|
1349
|
+
const specifier = src.slice(NPM_SCHEME.length);
|
|
1350
|
+
return importModule(_npmModuleResolver?.(specifier) ?? specifier);
|
|
1351
|
+
}
|
|
1352
|
+
if (base) {
|
|
1353
|
+
try {
|
|
1354
|
+
return await importModule(new URL(src, base).href);
|
|
1355
|
+
} catch {
|
|
1356
|
+
return importModule(src);
|
|
1357
|
+
}
|
|
1358
|
+
}
|
|
1359
|
+
return importModule(src);
|
|
1360
|
+
}
|
|
1361
|
+
|
|
1362
|
+
function importModule(specifier: string): Promise<ImportedModule> {
|
|
1363
|
+
/* SAFETY: trust boundary — a module exports whatever it exports. `import()` resolves to the
|
|
1364
|
+
module namespace object, whose keys are its export names, and every export is typed as the scope
|
|
1365
|
+
member it becomes: the runtime uses one only after `isFunction` has accepted it (a Function
|
|
1366
|
+
entry's `$src`, a server function, a `$implementation` class) or after testing it for
|
|
1367
|
+
presence, and both tests are defined for any value a module can export. `default` is only ever
|
|
1368
|
+
read through optional property access. */
|
|
1369
|
+
return import(specifier) as Promise<ImportedModule>;
|
|
1370
|
+
}
|
|
1066
1371
|
|
|
1067
|
-
/**
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1372
|
+
/**
|
|
1373
|
+
* An external class's instance as the runtime uses it (spec.md §12.3): a record read by member
|
|
1374
|
+
* name, three members of which are its contract with the runtime — a `value`, a `resolve()` that
|
|
1375
|
+
* produces one (possibly a promise), and a `subscribe()` that pushes later ones. Each is optional
|
|
1376
|
+
* and checked before it is used.
|
|
1377
|
+
*/
|
|
1378
|
+
interface ExternalClassInstance extends JxScope {
|
|
1379
|
+
value?: JxScopeValue;
|
|
1380
|
+
resolve?: () => JxScopeValue | Promise<JxScopeValue>;
|
|
1381
|
+
subscribe?: (callback: (value: JxScopeValue) => void) => void;
|
|
1072
1382
|
}
|
|
1073
1383
|
|
|
1074
1384
|
/** Module cache for $src imports (shared with external class resolution). */
|
|
1075
1385
|
const _moduleCache = new Map<string, ImportedModule>();
|
|
1076
1386
|
|
|
1077
1387
|
/** A module the host will import on first use, keyed as the document spells its `$src`. */
|
|
1078
|
-
type ModuleLoader = () => Promise<
|
|
1388
|
+
type ModuleLoader = () => Promise<ImportedModule>;
|
|
1079
1389
|
|
|
1080
1390
|
/**
|
|
1081
1391
|
* Lazy registrations: a loader until it runs, then the in-flight load, so two entries resolving the
|
|
@@ -1083,6 +1393,14 @@ type ModuleLoader = () => Promise<Record<string, unknown>>;
|
|
|
1083
1393
|
*/
|
|
1084
1394
|
const _moduleLoaders = new Map<string, ModuleLoader | Promise<ImportedModule>>();
|
|
1085
1395
|
|
|
1396
|
+
/**
|
|
1397
|
+
* The specifiers a host registered through `preloadModule`, in either form. Kept apart from
|
|
1398
|
+
* `_moduleCache` because that cache is shared: a Function entry's `$src` import lands there too,
|
|
1399
|
+
* and a `timing: "server"` entry naming the same specifier must still not run in the browser
|
|
1400
|
+
* (spec.md §11.4). Only the host's own registration admits a server module in process.
|
|
1401
|
+
*/
|
|
1402
|
+
const _hostSeeded = new Set<string>();
|
|
1403
|
+
|
|
1086
1404
|
/**
|
|
1087
1405
|
* Seed the `$src` module cache with a module the host already imported, under the specifier the
|
|
1088
1406
|
* document spells. A bundled document cannot have its sidecar fetched by URL — the bundler saw
|
|
@@ -1095,21 +1413,22 @@ const _moduleLoaders = new Map<string, ModuleLoader | Promise<ImportedModule>>()
|
|
|
1095
1413
|
* canvas frame registers every kit behaviour this way, so a page that uses no kit element loads
|
|
1096
1414
|
* none of them (embedding.md §6).
|
|
1097
1415
|
*
|
|
1416
|
+
* A registered module is also how a host runs a `timing: "server"` entry in process; for any
|
|
1417
|
+
* specifier not registered here, the runtime calls the host's `/__jx_server__` proxy instead.
|
|
1418
|
+
*
|
|
1098
1419
|
* @param {string} specifier - The `$src` value as written in the document
|
|
1099
|
-
* @param {
|
|
1100
|
-
*
|
|
1420
|
+
* @param {ImportedModule | ModuleLoader} mod - The imported module namespace, or a loader that
|
|
1421
|
+
* imports it on first use
|
|
1101
1422
|
*/
|
|
1102
|
-
export function preloadModule(
|
|
1103
|
-
specifier
|
|
1104
|
-
mod
|
|
1105
|
-
): void {
|
|
1106
|
-
if (typeof mod === "function") {
|
|
1423
|
+
export function preloadModule(specifier: string, mod: ImportedModule | ModuleLoader): void {
|
|
1424
|
+
_hostSeeded.add(specifier);
|
|
1425
|
+
if (isFunction(mod)) {
|
|
1107
1426
|
_moduleCache.delete(specifier);
|
|
1108
1427
|
_moduleLoaders.set(specifier, mod);
|
|
1109
1428
|
return;
|
|
1110
1429
|
}
|
|
1111
1430
|
_moduleLoaders.delete(specifier);
|
|
1112
|
-
_moduleCache.set(specifier, mod
|
|
1431
|
+
_moduleCache.set(specifier, mod);
|
|
1113
1432
|
}
|
|
1114
1433
|
|
|
1115
1434
|
/**
|
|
@@ -1134,28 +1453,37 @@ function seededModule(src: string): ImportedModule | Promise<ImportedModule> | u
|
|
|
1134
1453
|
if (!pending) {
|
|
1135
1454
|
return undefined;
|
|
1136
1455
|
}
|
|
1137
|
-
if (
|
|
1456
|
+
if (!isFunction(pending)) {
|
|
1138
1457
|
return pending;
|
|
1139
1458
|
}
|
|
1140
|
-
/*
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
1459
|
+
/* Started here, outside the async body, so a loader that throws synchronously still throws to
|
|
1460
|
+
the caller. The async body turns a thenable from a host's loader into a Promise the call sites
|
|
1461
|
+
can tell from a namespace. */
|
|
1462
|
+
const started = pending();
|
|
1463
|
+
const load = (async () => {
|
|
1464
|
+
try {
|
|
1465
|
+
const mod = await started;
|
|
1466
|
+
_moduleCache.set(src, mod);
|
|
1145
1467
|
_moduleLoaders.delete(src);
|
|
1146
|
-
return mod
|
|
1147
|
-
}
|
|
1148
|
-
(error: unknown) => {
|
|
1468
|
+
return mod;
|
|
1469
|
+
} catch (error) {
|
|
1149
1470
|
/* A failed load — a chunk the network dropped — puts the loader back, so the next document to
|
|
1150
1471
|
name the specifier tries again rather than inheriting this rejection for good. */
|
|
1151
1472
|
_moduleLoaders.set(src, pending);
|
|
1152
1473
|
throw error;
|
|
1153
|
-
}
|
|
1154
|
-
);
|
|
1474
|
+
}
|
|
1475
|
+
})();
|
|
1155
1476
|
_moduleLoaders.set(src, load);
|
|
1156
1477
|
return load;
|
|
1157
1478
|
}
|
|
1158
1479
|
|
|
1480
|
+
/**
|
|
1481
|
+
* A Function entry's implementation as the runtime calls it — a compiled `body` or a `$src` export
|
|
1482
|
+
* — with the scope as its first argument. JavaScript checks none of the arguments, and what the
|
|
1483
|
+
* call returns is bound into the scope like any other member.
|
|
1484
|
+
*/
|
|
1485
|
+
type ScopeFunction = (...args: JxScopeValue[]) => JxScopeValue;
|
|
1486
|
+
|
|
1159
1487
|
/**
|
|
1160
1488
|
* Resolve a $prototype: "Function" entry into a function or computed.
|
|
1161
1489
|
*
|
|
@@ -1166,7 +1494,7 @@ function seededModule(src: string): ImportedModule | Promise<ImportedModule> | u
|
|
|
1166
1494
|
* @param {JxScope} state - Reactive scope proxy
|
|
1167
1495
|
* @param {string} key - Def key name
|
|
1168
1496
|
* @param {string} [base] - Base URL for resolving $src imports
|
|
1169
|
-
* @returns {Promise<
|
|
1497
|
+
* @returns {Promise<JxScopeValue>}
|
|
1170
1498
|
*/
|
|
1171
1499
|
async function resolveFunction(def: JxFunctionDef, state: JxScope, key: string, base?: string) {
|
|
1172
1500
|
if (def.body && def.$src) {
|
|
@@ -1182,11 +1510,13 @@ async function resolveFunction(def: JxFunctionDef, state: JxScope, key: string,
|
|
|
1182
1510
|
return noop;
|
|
1183
1511
|
}
|
|
1184
1512
|
|
|
1185
|
-
let fn:
|
|
1513
|
+
let fn: ScopeFunction;
|
|
1186
1514
|
|
|
1187
|
-
if (
|
|
1515
|
+
if (isString(def.body)) {
|
|
1188
1516
|
const params = resolveParamNames(def);
|
|
1189
|
-
|
|
1517
|
+
/* SAFETY: `new Function` returns an ordinary function of the named parameters, `state` first.
|
|
1518
|
+
A call never checks its arguments, and what the body returns is one more scope member. */
|
|
1519
|
+
fn = new Function(...params, def.body) as ScopeFunction;
|
|
1190
1520
|
Object.defineProperty(fn, "name", {
|
|
1191
1521
|
configurable: true,
|
|
1192
1522
|
value: def.name ?? key,
|
|
@@ -1205,23 +1535,16 @@ async function resolveFunction(def: JxFunctionDef, state: JxScope, key: string,
|
|
|
1205
1535
|
} else if (seeded) {
|
|
1206
1536
|
mod = seeded;
|
|
1207
1537
|
} else {
|
|
1208
|
-
|
|
1209
|
-
const resolvedSrc = new URL(src, base).href;
|
|
1210
|
-
try {
|
|
1211
|
-
mod = (await import(resolvedSrc)) as ImportedModule;
|
|
1212
|
-
} catch {
|
|
1213
|
-
mod = (await import(src)) as ImportedModule;
|
|
1214
|
-
}
|
|
1215
|
-
} else {
|
|
1216
|
-
mod = (await import(src)) as ImportedModule;
|
|
1217
|
-
}
|
|
1538
|
+
mod = await importSource(src, base);
|
|
1218
1539
|
_moduleCache.set(src, mod);
|
|
1219
1540
|
}
|
|
1220
1541
|
const candidate = mod[exportName] ?? mod.default?.[exportName];
|
|
1221
|
-
if (
|
|
1542
|
+
if (!isFunction(candidate)) {
|
|
1222
1543
|
throw new TypeError(`Jx: export "${exportName}" not found or not a function in "${src}"`);
|
|
1223
1544
|
}
|
|
1224
|
-
|
|
1545
|
+
/* SAFETY: the guard proved a function, and a function accepts any call: the runtime passes the
|
|
1546
|
+
scope first, as a Function entry's contract says, and binds the result as a scope member. */
|
|
1547
|
+
fn = candidate as ScopeFunction;
|
|
1225
1548
|
}
|
|
1226
1549
|
|
|
1227
1550
|
// Detect computed: body contains a return statement, or $src function introspection.
|
|
@@ -1229,11 +1552,9 @@ async function resolveFunction(def: JxFunctionDef, state: JxScope, key: string,
|
|
|
1229
1552
|
const hasParams = (def.parameters ?? def.arguments ?? []).length > 0;
|
|
1230
1553
|
let isComputed = false;
|
|
1231
1554
|
if (!hasParams) {
|
|
1232
|
-
|
|
1233
|
-
|
|
1234
|
-
|
|
1235
|
-
isComputed = fn.length <= 1 && bodyReturnsValue(fn.toString());
|
|
1236
|
-
}
|
|
1555
|
+
isComputed = isString(def.body)
|
|
1556
|
+
? bodyReturnsValue(def.body)
|
|
1557
|
+
: fn.length <= 1 && bodyReturnsValue(fn.toString());
|
|
1237
1558
|
}
|
|
1238
1559
|
if (isComputed) {
|
|
1239
1560
|
return computed(() => fn(state));
|
|
@@ -1297,6 +1618,72 @@ export const RESERVED_KEYS = new Set([
|
|
|
1297
1618
|
"observedAttributes",
|
|
1298
1619
|
]);
|
|
1299
1620
|
|
|
1621
|
+
/**
|
|
1622
|
+
* A child scope: reads fall through to `parent`, writes land on the child.
|
|
1623
|
+
*
|
|
1624
|
+
* @param {JxScope} parent
|
|
1625
|
+
* @returns {JxScope}
|
|
1626
|
+
*/
|
|
1627
|
+
function inheritScope(parent: JxScope): JxScope {
|
|
1628
|
+
/* SAFETY: `Object.create` is typed `any`, but what it returns is a plain object whose prototype
|
|
1629
|
+
is `parent`, so it is keyed by string exactly as the scope it extends is. */
|
|
1630
|
+
return Object.create(parent) as JxScope;
|
|
1631
|
+
}
|
|
1632
|
+
|
|
1633
|
+
/**
|
|
1634
|
+
* The named breakpoints a scope carries, or none.
|
|
1635
|
+
*
|
|
1636
|
+
* @param {JxScope} state
|
|
1637
|
+
* @returns {Record<string, string>}
|
|
1638
|
+
*/
|
|
1639
|
+
function scopeMedia(state: JxScope): Record<string, string> {
|
|
1640
|
+
/* SAFETY: `$media` is runtime vocabulary a host scope may not supply (`assertHostScope`); the one
|
|
1641
|
+
writer is `buildScope`, which stores a document's or the mount's `$media` map, a record of
|
|
1642
|
+
breakpoint name to media query. */
|
|
1643
|
+
return (state["$media"] as Record<string, string> | undefined) ?? {};
|
|
1644
|
+
}
|
|
1645
|
+
|
|
1646
|
+
/**
|
|
1647
|
+
* An element as the property bag a Jx key writes into, `el[key] = value`. Reading or writing a key
|
|
1648
|
+
* the element does not define is an ordinary expando, as it is in JavaScript.
|
|
1649
|
+
*/
|
|
1650
|
+
type ElementProperties = HTMLElement & Record<string, JxScopeValue>;
|
|
1651
|
+
|
|
1652
|
+
/**
|
|
1653
|
+
* View an element as its property bag.
|
|
1654
|
+
*
|
|
1655
|
+
* @param {HTMLElement} el
|
|
1656
|
+
* @returns {ElementProperties}
|
|
1657
|
+
*/
|
|
1658
|
+
function elementProperties(el: HTMLElement): ElementProperties {
|
|
1659
|
+
/* SAFETY: every element is an ordinary extensible object, so any string key can be read (a
|
|
1660
|
+
missing one is undefined) and written; the added index signature claims nothing more. */
|
|
1661
|
+
return el as ElementProperties;
|
|
1662
|
+
}
|
|
1663
|
+
|
|
1664
|
+
/**
|
|
1665
|
+
* The reactive state a connected Jx instance renders from, or null for any other element (and for
|
|
1666
|
+
* an instance that has not connected yet).
|
|
1667
|
+
*
|
|
1668
|
+
* @param {HTMLElement} el
|
|
1669
|
+
* @returns {JxScope | null}
|
|
1670
|
+
*/
|
|
1671
|
+
function instanceStateOf(el: HTMLElement): JxScope | null {
|
|
1672
|
+
const state = elementProperties(el)._state;
|
|
1673
|
+
/* SAFETY: `_state` is written by one writer, the Jx element class's `connectedCallback`, which
|
|
1674
|
+
stores the scope `buildScope` answered; on any other element the expando is absent, and a
|
|
1675
|
+
non-object is refused below. */
|
|
1676
|
+
return isNonNullObject(state) ? (state as JxScope) : null;
|
|
1677
|
+
}
|
|
1678
|
+
|
|
1679
|
+
/**
|
|
1680
|
+
* Whether a scope member can be called as an event handler, `(scope, event)`. Being a function is
|
|
1681
|
+
* the whole contract, for the reason {@link isScopeHook} gives.
|
|
1682
|
+
*/
|
|
1683
|
+
function isEventHandler(value: unknown): value is JxEventHandler {
|
|
1684
|
+
return isFunction(value);
|
|
1685
|
+
}
|
|
1686
|
+
|
|
1300
1687
|
/**
|
|
1301
1688
|
* Recursively render a Jx element definition into a DOM element.
|
|
1302
1689
|
*
|
|
@@ -1342,12 +1729,18 @@ export function renderNode(
|
|
|
1342
1729
|
const path = options?._path ?? [];
|
|
1343
1730
|
|
|
1344
1731
|
// Text node children: bare strings/numbers/booleans produce DOM Text nodes
|
|
1345
|
-
if (
|
|
1732
|
+
if (isString(def) || isNumber(def) || isBoolean(def)) {
|
|
1346
1733
|
const textNode = document.createTextNode(String(def));
|
|
1347
|
-
if (
|
|
1348
|
-
|
|
1349
|
-
|
|
1350
|
-
|
|
1734
|
+
if (isTemplateString(def)) {
|
|
1735
|
+
bindTemplate(
|
|
1736
|
+
def,
|
|
1737
|
+
() => {
|
|
1738
|
+
textNode.textContent = evaluateTemplate(def, state);
|
|
1739
|
+
},
|
|
1740
|
+
() => {
|
|
1741
|
+
textNode.textContent = "";
|
|
1742
|
+
},
|
|
1743
|
+
);
|
|
1351
1744
|
}
|
|
1352
1745
|
return textNode;
|
|
1353
1746
|
}
|
|
@@ -1357,9 +1750,9 @@ export function renderNode(
|
|
|
1357
1750
|
for (const [key, val] of Object.entries(def)) {
|
|
1358
1751
|
if (key.startsWith("$") && !RESERVED_KEYS.has(key)) {
|
|
1359
1752
|
if (localState === state) {
|
|
1360
|
-
localState =
|
|
1753
|
+
localState = inheritScope(state);
|
|
1361
1754
|
}
|
|
1362
|
-
localState[key] =
|
|
1755
|
+
localState[key] = isRefValue(val) ? resolveRef(val.$ref, state) : val;
|
|
1363
1756
|
}
|
|
1364
1757
|
}
|
|
1365
1758
|
|
|
@@ -1369,7 +1762,7 @@ export function renderNode(
|
|
|
1369
1762
|
* the supported mechanism is $elements + a custom-element tag (see spec §13).
|
|
1370
1763
|
*/
|
|
1371
1764
|
if ("$ref" in def && !def.tagName) {
|
|
1372
|
-
const refTarget = String(
|
|
1765
|
+
const refTarget = String(def.$ref ?? "");
|
|
1373
1766
|
if (!warnedRefChildren.has(refTarget)) {
|
|
1374
1767
|
warnedRefChildren.add(refTarget);
|
|
1375
1768
|
console.warn(
|
|
@@ -1422,13 +1815,12 @@ export function renderNode(
|
|
|
1422
1815
|
* DESCENDANTS are HTML, so the element and its children carry different namespaces.
|
|
1423
1816
|
*/
|
|
1424
1817
|
const childNs = tagName === "foreignObject" ? null : ns;
|
|
1425
|
-
/*
|
|
1426
|
-
|
|
1427
|
-
|
|
1428
|
-
|
|
1429
|
-
|
|
1430
|
-
|
|
1431
|
-
: document.createElement(tagName)) as unknown as HTMLElement;
|
|
1818
|
+
/* SAFETY: a namespaced node is an Element, not an HTMLElement, but every apply* helper below
|
|
1819
|
+
touches only members SVG and MathML elements share with HTML ones (style, dataset,
|
|
1820
|
+
setAttribute, append), and `bindProperty` writes `className` as an attribute on them. */
|
|
1821
|
+
const el = ns
|
|
1822
|
+
? (document.createElementNS(ns, tagName) as HTMLElement)
|
|
1823
|
+
: document.createElement(tagName);
|
|
1432
1824
|
|
|
1433
1825
|
if (options?.onNodeCreated) {
|
|
1434
1826
|
options.onNodeCreated(el, path, def, localState);
|
|
@@ -1441,12 +1833,7 @@ export function renderNode(
|
|
|
1441
1833
|
if (def.style && tagName.includes("-")) {
|
|
1442
1834
|
callSiteStyles.set(el, def.style);
|
|
1443
1835
|
}
|
|
1444
|
-
applyStyle(
|
|
1445
|
-
el,
|
|
1446
|
-
def.style ?? {},
|
|
1447
|
-
(localState["$media"] as Record<string, string>) ?? {},
|
|
1448
|
-
localState,
|
|
1449
|
-
);
|
|
1836
|
+
applyStyle(el, def.style ?? {}, scopeMedia(localState), localState);
|
|
1450
1837
|
applyAttributes(el, def.attributes ?? {}, localState);
|
|
1451
1838
|
|
|
1452
1839
|
const kids = def.children;
|
|
@@ -1514,18 +1901,22 @@ function applyProperties(el: HTMLElement, def: JxElement, state: JxScope) {
|
|
|
1514
1901
|
*
|
|
1515
1902
|
* @param {HTMLElement} el
|
|
1516
1903
|
* @param {string} key The `on`-prefixed key
|
|
1517
|
-
* @param {
|
|
1904
|
+
* @param {JxDocumentValue | undefined} val
|
|
1518
1905
|
* @param {JxScope} state
|
|
1519
1906
|
* @returns {boolean} Whether a listener was attached
|
|
1520
1907
|
*/
|
|
1521
|
-
function bindHandler(
|
|
1908
|
+
function bindHandler(
|
|
1909
|
+
el: HTMLElement,
|
|
1910
|
+
key: string,
|
|
1911
|
+
val: JxDocumentValue | undefined,
|
|
1912
|
+
state: JxScope,
|
|
1913
|
+
): boolean {
|
|
1522
1914
|
const type = key.slice(2);
|
|
1523
1915
|
const scope = state;
|
|
1524
|
-
if (
|
|
1916
|
+
if (isRefValue(val)) {
|
|
1525
1917
|
const handler = resolveRef(val.$ref, state);
|
|
1526
|
-
if (
|
|
1527
|
-
|
|
1528
|
-
el.addEventListener(type, (e) => handlerFn(scope, e));
|
|
1918
|
+
if (isEventHandler(handler)) {
|
|
1919
|
+
el.addEventListener(type, (e) => handler(scope, e));
|
|
1529
1920
|
}
|
|
1530
1921
|
return true;
|
|
1531
1922
|
}
|
|
@@ -1536,9 +1927,11 @@ function bindHandler(el: HTMLElement, key: string, val: unknown, state: JxScope)
|
|
|
1536
1927
|
});
|
|
1537
1928
|
return true;
|
|
1538
1929
|
}
|
|
1539
|
-
if (isFunctionDef(val) &&
|
|
1930
|
+
if (isFunctionDef(val) && isString(val.body)) {
|
|
1540
1931
|
const params = resolveParamNames(val);
|
|
1541
|
-
|
|
1932
|
+
/* SAFETY: `new Function` returns an ordinary function of the named parameters, `state` first;
|
|
1933
|
+
the listener calls it with the scope and the event, and discards what it returns. */
|
|
1934
|
+
const fn = new Function(...params, val.body) as JxEventHandler;
|
|
1542
1935
|
el.addEventListener(type, (e) => fn(scope, e));
|
|
1543
1936
|
return true;
|
|
1544
1937
|
}
|
|
@@ -1571,11 +1964,16 @@ function bindDefinitionHandlers(host: HTMLElement, def: JxElement, state: JxScop
|
|
|
1571
1964
|
/**
|
|
1572
1965
|
* @param {HTMLElement} el
|
|
1573
1966
|
* @param {string} key
|
|
1574
|
-
* @param {
|
|
1967
|
+
* @param {JxDocumentValue | undefined} val
|
|
1575
1968
|
* @param {JxScope} state
|
|
1576
1969
|
*/
|
|
1577
|
-
function bindProperty(
|
|
1578
|
-
|
|
1970
|
+
function bindProperty(
|
|
1971
|
+
el: HTMLElement,
|
|
1972
|
+
key: string,
|
|
1973
|
+
val: JxDocumentValue | undefined,
|
|
1974
|
+
state: JxScope,
|
|
1975
|
+
) {
|
|
1976
|
+
const target = elementProperties(el);
|
|
1579
1977
|
const boundProp = boundPropKey(key, val, state);
|
|
1580
1978
|
if (boundProp) {
|
|
1581
1979
|
el.dataset.jxBoundProp = boundProp;
|
|
@@ -1583,15 +1981,16 @@ function bindProperty(el: HTMLElement, key: string, val: unknown, state: JxScope
|
|
|
1583
1981
|
/* An `<img>` in a document says `{"tagName": "img", "src": "/hero.jpg"}`, so `src` arrives HERE
|
|
1584
1982
|
as a top-level key and is written as a DOM PROPERTY — `attributes: { src }` is the rarer
|
|
1585
1983
|
spelling. Both are the same reference and both need the same resolution. */
|
|
1586
|
-
const asAsset = (resolved:
|
|
1587
|
-
|
|
1984
|
+
const asAsset = (resolved: JxScopeValue): JxScopeValue =>
|
|
1985
|
+
isString(resolved) ? canvasAssetValue(el.tagName, key, resolved) : resolved;
|
|
1588
1986
|
/*
|
|
1589
1987
|
* `className` on an SVGElement is a read-only SVGAnimatedString, so assigning it throws in the
|
|
1590
1988
|
* strict mode an ES module always runs under. It is the most common Jx element property, so the
|
|
1591
1989
|
* attribute form is the only one that works on both.
|
|
1592
1990
|
*/
|
|
1593
|
-
const write = (resolved:
|
|
1594
|
-
|
|
1991
|
+
const write = (resolved: JxScopeValue): void => {
|
|
1992
|
+
// Widened to Element: a namespaced node is typed HTMLElement here but is not one at runtime.
|
|
1993
|
+
const node: Element = el;
|
|
1595
1994
|
if (key === "className" && !(node instanceof HTMLElement)) {
|
|
1596
1995
|
node.setAttribute("class", resolved == null ? "" : String(resolved));
|
|
1597
1996
|
return;
|
|
@@ -1605,9 +2004,11 @@ function bindProperty(el: HTMLElement, key: string, val: unknown, state: JxScope
|
|
|
1605
2004
|
}
|
|
1606
2005
|
target[key] = resolved;
|
|
1607
2006
|
};
|
|
1608
|
-
if (
|
|
1609
|
-
const refVal = val
|
|
2007
|
+
if (isRefValue(val)) {
|
|
2008
|
+
const refVal = val;
|
|
1610
2009
|
if (key === "id") {
|
|
2010
|
+
/* SAFETY: the `id` setter is a WebIDL DOMString attribute, which converts whatever it is
|
|
2011
|
+
given to a string itself; the cast only lets the bound value reach that conversion. */
|
|
1611
2012
|
target[key] = resolveRef(refVal.$ref, state) as string;
|
|
1612
2013
|
return;
|
|
1613
2014
|
}
|
|
@@ -1619,8 +2020,9 @@ function bindProperty(el: HTMLElement, key: string, val: unknown, state: JxScope
|
|
|
1619
2020
|
|
|
1620
2021
|
// Universal ${} reactivity — template strings in element properties
|
|
1621
2022
|
if (isTemplateString(val)) {
|
|
1622
|
-
|
|
1623
|
-
|
|
2023
|
+
const template = val;
|
|
2024
|
+
bindTemplate(template, () => {
|
|
2025
|
+
write(asAsset(evaluateTemplate(template, state)));
|
|
1624
2026
|
});
|
|
1625
2027
|
return;
|
|
1626
2028
|
}
|
|
@@ -1794,7 +2196,7 @@ function textSink(tag: HTMLStyleElement): StyleSink {
|
|
|
1794
2196
|
write();
|
|
1795
2197
|
},
|
|
1796
2198
|
text() {
|
|
1797
|
-
return tag.textContent
|
|
2199
|
+
return tag.textContent;
|
|
1798
2200
|
},
|
|
1799
2201
|
};
|
|
1800
2202
|
}
|
|
@@ -1810,9 +2212,10 @@ function textSink(tag: HTMLStyleElement): StyleSink {
|
|
|
1810
2212
|
* @returns {StyleSink}
|
|
1811
2213
|
*/
|
|
1812
2214
|
function openSink(doc: Document): StyleSink {
|
|
1813
|
-
|
|
2215
|
+
// A window without constructable stylesheets (an older engine, a test DOM) lacks the global.
|
|
2216
|
+
const view: (Window & { CSSStyleSheet?: typeof CSSStyleSheet }) | null = doc.defaultView;
|
|
1814
2217
|
const SheetConstructor = view?.CSSStyleSheet;
|
|
1815
|
-
if (
|
|
2218
|
+
if (isFunction(SheetConstructor) && Array.isArray(doc.adoptedStyleSheets)) {
|
|
1816
2219
|
try {
|
|
1817
2220
|
const sheet = new SheetConstructor();
|
|
1818
2221
|
doc.adoptedStyleSheets = [...doc.adoptedStyleSheets, sheet];
|
|
@@ -1825,10 +2228,21 @@ function openSink(doc: Document): StyleSink {
|
|
|
1825
2228
|
}
|
|
1826
2229
|
const tag = doc.createElement("style");
|
|
1827
2230
|
tag.dataset.jxSheet = "";
|
|
1828
|
-
(doc
|
|
2231
|
+
(documentHead(doc) ?? doc.documentElement).append(tag);
|
|
1829
2232
|
return tag.sheet ? cssomSink(tag.sheet, () => tag.remove()) : textSink(tag);
|
|
1830
2233
|
}
|
|
1831
2234
|
|
|
2235
|
+
/**
|
|
2236
|
+
* A document's `<head>`, or null when it has none. HTML defines `document.head` as nullable (a
|
|
2237
|
+
* document whose root element has no `head` child), though the DOM typings declare it non-null.
|
|
2238
|
+
*
|
|
2239
|
+
* @param {Document} doc
|
|
2240
|
+
* @returns {HTMLHeadElement | null}
|
|
2241
|
+
*/
|
|
2242
|
+
function documentHead(doc: Document): HTMLHeadElement | null {
|
|
2243
|
+
return doc.head;
|
|
2244
|
+
}
|
|
2245
|
+
|
|
1832
2246
|
/** One interned rule set: the rules as written, and how many elements point at them. */
|
|
1833
2247
|
interface InternEntry {
|
|
1834
2248
|
inserted: InsertedRules;
|
|
@@ -2090,7 +2504,7 @@ function declaresDisplay(style: JxStyle | undefined): boolean {
|
|
|
2090
2504
|
`@media` block as "the author supplied one", so the element stayed `inline` at rest and
|
|
2091
2505
|
became a block on hover — a live trap rather than a subtlety. An author who wants the
|
|
2092
2506
|
platform's own default writes `display: revert` in the base block, which this sees. */
|
|
2093
|
-
return
|
|
2507
|
+
return isNonNullObject(style) && "display" in style;
|
|
2094
2508
|
}
|
|
2095
2509
|
|
|
2096
2510
|
/**
|
|
@@ -2115,19 +2529,20 @@ function mergeStyle(base: JxStyle, over: JxStyle | undefined): JxStyle {
|
|
|
2115
2529
|
if (!over) {
|
|
2116
2530
|
return base;
|
|
2117
2531
|
}
|
|
2118
|
-
const out:
|
|
2119
|
-
for (const [key, value] of Object.entries(over
|
|
2532
|
+
const out: JxStyle = { ...base };
|
|
2533
|
+
for (const [key, value] of Object.entries(over)) {
|
|
2120
2534
|
const prior = out[key];
|
|
2535
|
+
/* A block merges into whatever object the definition holds there. A repeated-block list is
|
|
2536
|
+
spread to its index keys first, which is what spreading it as a style object always did. */
|
|
2121
2537
|
out[key] =
|
|
2122
|
-
|
|
2123
|
-
|
|
2124
|
-
|
|
2125
|
-
|
|
2126
|
-
|
|
2127
|
-
? mergeStyle(prior as JxStyle, value as JxStyle)
|
|
2538
|
+
isNestedStyle(value) && isNonNullObject(prior)
|
|
2539
|
+
? mergeStyle(
|
|
2540
|
+
Array.isArray(prior) ? Object.fromEntries(Object.entries(prior)) : prior,
|
|
2541
|
+
value,
|
|
2542
|
+
)
|
|
2128
2543
|
: value;
|
|
2129
2544
|
}
|
|
2130
|
-
return out
|
|
2545
|
+
return out;
|
|
2131
2546
|
}
|
|
2132
2547
|
|
|
2133
2548
|
/**
|
|
@@ -2198,7 +2613,7 @@ function applyStyleInto(
|
|
|
2198
2613
|
live: boolean,
|
|
2199
2614
|
) {
|
|
2200
2615
|
releaseElementStyles(el);
|
|
2201
|
-
const doc = el.ownerDocument
|
|
2616
|
+
const doc = el.ownerDocument;
|
|
2202
2617
|
const sheetState = sheetStateFor(doc);
|
|
2203
2618
|
|
|
2204
2619
|
/* One gate for the whole call, so every recursion answers alike. Off in production and in
|
|
@@ -2221,8 +2636,9 @@ function applyStyleInto(
|
|
|
2221
2636
|
variable inherits, which is correct, and the declaration stays in the rule where `:hover` can
|
|
2222
2637
|
override it. The serial keeps two elements' variables apart: a descendant rule reads `var()`
|
|
2223
2638
|
from the nearest ancestor that set it, and without it a styled child would shadow its parent's
|
|
2224
|
-
`--jx-r0` with its own.
|
|
2225
|
-
|
|
2639
|
+
`--jx-r0` with its own. Only a DESCENDANT rule needs that: a self-target declaration reads the
|
|
2640
|
+
variable the element itself sets, so it is named by position alone (`--jx-s<n>`) and elements
|
|
2641
|
+
that style alike keep sharing one rule set. */
|
|
2226
2642
|
let serial = -1;
|
|
2227
2643
|
let indirect = 0;
|
|
2228
2644
|
const sources: { name: string; value: string | JxRef }[] = [];
|
|
@@ -2244,15 +2660,26 @@ function applyStyleInto(
|
|
|
2244
2660
|
sources.push({ name: property, value });
|
|
2245
2661
|
return null;
|
|
2246
2662
|
}
|
|
2247
|
-
if (serial < 0) {
|
|
2248
|
-
({ serial } = sheetState);
|
|
2249
|
-
sheetState.serial += 1;
|
|
2250
|
-
}
|
|
2251
2663
|
/* Counted separately from `sources`, so an inline custom property before it cannot shift the
|
|
2252
2664
|
name of an indirected one. The name is IN the rule text, and the interning handle is a
|
|
2253
2665
|
hash of that text — a number that moved with unrelated declarations would give two
|
|
2254
2666
|
otherwise identical styles two rules. */
|
|
2255
|
-
|
|
2667
|
+
let name: string;
|
|
2668
|
+
if (target === "self") {
|
|
2669
|
+
/* A SELF-target rule reads its variable from the element that sets it inline, which is
|
|
2670
|
+
this element — no ancestor's value can reach it — so the name needs no serial, only its
|
|
2671
|
+
position. Without this, every instance of a definition with one reactive declaration
|
|
2672
|
+
(the kit's tree row binds its indent) interned a rule set of its own: an Outline
|
|
2673
|
+
scrolling through a long page minted fifteen rules per row and grew the shared sheet
|
|
2674
|
+
past fourteen thousand, slowing every insert and every style recalculation after. */
|
|
2675
|
+
name = `--jx-s${indirect}`;
|
|
2676
|
+
} else {
|
|
2677
|
+
if (serial < 0) {
|
|
2678
|
+
({ serial } = sheetState);
|
|
2679
|
+
sheetState.serial += 1;
|
|
2680
|
+
}
|
|
2681
|
+
name = `--jx-r${serial}-${indirect}`;
|
|
2682
|
+
}
|
|
2256
2683
|
indirect += 1;
|
|
2257
2684
|
sources.push({ name, value });
|
|
2258
2685
|
return `var(${name})`;
|
|
@@ -2304,7 +2731,7 @@ function applyStyleInto(
|
|
|
2304
2731
|
scope.run(() => {
|
|
2305
2732
|
for (const { name, value } of sources) {
|
|
2306
2733
|
effect(() => {
|
|
2307
|
-
const resolved =
|
|
2734
|
+
const resolved = isRefValue(value)
|
|
2308
2735
|
? resolveRef(value.$ref, state)
|
|
2309
2736
|
: evaluateTemplate(value, state);
|
|
2310
2737
|
el.style.setProperty(name, canvasStyleValue(String(resolved ?? "")));
|
|
@@ -2397,7 +2824,7 @@ function applyAttributes(el: HTMLElement, attrs: Record<string, JxAttributeValue
|
|
|
2397
2824
|
const attr = canvasAttrName(el, k);
|
|
2398
2825
|
/* Inside the effects, not before them. A `$ref` or `${…}` value is not a string until the
|
|
2399
2826
|
effect runs — which is exactly why the document walk this replaced could never see it. */
|
|
2400
|
-
const write = (resolved:
|
|
2827
|
+
const write = (resolved: JxScopeValue) => {
|
|
2401
2828
|
if (resolved === null || resolved === undefined) {
|
|
2402
2829
|
/* Nothing is an attribute the element does not have. A binding that resolves to `null`
|
|
2403
2830
|
has to take the attribute WITH it, for the same reason a `false` does below — and an
|
|
@@ -2405,7 +2832,7 @@ function applyAttributes(el: HTMLElement, attrs: Record<string, JxAttributeValue
|
|
|
2405
2832
|
el.removeAttribute(attr);
|
|
2406
2833
|
return;
|
|
2407
2834
|
}
|
|
2408
|
-
if (
|
|
2835
|
+
if (isBoolean(resolved)) {
|
|
2409
2836
|
const text = booleanAttrValue(attr, resolved);
|
|
2410
2837
|
/* `removeAttribute`, not `setAttribute(attr, "false")`. A binding that flips back has to
|
|
2411
2838
|
take the attribute WITH it: the element is being re-used, so anything left behind is the
|
|
@@ -2420,10 +2847,10 @@ function applyAttributes(el: HTMLElement, attrs: Record<string, JxAttributeValue
|
|
|
2420
2847
|
}
|
|
2421
2848
|
el.setAttribute(attr, canvasAssetValue(el.tagName, k, String(resolved)));
|
|
2422
2849
|
};
|
|
2423
|
-
if (
|
|
2850
|
+
if (isRefValue(v)) {
|
|
2424
2851
|
effect(() => write(resolveRef(v.$ref, state)));
|
|
2425
2852
|
} else if (isTemplateString(v)) {
|
|
2426
|
-
|
|
2853
|
+
bindTemplate(v, () => write(evaluateValueTemplate(v, state)));
|
|
2427
2854
|
} else {
|
|
2428
2855
|
write(v);
|
|
2429
2856
|
}
|
|
@@ -2469,7 +2896,7 @@ function renderMappedArrayInto(
|
|
|
2469
2896
|
const anchor = document.createComment("jx-array");
|
|
2470
2897
|
parentEl.append(anchor);
|
|
2471
2898
|
const { items: itemsSrc, map: mapDef, filter: filterRef, sort: sortRef, key: keyRef } = arrayDef;
|
|
2472
|
-
const keyPointer =
|
|
2899
|
+
const keyPointer = isRefValue(keyRef) ? keyRef.$ref : null;
|
|
2473
2900
|
/* The instantiation chain the list belongs to (spec.md §16.9), for the same reason
|
|
2474
2901
|
{@link renderSwitch} keeps one: a row that arrives with new data is inserted from an effect
|
|
2475
2902
|
re-run, and the instance it holds must connect under the chain of the definition that drew
|
|
@@ -2487,14 +2914,22 @@ function renderMappedArrayInto(
|
|
|
2487
2914
|
/** The rows currently rendered, in DOM order. */
|
|
2488
2915
|
let rows: MappedRow[] = [];
|
|
2489
2916
|
|
|
2917
|
+
/* Out of the document FIRST, then stopped: stopping releases every element's styles
|
|
2918
|
+
(`releaseElementStyles`), and on a connected node each of those inline-property and `data-jx`
|
|
2919
|
+
writes schedules a style invalidation for a node that is about to leave. Detached, they are
|
|
2920
|
+
plain writes. See `dispose` in `mount`. */
|
|
2490
2921
|
const teardown = (row: MappedRow): void => {
|
|
2491
|
-
row.scope.stop();
|
|
2492
2922
|
row.node.remove();
|
|
2923
|
+
row.scope.stop();
|
|
2493
2924
|
};
|
|
2494
2925
|
|
|
2495
|
-
const createRow = (
|
|
2926
|
+
const createRow = (
|
|
2927
|
+
key: MappedRow["key"],
|
|
2928
|
+
item: MappedRow["map"]["item"],
|
|
2929
|
+
index: number,
|
|
2930
|
+
): MappedRow => {
|
|
2496
2931
|
const map = shallowReactive({ index, item });
|
|
2497
|
-
const child =
|
|
2932
|
+
const child = inheritScope(state);
|
|
2498
2933
|
child.$map = map;
|
|
2499
2934
|
// The flat aliases stay readable by legacy readers, and follow the reactive context.
|
|
2500
2935
|
Object.defineProperty(child, "$map/item", { enumerable: true, get: () => map.item });
|
|
@@ -2505,8 +2940,8 @@ function renderMappedArrayInto(
|
|
|
2505
2940
|
return { key, map, node, scope };
|
|
2506
2941
|
};
|
|
2507
2942
|
|
|
2508
|
-
const reconcile = (list:
|
|
2509
|
-
const previous = new Map<
|
|
2943
|
+
const reconcile = (list: readonly JxScopeValue[], keys: readonly MappedRow["key"][]): void => {
|
|
2944
|
+
const previous = new Map<MappedRow["key"], MappedRow>();
|
|
2510
2945
|
for (const row of rows) {
|
|
2511
2946
|
previous.set(row.key, row);
|
|
2512
2947
|
}
|
|
@@ -2549,24 +2984,30 @@ function renderMappedArrayInto(
|
|
|
2549
2984
|
};
|
|
2550
2985
|
|
|
2551
2986
|
const update = (): void => {
|
|
2552
|
-
let items:
|
|
2553
|
-
if (Array.isArray(items) &&
|
|
2987
|
+
let items: JxScopeValue = isRefValue(itemsSrc) ? resolveRef(itemsSrc.$ref, state) : itemsSrc;
|
|
2988
|
+
if (Array.isArray(items) && isRefValue(filterRef)) {
|
|
2554
2989
|
const fn = resolveRef(filterRef.$ref, state);
|
|
2555
|
-
if (
|
|
2556
|
-
|
|
2990
|
+
if (isFunction(fn)) {
|
|
2991
|
+
/* SAFETY: the guard proved a function, and a function accepts any call: `Array#filter`
|
|
2992
|
+
passes each item (with its index and the list) and reads only the result's truthiness. */
|
|
2993
|
+
items = items.filter(fn as (item: MappedRow["map"]["item"]) => boolean);
|
|
2557
2994
|
}
|
|
2558
2995
|
}
|
|
2559
|
-
if (Array.isArray(items) &&
|
|
2996
|
+
if (Array.isArray(items) && isRefValue(sortRef)) {
|
|
2560
2997
|
const fn = resolveRef(sortRef.$ref, state);
|
|
2561
|
-
if (
|
|
2562
|
-
|
|
2998
|
+
if (isFunction(fn)) {
|
|
2999
|
+
/* SAFETY: the guard proved a function, and a function accepts any call: `toSorted` passes
|
|
3000
|
+
two items and orders by the sign of the number the comparator returns. */
|
|
3001
|
+
const compare = fn as (a: MappedRow["map"]["item"], b: MappedRow["map"]["item"]) => number;
|
|
3002
|
+
const unsorted: readonly JxScopeValue[] = items;
|
|
3003
|
+
items = [...unsorted].toSorted(compare);
|
|
2563
3004
|
}
|
|
2564
3005
|
}
|
|
2565
|
-
const list = Array.isArray(items) && mapDef ?
|
|
3006
|
+
const list: readonly JxScopeValue[] = Array.isArray(items) && mapDef ? items : [];
|
|
2566
3007
|
|
|
2567
3008
|
// Keys are read HERE, tracked, so a key that changes reconciles the list.
|
|
2568
3009
|
const keys = list.map((item, index) => mappedRowKey(keyPointer, item, index, warnOnce));
|
|
2569
|
-
const seen = new Set<
|
|
3010
|
+
const seen = new Set<MappedRow["key"]>();
|
|
2570
3011
|
for (let i = 0; i < keys.length; i++) {
|
|
2571
3012
|
if (seen.has(keys[i])) {
|
|
2572
3013
|
warnOnce(
|
|
@@ -2620,9 +3061,9 @@ function renderMappedArrayInto(
|
|
|
2620
3061
|
|
|
2621
3062
|
/** A rendered row of a mapped array. */
|
|
2622
3063
|
interface MappedRow {
|
|
2623
|
-
key:
|
|
3064
|
+
key: JxScopeValue;
|
|
2624
3065
|
/** The row's `$map` context; `item` and `index` are written in place when the row is reused. */
|
|
2625
|
-
map:
|
|
3066
|
+
map: JxRowContext;
|
|
2626
3067
|
node: HTMLElement | Text;
|
|
2627
3068
|
scope: EffectScope;
|
|
2628
3069
|
}
|
|
@@ -2634,10 +3075,10 @@ interface MappedRow {
|
|
|
2634
3075
|
*/
|
|
2635
3076
|
function mappedRowKey(
|
|
2636
3077
|
pointer: string | null,
|
|
2637
|
-
item:
|
|
3078
|
+
item: MappedRow["map"]["item"],
|
|
2638
3079
|
index: number,
|
|
2639
3080
|
warnOnce: (message: string) => void,
|
|
2640
|
-
):
|
|
3081
|
+
): MappedRow["key"] {
|
|
2641
3082
|
if (pointer === null) {
|
|
2642
3083
|
return index;
|
|
2643
3084
|
}
|
|
@@ -2663,12 +3104,12 @@ function mappedRowKey(
|
|
|
2663
3104
|
* schema already held to the tag-name pattern — the property that lets the compiler enumerate the
|
|
2664
3105
|
* candidates and lets `createElement` be called without a second thought here.
|
|
2665
3106
|
*
|
|
2666
|
-
* @param {
|
|
3107
|
+
* @param {TagNameSlot} tagName The element's declared tag.
|
|
2667
3108
|
* @param {JxScope} scope The state the discriminant reads.
|
|
2668
3109
|
* @returns {string} A literal tag name; `"div"` when nothing is declared.
|
|
2669
3110
|
*/
|
|
2670
|
-
export function resolveTagName(tagName:
|
|
2671
|
-
if (
|
|
3111
|
+
export function resolveTagName(tagName: TagNameSlot, scope: JxScope): string {
|
|
3112
|
+
if (isString(tagName)) {
|
|
2672
3113
|
return tagName;
|
|
2673
3114
|
}
|
|
2674
3115
|
if (!isTagExpression(tagName)) {
|
|
@@ -2706,7 +3147,7 @@ function renderSwitch(def: JxElement, state: JxScope, options?: JxRenderOptions)
|
|
|
2706
3147
|
}
|
|
2707
3148
|
|
|
2708
3149
|
applyProperties(container, def, state);
|
|
2709
|
-
applyStyle(container, def.style ?? {}, (state
|
|
3150
|
+
applyStyle(container, def.style ?? {}, scopeMedia(state), state);
|
|
2710
3151
|
applyAttributes(container, def.attributes ?? {}, state);
|
|
2711
3152
|
/* Every case renders in a detached scope of its own, stopped before the next case renders and
|
|
2712
3153
|
when the container's own scope stops. A generation counter names the case a pending external
|
|
@@ -2725,9 +3166,10 @@ function renderSwitch(def: JxElement, state: JxScope, options?: JxRenderOptions)
|
|
|
2725
3166
|
};
|
|
2726
3167
|
|
|
2727
3168
|
effect(() => {
|
|
2728
|
-
if (!
|
|
2729
|
-
|
|
3169
|
+
if (!isRefValue(def.$switch)) {
|
|
3170
|
+
// Detached before it is stopped, for the reason `teardown` in `renderMappedArray` gives.
|
|
2730
3171
|
container.replaceChildren();
|
|
3172
|
+
retire();
|
|
2731
3173
|
generation += 1;
|
|
2732
3174
|
return;
|
|
2733
3175
|
}
|
|
@@ -2736,12 +3178,13 @@ function renderSwitch(def: JxElement, state: JxScope, options?: JxRenderOptions)
|
|
|
2736
3178
|
return;
|
|
2737
3179
|
}
|
|
2738
3180
|
renderedKey = key;
|
|
2739
|
-
retire();
|
|
2740
3181
|
/* `replaceChildren()` rather than `innerHTML = ""`: identical semantics, and it is not a
|
|
2741
3182
|
Trusted Types injection sink — under `require-trusted-types-for 'script'` an innerHTML write
|
|
2742
3183
|
needs a policy even when the string is empty. Four sinks that were never injecting anything
|
|
2743
|
-
is four fewer things a policy has to be permissive about.
|
|
3184
|
+
is four fewer things a policy has to be permissive about. Detached BEFORE the case is
|
|
3185
|
+
stopped, for the reason `teardown` in `renderMappedArray` gives. */
|
|
2744
3186
|
container.replaceChildren();
|
|
3187
|
+
retire();
|
|
2745
3188
|
generation += 1;
|
|
2746
3189
|
const caseDef = def.cases?.[key];
|
|
2747
3190
|
if (!caseDef) {
|
|
@@ -2752,7 +3195,7 @@ function renderSwitch(def: JxElement, state: JxScope, options?: JxRenderOptions)
|
|
|
2752
3195
|
live = scope;
|
|
2753
3196
|
const childOpts = options ? { ...options, _path: [...path, "cases", key] } : undefined;
|
|
2754
3197
|
|
|
2755
|
-
if (
|
|
3198
|
+
if (isRefValue(caseDef)) {
|
|
2756
3199
|
// External $ref — fetch and render asynchronously, against the mount's base so a bundled
|
|
2757
3200
|
// Document need not live at the page URL.
|
|
2758
3201
|
const { href } = new URL(caseDef.$ref, options?._ctx?.base ?? location.href);
|
|
@@ -2770,7 +3213,7 @@ function renderSwitch(def: JxElement, state: JxScope, options?: JxRenderOptions)
|
|
|
2770
3213
|
scope.run(() => container.append(renderNode(doc, childScope, childOpts))),
|
|
2771
3214
|
);
|
|
2772
3215
|
})
|
|
2773
|
-
.catch((error
|
|
3216
|
+
.catch((error) =>
|
|
2774
3217
|
console.error("Jx $switch: failed to load external case", caseDef.$ref, error),
|
|
2775
3218
|
);
|
|
2776
3219
|
return;
|
|
@@ -2795,6 +3238,15 @@ function renderSwitch(def: JxElement, state: JxScope, options?: JxRenderOptions)
|
|
|
2795
3238
|
|
|
2796
3239
|
// ─── Prototype namespaces (Shape 5) ──────────────────────────────────────────
|
|
2797
3240
|
|
|
3241
|
+
/**
|
|
3242
|
+
* `ref()`, for a value that is one scope member: the same function, under a signature that says so.
|
|
3243
|
+
*
|
|
3244
|
+
* `ref`'s own return type unwraps its argument's type recursively, and over the recursive
|
|
3245
|
+
* `JxScopeValue` union (an array of scope values holds scope values) that unwrapping never
|
|
3246
|
+
* terminates (TS2589). The value goes in as it is, so the ref is typed as holding a scope member.
|
|
3247
|
+
*/
|
|
3248
|
+
const scopeRef: (value: JxScopeValue) => Ref<JxScopeValue> = ref;
|
|
3249
|
+
|
|
2798
3250
|
/**
|
|
2799
3251
|
* Resolve a $prototype definition into a value for the reactive scope.
|
|
2800
3252
|
*
|
|
@@ -2805,7 +3257,7 @@ function renderSwitch(def: JxElement, state: JxScope, options?: JxRenderOptions)
|
|
|
2805
3257
|
* @param {JxScope} state - Reactive scope proxy
|
|
2806
3258
|
* @param {string} key - Def key (for diagnostics)
|
|
2807
3259
|
* @param {string} [base] - Base URL for resolving $src imports
|
|
2808
|
-
* @returns {Promise<
|
|
3260
|
+
* @returns {Promise<JxScopeValue>}
|
|
2809
3261
|
*/
|
|
2810
3262
|
export async function resolvePrototype(
|
|
2811
3263
|
def: JxPrototypeDef,
|
|
@@ -2821,7 +3273,7 @@ export async function resolvePrototype(
|
|
|
2821
3273
|
|
|
2822
3274
|
switch (def.$prototype) {
|
|
2823
3275
|
case "Request": {
|
|
2824
|
-
const s: Ref<
|
|
3276
|
+
const s: Ref<JxScopeValue> = ref(null);
|
|
2825
3277
|
const debounceMs = def.debounce ?? 0;
|
|
2826
3278
|
let debounceTimer: ReturnType<typeof setTimeout> | null = null;
|
|
2827
3279
|
|
|
@@ -2851,14 +3303,14 @@ export async function resolvePrototype(
|
|
|
2851
3303
|
signal: controller.signal,
|
|
2852
3304
|
...(def.headers && { headers: def.headers }),
|
|
2853
3305
|
...(def.body && {
|
|
2854
|
-
body:
|
|
3306
|
+
body: isNonNullObject(def.body) ? JSON.stringify(def.body) : def.body,
|
|
2855
3307
|
}),
|
|
2856
3308
|
})
|
|
2857
|
-
.then((r) => (r.ok ? r.json() : Promise.reject(r.statusText)))
|
|
3309
|
+
.then((r): Promise<JsonValue> => (r.ok ? r.json() : Promise.reject(r.statusText)))
|
|
2858
3310
|
.then((d) => {
|
|
2859
3311
|
s.value = d;
|
|
2860
3312
|
})
|
|
2861
|
-
.catch((error
|
|
3313
|
+
.catch((error) => {
|
|
2862
3314
|
if (!(error instanceof Error && error.name === "AbortError")) {
|
|
2863
3315
|
s.value = { error: String(error) };
|
|
2864
3316
|
}
|
|
@@ -2880,8 +3332,11 @@ export async function resolvePrototype(
|
|
|
2880
3332
|
const p: Record<string, string> = {};
|
|
2881
3333
|
for (const [k, v] of Object.entries(def)) {
|
|
2882
3334
|
if (k !== "$prototype") {
|
|
3335
|
+
/* SAFETY: the record goes straight to `URLSearchParams`, whose WebIDL record<USVString,
|
|
3336
|
+
USVString> conversion stringifies every value itself; a bound number or boolean
|
|
3337
|
+
reaches that conversion exactly as it always has. */
|
|
2883
3338
|
p[k] = (
|
|
2884
|
-
|
|
3339
|
+
isRefValue(v)
|
|
2885
3340
|
? resolveRef(v.$ref, state)
|
|
2886
3341
|
: isTemplateString(v)
|
|
2887
3342
|
? evaluateTemplate(v, state)
|
|
@@ -2897,14 +3352,16 @@ export async function resolvePrototype(
|
|
|
2897
3352
|
case "SessionStorage": {
|
|
2898
3353
|
const store = def.$prototype === "LocalStorage" ? localStorage : sessionStorage;
|
|
2899
3354
|
const k = def.key ?? key;
|
|
2900
|
-
let init:
|
|
3355
|
+
let init: JxScopeValue;
|
|
2901
3356
|
try {
|
|
2902
3357
|
const s = store.getItem(k);
|
|
2903
|
-
|
|
3358
|
+
/* SAFETY: `JSON.parse` answers the JSON value the stored text encodes, or throws, which the
|
|
3359
|
+
catch below answers with the entry's default. */
|
|
3360
|
+
init = s !== null ? (JSON.parse(s) as JsonValue) : (def.default ?? null);
|
|
2904
3361
|
} catch {
|
|
2905
3362
|
init = def.default ?? null;
|
|
2906
3363
|
}
|
|
2907
|
-
const storageState
|
|
3364
|
+
const storageState = scopeRef(init);
|
|
2908
3365
|
// Persist on change
|
|
2909
3366
|
effect(() => {
|
|
2910
3367
|
const v = storageState.value;
|
|
@@ -2923,28 +3380,33 @@ export async function resolvePrototype(
|
|
|
2923
3380
|
|
|
2924
3381
|
case "Cookie": {
|
|
2925
3382
|
const name = def.name ?? key;
|
|
2926
|
-
const read = () => {
|
|
3383
|
+
const read = (): JsonValue => {
|
|
2927
3384
|
const raw = readCookie(document.cookie, name);
|
|
2928
3385
|
if (raw === null) {
|
|
2929
3386
|
return null;
|
|
2930
3387
|
}
|
|
2931
3388
|
try {
|
|
2932
|
-
|
|
3389
|
+
/* SAFETY: `JSON.parse` answers the JSON value the cookie encodes, or throws, which the
|
|
3390
|
+
catch below answers with the raw text. */
|
|
3391
|
+
return JSON.parse(decodeURIComponent(raw)) as JsonValue;
|
|
2933
3392
|
} catch {
|
|
2934
3393
|
return raw;
|
|
2935
3394
|
}
|
|
2936
3395
|
};
|
|
2937
|
-
const cookieState
|
|
3396
|
+
const cookieState = scopeRef(read() ?? def.default ?? null);
|
|
2938
3397
|
// Persist on change
|
|
2939
3398
|
effect(() => {
|
|
3399
|
+
/* SAFETY: the store is seeded with JSON (`read` parses it, `default` is JSON) and holds
|
|
3400
|
+
what the page writes back; `serializeCookie` reads it only through `JSON.stringify`,
|
|
3401
|
+
which is defined for any value and keeps exactly the JSON a cookie can carry. */
|
|
2940
3402
|
// oxlint-disable-next-line unicorn/no-document-cookie -- the Cookie $prototype IS the cookie store binding
|
|
2941
|
-
document.cookie = serializeCookie(name, cookieState.value, def);
|
|
3403
|
+
document.cookie = serializeCookie(name, cookieState.value as JsonValue, def);
|
|
2942
3404
|
});
|
|
2943
3405
|
return cookieState;
|
|
2944
3406
|
}
|
|
2945
3407
|
|
|
2946
3408
|
case "IndexedDB": {
|
|
2947
|
-
const idbState: Ref<
|
|
3409
|
+
const idbState: Ref<JxScopeValue> = ref(null);
|
|
2948
3410
|
const {
|
|
2949
3411
|
database,
|
|
2950
3412
|
store,
|
|
@@ -2958,6 +3420,7 @@ export async function resolvePrototype(
|
|
|
2958
3420
|
}
|
|
2959
3421
|
const req = indexedDB.open(database, version);
|
|
2960
3422
|
req.addEventListener("upgradeneeded", (e) => {
|
|
3423
|
+
// SAFETY: an open request's events are dispatched at the request itself.
|
|
2961
3424
|
const db: IDBDatabase = (e.target as IDBOpenDBRequest).result;
|
|
2962
3425
|
if (!db.objectStoreNames.contains(store)) {
|
|
2963
3426
|
const os = db.createObjectStore(store, { autoIncrement, keyPath });
|
|
@@ -2967,6 +3430,7 @@ export async function resolvePrototype(
|
|
|
2967
3430
|
}
|
|
2968
3431
|
});
|
|
2969
3432
|
req.addEventListener("success", (e) => {
|
|
3433
|
+
// SAFETY: an open request's events are dispatched at the request itself.
|
|
2970
3434
|
const db: IDBDatabase = (e.target as IDBOpenDBRequest).result;
|
|
2971
3435
|
idbState.value = {
|
|
2972
3436
|
database,
|
|
@@ -2994,14 +3458,19 @@ export async function resolvePrototype(
|
|
|
2994
3458
|
case "FormData": {
|
|
2995
3459
|
const fd = new FormData();
|
|
2996
3460
|
for (const [k, v] of Object.entries(def.fields ?? {})) {
|
|
3461
|
+
/* SAFETY: `append(name, value)` converts a non-Blob value to a USVString itself, so a JSON
|
|
3462
|
+
number or boolean field reaches that conversion exactly as it always has. */
|
|
2997
3463
|
fd.append(k, v as string);
|
|
2998
3464
|
}
|
|
2999
3465
|
return fd;
|
|
3000
3466
|
}
|
|
3001
3467
|
|
|
3002
3468
|
case "Blob": {
|
|
3003
|
-
|
|
3004
|
-
|
|
3469
|
+
/* SAFETY: a Blob entry's `parts` is the BlobPart list its document declares; anything else
|
|
3470
|
+
reaches the Blob constructor, whose own WebIDL sequence check refuses it as before. */
|
|
3471
|
+
const parts = def.parts as BlobPart[] | null | undefined;
|
|
3472
|
+
return new Blob(parts ?? [], {
|
|
3473
|
+
type: isString(def.type) ? def.type : "text/plain",
|
|
3005
3474
|
});
|
|
3006
3475
|
}
|
|
3007
3476
|
|
|
@@ -3034,6 +3503,28 @@ const EXTERNAL_RESERVED = new Set([
|
|
|
3034
3503
|
"name",
|
|
3035
3504
|
]);
|
|
3036
3505
|
|
|
3506
|
+
/**
|
|
3507
|
+
* The config an external class is constructed with: the entry's own keys, minus the runtime's.
|
|
3508
|
+
*
|
|
3509
|
+
* @param {JxPrototypeDef} def
|
|
3510
|
+
* @returns {JxScope}
|
|
3511
|
+
*/
|
|
3512
|
+
function externalConfig(def: JxPrototypeDef): JxScope {
|
|
3513
|
+
const config: JxScope = {};
|
|
3514
|
+
for (const [k, v] of Object.entries(def)) {
|
|
3515
|
+
if (!EXTERNAL_RESERVED.has(k)) {
|
|
3516
|
+
config[k] = v;
|
|
3517
|
+
}
|
|
3518
|
+
}
|
|
3519
|
+
return config;
|
|
3520
|
+
}
|
|
3521
|
+
|
|
3522
|
+
/**
|
|
3523
|
+
* A class an external module exports, as the runtime uses it: a function, which is what `typeof`
|
|
3524
|
+
* says of every class, constructed with the entry's config.
|
|
3525
|
+
*/
|
|
3526
|
+
type ExternalClass = JxScopeFunction & (new (config: JxScope) => ExternalClassInstance);
|
|
3527
|
+
|
|
3037
3528
|
/**
|
|
3038
3529
|
* Resolve an external class prototype via $src.
|
|
3039
3530
|
*
|
|
@@ -3041,7 +3532,7 @@ const EXTERNAL_RESERVED = new Set([
|
|
|
3041
3532
|
* @param {JxScope} state
|
|
3042
3533
|
* @param {string} key
|
|
3043
3534
|
* @param {string} [base]
|
|
3044
|
-
* @returns {Promise<
|
|
3535
|
+
* @returns {Promise<Ref<JxScopeValue>>}
|
|
3045
3536
|
*/
|
|
3046
3537
|
async function resolveExternalPrototype(
|
|
3047
3538
|
def: JxPrototypeDef,
|
|
@@ -3066,13 +3557,18 @@ async function resolveExternalPrototype(
|
|
|
3066
3557
|
* Import a JS module and instantiate a class from it. Internal helper used by resolveClassJson for
|
|
3067
3558
|
* $implementation.
|
|
3068
3559
|
*
|
|
3069
|
-
* @param {
|
|
3560
|
+
* @param {JxPrototypeDef} def - Original state entry (for config extraction)
|
|
3070
3561
|
* @param {string} src - JS module URL to import
|
|
3071
3562
|
* @param {string} exportName - Export name to look up
|
|
3072
3563
|
* @param {string} [base] - Base URL for resolution
|
|
3073
|
-
* @returns {Promise<
|
|
3564
|
+
* @returns {Promise<Ref<JxScopeValue>>}
|
|
3074
3565
|
*/
|
|
3075
|
-
async function importAndInstantiate(
|
|
3566
|
+
async function importAndInstantiate(
|
|
3567
|
+
def: JxPrototypeDef,
|
|
3568
|
+
src: string,
|
|
3569
|
+
exportName: string,
|
|
3570
|
+
base?: string,
|
|
3571
|
+
) {
|
|
3076
3572
|
let mod: ImportedModule;
|
|
3077
3573
|
const seeded = seededModule(src);
|
|
3078
3574
|
if (seeded instanceof Promise) {
|
|
@@ -3081,11 +3577,11 @@ async function importAndInstantiate(def: JxScope, src: string, exportName: strin
|
|
|
3081
3577
|
mod = seeded;
|
|
3082
3578
|
} else {
|
|
3083
3579
|
try {
|
|
3084
|
-
mod =
|
|
3580
|
+
mod = await importModule(src);
|
|
3085
3581
|
} catch {
|
|
3086
3582
|
if (base) {
|
|
3087
3583
|
const resolvedSrc = new URL(src, base).href;
|
|
3088
|
-
mod =
|
|
3584
|
+
mod = await importModule(resolvedSrc);
|
|
3089
3585
|
} else {
|
|
3090
3586
|
throw new Error(`Failed to import "${src}"`);
|
|
3091
3587
|
}
|
|
@@ -3097,33 +3593,27 @@ async function importAndInstantiate(def: JxScope, src: string, exportName: strin
|
|
|
3097
3593
|
if (!ExportedClass) {
|
|
3098
3594
|
throw new Error(`Jx: export "${exportName}" not found in "${src}"`);
|
|
3099
3595
|
}
|
|
3100
|
-
if (
|
|
3596
|
+
if (!isFunction(ExportedClass)) {
|
|
3101
3597
|
throw new TypeError(`Jx: "${exportName}" from "${src}" is not a class`);
|
|
3102
3598
|
}
|
|
3103
3599
|
|
|
3104
|
-
|
|
3105
|
-
|
|
3106
|
-
|
|
3107
|
-
|
|
3108
|
-
|
|
3109
|
-
|
|
3110
|
-
|
|
3111
|
-
const Ctor = ExportedClass as new (config: JxScope) => ExternalClassInstance;
|
|
3112
|
-
const instance = new Ctor(config);
|
|
3600
|
+
/* SAFETY: trust boundary — the external class contract, spec.md §12.3. A `$implementation`
|
|
3601
|
+
module's export is written as a class constructed with the entry's config; `isFunction` is all
|
|
3602
|
+
that can be checked before `new`, and a function that is not constructible throws its own
|
|
3603
|
+
TypeError there, as it always has. The instance is read only through `ExternalClassInstance`'s
|
|
3604
|
+
optional members, each checked before it is used, and the one it offers is the entry's value. */
|
|
3605
|
+
const instance = new (ExportedClass as ExternalClass)(externalConfig(def));
|
|
3113
3606
|
|
|
3114
|
-
|
|
3115
|
-
|
|
3116
|
-
value
|
|
3117
|
-
|
|
3118
|
-
|
|
3119
|
-
} else {
|
|
3120
|
-
value = instance;
|
|
3121
|
-
}
|
|
3607
|
+
const value = isFunction(instance.resolve)
|
|
3608
|
+
? await instance.resolve()
|
|
3609
|
+
: "value" in instance
|
|
3610
|
+
? instance.value
|
|
3611
|
+
: instance;
|
|
3122
3612
|
|
|
3123
3613
|
// Always wrap in ref for reactivity with external classes
|
|
3124
|
-
const s
|
|
3125
|
-
if (
|
|
3126
|
-
instance.subscribe((newVal
|
|
3614
|
+
const s = scopeRef(value);
|
|
3615
|
+
if (isFunction(instance.subscribe)) {
|
|
3616
|
+
instance.subscribe((newVal) => {
|
|
3127
3617
|
s.value = newVal;
|
|
3128
3618
|
});
|
|
3129
3619
|
}
|
|
@@ -3141,7 +3631,7 @@ async function importAndInstantiate(def: JxScope, src: string, exportName: strin
|
|
|
3141
3631
|
* @param {JxScope} state
|
|
3142
3632
|
* @param {string} key
|
|
3143
3633
|
* @param {string} [base]
|
|
3144
|
-
* @returns {Promise<
|
|
3634
|
+
* @returns {Promise<Ref<JxScopeValue>>}
|
|
3145
3635
|
*/
|
|
3146
3636
|
async function resolveClassJson(def: JxPrototypeDef, state: JxScope, key: string, base?: string) {
|
|
3147
3637
|
const src = def.$src!;
|
|
@@ -3165,7 +3655,9 @@ async function resolveClassJson(def: JxPrototypeDef, state: JxScope, key: string
|
|
|
3165
3655
|
if (!res.ok) {
|
|
3166
3656
|
throw new Error(`HTTP ${res.status}`);
|
|
3167
3657
|
}
|
|
3168
|
-
|
|
3658
|
+
/* SAFETY: trust boundary. Only a `$src` ending in `.class.json` reaches this fetch
|
|
3659
|
+
(`resolveExternalPrototype` refuses anything else), and a 2xx answer for one is a class
|
|
3660
|
+
definition document; the runtime does not schema-validate documents by design. */
|
|
3169
3661
|
classDef = (await res.json()) as JxClassDef;
|
|
3170
3662
|
} catch {
|
|
3171
3663
|
// Fall back to dev proxy (server will handle .class.json resolution)
|
|
@@ -3192,71 +3684,81 @@ async function resolveClassJson(def: JxPrototypeDef, state: JxScope, key: string
|
|
|
3192
3684
|
|
|
3193
3685
|
// Self-contained: construct class dynamically from schema
|
|
3194
3686
|
const DynClass = classFromSchema(classDef);
|
|
3195
|
-
|
|
3196
|
-
|
|
3197
|
-
|
|
3198
|
-
|
|
3199
|
-
}
|
|
3200
|
-
}
|
|
3201
|
-
const instance = new DynClass(config) as ExternalClassInstance;
|
|
3687
|
+
/* SAFETY: every member of ExternalClassInstance is optional, and each is checked below before it
|
|
3688
|
+
is used, so any constructed object satisfies it; a schema class that defines `resolve`,
|
|
3689
|
+
`value` or `subscribe` methods or fields is the shape it names. */
|
|
3690
|
+
const instance = new DynClass(externalConfig(def)) as ExternalClassInstance;
|
|
3202
3691
|
|
|
3203
|
-
|
|
3204
|
-
|
|
3205
|
-
value
|
|
3206
|
-
|
|
3207
|
-
|
|
3208
|
-
} else {
|
|
3209
|
-
value = instance;
|
|
3210
|
-
}
|
|
3692
|
+
const value = isFunction(instance.resolve)
|
|
3693
|
+
? await instance.resolve()
|
|
3694
|
+
: "value" in instance
|
|
3695
|
+
? instance.value
|
|
3696
|
+
: instance;
|
|
3211
3697
|
|
|
3212
3698
|
// Always wrap in ref for reactivity
|
|
3213
|
-
const s
|
|
3214
|
-
if (
|
|
3215
|
-
instance.subscribe((newVal
|
|
3699
|
+
const s = scopeRef(value);
|
|
3700
|
+
if (isFunction(instance.subscribe)) {
|
|
3701
|
+
instance.subscribe((newVal) => {
|
|
3216
3702
|
s.value = newVal;
|
|
3217
3703
|
});
|
|
3218
3704
|
}
|
|
3219
3705
|
return s;
|
|
3220
3706
|
}
|
|
3221
3707
|
|
|
3708
|
+
/**
|
|
3709
|
+
* Whether a class document's `$defs.constructor` is its own constructor definition, rather than the
|
|
3710
|
+
* `Object.prototype.constructor` function every object parsed from JSON inherits.
|
|
3711
|
+
*
|
|
3712
|
+
* @param rawCtor What `$defs.constructor` reads as
|
|
3713
|
+
*/
|
|
3714
|
+
function isCtorDef(
|
|
3715
|
+
rawCtor: NonNullable<JxClassDef["$defs"]>["constructor"],
|
|
3716
|
+
): rawCtor is JxClassCtorDef {
|
|
3717
|
+
return isNonNullObject(rawCtor);
|
|
3718
|
+
}
|
|
3719
|
+
|
|
3222
3720
|
/**
|
|
3223
3721
|
* Dynamically construct a class from a .class.json schema definition. Browser-side: maps private
|
|
3224
3722
|
* fields to _-prefixed public fields.
|
|
3225
3723
|
*
|
|
3226
3724
|
* @param {import("@jxsuite/schema/types").JxClassDef} classDef
|
|
3227
|
-
* @returns {
|
|
3725
|
+
* @returns {typeof DynClass} A class whose instances hold the schema's fields by name
|
|
3228
3726
|
*/
|
|
3229
3727
|
function classFromSchema(classDef: JxClassDef) {
|
|
3230
3728
|
const fields = classDef.$defs?.fields ?? {};
|
|
3231
3729
|
// JSON objects inherit Object.prototype.constructor — only an own object value counts.
|
|
3232
3730
|
const rawCtor = classDef.$defs?.constructor;
|
|
3233
|
-
const ctor =
|
|
3731
|
+
const ctor = isCtorDef(rawCtor) ? rawCtor : undefined;
|
|
3234
3732
|
const methods = classDef.$defs?.methods ?? {};
|
|
3235
3733
|
|
|
3236
3734
|
// oxlint-disable-next-line typescript/no-extraneous-class -- methods are attached to the prototype dynamically below
|
|
3237
3735
|
class DynClass {
|
|
3238
|
-
|
|
3736
|
+
/** Static methods, attached by name below. */
|
|
3737
|
+
static [member: string]: JxScopeValue;
|
|
3738
|
+
/** An instance holds its fields, and reaches its methods, by the names the schema gives. */
|
|
3739
|
+
[member: string]: JxScopeValue;
|
|
3740
|
+
|
|
3741
|
+
constructor(config: JxScope = {}) {
|
|
3239
3742
|
for (const [key, typedField] of Object.entries(fields)) {
|
|
3240
3743
|
const id = typedField.identifier ?? key;
|
|
3241
3744
|
const propName = typedField.access === "private" ? `_${id}` : id;
|
|
3242
3745
|
if (config[id] !== undefined) {
|
|
3243
|
-
|
|
3746
|
+
this[propName] = config[id];
|
|
3244
3747
|
} else if (typedField.initializer !== undefined) {
|
|
3245
|
-
|
|
3748
|
+
this[propName] = typedField.initializer;
|
|
3246
3749
|
} else if (typedField.default !== undefined) {
|
|
3247
|
-
|
|
3750
|
+
this[propName] = structuredClone(typedField.default);
|
|
3248
3751
|
} else {
|
|
3249
|
-
|
|
3752
|
+
this[propName] = null;
|
|
3250
3753
|
}
|
|
3251
3754
|
}
|
|
3252
3755
|
if (ctor?.body) {
|
|
3253
3756
|
const bodyStr = Array.isArray(ctor.body) ? ctor.body.join("\n") : ctor.body;
|
|
3254
|
-
|
|
3255
|
-
|
|
3256
|
-
|
|
3257
|
-
|
|
3258
|
-
|
|
3259
|
-
).call(this, config);
|
|
3757
|
+
/* SAFETY: `new Function` returns an ordinary function of the one named parameter; `call`
|
|
3758
|
+
runs it with the instance under construction as `this`, which is the constructor
|
|
3759
|
+
body's contract. */
|
|
3760
|
+
const init = new Function("config", bodyStr) as (this: DynClass, config: JxScope) => void;
|
|
3761
|
+
init.call(this, config);
|
|
3260
3762
|
}
|
|
3261
3763
|
}
|
|
3262
3764
|
}
|
|
@@ -3265,10 +3767,11 @@ function classFromSchema(classDef: JxClassDef) {
|
|
|
3265
3767
|
const name = typedMethod.identifier ?? key;
|
|
3266
3768
|
const params = (typedMethod.parameters ?? []).map((p) => {
|
|
3267
3769
|
if (p.$ref) {
|
|
3268
|
-
|
|
3770
|
+
// `split` always yields at least one segment, so `pop` always finds one.
|
|
3771
|
+
return p.$ref.split("/").pop() ?? "";
|
|
3269
3772
|
}
|
|
3270
3773
|
const n = p.identifier ?? p.name;
|
|
3271
|
-
return
|
|
3774
|
+
return isString(n) ? n : "arg";
|
|
3272
3775
|
});
|
|
3273
3776
|
const bodyStr = Array.isArray(typedMethod.body)
|
|
3274
3777
|
? typedMethod.body.join("\n")
|
|
@@ -3277,22 +3780,28 @@ function classFromSchema(classDef: JxClassDef) {
|
|
|
3277
3780
|
if (typedMethod.role === "accessor") {
|
|
3278
3781
|
const descriptor: PropertyDescriptor = {};
|
|
3279
3782
|
if (typedMethod.getter) {
|
|
3280
|
-
|
|
3783
|
+
/* SAFETY: `new Function` returns an ordinary function; as a getter it is called with no
|
|
3784
|
+
arguments and the instance as `this`, and what it returns is the property's value. */
|
|
3785
|
+
descriptor.get = new Function(typedMethod.getter.body ?? "") as () => JxScopeValue;
|
|
3281
3786
|
}
|
|
3282
3787
|
if (typedMethod.setter) {
|
|
3283
3788
|
const sp = (typedMethod.setter.parameters ?? []).map(
|
|
3284
3789
|
(p) => p.$ref?.split("/").pop() ?? "v",
|
|
3285
3790
|
);
|
|
3286
|
-
|
|
3791
|
+
/* SAFETY: `new Function` returns an ordinary function of the named parameters; as a setter
|
|
3792
|
+
it receives the assigned value as its one argument, and its return is discarded. */
|
|
3793
|
+
descriptor.set = new Function(...sp, typedMethod.setter.body ?? "") as (
|
|
3794
|
+
v: JxScopeValue,
|
|
3795
|
+
) => void;
|
|
3287
3796
|
}
|
|
3288
3797
|
Object.defineProperty(DynClass.prototype, name, {
|
|
3289
3798
|
...descriptor,
|
|
3290
3799
|
configurable: true,
|
|
3291
3800
|
});
|
|
3292
3801
|
} else if (typedMethod.scope === "static") {
|
|
3293
|
-
|
|
3802
|
+
DynClass[name] = new Function(...params, bodyStr);
|
|
3294
3803
|
} else {
|
|
3295
|
-
|
|
3804
|
+
DynClass.prototype[name] = new Function(...params, bodyStr);
|
|
3296
3805
|
}
|
|
3297
3806
|
}
|
|
3298
3807
|
|
|
@@ -3300,8 +3809,7 @@ function classFromSchema(classDef: JxClassDef) {
|
|
|
3300
3809
|
configurable: true,
|
|
3301
3810
|
value: classDef.title,
|
|
3302
3811
|
});
|
|
3303
|
-
|
|
3304
|
-
return dynCtor;
|
|
3812
|
+
return DynClass;
|
|
3305
3813
|
}
|
|
3306
3814
|
|
|
3307
3815
|
/**
|
|
@@ -3313,17 +3821,12 @@ function classFromSchema(classDef: JxClassDef) {
|
|
|
3313
3821
|
* @param {JxScope} state
|
|
3314
3822
|
* @param {string} key
|
|
3315
3823
|
* @param {string} [base]
|
|
3316
|
-
* @returns {Promise<
|
|
3824
|
+
* @returns {Promise<Ref<JxScopeValue>>}
|
|
3317
3825
|
*/
|
|
3318
3826
|
async function resolveViaDevProxy(def: JxPrototypeDef, state: JxScope, key: string, base?: string) {
|
|
3319
|
-
const config
|
|
3320
|
-
for (const [k, v] of Object.entries(def)) {
|
|
3321
|
-
if (!EXTERNAL_RESERVED.has(k)) {
|
|
3322
|
-
config[k] = v;
|
|
3323
|
-
}
|
|
3324
|
-
}
|
|
3827
|
+
const config = externalConfig(def);
|
|
3325
3828
|
|
|
3326
|
-
const hasTemplates = Object.values(config).some((v
|
|
3829
|
+
const hasTemplates = Object.values(config).some((v) => isTemplateString(v));
|
|
3327
3830
|
|
|
3328
3831
|
/** @param {JxScope} resolvedConfig */
|
|
3329
3832
|
const doResolve = (resolvedConfig: JxScope) =>
|
|
@@ -3337,15 +3840,16 @@ async function resolveViaDevProxy(def: JxPrototypeDef, state: JxScope, key: stri
|
|
|
3337
3840
|
}),
|
|
3338
3841
|
headers: { "Content-Type": "application/json" },
|
|
3339
3842
|
method: "POST",
|
|
3340
|
-
}).then((r) => {
|
|
3843
|
+
}).then((r): Promise<JsonValue> => {
|
|
3341
3844
|
if (!r.ok) {
|
|
3342
3845
|
throw new Error(`Jx dev proxy ${r.status} for "${key}"`);
|
|
3343
3846
|
}
|
|
3847
|
+
// The dev proxy answers with the resolved value serialized as JSON.
|
|
3344
3848
|
return r.json();
|
|
3345
3849
|
});
|
|
3346
3850
|
|
|
3347
3851
|
// Always wrap in ref for reactivity
|
|
3348
|
-
const s: Ref<
|
|
3852
|
+
const s: Ref<JxScopeValue> = ref(null);
|
|
3349
3853
|
if (hasTemplates) {
|
|
3350
3854
|
effect(() => {
|
|
3351
3855
|
const resolvedConfig: JxScope = {};
|
|
@@ -3353,32 +3857,85 @@ async function resolveViaDevProxy(def: JxPrototypeDef, state: JxScope, key: stri
|
|
|
3353
3857
|
resolvedConfig[k] = isTemplateString(v) ? evaluateTemplate(v, state) : v;
|
|
3354
3858
|
}
|
|
3355
3859
|
doResolve(resolvedConfig)
|
|
3356
|
-
.then((value
|
|
3860
|
+
.then((value) => {
|
|
3357
3861
|
s.value = value;
|
|
3358
3862
|
})
|
|
3359
|
-
.catch((error
|
|
3863
|
+
.catch((error) => console.error("Jx dev proxy:", error));
|
|
3360
3864
|
});
|
|
3361
3865
|
} else {
|
|
3362
3866
|
doResolve(config)
|
|
3363
|
-
.then((value
|
|
3867
|
+
.then((value) => {
|
|
3364
3868
|
s.value = value;
|
|
3365
3869
|
})
|
|
3366
|
-
.catch((error
|
|
3870
|
+
.catch((error) => console.error("Jx dev proxy:", error));
|
|
3367
3871
|
}
|
|
3368
3872
|
return s;
|
|
3369
3873
|
}
|
|
3370
3874
|
|
|
3371
|
-
// ─── Server function resolution
|
|
3875
|
+
// ─── Server function resolution ───────────────────────────────────────────────
|
|
3372
3876
|
|
|
3373
3877
|
/**
|
|
3374
|
-
*
|
|
3375
|
-
*
|
|
3376
|
-
*
|
|
3377
|
-
*
|
|
3878
|
+
* Start one `timing: "server"` entry's calls, wherever they run. Each call takes the next number,
|
|
3879
|
+
* and only the latest may write the entry or report a failure: the answer to a call whose arguments
|
|
3880
|
+
* have since changed is dropped whenever it arrives, and a failed call keeps the entry's last
|
|
3881
|
+
* value. A call that throws before it returns a promise (an argument the proxy cannot serialize, or
|
|
3882
|
+
* a host function that throws synchronously) fails the same way, rather than out of whichever state
|
|
3883
|
+
* write started it. The proxy and a host-registered module share it, so a reactive entry's calls
|
|
3884
|
+
* are ordered and reported the same way on either path.
|
|
3885
|
+
*
|
|
3886
|
+
* @param {Ref<JxScopeValue>} s - The entry's value
|
|
3887
|
+
* @param {ServerCall} invoke - One call, with resolved arguments: it may return a value or a
|
|
3888
|
+
* promise, or throw
|
|
3889
|
+
* @param {string} label - The `console.error` prefix a failed call is reported under
|
|
3890
|
+
* @returns {(args: JxScope) => void} Starts a call
|
|
3891
|
+
*/
|
|
3892
|
+
function latestServerCall(
|
|
3893
|
+
s: Ref<JxScopeValue>,
|
|
3894
|
+
invoke: ServerCall,
|
|
3895
|
+
label: string,
|
|
3896
|
+
): (args: JxScope) => void {
|
|
3897
|
+
let seq = 0;
|
|
3898
|
+
return (args: JxScope) => {
|
|
3899
|
+
seq += 1;
|
|
3900
|
+
const n = seq;
|
|
3901
|
+
/* The executor runs before the constructor returns, so the call still starts synchronously,
|
|
3902
|
+
and a throw inside it rejects this promise instead of escaping to the caller. */
|
|
3903
|
+
new Promise<JsonValue>((settle) => {
|
|
3904
|
+
settle(invoke(args));
|
|
3905
|
+
})
|
|
3906
|
+
.then((result) => {
|
|
3907
|
+
if (n === seq) {
|
|
3908
|
+
s.value = result;
|
|
3909
|
+
}
|
|
3910
|
+
})
|
|
3911
|
+
.catch((error) => {
|
|
3912
|
+
if (n === seq) {
|
|
3913
|
+
console.error(label, error);
|
|
3914
|
+
}
|
|
3915
|
+
});
|
|
3916
|
+
};
|
|
3917
|
+
}
|
|
3918
|
+
|
|
3919
|
+
/**
|
|
3920
|
+
* One call of a `timing: "server"` function, with its resolved arguments. Its answer is JSON, or a
|
|
3921
|
+
* promise of JSON: on the proxy path the answer crosses the wire as JSON, and a host-registered
|
|
3922
|
+
* module honours the same contract so the two paths are interchangeable.
|
|
3923
|
+
*/
|
|
3924
|
+
type ServerCall = (args: JxScope) => JsonValue | Promise<JsonValue>;
|
|
3925
|
+
|
|
3926
|
+
/**
|
|
3927
|
+
* Resolve a timing: "server" entry. The runtime never imports a server module itself: every call is
|
|
3928
|
+
* a POST to the host's `/__jx_server__` proxy, which runs the function server-side with `env`, so
|
|
3929
|
+
* the runtime never requests the module's source (spec.md §11.4). A host may still serve that file
|
|
3930
|
+
* at its URL, which is why a secret belongs in `env` and never in the module body. The one
|
|
3931
|
+
* exception is the host's own act: a module it registered with `preloadModule` (embedding.md §6) is
|
|
3932
|
+
* called in process, and a reactive entry's calls are ordered there as the proxy orders them.
|
|
3933
|
+
*
|
|
3934
|
+
* @param {JxServerFnDef} def
|
|
3378
3935
|
* @param {JxScope} state
|
|
3379
3936
|
* @param {string} key
|
|
3380
3937
|
* @param {string} [base]
|
|
3381
|
-
* @returns {Promise<
|
|
3938
|
+
* @returns {Promise<Ref<JxScopeValue>>}
|
|
3382
3939
|
*/
|
|
3383
3940
|
async function resolveServerFunction(
|
|
3384
3941
|
def: JxServerFnDef,
|
|
@@ -3389,61 +3946,44 @@ async function resolveServerFunction(
|
|
|
3389
3946
|
const src = def.$src;
|
|
3390
3947
|
const exportName = def.$export;
|
|
3391
3948
|
|
|
3392
|
-
|
|
3393
|
-
|
|
3394
|
-
|
|
3395
|
-
|
|
3396
|
-
|
|
3397
|
-
mod = seeded;
|
|
3398
|
-
} else {
|
|
3399
|
-
try {
|
|
3400
|
-
mod = (await import(src)) as ImportedModule;
|
|
3401
|
-
} catch {
|
|
3402
|
-
if (base) {
|
|
3403
|
-
try {
|
|
3404
|
-
const resolvedSrc = new URL(src, base).href;
|
|
3405
|
-
mod = (await import(resolvedSrc)) as ImportedModule;
|
|
3406
|
-
} catch {
|
|
3407
|
-
// Module cannot run in the browser — fall back to dev server proxy
|
|
3408
|
-
return resolveServerFunctionViaProxy(def, state, key, base);
|
|
3409
|
-
}
|
|
3410
|
-
} else {
|
|
3411
|
-
return resolveServerFunctionViaProxy(def, state, key, base);
|
|
3412
|
-
}
|
|
3413
|
-
}
|
|
3414
|
-
_moduleCache.set(src, mod);
|
|
3949
|
+
/* The host-seeded set, not the module cache: a Function entry's own `$src` import lands in that
|
|
3950
|
+
cache too, and reusing it here would put a server module in the browser after all. */
|
|
3951
|
+
const seeded = _hostSeeded.has(src) ? seededModule(src) : undefined;
|
|
3952
|
+
if (!seeded) {
|
|
3953
|
+
return resolveServerFunctionViaProxy(def, state, key, base);
|
|
3415
3954
|
}
|
|
3955
|
+
const mod = seeded instanceof Promise ? await seeded : seeded;
|
|
3416
3956
|
|
|
3417
3957
|
const candidate = mod[exportName] ?? mod.default?.[exportName];
|
|
3418
3958
|
if (!candidate) {
|
|
3419
3959
|
throw new Error(`Jx: export "${exportName}" not found in "${src}" for "${key}"`);
|
|
3420
3960
|
}
|
|
3421
|
-
if (
|
|
3961
|
+
if (!isFunction(candidate)) {
|
|
3422
3962
|
throw new TypeError(`Jx: "${exportName}" from "${src}" is not a function`);
|
|
3423
3963
|
}
|
|
3424
|
-
|
|
3964
|
+
/* SAFETY: the guard proved a function, and a function accepts any call. A `timing: "server"`
|
|
3965
|
+
export is written to be called through the proxy, which hands it one arguments object and
|
|
3966
|
+
sends its answer back as JSON, so that is the contract it is held to in process too. */
|
|
3967
|
+
const fn = candidate as ServerCall;
|
|
3425
3968
|
|
|
3426
3969
|
const rawArgs = def.arguments ?? {};
|
|
3427
|
-
const hasReactiveArg = Object.values(rawArgs).some((v
|
|
3970
|
+
const hasReactiveArg = Object.values(rawArgs).some((v) => isRefValue(v));
|
|
3428
3971
|
const resolveArgs = () => {
|
|
3429
3972
|
const args: JxScope = {};
|
|
3430
3973
|
for (const [k, v] of Object.entries(rawArgs)) {
|
|
3431
|
-
args[k] =
|
|
3974
|
+
args[k] = isRefValue(v) ? resolveRef(v.$ref, state) : v;
|
|
3432
3975
|
}
|
|
3433
3976
|
return args;
|
|
3434
3977
|
};
|
|
3435
3978
|
|
|
3436
3979
|
// Always wrap in ref for reactivity
|
|
3437
|
-
const s: Ref<
|
|
3980
|
+
const s: Ref<JxScopeValue> = ref(null);
|
|
3438
3981
|
if (hasReactiveArg) {
|
|
3982
|
+
const call = latestServerCall(s, fn, "Jx server function:");
|
|
3439
3983
|
effect(() => {
|
|
3440
3984
|
const args = resolveArgs();
|
|
3441
3985
|
onEffectCleanup(() => {});
|
|
3442
|
-
|
|
3443
|
-
.then((result: unknown) => {
|
|
3444
|
-
s.value = result;
|
|
3445
|
-
})
|
|
3446
|
-
.catch(() => {});
|
|
3986
|
+
call(args);
|
|
3447
3987
|
});
|
|
3448
3988
|
} else {
|
|
3449
3989
|
s.value = await fn(resolveArgs());
|
|
@@ -3452,29 +3992,32 @@ async function resolveServerFunction(
|
|
|
3452
3992
|
}
|
|
3453
3993
|
|
|
3454
3994
|
/**
|
|
3455
|
-
*
|
|
3456
|
-
*
|
|
3457
|
-
*
|
|
3458
|
-
*
|
|
3459
|
-
*
|
|
3995
|
+
* Call a timing: "server" entry through the host's `/__jx_server__` proxy, which imports the module
|
|
3996
|
+
* server-side and calls the export with `env` (server.md §3.3). This is the only path an
|
|
3997
|
+
* interpreting runtime takes for a module the host did not register. A `$ref` argument makes the
|
|
3998
|
+
* call reactive: it re-posts from an `effect()` when the reference changes. A failed call leaves
|
|
3999
|
+
* the entry's last value and logs, and the answer to a superseded call is dropped, so a slow answer
|
|
4000
|
+
* to an earlier call can never overwrite a later one (`latestServerCall`).
|
|
4001
|
+
*
|
|
4002
|
+
* @param {JxServerFnDef} def
|
|
3460
4003
|
* @param {JxScope} state
|
|
3461
4004
|
* @param {string} key
|
|
3462
4005
|
* @param {string} [base]
|
|
3463
|
-
* @returns {Promise<
|
|
4006
|
+
* @returns {Promise<Ref<JxScopeValue>>}
|
|
3464
4007
|
*/
|
|
3465
4008
|
async function resolveServerFunctionViaProxy(
|
|
3466
|
-
def:
|
|
4009
|
+
def: JxServerFnDef,
|
|
3467
4010
|
state: JxScope,
|
|
3468
4011
|
key: string,
|
|
3469
4012
|
base?: string,
|
|
3470
4013
|
) {
|
|
3471
4014
|
const rawArgs = def.arguments ?? {};
|
|
3472
|
-
const hasReactiveArg = Object.values(rawArgs).some((v
|
|
4015
|
+
const hasReactiveArg = Object.values(rawArgs).some((v) => isRefValue(v));
|
|
3473
4016
|
|
|
3474
4017
|
const resolveArgs = () => {
|
|
3475
4018
|
const args: JxScope = {};
|
|
3476
4019
|
for (const [k, v] of Object.entries(rawArgs)) {
|
|
3477
|
-
args[k] =
|
|
4020
|
+
args[k] = isRefValue(v) ? resolveRef(v.$ref, state) : v;
|
|
3478
4021
|
}
|
|
3479
4022
|
return args;
|
|
3480
4023
|
};
|
|
@@ -3490,31 +4033,25 @@ async function resolveServerFunctionViaProxy(
|
|
|
3490
4033
|
}),
|
|
3491
4034
|
headers: { "Content-Type": "application/json" },
|
|
3492
4035
|
method: "POST",
|
|
3493
|
-
}).then((r) => {
|
|
4036
|
+
}).then((r): Promise<JsonValue> => {
|
|
3494
4037
|
if (!r.ok) {
|
|
3495
4038
|
throw new Error(`Jx server proxy ${r.status} for "${key}"`);
|
|
3496
4039
|
}
|
|
4040
|
+
// The proxy answers with the function's result serialized as JSON.
|
|
3497
4041
|
return r.json();
|
|
3498
4042
|
});
|
|
3499
4043
|
|
|
3500
4044
|
// Always wrap in ref for reactivity
|
|
3501
|
-
const s: Ref<
|
|
4045
|
+
const s: Ref<JxScopeValue> = ref(null);
|
|
4046
|
+
const call = latestServerCall(s, doResolve, "Jx server proxy:");
|
|
3502
4047
|
if (hasReactiveArg) {
|
|
3503
4048
|
effect(() => {
|
|
3504
4049
|
const args = resolveArgs();
|
|
3505
4050
|
onEffectCleanup(() => {});
|
|
3506
|
-
|
|
3507
|
-
.then((result: unknown) => {
|
|
3508
|
-
s.value = result;
|
|
3509
|
-
})
|
|
3510
|
-
.catch((error: unknown) => console.error("Jx server proxy:", error));
|
|
4051
|
+
call(args);
|
|
3511
4052
|
});
|
|
3512
4053
|
} else {
|
|
3513
|
-
|
|
3514
|
-
.then((result: unknown) => {
|
|
3515
|
-
s.value = result;
|
|
3516
|
-
})
|
|
3517
|
-
.catch((error: unknown) => console.error("Jx server proxy:", error));
|
|
4054
|
+
call(resolveArgs());
|
|
3518
4055
|
}
|
|
3519
4056
|
return s;
|
|
3520
4057
|
}
|
|
@@ -3527,10 +4064,11 @@ async function resolveServerFunctionViaProxy(
|
|
|
3527
4064
|
*
|
|
3528
4065
|
* @param {string} ref
|
|
3529
4066
|
* @param {JxScope} state - Reactive scope proxy (or child scope)
|
|
3530
|
-
* @returns {
|
|
4067
|
+
* @returns {JxScopeValue}
|
|
3531
4068
|
*/
|
|
3532
4069
|
export function resolveRef(refPath: string, state: JxScope) {
|
|
3533
|
-
|
|
4070
|
+
// A `$ref` read from a document is not schema-validated, so a non-string one passes through.
|
|
4071
|
+
if (!isString(refPath)) {
|
|
3534
4072
|
return refPath;
|
|
3535
4073
|
}
|
|
3536
4074
|
if (refPath.startsWith("$map/") || refPath.startsWith("#/$map/")) {
|
|
@@ -3539,9 +4077,11 @@ export function resolveRef(refPath: string, state: JxScope) {
|
|
|
3539
4077
|
rendered ZERO rows with no warning. */
|
|
3540
4078
|
const parts = (refPath.startsWith("#/") ? refPath.slice(2) : refPath).split("/");
|
|
3541
4079
|
const [, key] = parts; // "item" or "index"
|
|
3542
|
-
|
|
4080
|
+
/* SAFETY: `$map` holds only the row record `renderMappedArrayInto` binds (see `rowContext`), an
|
|
4081
|
+
ordinary object read here by whichever member the ref names. */
|
|
4082
|
+
const map = state.$map as Record<string, JxScopeValue> | undefined;
|
|
3543
4083
|
const base = map?.[key!] ?? state[`$map/${key}`];
|
|
3544
|
-
return parts.length > 2 ?
|
|
4084
|
+
return parts.length > 2 ? readPath(base, parts.slice(2).join("/")) : base;
|
|
3545
4085
|
}
|
|
3546
4086
|
if (refPath.startsWith("#/state/")) {
|
|
3547
4087
|
// One call, not a hand-split leading token: slicing at the first `/` skipped unescaping it, so
|
|
@@ -3556,10 +4096,10 @@ export function resolveRef(refPath: string, state: JxScope) {
|
|
|
3556
4096
|
return readPath(state, refPath.slice("parent#/".length));
|
|
3557
4097
|
}
|
|
3558
4098
|
if (refPath.startsWith("window#/")) {
|
|
3559
|
-
return
|
|
4099
|
+
return readPath(globalThis.window, refPath.slice("window#/".length));
|
|
3560
4100
|
}
|
|
3561
4101
|
if (refPath.startsWith("document#/")) {
|
|
3562
|
-
return
|
|
4102
|
+
return readPath(globalThis.document, refPath.slice("document#/".length));
|
|
3563
4103
|
}
|
|
3564
4104
|
return readPath(state, refPath) ?? null;
|
|
3565
4105
|
}
|
|
@@ -3570,32 +4110,12 @@ export function resolveRef(refPath: string, state: JxScope) {
|
|
|
3570
4110
|
* Check if v is a Vue ref (including computed).
|
|
3571
4111
|
*
|
|
3572
4112
|
* @param {unknown} v
|
|
3573
|
-
* @returns {
|
|
4113
|
+
* @returns {v is Ref<unknown>}
|
|
3574
4114
|
*/
|
|
3575
|
-
export function isSignal(v: unknown) {
|
|
4115
|
+
export function isSignal(v: unknown): v is Ref<unknown> {
|
|
3576
4116
|
return isRef(v);
|
|
3577
4117
|
}
|
|
3578
4118
|
|
|
3579
|
-
/**
|
|
3580
|
-
* @param {unknown} v
|
|
3581
|
-
* @returns {v is import("@jxsuite/schema/types").JxRef}
|
|
3582
|
-
*/
|
|
3583
|
-
function isRefObj(v: unknown): v is JxRef {
|
|
3584
|
-
return isRefValue(v);
|
|
3585
|
-
}
|
|
3586
|
-
|
|
3587
|
-
/**
|
|
3588
|
-
* Walk a path off a value. Delegates to the one tokenizer (`pointer.ts`) — this used to split on
|
|
3589
|
-
* `/[./]/`, which no other path in the codebase agreed with.
|
|
3590
|
-
*
|
|
3591
|
-
* @param {unknown} obj
|
|
3592
|
-
* @param {string} path
|
|
3593
|
-
* @returns {unknown}
|
|
3594
|
-
*/
|
|
3595
|
-
function getPath(obj: unknown, path: string) {
|
|
3596
|
-
return readPath(obj, path);
|
|
3597
|
-
}
|
|
3598
|
-
|
|
3599
4119
|
/**
|
|
3600
4120
|
* Write an observed attribute into element state, coerced by the type the state entry already holds
|
|
3601
4121
|
* (spec §16.5): a number parses, a boolean is presence (`"false"` counts as absent), and anything
|
|
@@ -3615,40 +4135,39 @@ function absorbAttribute(
|
|
|
3615
4135
|
state: JxScope,
|
|
3616
4136
|
name: string,
|
|
3617
4137
|
value: string | null,
|
|
3618
|
-
defaults?: ReadonlyMap<string,
|
|
4138
|
+
defaults?: ReadonlyMap<string, JxScopeValue>,
|
|
3619
4139
|
): void {
|
|
3620
4140
|
const camelKey = name.replaceAll(/-([a-z])/g, (_: string, c: string) => c.toUpperCase());
|
|
3621
4141
|
const current = state[camelKey];
|
|
3622
|
-
if (
|
|
4142
|
+
if (isBoolean(current)) {
|
|
3623
4143
|
// Presence IS the value for a boolean, so a removal is already spelled by the same rule.
|
|
3624
4144
|
state[camelKey] = value !== null && value !== "false";
|
|
3625
4145
|
return;
|
|
3626
4146
|
}
|
|
3627
4147
|
if (value === null) {
|
|
3628
4148
|
const declared = defaults?.get(camelKey);
|
|
3629
|
-
state[camelKey] = declared === undefined ? (
|
|
4149
|
+
state[camelKey] = declared === undefined ? (isNumber(current) ? 0 : "") : declared;
|
|
3630
4150
|
return;
|
|
3631
4151
|
}
|
|
3632
|
-
state[camelKey] =
|
|
4152
|
+
state[camelKey] = isNumber(current) ? Number(value) : value;
|
|
3633
4153
|
}
|
|
3634
4154
|
|
|
3635
4155
|
/**
|
|
3636
4156
|
* The declared default of every state entry that has one, by state key.
|
|
3637
4157
|
*
|
|
3638
4158
|
* @param def The element definition
|
|
3639
|
-
* @returns {Map<string,
|
|
4159
|
+
* @returns {Map<string, JxScopeValue>} Key → default
|
|
3640
4160
|
*/
|
|
3641
|
-
function declaredDefaults(def: JxElement): Map<string,
|
|
3642
|
-
const out = new Map<string,
|
|
4161
|
+
function declaredDefaults(def: JxElement): Map<string, JxScopeValue> {
|
|
4162
|
+
const out = new Map<string, JxScopeValue>();
|
|
3643
4163
|
for (const [key, entry] of Object.entries(def.state ?? {})) {
|
|
3644
|
-
if (entry
|
|
3645
|
-
|
|
3646
|
-
|
|
3647
|
-
out.set(key, obj["default"]);
|
|
4164
|
+
if (isNonNullObject(entry)) {
|
|
4165
|
+
if ("default" in entry) {
|
|
4166
|
+
out.set(key, entry.default);
|
|
3648
4167
|
continue;
|
|
3649
4168
|
}
|
|
3650
4169
|
// A computed entry has no default: its value is produced, and a removal leaves it alone.
|
|
3651
|
-
if (COMPUTED_STATE_KEYS.some((marker) => marker in
|
|
4170
|
+
if (COMPUTED_STATE_KEYS.some((marker) => marker in entry)) {
|
|
3652
4171
|
continue;
|
|
3653
4172
|
}
|
|
3654
4173
|
}
|
|
@@ -3709,6 +4228,27 @@ function instanceSupplies(el: HTMLElement, key: string): boolean {
|
|
|
3709
4228
|
return kebab !== key && el.hasAttribute(kebab);
|
|
3710
4229
|
}
|
|
3711
4230
|
|
|
4231
|
+
/**
|
|
4232
|
+
* The platform's setter for `key` on an element, when the name is one the platform already owns.
|
|
4233
|
+
*
|
|
4234
|
+
* @param {HTMLElement} el
|
|
4235
|
+
* @param {string} key
|
|
4236
|
+
* @returns {((value: JxScopeValue) => void) | undefined}
|
|
4237
|
+
*/
|
|
4238
|
+
function nativeSetter(el: HTMLElement, key: string): ((value: JxScopeValue) => void) | undefined {
|
|
4239
|
+
for (
|
|
4240
|
+
let proto: object | null = Reflect.getPrototypeOf(el);
|
|
4241
|
+
proto;
|
|
4242
|
+
proto = Reflect.getPrototypeOf(proto)
|
|
4243
|
+
) {
|
|
4244
|
+
const descriptor = Object.getOwnPropertyDescriptor(proto, key);
|
|
4245
|
+
if (descriptor) {
|
|
4246
|
+
return descriptor.set;
|
|
4247
|
+
}
|
|
4248
|
+
}
|
|
4249
|
+
return undefined;
|
|
4250
|
+
}
|
|
4251
|
+
|
|
3712
4252
|
function refusePrivateProp(key: string, where?: string): boolean {
|
|
3713
4253
|
if (!isPrivateStateKey(key)) {
|
|
3714
4254
|
return false;
|
|
@@ -3736,12 +4276,12 @@ export function _resetPrivatePropWarnings(): void {
|
|
|
3736
4276
|
* @returns {JxScope}
|
|
3737
4277
|
*/
|
|
3738
4278
|
function mergeProps(def: JxElement, parentState: JxScope): JxScope {
|
|
3739
|
-
const child =
|
|
4279
|
+
const child = inheritScope(parentState);
|
|
3740
4280
|
for (const [k, v] of Object.entries(def.$props ?? {})) {
|
|
3741
4281
|
if (refusePrivateProp(k, "$props")) {
|
|
3742
4282
|
continue;
|
|
3743
4283
|
}
|
|
3744
|
-
child[k] =
|
|
4284
|
+
child[k] = isRefValue(v) ? resolveRef(v.$ref, parentState) : v;
|
|
3745
4285
|
}
|
|
3746
4286
|
return child;
|
|
3747
4287
|
}
|
|
@@ -3782,12 +4322,12 @@ export type { CssBuildOptions, CssKeyframeBlock, CssRule, CssRuleTarget } from "
|
|
|
3782
4322
|
* custom property survives it: `--fooBar` and `--foo-bar` are two different properties, and
|
|
3783
4323
|
* kebab-casing one renames it out from under the `var()` that reads it.
|
|
3784
4324
|
*
|
|
3785
|
-
* @param {
|
|
4325
|
+
* @param {JxStyle} rules
|
|
3786
4326
|
* @returns {string}
|
|
3787
4327
|
*/
|
|
3788
|
-
export function toCSSText(rules:
|
|
4328
|
+
export function toCSSText(rules: JxStyle) {
|
|
3789
4329
|
return Object.entries(rules)
|
|
3790
|
-
.filter(([k, v]) => !isNestedSelectorKey(k) && (v
|
|
4330
|
+
.filter(([k, v]) => !isNestedSelectorKey(k) && !isNonNullObject(v))
|
|
3791
4331
|
.map(([p, v]) => `${cssPropertyName(p)}: ${canvasStyleValue(String(v))}`)
|
|
3792
4332
|
.join("; ");
|
|
3793
4333
|
}
|
|
@@ -3855,9 +4395,9 @@ export async function redefineElement(doc: JxDocument, baseUrl?: string): Promis
|
|
|
3855
4395
|
* before `buildScope`, and re-set it every render so a stale map from a previous document cannot
|
|
3856
4396
|
* leak.
|
|
3857
4397
|
*
|
|
3858
|
-
* @param {Record<string, string>} map
|
|
4398
|
+
* @param {Record<string, string> | null} [map] - Absent or null clears it
|
|
3859
4399
|
*/
|
|
3860
|
-
export function setRootMedia(map
|
|
4400
|
+
export function setRootMedia(map?: Record<string, string> | null): void {
|
|
3861
4401
|
_rootMedia = map ?? {};
|
|
3862
4402
|
}
|
|
3863
4403
|
|
|
@@ -3875,7 +4415,7 @@ async function registerElements(
|
|
|
3875
4415
|
) {
|
|
3876
4416
|
for (const entry of elements) {
|
|
3877
4417
|
// Bare string: npm package side-effect import (registers custom elements)
|
|
3878
|
-
if (
|
|
4418
|
+
if (isString(entry)) {
|
|
3879
4419
|
try {
|
|
3880
4420
|
// Bare specifiers need a URL path for the browser; the dev server resolves
|
|
3881
4421
|
// /node_modules/<pkg> to the package entry point via exports/module/main.
|
|
@@ -3887,7 +4427,7 @@ async function registerElements(
|
|
|
3887
4427
|
}
|
|
3888
4428
|
continue;
|
|
3889
4429
|
}
|
|
3890
|
-
if (!
|
|
4430
|
+
if (!isRefValue(entry)) {
|
|
3891
4431
|
continue;
|
|
3892
4432
|
}
|
|
3893
4433
|
const { href } = new URL(entry.$ref, base);
|
|
@@ -3915,7 +4455,7 @@ async function registerElements(
|
|
|
3915
4455
|
* @param {import("@jxsuite/schema/types").JxHeadEntry[]} entries
|
|
3916
4456
|
* @param {string} _base - Document base URL for resolving relative paths
|
|
3917
4457
|
*/
|
|
3918
|
-
function injectHead(entries: JxHeadEntry[], _base: string) {
|
|
4458
|
+
function injectHead(entries: readonly (JxHeadEntry | null | undefined)[], _base: string) {
|
|
3919
4459
|
for (const entry of entries) {
|
|
3920
4460
|
if (!entry || !entry.tagName) {
|
|
3921
4461
|
continue;
|
|
@@ -3925,7 +4465,7 @@ function injectHead(entries: JxHeadEntry[], _base: string) {
|
|
|
3925
4465
|
// Resolve href/src: bare npm specifiers -> /node_modules/ path
|
|
3926
4466
|
for (const key of ["href", "src"] as const) {
|
|
3927
4467
|
const v = attrs[key];
|
|
3928
|
-
if (
|
|
4468
|
+
if (!isString(v) || v === "") {
|
|
3929
4469
|
continue;
|
|
3930
4470
|
}
|
|
3931
4471
|
if (!v.startsWith("/") && !v.startsWith(".") && !v.startsWith("http")) {
|
|
@@ -3951,10 +4491,9 @@ function injectHead(entries: JxHeadEntry[], _base: string) {
|
|
|
3951
4491
|
if (entry.textContent) {
|
|
3952
4492
|
// An object is a structured data block (JSON-LD, site-architecture.md §8.5). The compiler
|
|
3953
4493
|
// Serializes it the same way, so the interpreted page and the built page agree.
|
|
3954
|
-
el.textContent =
|
|
3955
|
-
|
|
3956
|
-
|
|
3957
|
-
: entry.textContent;
|
|
4494
|
+
el.textContent = isNonNullObject(entry.textContent)
|
|
4495
|
+
? JSON.stringify(entry.textContent, null, 2)
|
|
4496
|
+
: entry.textContent;
|
|
3958
4497
|
}
|
|
3959
4498
|
document.head.append(el);
|
|
3960
4499
|
}
|
|
@@ -3963,18 +4502,18 @@ function injectHead(entries: JxHeadEntry[], _base: string) {
|
|
|
3963
4502
|
// ─── Instantiation chain (spec.md §16.9) ─────────────────────────────────────
|
|
3964
4503
|
|
|
3965
4504
|
/**
|
|
3966
|
-
* How deep component nesting may go before an instance refuses to render
|
|
3967
|
-
*
|
|
3968
|
-
*
|
|
3969
|
-
*
|
|
3970
|
-
*
|
|
3971
|
-
*
|
|
3972
|
-
* may not bottom out. The seen-set cannot tell those apart, so this cap is what turns a
|
|
3973
|
-
* stop into a diagnostic instead of a page that never settles
|
|
3974
|
-
*
|
|
3975
|
-
*
|
|
3976
|
-
*
|
|
3977
|
-
*
|
|
4505
|
+
* How deep component nesting may go before an instance refuses to render, and before the compiler
|
|
4506
|
+
* (`@jxsuite/compiler`, `shared.ts`, which imports this export) refuses to continue a build.
|
|
4507
|
+
*
|
|
4508
|
+
* The cycle check in {@link extendInstancePath} catches a definition that renders ITSELF — same tag,
|
|
4509
|
+
* same props — which is the only shape that can never terminate, while a self-reference whose props
|
|
4510
|
+
* change at every level (`depth: "${state.depth + 1}"`) is data-driven recursion that a `$switch`
|
|
4511
|
+
* may or may not bottom out. The seen-set cannot tell those apart, so this cap is what turns a
|
|
4512
|
+
* forgotten stop into a diagnostic instead of a page that never settles or a build that overflows
|
|
4513
|
+
* its stack. Thirty-two is far beyond any component library's real depth and far below the call
|
|
4514
|
+
* stack's. It lives here because the compiler depends on the runtime and not the other way round,
|
|
4515
|
+
* so one declaration serves both sides of spec.md §16.9: a page that builds is a page that
|
|
4516
|
+
* renders.
|
|
3978
4517
|
*/
|
|
3979
4518
|
export const MAX_COMPONENT_NESTING = 32;
|
|
3980
4519
|
|
|
@@ -3982,7 +4521,7 @@ export const MAX_COMPONENT_NESTING = 32;
|
|
|
3982
4521
|
interface InstanceFrame {
|
|
3983
4522
|
tag: string;
|
|
3984
4523
|
/** Every value the instance supplied that its definition's state read (§16.9). */
|
|
3985
|
-
props:
|
|
4524
|
+
props: JxScope;
|
|
3986
4525
|
}
|
|
3987
4526
|
|
|
3988
4527
|
/**
|
|
@@ -4035,6 +4574,30 @@ const ROOT_INSTANCE_PATH: InstancePath = { expansion: 0, frames: [] };
|
|
|
4035
4574
|
*/
|
|
4036
4575
|
let _instancePath: InstancePath = ROOT_INSTANCE_PATH;
|
|
4037
4576
|
|
|
4577
|
+
/**
|
|
4578
|
+
* Say that an instance was not rendered, on the two surfaces a host can reach (spec §16.4, §16.9):
|
|
4579
|
+
* the console, and a bubbling, composed `jx-error` `ErrorEvent` from the element carrying the
|
|
4580
|
+
* error. Nothing is said for a host no longer in the document, whose replacement reports for it.
|
|
4581
|
+
*
|
|
4582
|
+
* @param {HTMLElement} host - The instance
|
|
4583
|
+
* @param {string} tagName - Its tag, for the console line
|
|
4584
|
+
* @param {Error} error - Why
|
|
4585
|
+
*/
|
|
4586
|
+
function reportUnrendered(host: HTMLElement, tagName: string, error: Error): void {
|
|
4587
|
+
if (!host.isConnected) {
|
|
4588
|
+
return;
|
|
4589
|
+
}
|
|
4590
|
+
console.error(`Jx: <${tagName}> was not rendered:`, error);
|
|
4591
|
+
host.dispatchEvent(
|
|
4592
|
+
new ErrorEvent("jx-error", {
|
|
4593
|
+
bubbles: true,
|
|
4594
|
+
composed: true,
|
|
4595
|
+
error,
|
|
4596
|
+
message: error.message,
|
|
4597
|
+
}),
|
|
4598
|
+
);
|
|
4599
|
+
}
|
|
4600
|
+
|
|
4038
4601
|
/**
|
|
4039
4602
|
* Run `fn` with `path` as the chain, so every instance that CONNECTS during it reads that chain.
|
|
4040
4603
|
*
|
|
@@ -4083,11 +4646,11 @@ function reexpand(path: InstancePath): InstancePath {
|
|
|
4083
4646
|
* ended. Inside plain data the comparison IS the JSON form, so a member JSON cannot see (a
|
|
4084
4647
|
* function, a `Map` two levels down) is not seen here either; §16.9 says so.
|
|
4085
4648
|
*
|
|
4086
|
-
* @param {
|
|
4087
|
-
* @param {
|
|
4649
|
+
* @param {JxScopeValue} a
|
|
4650
|
+
* @param {JxScopeValue} b
|
|
4088
4651
|
* @returns {boolean}
|
|
4089
4652
|
*/
|
|
4090
|
-
function samePropValue(a:
|
|
4653
|
+
function samePropValue(a: JxScopeValue, b: JxScopeValue): boolean {
|
|
4091
4654
|
if (Object.is(a, b)) {
|
|
4092
4655
|
return true;
|
|
4093
4656
|
}
|
|
@@ -4104,11 +4667,11 @@ function samePropValue(a: unknown, b: unknown): boolean {
|
|
|
4104
4667
|
/**
|
|
4105
4668
|
* Whether a value is an array or a plain object — the shapes whose JSON form is the whole value.
|
|
4106
4669
|
*
|
|
4107
|
-
* @param {
|
|
4108
|
-
* @returns {
|
|
4670
|
+
* @param {JxScopeValue} value
|
|
4671
|
+
* @returns {boolean}
|
|
4109
4672
|
*/
|
|
4110
|
-
function isPlainData(value:
|
|
4111
|
-
if (
|
|
4673
|
+
function isPlainData(value: JxScopeValue): boolean {
|
|
4674
|
+
if (!isNonNullObject(value)) {
|
|
4112
4675
|
return false;
|
|
4113
4676
|
}
|
|
4114
4677
|
const raw: unknown = toRaw(value);
|
|
@@ -4184,19 +4747,19 @@ const _definedSources = new Set<string>();
|
|
|
4184
4747
|
|
|
4185
4748
|
export async function defineElement(source: string | JxDocument, baseUrl?: string) {
|
|
4186
4749
|
let base = baseUrl;
|
|
4187
|
-
let
|
|
4188
|
-
if (
|
|
4750
|
+
let source_: JxDocument;
|
|
4751
|
+
if (isString(source)) {
|
|
4189
4752
|
base = new URL(source, base ?? location.href).href;
|
|
4190
4753
|
if (_definedSources.has(base)) {
|
|
4191
4754
|
return;
|
|
4192
4755
|
}
|
|
4193
4756
|
_definedSources.add(base);
|
|
4194
|
-
|
|
4757
|
+
source_ = await resolve(source);
|
|
4758
|
+
} else {
|
|
4759
|
+
source_ = source;
|
|
4195
4760
|
}
|
|
4196
4761
|
base ??= location.href;
|
|
4197
4762
|
|
|
4198
|
-
const source_: JxDocument = doc as JxDocument;
|
|
4199
|
-
|
|
4200
4763
|
const { tagName } = source_;
|
|
4201
4764
|
if (!tagName || !tagName.includes("-")) {
|
|
4202
4765
|
throw new Error(`Jx defineElement: tagName "${tagName}" must contain a hyphen`);
|
|
@@ -4248,7 +4811,7 @@ export async function defineElement(source: string | JxDocument, baseUrl?: strin
|
|
|
4248
4811
|
the same values render the same thing, forever. Only a value the definition's state READS
|
|
4249
4812
|
is recorded — a `$props` key no state entry declares changes nothing about the render, so
|
|
4250
4813
|
a definition that "varies" one at each level is still rendering itself. */
|
|
4251
|
-
const supplied:
|
|
4814
|
+
const supplied: JxScope = {};
|
|
4252
4815
|
|
|
4253
4816
|
// The definition as it stands NOW — a redefinition since this class was made is honoured.
|
|
4254
4817
|
const live = _elementDefs.get(tagName) ?? { base: initialBase, doc: initialDef };
|
|
@@ -4276,8 +4839,13 @@ export async function defineElement(source: string | JxDocument, baseUrl?: strin
|
|
|
4276
4839
|
const propsAttr = this.dataset.jxProps;
|
|
4277
4840
|
if (propsAttr) {
|
|
4278
4841
|
try {
|
|
4279
|
-
|
|
4280
|
-
|
|
4842
|
+
/* SAFETY: `JSON.parse` answers the JSON value the attribute encodes, or throws, which the
|
|
4843
|
+
catch below ignores as it always has. */
|
|
4844
|
+
const props = JSON.parse(propsAttr) as JsonValue;
|
|
4845
|
+
/* The site build writes an object. An array or string payload still yields its index
|
|
4846
|
+
keys, and a number, boolean or null yields none, as `Object.entries` always gave. */
|
|
4847
|
+
const entries = isNonNullObject(props) || isString(props) ? Object.entries(props) : [];
|
|
4848
|
+
for (const [key, val] of entries) {
|
|
4281
4849
|
if (refusePrivateProp(key, "data-jx-props")) {
|
|
4282
4850
|
continue;
|
|
4283
4851
|
}
|
|
@@ -4315,13 +4883,10 @@ export async function defineElement(source: string | JxDocument, baseUrl?: strin
|
|
|
4315
4883
|
if (isPrivateStateKey(key)) {
|
|
4316
4884
|
continue;
|
|
4317
4885
|
}
|
|
4318
|
-
|
|
4319
|
-
|
|
4320
|
-
|
|
4321
|
-
|
|
4322
|
-
) {
|
|
4323
|
-
state[key] = (this as Record<string, unknown>)[key];
|
|
4324
|
-
supplied[key] = (this as Record<string, unknown>)[key];
|
|
4886
|
+
const own = elementProperties(this);
|
|
4887
|
+
if (key in this && own[key] !== undefined && instanceSupplies(this, key)) {
|
|
4888
|
+
state[key] = own[key];
|
|
4889
|
+
supplied[key] = own[key];
|
|
4325
4890
|
}
|
|
4326
4891
|
}
|
|
4327
4892
|
|
|
@@ -4350,18 +4915,7 @@ export async function defineElement(source: string | JxDocument, baseUrl?: strin
|
|
|
4350
4915
|
ownPath = extendInstancePath(enclosingPath, { props: supplied, tag: tagName });
|
|
4351
4916
|
} catch (error) {
|
|
4352
4917
|
this.replaceChildren();
|
|
4353
|
-
|
|
4354
|
-
return;
|
|
4355
|
-
}
|
|
4356
|
-
console.error(`Jx: <${tagName}> was not rendered:`, error);
|
|
4357
|
-
this.dispatchEvent(
|
|
4358
|
-
new ErrorEvent("jx-error", {
|
|
4359
|
-
bubbles: true,
|
|
4360
|
-
composed: true,
|
|
4361
|
-
error,
|
|
4362
|
-
message: error instanceof Error ? error.message : String(error),
|
|
4363
|
-
}),
|
|
4364
|
-
);
|
|
4918
|
+
reportUnrendered(this, tagName, error instanceof Error ? error : new Error(String(error)));
|
|
4365
4919
|
return;
|
|
4366
4920
|
}
|
|
4367
4921
|
/*
|
|
@@ -4373,21 +4927,26 @@ export async function defineElement(source: string | JxDocument, baseUrl?: strin
|
|
|
4373
4927
|
if (isPrivateStateKey(key)) {
|
|
4374
4928
|
continue;
|
|
4375
4929
|
}
|
|
4376
|
-
|
|
4377
|
-
|
|
4378
|
-
|
|
4379
|
-
|
|
4380
|
-
|
|
4381
|
-
|
|
4382
|
-
|
|
4383
|
-
|
|
4384
|
-
|
|
4930
|
+
/* A name the platform already owns (`title`, `lang`, `hidden`) keeps its native setter, so
|
|
4931
|
+
the attribute still reflects — and ALSO reaches state. Skipping it entirely meant a prop
|
|
4932
|
+
delivered after connection (a template over data that resolves asynchronously) wrote
|
|
4933
|
+
only the attribute: the instance kept the empty value it connected with, and a card's
|
|
4934
|
+
`title` never rendered. */
|
|
4935
|
+
const native = nativeSetter(this, key);
|
|
4936
|
+
Object.defineProperty(this, key, {
|
|
4937
|
+
configurable: true,
|
|
4938
|
+
get: () => state[key],
|
|
4939
|
+
set: (v: JxScopeValue) => {
|
|
4940
|
+
native?.call(this, v);
|
|
4941
|
+
state[key] = v;
|
|
4942
|
+
},
|
|
4943
|
+
});
|
|
4385
4944
|
}
|
|
4386
4945
|
|
|
4387
4946
|
this._state = state;
|
|
4388
4947
|
|
|
4389
4948
|
// Capture light DOM children (for slot distribution) before rendering
|
|
4390
|
-
const slottedChildren =
|
|
4949
|
+
const slottedChildren = captureSlotted(this);
|
|
4391
4950
|
this.replaceChildren();
|
|
4392
4951
|
|
|
4393
4952
|
/* Custom elements default to `display: inline`; a Jx container behaves like a `<div>`, so
|
|
@@ -4396,14 +4955,16 @@ export async function defineElement(source: string | JxDocument, baseUrl?: strin
|
|
|
4396
4955
|
without one. At (0,1,0) a consumer's rule, a cascade layer or a parent's descendant rule
|
|
4397
4956
|
all win normally, and the definition's own `display` still wins because it is written into
|
|
4398
4957
|
the same rule after this. */
|
|
4399
|
-
const
|
|
4958
|
+
const ownStyle = def.style;
|
|
4959
|
+
const defStyle =
|
|
4960
|
+
ownStyle && declaresDisplay(ownStyle) ? ownStyle : { display: "block", ...ownStyle };
|
|
4400
4961
|
/* Definition first, call site second, so the call site wins at equal specificity by source
|
|
4401
4962
|
order — the promise §9.2 already makes for base-before-nested. Merged into ONE call
|
|
4402
4963
|
because each `applyStyle` releases what the last one interned. */
|
|
4403
|
-
const hostStyle = mergeStyle(defStyle
|
|
4964
|
+
const hostStyle = mergeStyle(defStyle, callSiteStyles.get(this));
|
|
4404
4965
|
|
|
4405
4966
|
// Render template into light DOM (once, not in effect — inner effects handle reactivity)
|
|
4406
|
-
applyStyle(this, hostStyle, (state
|
|
4967
|
+
applyStyle(this, hostStyle, scopeMedia(state), state);
|
|
4407
4968
|
applyAttributes(this, def.attributes ?? {}, state);
|
|
4408
4969
|
bindDefinitionHandlers(this, def, state);
|
|
4409
4970
|
|
|
@@ -4416,46 +4977,122 @@ export async function defineElement(source: string | JxDocument, baseUrl?: strin
|
|
|
4416
4977
|
bindProperty(this, "textContent", def.textContent, state);
|
|
4417
4978
|
}
|
|
4418
4979
|
const children = Array.isArray(def.children) ? def.children : [];
|
|
4419
|
-
/*
|
|
4420
|
-
|
|
4421
|
-
|
|
4422
|
-
|
|
4423
|
-
|
|
4424
|
-
|
|
4425
|
-
|
|
4426
|
-
|
|
4427
|
-
|
|
4428
|
-
|
|
4429
|
-
|
|
4430
|
-
|
|
4431
|
-
|
|
4432
|
-
|
|
4433
|
-
|
|
4434
|
-
|
|
4435
|
-
|
|
4436
|
-
|
|
4437
|
-
|
|
4980
|
+
/* A render that throws is reported the way a refusal is (§16.4), for the same reason: thrown
|
|
4981
|
+
onward from this async callback it is an unhandled rejection naming no element. The
|
|
4982
|
+
children drawn before the throw stay; the one that threw, its later siblings and `onMount`
|
|
4983
|
+
do not happen, and slot content is still distributed into the slots that were drawn. */
|
|
4984
|
+
const draw = (): Error | null => {
|
|
4985
|
+
/* An instance the studio stamped is part of the page being edited, so everything the
|
|
4986
|
+
definition draws inside it is too — and the de-link rules cannot see that from the nodes
|
|
4987
|
+
themselves. Restored rather than cleared, because one definition may render another. */
|
|
4988
|
+
const wasInside = _canvasInsideStampedHost;
|
|
4989
|
+
_canvasInsideStampedHost ||= this.dataset.jxPath !== undefined;
|
|
4990
|
+
/* And the chain, for the same span: an instance this definition draws connects inside
|
|
4991
|
+
`this.append` below and reads it there ({@link underInstancePath}). */
|
|
4992
|
+
let failure: Error | null = null;
|
|
4993
|
+
try {
|
|
4994
|
+
underInstancePath(ownPath, () => {
|
|
4995
|
+
for (const childDef of children) {
|
|
4996
|
+
this.append(renderNode(childDef, state));
|
|
4997
|
+
}
|
|
4998
|
+
});
|
|
4999
|
+
} catch (error) {
|
|
5000
|
+
failure = error instanceof Error ? error : new Error(String(error));
|
|
5001
|
+
} finally {
|
|
5002
|
+
_canvasInsideStampedHost = wasInside;
|
|
5003
|
+
}
|
|
5004
|
+
// Slot distribution (light DOM)
|
|
5005
|
+
distributeSlots(this, slottedChildren);
|
|
5006
|
+
return failure;
|
|
5007
|
+
};
|
|
4438
5008
|
|
|
4439
5009
|
/* Lifecycle: onMount, with the HOST as its second argument.
|
|
4440
5010
|
A sidecar that must reach the element it belongs to had no way to: the runtime handed it
|
|
4441
5011
|
the scope alone, so elements resorted to dispatching a `jx-ready` event at themselves and
|
|
4442
5012
|
catching it with a root handler purely to learn `currentTarget`. The host is what the
|
|
4443
|
-
callback needed; the event round trip was the workaround.
|
|
4444
|
-
|
|
4445
|
-
|
|
4446
|
-
|
|
5013
|
+
callback needed; the event round trip was the workaround.
|
|
5014
|
+
And with the host as its RECEIVER, which is how the compiled element calls it: there a
|
|
5015
|
+
state function is an arrow inside the element class, so a body's `this` is the element.
|
|
5016
|
+
Called bare here, a body written for the built site (jxsuite.com's search reads
|
|
5017
|
+
`const host = this`) read the window instead, and threw on every page that drew it live. */
|
|
5018
|
+
const mounted = (): void => {
|
|
5019
|
+
const { onMount } = state;
|
|
5020
|
+
if (isScopeHook(onMount)) {
|
|
5021
|
+
queueMicrotask(() => onMount.call(this, state, this));
|
|
5022
|
+
}
|
|
5023
|
+
};
|
|
5024
|
+
|
|
5025
|
+
let attempt = runScoped(draw);
|
|
5026
|
+
const failure = attempt.result;
|
|
5027
|
+
if (!failure) {
|
|
5028
|
+
mounted();
|
|
5029
|
+
return;
|
|
4447
5030
|
}
|
|
5031
|
+
reportUnrendered(this, tagName, failure);
|
|
5032
|
+
/* …and it is drawn again when what it was given changes (§16.4). A prop bound to data that
|
|
5033
|
+
arrives later (a collection the page resolves after its first render, a row a list fills
|
|
5034
|
+
in) reaches the instance AFTER it connected, so the render that threw was a render of
|
|
5035
|
+
the empty values, not of the instance. Without a retry it stayed blank for good even once
|
|
5036
|
+
every value it needed was there. The watch reads the definition's authored entries only,
|
|
5037
|
+
the ones a prop can write: a computed entry is what threw, and reading it here would
|
|
5038
|
+
throw again. Writes are coalesced into one microtask, because a parent sets its props one
|
|
5039
|
+
at a time. The same error is reported once, not once per attempt. */
|
|
5040
|
+
const inputs = [...declaredDefaults(def).keys()].filter((key) => !isPrivateStateKey(key));
|
|
5041
|
+
let lastMessage = failure.message;
|
|
5042
|
+
let queued = false;
|
|
5043
|
+
const watch = effect(
|
|
5044
|
+
() => {
|
|
5045
|
+
for (const key of inputs) {
|
|
5046
|
+
void state[key];
|
|
5047
|
+
}
|
|
5048
|
+
},
|
|
5049
|
+
{
|
|
5050
|
+
scheduler: () => {
|
|
5051
|
+
if (queued) {
|
|
5052
|
+
return;
|
|
5053
|
+
}
|
|
5054
|
+
queued = true;
|
|
5055
|
+
queueMicrotask(() => {
|
|
5056
|
+
queued = false;
|
|
5057
|
+
if (!this.isConnected) {
|
|
5058
|
+
watch.effect.stop();
|
|
5059
|
+
return;
|
|
5060
|
+
}
|
|
5061
|
+
watch();
|
|
5062
|
+
attempt.stop();
|
|
5063
|
+
this.replaceChildren();
|
|
5064
|
+
pauseTracking();
|
|
5065
|
+
try {
|
|
5066
|
+
attempt = runScoped(draw);
|
|
5067
|
+
} finally {
|
|
5068
|
+
resetTracking();
|
|
5069
|
+
}
|
|
5070
|
+
const again = attempt.result;
|
|
5071
|
+
if (!again) {
|
|
5072
|
+
watch.effect.stop();
|
|
5073
|
+
mounted();
|
|
5074
|
+
} else if (again.message !== lastMessage) {
|
|
5075
|
+
lastMessage = again.message;
|
|
5076
|
+
reportUnrendered(this, tagName, again);
|
|
5077
|
+
}
|
|
5078
|
+
});
|
|
5079
|
+
},
|
|
5080
|
+
},
|
|
5081
|
+
);
|
|
4448
5082
|
}
|
|
4449
5083
|
|
|
4450
5084
|
disconnectedCallback() {
|
|
4451
|
-
|
|
4452
|
-
|
|
5085
|
+
// Called as a method, so a hook that reads `this` sees the scope.
|
|
5086
|
+
const state = this._state;
|
|
5087
|
+
if (state && isScopeHook(state.onUnmount)) {
|
|
5088
|
+
state.onUnmount(state);
|
|
4453
5089
|
}
|
|
4454
5090
|
}
|
|
4455
5091
|
|
|
4456
5092
|
adoptedCallback() {
|
|
4457
|
-
|
|
4458
|
-
|
|
5093
|
+
const state = this._state;
|
|
5094
|
+
if (state && isScopeHook(state.onAdopted)) {
|
|
5095
|
+
state.onAdopted(state);
|
|
4459
5096
|
}
|
|
4460
5097
|
}
|
|
4461
5098
|
|
|
@@ -4507,11 +5144,25 @@ function renderCustomElementWithProps(
|
|
|
4507
5144
|
element is an asset reference authored at the USAGE site, so it belongs to the document that
|
|
4508
5145
|
wrote it — a media prop the component forwards into its own `<img>` is resolved separately,
|
|
4509
5146
|
inside the component's render, against the same context. */
|
|
4510
|
-
const write = (resolved:
|
|
4511
|
-
|
|
4512
|
-
|
|
5147
|
+
const write = (resolved: JxScopeValue): void => {
|
|
5148
|
+
const value = isString(resolved) ? canvasAssetValue(el.tagName, key, resolved) : resolved;
|
|
5149
|
+
elementProperties(el)[key] = value;
|
|
5150
|
+
/* A declared prop that shares its name with an `HTMLElement` property (`title`, `lang`) gets
|
|
5151
|
+
no forwarding accessor (§16.2), so the write above lands on the element and never reaches
|
|
5152
|
+
the instance's state. Connection reads it once; a binding that changes AFTER connection
|
|
5153
|
+
(a row whose data arrives later) was lost, and the instance went on drawing the value it
|
|
5154
|
+
connected with. The binding is the parent's `$props`, addressed to the definition's prop,
|
|
5155
|
+
so it writes that prop's state. */
|
|
5156
|
+
const instanceState = instanceStateOf(el);
|
|
5157
|
+
if (
|
|
5158
|
+
instanceState &&
|
|
5159
|
+
key in HTMLElement.prototype &&
|
|
5160
|
+
Object.hasOwn(toRaw(instanceState), key)
|
|
5161
|
+
) {
|
|
5162
|
+
instanceState[key] = value;
|
|
5163
|
+
}
|
|
4513
5164
|
};
|
|
4514
|
-
if (
|
|
5165
|
+
if (isRefValue(val)) {
|
|
4515
5166
|
const refVal = val;
|
|
4516
5167
|
write(resolveRef(refVal.$ref, state));
|
|
4517
5168
|
// Reactive forwarding: re-set the property when the source changes
|
|
@@ -4519,7 +5170,7 @@ function renderCustomElementWithProps(
|
|
|
4519
5170
|
write(resolveRef(refVal.$ref, state));
|
|
4520
5171
|
});
|
|
4521
5172
|
} else if (isTemplateString(val)) {
|
|
4522
|
-
|
|
5173
|
+
bindTemplate(val, () => {
|
|
4523
5174
|
write(evaluateTemplate(val, state));
|
|
4524
5175
|
});
|
|
4525
5176
|
} else {
|
|
@@ -4535,7 +5186,7 @@ function renderCustomElementWithProps(
|
|
|
4535
5186
|
if (def.style) {
|
|
4536
5187
|
callSiteStyles.set(el, def.style);
|
|
4537
5188
|
}
|
|
4538
|
-
applyStyle(el, def.style ?? {}, (state
|
|
5189
|
+
applyStyle(el, def.style ?? {}, scopeMedia(state), state);
|
|
4539
5190
|
applyAttributes(el, def.attributes ?? {}, state);
|
|
4540
5191
|
/* And its ordinary properties and handlers — `id`, `hidden`, `className`, an `onselect` — exactly
|
|
4541
5192
|
as an ordinary element gets them. A custom element used in a document is still an element in
|
|
@@ -4560,47 +5211,184 @@ function renderCustomElementWithProps(
|
|
|
4560
5211
|
}
|
|
4561
5212
|
|
|
4562
5213
|
/**
|
|
4563
|
-
*
|
|
5214
|
+
* Whether a node is an element, by its `nodeType` (which, unlike `instanceof`, holds across
|
|
5215
|
+
* realms).
|
|
4564
5216
|
*
|
|
4565
|
-
* @param {
|
|
4566
|
-
* @
|
|
4567
|
-
*/
|
|
4568
|
-
function
|
|
4569
|
-
|
|
4570
|
-
|
|
4571
|
-
|
|
4572
|
-
|
|
4573
|
-
|
|
4574
|
-
|
|
4575
|
-
|
|
4576
|
-
|
|
4577
|
-
|
|
5217
|
+
* @param {Node} node
|
|
5218
|
+
* @returns {node is Element}
|
|
5219
|
+
*/
|
|
5220
|
+
function isElementNode(node: Node): node is Element {
|
|
5221
|
+
return node.nodeType === Node.ELEMENT_NODE;
|
|
5222
|
+
}
|
|
5223
|
+
|
|
5224
|
+
/**
|
|
5225
|
+
* Whether a node is a text node, by its `nodeType`.
|
|
5226
|
+
*
|
|
5227
|
+
* @param {Node} node
|
|
5228
|
+
* @returns {node is Text}
|
|
5229
|
+
*/
|
|
5230
|
+
function isTextNode(node: Node): node is Text {
|
|
5231
|
+
return node.nodeType === Node.TEXT_NODE;
|
|
5232
|
+
}
|
|
5233
|
+
|
|
5234
|
+
/**
|
|
5235
|
+
* Whether a node is a comment, by its `nodeType`.
|
|
5236
|
+
*
|
|
5237
|
+
* @param {Node} node
|
|
5238
|
+
* @returns {node is Comment}
|
|
5239
|
+
*/
|
|
5240
|
+
function isCommentNode(node: Node): node is Comment {
|
|
5241
|
+
return node.nodeType === Node.COMMENT_NODE;
|
|
5242
|
+
}
|
|
5243
|
+
|
|
5244
|
+
// ─── Slot distribution (spec.md §8.5) ─────────────────────────────────────────
|
|
4578
5245
|
|
|
4579
|
-
|
|
4580
|
-
|
|
5246
|
+
/**
|
|
5247
|
+
* Text that is only HTML whitespace (space, tab, line feed, form feed, carriage return): never slot
|
|
5248
|
+
* content. A non-breaking space is not in the set, and `+` keeps an empty text node out of it, so
|
|
5249
|
+
* the text node a binding is about to write stays with its group.
|
|
5250
|
+
*/
|
|
5251
|
+
export const SLOT_WHITESPACE = /^[\t\n\f\r ]+$/u;
|
|
5252
|
+
|
|
5253
|
+
/** The word that opens a prerendered slot group's comment bracket: `<!--jx-slot n name-->`. */
|
|
5254
|
+
export const SLOT_MARK = "jx-slot";
|
|
5255
|
+
|
|
5256
|
+
/** The attribute a prerendered instance carries when its slot groups are bracketed by `n`. */
|
|
5257
|
+
export const SLOT_ATTR = "data-jx-slots";
|
|
5258
|
+
|
|
5259
|
+
/**
|
|
5260
|
+
* The `Symbol.for` key of the shared group record: node → the slot group it belongs to.
|
|
5261
|
+
*
|
|
5262
|
+
* Shared through `globalThis` because each compiled component module carries its own copy of the
|
|
5263
|
+
* distribution helper, and a record one of them (or this runtime) writes has to be read by
|
|
5264
|
+
* whichever element captures the node next.
|
|
5265
|
+
*/
|
|
5266
|
+
export const SLOT_RECORD_KEY = "jx.slot.group";
|
|
5267
|
+
|
|
5268
|
+
/** The record itself; read before a node's own `slot` attribute, and deleted once read. */
|
|
5269
|
+
const slotRecord: WeakMap<Node, string> = (() => {
|
|
5270
|
+
const key = Symbol.for(SLOT_RECORD_KEY);
|
|
5271
|
+
/* SAFETY: the one value ever stored under this registered symbol is this WeakMap, written by
|
|
5272
|
+
this runtime or by a compiled module's inlined helper (`slotHelperSource` in the compiler). */
|
|
5273
|
+
const holder = globalThis as typeof globalThis & Record<symbol, WeakMap<Node, string>>;
|
|
5274
|
+
holder[key] ??= new WeakMap<Node, string>();
|
|
5275
|
+
return holder[key];
|
|
5276
|
+
})();
|
|
4581
5277
|
|
|
4582
|
-
|
|
4583
|
-
|
|
4584
|
-
|
|
4585
|
-
|
|
4586
|
-
|
|
5278
|
+
/**
|
|
5279
|
+
* The children an element distributes into its slots, taken before its template renders.
|
|
5280
|
+
*
|
|
5281
|
+
* A prerendered instance is the exception, and the reason this is a function. Its children are the
|
|
5282
|
+
* prerendered markup, and re-rendering would capture the component's own template as slot content.
|
|
5283
|
+
* The prerender therefore stamps it `data-jx-slots="n"` and brackets every group it placed with
|
|
5284
|
+
* `<!--jx-slot n name-->` … `<!--/jx-slot n-->`; this returns exactly the nodes inside this host's
|
|
5285
|
+
* brackets, recording each one's group name, and removes the attribute. The id is needed because a
|
|
5286
|
+
* forwarded group sits inside a nested instance's own brackets, and the name because a nested
|
|
5287
|
+
* instance that upgrades first must not regroup the enclosing instance's nodes.
|
|
5288
|
+
*
|
|
5289
|
+
* @param {Element} host
|
|
5290
|
+
* @returns {ChildNode[]}
|
|
5291
|
+
*/
|
|
5292
|
+
export function captureSlotted(host: Element): ChildNode[] {
|
|
5293
|
+
const id = host.getAttribute(SLOT_ATTR);
|
|
5294
|
+
if (id === null) {
|
|
5295
|
+
return [...host.childNodes];
|
|
5296
|
+
}
|
|
5297
|
+
host.removeAttribute(SLOT_ATTR);
|
|
5298
|
+
const opener = `${SLOT_MARK} ${id} `;
|
|
5299
|
+
const closer = `/${SLOT_MARK} ${id}`;
|
|
5300
|
+
const openers: Comment[] = [];
|
|
5301
|
+
const walker = host.ownerDocument.createTreeWalker(host, NodeFilter.SHOW_COMMENT);
|
|
5302
|
+
for (let n = walker.nextNode(); n; n = walker.nextNode()) {
|
|
5303
|
+
if (isCommentNode(n) && n.data.startsWith(opener)) {
|
|
5304
|
+
openers.push(n);
|
|
5305
|
+
}
|
|
5306
|
+
}
|
|
5307
|
+
const captured: ChildNode[] = [];
|
|
5308
|
+
for (const open of openers) {
|
|
5309
|
+
const name = decodeURIComponent(open.data.slice(opener.length));
|
|
5310
|
+
for (let n = open.nextSibling; n; n = n.nextSibling) {
|
|
5311
|
+
if (isCommentNode(n) && n.data === closer) {
|
|
5312
|
+
break;
|
|
4587
5313
|
}
|
|
4588
|
-
|
|
4589
|
-
|
|
4590
|
-
unnamed.push(child);
|
|
5314
|
+
slotRecord.set(n, name);
|
|
5315
|
+
captured.push(n);
|
|
4591
5316
|
}
|
|
4592
5317
|
}
|
|
5318
|
+
return captured;
|
|
5319
|
+
}
|
|
5320
|
+
|
|
5321
|
+
/**
|
|
5322
|
+
* Whether a group holds content: an element, or a text node that is not empty. A comment (a lit
|
|
5323
|
+
* part marker, a repeater's anchor) travels with its group but does not stop the fallback showing.
|
|
5324
|
+
*
|
|
5325
|
+
* @param {readonly ChildNode[]} group
|
|
5326
|
+
* @returns {boolean}
|
|
5327
|
+
*/
|
|
5328
|
+
function slotGroupHasContent(group: readonly ChildNode[]): boolean {
|
|
5329
|
+
return group.some((n) => isElementNode(n) || (isTextNode(n) && n.data !== ""));
|
|
5330
|
+
}
|
|
4593
5331
|
|
|
4594
|
-
|
|
4595
|
-
|
|
4596
|
-
|
|
4597
|
-
|
|
4598
|
-
|
|
4599
|
-
|
|
4600
|
-
|
|
5332
|
+
/**
|
|
5333
|
+
* Light-DOM slot distribution: replace each `<slot>` under `host` with the captured children of its
|
|
5334
|
+
* group, or with its own fallback children when that group has no content (spec.md §8.5).
|
|
5335
|
+
*
|
|
5336
|
+
* - **Group.** A node's group is its record when it has one, else an element's `slot` attribute, else
|
|
5337
|
+
* the unnamed group (`""`). Whitespace-only text is discarded: compiled hosts are parsed HTML
|
|
5338
|
+
* full of formatting whitespace, and whitespace placed in a part makes its `:empty` false.
|
|
5339
|
+
* - **Place.** Slots are taken in tree order and the FIRST of a name takes its group; a later one of
|
|
5340
|
+
* the same name shows its fallback. That is the DOM's "find a slot", so the answer does not
|
|
5341
|
+
* change when a component moves to a shadow root. A slot inside a fallback that was not shown
|
|
5342
|
+
* still takes its name, which keeps the static prerender's answer the same as this one.
|
|
5343
|
+
* - **Forward.** A slot written directly inside ANOTHER custom element's children is that element's
|
|
5344
|
+
* child. What it places there is recorded with the slot's own group (its record, else its `slot`
|
|
5345
|
+
* attribute), so the nested element distributes it by that name whether it captures before or
|
|
5346
|
+
* after this runs.
|
|
5347
|
+
*
|
|
5348
|
+
* No early return when nothing was slotted: a slot leaves no node either way, and a surviving slot
|
|
5349
|
+
* made `[part="label"]:empty` false for a control with no label. A slot is replaced, not filled:
|
|
5350
|
+
* the selector tree is what a definition's `& > x` rules match, and a slot left as a wrapper turned
|
|
5351
|
+
* every slotted child into a grandchild (§16.6).
|
|
5352
|
+
*
|
|
5353
|
+
* @param {ParentNode} host
|
|
5354
|
+
* @param {readonly ChildNode[]} captured
|
|
5355
|
+
*/
|
|
5356
|
+
export function distributeSlots(host: ParentNode, captured: readonly ChildNode[]): void {
|
|
5357
|
+
const slots = [...host.querySelectorAll("slot")];
|
|
5358
|
+
if (slots.length === 0) {
|
|
5359
|
+
return;
|
|
5360
|
+
}
|
|
5361
|
+
const groups = new Map<string, ChildNode[]>();
|
|
5362
|
+
for (const n of captured) {
|
|
5363
|
+
if (isTextNode(n) && SLOT_WHITESPACE.test(n.data)) {
|
|
5364
|
+
continue;
|
|
5365
|
+
}
|
|
5366
|
+
const recorded = slotRecord.get(n);
|
|
5367
|
+
slotRecord.delete(n);
|
|
5368
|
+
const name = recorded ?? (isElementNode(n) ? (n.getAttribute("slot") ?? "") : "");
|
|
5369
|
+
let group = groups.get(name);
|
|
5370
|
+
if (!group) {
|
|
5371
|
+
group = [];
|
|
5372
|
+
groups.set(name, group);
|
|
5373
|
+
}
|
|
5374
|
+
group.push(n);
|
|
5375
|
+
}
|
|
5376
|
+
const taken = new Set<string>();
|
|
4601
5377
|
for (const slot of slots) {
|
|
4602
|
-
const name = slot.getAttribute("name");
|
|
4603
|
-
|
|
4604
|
-
|
|
5378
|
+
const name = slot.getAttribute("name") ?? "";
|
|
5379
|
+
let group: ChildNode[] = [];
|
|
5380
|
+
if (!taken.has(name)) {
|
|
5381
|
+
taken.add(name);
|
|
5382
|
+
group = groups.get(name) ?? [];
|
|
5383
|
+
}
|
|
5384
|
+
const placed = slotGroupHasContent(group) ? group : [...group, ...slot.childNodes];
|
|
5385
|
+
const parent = slot.parentElement;
|
|
5386
|
+
if (parent && parent !== host && parent.localName.includes("-")) {
|
|
5387
|
+
const forwarded = slotRecord.get(slot) ?? slot.getAttribute("slot") ?? "";
|
|
5388
|
+
for (const n of placed) {
|
|
5389
|
+
slotRecord.set(n, forwarded);
|
|
5390
|
+
}
|
|
5391
|
+
}
|
|
5392
|
+
slot.replaceWith(...placed);
|
|
4605
5393
|
}
|
|
4606
5394
|
}
|