@jxsuite/runtime 4.1.0 → 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 +720 -442
- 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 +1317 -565
- package/src/statements.ts +58 -48
- package/src/types.ts +114 -13
package/src/runtime.ts
CHANGED
|
@@ -39,38 +39,52 @@ import {
|
|
|
39
39
|
import { evaluateExpression, evaluateOperand, isMutating } from "./expression.ts";
|
|
40
40
|
import { readPath } from "./pointer.ts";
|
|
41
41
|
import type {
|
|
42
|
-
DynamicClass,
|
|
43
42
|
JxContext,
|
|
44
43
|
JxEventHandler,
|
|
44
|
+
JxHostScope,
|
|
45
45
|
JxMount,
|
|
46
46
|
JxMountOptions,
|
|
47
47
|
JxPath,
|
|
48
48
|
JxRenderOptions,
|
|
49
|
+
JxRowContext,
|
|
49
50
|
JxScope,
|
|
51
|
+
JxScopeFunction,
|
|
52
|
+
JxScopeValue,
|
|
50
53
|
} from "./types.ts";
|
|
51
54
|
import {
|
|
52
55
|
bodyReturnsValue,
|
|
53
56
|
hasStructuredBody,
|
|
57
|
+
isBoolean,
|
|
54
58
|
isExpressionDef,
|
|
59
|
+
isFunction,
|
|
55
60
|
isFunctionDef,
|
|
56
61
|
isJsonObject,
|
|
57
62
|
isMappedArray,
|
|
58
63
|
isNamedFormulaDef,
|
|
64
|
+
isNestedStyle,
|
|
65
|
+
isNonNullObject,
|
|
66
|
+
isNumber,
|
|
59
67
|
isPrivateStateKey,
|
|
60
68
|
isPrototypeDef,
|
|
61
69
|
isRef as isRefValue,
|
|
62
70
|
isServerFnDef,
|
|
71
|
+
isString,
|
|
63
72
|
isTagExpression,
|
|
64
73
|
isTemplateString,
|
|
65
74
|
paramNames,
|
|
66
75
|
} from "@jxsuite/schema/guards";
|
|
76
|
+
import type { TagNameSlot } from "@jxsuite/schema/guards";
|
|
67
77
|
import { formatSrcset, parseSrcset } from "@jxsuite/schema/asset-paths";
|
|
68
78
|
import { runStatements } from "./statements.ts";
|
|
69
79
|
import { readCookie, serializeCookie } from "./cookie.ts";
|
|
70
80
|
import type {
|
|
81
|
+
CemParameter,
|
|
82
|
+
JsonValue,
|
|
71
83
|
JxAttributeValue,
|
|
84
|
+
JxClassCtorDef,
|
|
72
85
|
JxClassDef,
|
|
73
86
|
JxDocument,
|
|
87
|
+
JxDocumentValue,
|
|
74
88
|
JxElement,
|
|
75
89
|
JxFunctionDef,
|
|
76
90
|
JxHeadEntry,
|
|
@@ -106,8 +120,8 @@ let _schemaVersionWarned = false;
|
|
|
106
120
|
* runtime supports. A relative or non-hosted `$schema` (local dev schema paths) is ignored. This is
|
|
107
121
|
* a diagnostic only — there is no document-format migration story yet (see spec §3.2 / §21.4).
|
|
108
122
|
*/
|
|
109
|
-
function checkSchemaVersion($schema:
|
|
110
|
-
if (_schemaVersionWarned ||
|
|
123
|
+
function checkSchemaVersion($schema: JxDocumentValue | undefined): void {
|
|
124
|
+
if (_schemaVersionWarned || !isString($schema)) {
|
|
111
125
|
return;
|
|
112
126
|
}
|
|
113
127
|
const match = $schema.match(/\/schema\/v(\d+)\b/);
|
|
@@ -127,7 +141,7 @@ export async function Jx(
|
|
|
127
141
|
) {
|
|
128
142
|
const base = options?.base
|
|
129
143
|
? new URL(options.base, location.href).href
|
|
130
|
-
:
|
|
144
|
+
: isString(source)
|
|
131
145
|
? new URL(source, location.href).href
|
|
132
146
|
: location.href;
|
|
133
147
|
const doc = await resolve(source);
|
|
@@ -139,18 +153,57 @@ export async function Jx(
|
|
|
139
153
|
_rootMedia = doc.$media;
|
|
140
154
|
}
|
|
141
155
|
|
|
142
|
-
const
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
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);
|
|
147
164
|
return mounted.scope;
|
|
148
165
|
}
|
|
149
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
|
+
|
|
150
187
|
/** Host-scope keys the runtime owns: `$media`, `$map`, the `$`-locals and `#`-private entries. */
|
|
151
188
|
const HOST_SCOPE_RESERVED = /^[$#]/;
|
|
152
189
|
|
|
153
|
-
|
|
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 {
|
|
154
207
|
for (const key of Object.keys(scope)) {
|
|
155
208
|
if (HOST_SCOPE_RESERVED.test(key)) {
|
|
156
209
|
throw new TypeError(
|
|
@@ -176,26 +229,28 @@ function assertHostScope(scope: Record<string, unknown>): void {
|
|
|
176
229
|
* @returns {Promise<JxMount>}
|
|
177
230
|
* @docs extending/embedding/runtime-host
|
|
178
231
|
*/
|
|
179
|
-
export async function mount(
|
|
232
|
+
export async function mount<S extends JxHostScope<S> = JxScope>(
|
|
180
233
|
doc: JxDocument,
|
|
181
234
|
target: ParentNode,
|
|
182
|
-
options: JxMountOptions = {},
|
|
235
|
+
options: JxMountOptions<S> = {},
|
|
183
236
|
): Promise<JxMount> {
|
|
184
|
-
const hostScope = options.scope
|
|
237
|
+
const hostScope: JxScope = options.scope ? hostScopeOf(options.scope) : {};
|
|
185
238
|
assertHostScope(hostScope);
|
|
186
239
|
const base = options.base ? new URL(options.base, location.href).href : location.href;
|
|
187
240
|
const ctx: JxContext = {
|
|
188
241
|
base,
|
|
189
242
|
media: options.media ?? doc.$media ?? null,
|
|
190
243
|
root: null,
|
|
191
|
-
...(options.resolver ? { resolver: options.resolver } : {}),
|
|
192
|
-
...(options.skipServerFunctions === undefined
|
|
193
|
-
? {}
|
|
194
|
-
: { skipServerFunctions: options.skipServerFunctions }),
|
|
195
|
-
...(options.skipAutoRequests === undefined
|
|
196
|
-
? {}
|
|
197
|
-
: { skipAutoRequests: options.skipAutoRequests }),
|
|
198
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
|
+
}
|
|
199
254
|
checkSchemaVersion(doc.$schema);
|
|
200
255
|
|
|
201
256
|
// Register custom elements declared in $elements (depth-first)
|
|
@@ -219,8 +274,10 @@ export async function mount(
|
|
|
219
274
|
}
|
|
220
275
|
options.onNodeCreated?.(el, path, def, state);
|
|
221
276
|
},
|
|
222
|
-
...(options.onNodeMoved ? { onNodeMoved: options.onNodeMoved } : {}),
|
|
223
277
|
};
|
|
278
|
+
if (options.onNodeMoved) {
|
|
279
|
+
renderOptions.onNodeMoved = options.onNodeMoved;
|
|
280
|
+
}
|
|
224
281
|
const { result: root, stop } = runScoped(() => renderNode(doc, scope, renderOptions));
|
|
225
282
|
ctx.root = root;
|
|
226
283
|
|
|
@@ -232,11 +289,18 @@ export async function mount(
|
|
|
232
289
|
}
|
|
233
290
|
disposed = true;
|
|
234
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();
|
|
235
299
|
stop();
|
|
236
|
-
|
|
237
|
-
|
|
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);
|
|
238
303
|
}
|
|
239
|
-
root.remove();
|
|
240
304
|
};
|
|
241
305
|
|
|
242
306
|
if (options.signal?.aborted) {
|
|
@@ -246,8 +310,8 @@ export async function mount(
|
|
|
246
310
|
options.signal?.addEventListener("abort", dispose, { once: true });
|
|
247
311
|
target.append(root);
|
|
248
312
|
mounted = true;
|
|
249
|
-
if (
|
|
250
|
-
|
|
313
|
+
if (isScopeHook(scope.onMount)) {
|
|
314
|
+
scope.onMount(scope);
|
|
251
315
|
}
|
|
252
316
|
}
|
|
253
317
|
return { scope, root, elements, dispose };
|
|
@@ -279,8 +343,20 @@ export function preloadDocument(url: string, doc: JxDocument): void {
|
|
|
279
343
|
}
|
|
280
344
|
}
|
|
281
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
|
+
|
|
282
358
|
export async function resolve(source: string | JxDocument, ctx?: JxContext): Promise<JxDocument> {
|
|
283
|
-
if (
|
|
359
|
+
if (!isString(source)) {
|
|
284
360
|
return source;
|
|
285
361
|
}
|
|
286
362
|
// A mount's resolver answers for THIS mount and its hit never enters the shared cache.
|
|
@@ -305,16 +381,16 @@ export async function resolve(source: string | JxDocument, ctx?: JxContext): Pro
|
|
|
305
381
|
* Checked on the content type rather than by sniffing the body: a host that says `text/html`
|
|
306
382
|
* has told us plainly, and saying so costs nothing.
|
|
307
383
|
*/
|
|
308
|
-
|
|
309
|
-
// About, and guessing is worse than proceeding. Every real `Response` has them.
|
|
310
|
-
const contentType = res.headers?.get("Content-Type") ?? "";
|
|
384
|
+
const contentType = responseContentType(res);
|
|
311
385
|
if (contentType.includes("text/html")) {
|
|
312
386
|
throw new Error(
|
|
313
387
|
`Jx: ${source} returned HTML, not a document — the host answered a missing file with its ` +
|
|
314
388
|
`app shell (a single-page fallback), so this path is not served.`,
|
|
315
389
|
);
|
|
316
390
|
}
|
|
317
|
-
|
|
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. */
|
|
318
394
|
return (await res.json()) as JxDocument;
|
|
319
395
|
});
|
|
320
396
|
_resolveCache.set(source, p);
|
|
@@ -378,11 +454,6 @@ function resolveProxyPath(path: string): string {
|
|
|
378
454
|
return _resolveToken ? `${path}?token=${encodeURIComponent(_resolveToken)}` : path;
|
|
379
455
|
}
|
|
380
456
|
|
|
381
|
-
/** @deprecated No longer needed — ContentCollection/ContentEntry resolve via generic class path */
|
|
382
|
-
export function setSkipContentResolution(_v: boolean) {
|
|
383
|
-
// No-op retained for API compatibility
|
|
384
|
-
}
|
|
385
|
-
|
|
386
457
|
/**
|
|
387
458
|
* Observe runtime-created reactive values: runs `fn` immediately inside a reactive effect and
|
|
388
459
|
* re-runs it whenever a tracked value changes; returns a disposer. Dep tracking in @vue/reactivity
|
|
@@ -401,6 +472,12 @@ export function observeScope(fn: () => void): () => void {
|
|
|
401
472
|
return () => scope.stop();
|
|
402
473
|
}
|
|
403
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
|
+
|
|
404
481
|
/**
|
|
405
482
|
* Run `fn` inside a detached reactive scope of THIS module's reactivity instance, returning its
|
|
406
483
|
* result plus a disposer that stops every effect `fn` created. Callers that render via
|
|
@@ -410,11 +487,13 @@ export function observeScope(fn: () => void): () => void {
|
|
|
410
487
|
* its stop() silently leaks every binding effect of the superseded render.
|
|
411
488
|
*
|
|
412
489
|
* @param {() => T} fn - Work that may create reactive effects (typically a renderNode call).
|
|
413
|
-
* @returns {
|
|
490
|
+
* @returns {ScopedRun<T>} The callback result and the scope disposer.
|
|
414
491
|
*/
|
|
415
|
-
export function runScoped<T>(fn: () => T):
|
|
492
|
+
export function runScoped<T>(fn: () => T): ScopedRun<T> {
|
|
416
493
|
const scope = effectScope(true);
|
|
417
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. */
|
|
418
497
|
const result = scope.run(fn) as T;
|
|
419
498
|
return { result, stop: () => scope.stop() };
|
|
420
499
|
} catch (error) {
|
|
@@ -438,14 +517,14 @@ export function setCanvasViewportTranspose(on: boolean) {
|
|
|
438
517
|
}
|
|
439
518
|
|
|
440
519
|
const VIEWPORT_UNIT_RE = /(-?\d*\.?\d+)(?:s|l|d)?v(h|w|min|max|i|b)\b/gi;
|
|
441
|
-
const VIEWPORT_UNIT_MAP
|
|
442
|
-
b
|
|
443
|
-
h
|
|
444
|
-
i
|
|
445
|
-
max
|
|
446
|
-
min
|
|
447
|
-
w
|
|
448
|
-
|
|
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
|
+
]);
|
|
449
528
|
|
|
450
529
|
/**
|
|
451
530
|
* Transpose CSS viewport units → container-query units in a value string, but only when the
|
|
@@ -458,7 +537,8 @@ export function transposeCanvasUnits(value: string): string {
|
|
|
458
537
|
}
|
|
459
538
|
return value.replace(
|
|
460
539
|
VIEWPORT_UNIT_RE,
|
|
461
|
-
(_m, num: string, dim: string) =>
|
|
540
|
+
(_m, num: string, dim: string) =>
|
|
541
|
+
`${num}${VIEWPORT_UNIT_MAP.get(dim.toLowerCase()) ?? `cq${dim}`}`,
|
|
462
542
|
);
|
|
463
543
|
}
|
|
464
544
|
|
|
@@ -657,15 +737,15 @@ function canvasAssetValue(tagName: string, key: string, value: string): string {
|
|
|
657
737
|
const name = key.toLowerCase();
|
|
658
738
|
if (SRCSET_KEYS.has(name)) {
|
|
659
739
|
const candidates = parseSrcset(value);
|
|
660
|
-
let changed = false;
|
|
661
740
|
const next = candidates.map((candidate) => {
|
|
662
741
|
const resolved = _canvasAssetResolver?.(candidate.url, name) ?? null;
|
|
663
742
|
if (resolved === null || resolved === candidate.url) {
|
|
664
743
|
return candidate;
|
|
665
744
|
}
|
|
666
|
-
changed = true;
|
|
667
745
|
return { descriptor: candidate.descriptor, url: resolved };
|
|
668
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]);
|
|
669
749
|
return changed ? formatSrcset(next) : value;
|
|
670
750
|
}
|
|
671
751
|
if (!isAssetKey(tagName, name)) {
|
|
@@ -727,18 +807,22 @@ export function setStampPropBindings(on: boolean) {
|
|
|
727
807
|
const PURE_STATE_TEMPLATE = /^\s*\$\{\s*state\.(\w+)\s*\}\s*$/;
|
|
728
808
|
|
|
729
809
|
/** The state key of an invertible `textContent` binding, or null (mixed template, deep path). */
|
|
730
|
-
function boundPropKey(
|
|
810
|
+
function boundPropKey(
|
|
811
|
+
key: string,
|
|
812
|
+
val: JxDocumentValue | undefined,
|
|
813
|
+
state: JxScope,
|
|
814
|
+
): string | null {
|
|
731
815
|
if (!_stampPropBindings || key !== "textContent") {
|
|
732
816
|
return null;
|
|
733
817
|
}
|
|
734
818
|
let sub: string | null = null;
|
|
735
|
-
if (
|
|
819
|
+
if (isRefValue(val)) {
|
|
736
820
|
if (!val.$ref.startsWith("#/state/")) {
|
|
737
821
|
return null;
|
|
738
822
|
}
|
|
739
823
|
const rest = val.$ref.slice("#/state/".length);
|
|
740
824
|
sub = rest && !rest.includes("/") ? rest : null;
|
|
741
|
-
} else if (
|
|
825
|
+
} else if (isString(val)) {
|
|
742
826
|
sub = PURE_STATE_TEMPLATE.exec(val)?.[1] ?? null;
|
|
743
827
|
}
|
|
744
828
|
if (!sub) {
|
|
@@ -748,11 +832,11 @@ function boundPropKey(key: string, val: unknown, state: JxScope): string | null
|
|
|
748
832
|
// In the raw scope (auto-unwrapped by the reactive proxy), and function/object entries (handlers,
|
|
749
833
|
// $prototype instances) are not prop-overridable either — an instance $props write against any of
|
|
750
834
|
// Them would clobber the definition's behavior, so their bindings are never marked editable.
|
|
751
|
-
const stored =
|
|
752
|
-
if (isRef(stored) ||
|
|
835
|
+
const stored = toRaw(state)[sub];
|
|
836
|
+
if (isRef(stored) || isFunction(stored)) {
|
|
753
837
|
return null;
|
|
754
838
|
}
|
|
755
|
-
if (
|
|
839
|
+
if (isNonNullObject(stored)) {
|
|
756
840
|
return null;
|
|
757
841
|
}
|
|
758
842
|
return sub;
|
|
@@ -790,7 +874,7 @@ export async function buildScope(
|
|
|
790
874
|
configurable: true,
|
|
791
875
|
enumerable: true,
|
|
792
876
|
get: () => parentScope[key],
|
|
793
|
-
set: (value:
|
|
877
|
+
set: (value: JxScopeValue) => {
|
|
794
878
|
parentScope[key] = value;
|
|
795
879
|
},
|
|
796
880
|
});
|
|
@@ -819,7 +903,7 @@ export async function buildScope(
|
|
|
819
903
|
// First pass: collect naked values, expanded defaults, plain objects
|
|
820
904
|
for (const [key, def] of Object.entries(defs)) {
|
|
821
905
|
// 1. String value
|
|
822
|
-
if (
|
|
906
|
+
if (isString(def)) {
|
|
823
907
|
if (!def.includes("${")) {
|
|
824
908
|
raw[key] = def;
|
|
825
909
|
} // Shape 1: naked string
|
|
@@ -827,7 +911,7 @@ export async function buildScope(
|
|
|
827
911
|
}
|
|
828
912
|
|
|
829
913
|
// 2. Number, boolean, null
|
|
830
|
-
if (
|
|
914
|
+
if (isNumber(def) || isBoolean(def) || def === null) {
|
|
831
915
|
raw[key] = def;
|
|
832
916
|
continue;
|
|
833
917
|
}
|
|
@@ -839,7 +923,7 @@ export async function buildScope(
|
|
|
839
923
|
}
|
|
840
924
|
|
|
841
925
|
// 4. Object
|
|
842
|
-
if (
|
|
926
|
+
if (isNonNullObject(def)) {
|
|
843
927
|
if (def.$prototype) {
|
|
844
928
|
continue;
|
|
845
929
|
} // Handled in later passes
|
|
@@ -863,10 +947,10 @@ export async function buildScope(
|
|
|
863
947
|
// Wrap in Vue reactive proxy — deep reactivity from this point on
|
|
864
948
|
const state = reactive(raw);
|
|
865
949
|
|
|
866
|
-
// Second pass: template strings → computed
|
|
950
|
+
// Second pass: template strings → computed, a single expression keeping its own type (§5.3)
|
|
867
951
|
for (const [key, def] of Object.entries(defs)) {
|
|
868
|
-
if (
|
|
869
|
-
state[key] = computed(() =>
|
|
952
|
+
if (isString(def) && def.includes("${")) {
|
|
953
|
+
state[key] = computed(() => evaluateValueTemplate(def, state));
|
|
870
954
|
}
|
|
871
955
|
}
|
|
872
956
|
|
|
@@ -876,21 +960,9 @@ export async function buildScope(
|
|
|
876
960
|
const node = def.$expression;
|
|
877
961
|
if (isNamedFormulaDef(def)) {
|
|
878
962
|
// A named formula: callable with positional args mapped onto its declared parameters.
|
|
879
|
-
const params = def.parameters
|
|
880
|
-
state[key] = (...argValues:
|
|
881
|
-
|
|
882
|
-
for (const [i, p] of params.entries()) {
|
|
883
|
-
const name = typeof p === "string" ? p : (p?.name ?? "");
|
|
884
|
-
if (!name) {
|
|
885
|
-
continue;
|
|
886
|
-
}
|
|
887
|
-
args[name] =
|
|
888
|
-
argValues[i] === undefined && typeof p === "object" && p !== null && "default" in p
|
|
889
|
-
? p.default
|
|
890
|
-
: argValues[i];
|
|
891
|
-
}
|
|
892
|
-
return evaluateExpression(node, state, null, { args });
|
|
893
|
-
};
|
|
963
|
+
const params = def.parameters;
|
|
964
|
+
state[key] = (...argValues: JxScopeValue[]) =>
|
|
965
|
+
evaluateExpression(node, state, null, { args: bindPositionalArgs(params, argValues) });
|
|
894
966
|
} else if (isMutating(node.operator)) {
|
|
895
967
|
const handler: JxEventHandler = (s, event) => evaluateExpression(node, s, event);
|
|
896
968
|
state[key] = handler;
|
|
@@ -907,23 +979,16 @@ export async function buildScope(
|
|
|
907
979
|
// $args names (invoked via the call operator); without → an event handler.
|
|
908
980
|
const { body } = def;
|
|
909
981
|
if (Array.isArray(def.parameters) && def.parameters.length > 0) {
|
|
910
|
-
const params = def.parameters
|
|
911
|
-
state[key] = (...argValues:
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
continue;
|
|
917
|
-
}
|
|
918
|
-
args[name] =
|
|
919
|
-
argValues[i] === undefined && typeof p === "object" && p !== null && "default" in p
|
|
920
|
-
? p.default
|
|
921
|
-
: argValues[i];
|
|
922
|
-
}
|
|
923
|
-
return runStatements(body, state, null, { args, target: dispatchRoot });
|
|
924
|
-
};
|
|
982
|
+
const params = def.parameters;
|
|
983
|
+
state[key] = (...argValues: JxScopeValue[]) =>
|
|
984
|
+
runStatements(body, state, null, {
|
|
985
|
+
args: bindPositionalArgs(params, argValues),
|
|
986
|
+
target: dispatchRoot,
|
|
987
|
+
});
|
|
925
988
|
} else {
|
|
926
|
-
|
|
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 => {
|
|
927
992
|
void runStatements(body, s, event ?? null, { target: dispatchRoot });
|
|
928
993
|
};
|
|
929
994
|
state[key] = handler;
|
|
@@ -960,14 +1025,42 @@ export async function buildScope(
|
|
|
960
1025
|
return state;
|
|
961
1026
|
}
|
|
962
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
|
+
|
|
963
1056
|
/**
|
|
964
1057
|
* Check whether an object contains any JSON Schema keywords. Used to discriminate Shape 2b (pure
|
|
965
1058
|
* type definition) from Shape 1 (naked object).
|
|
966
1059
|
*
|
|
967
|
-
* @param
|
|
1060
|
+
* @param obj A state entry object, as the document wrote it
|
|
968
1061
|
* @returns {boolean}
|
|
969
1062
|
*/
|
|
970
|
-
function hasSchemaKeywords(obj:
|
|
1063
|
+
function hasSchemaKeywords(obj: Readonly<Record<string, JxDocumentValue | undefined>>) {
|
|
971
1064
|
for (const k of Object.keys(obj)) {
|
|
972
1065
|
if (SCHEMA_KEYWORDS.has(k)) {
|
|
973
1066
|
return true;
|
|
@@ -977,23 +1070,151 @@ function hasSchemaKeywords(obj: object) {
|
|
|
977
1070
|
}
|
|
978
1071
|
export { hasSchemaKeywords };
|
|
979
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
|
+
|
|
980
1092
|
/**
|
|
981
1093
|
* Evaluate a template string in the context of state and optional $map. Templates use
|
|
982
|
-
* `state.varName
|
|
1094
|
+
* `state.varName`, `$map.item` (or `item`) and the page context `$page` / `$site`.
|
|
983
1095
|
*
|
|
984
1096
|
* @param {string} str
|
|
985
1097
|
* @param {JxScope} state
|
|
986
1098
|
* @returns {string}
|
|
987
1099
|
*/
|
|
988
1100
|
function evaluateTemplate(str: string, state: JxScope): string {
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
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;
|
|
997
1218
|
}
|
|
998
1219
|
|
|
999
1220
|
/**
|
|
@@ -1034,50 +1255,137 @@ export function isSingleExpression(str: string): boolean {
|
|
|
1034
1255
|
}
|
|
1035
1256
|
|
|
1036
1257
|
/**
|
|
1037
|
-
* Evaluate a template
|
|
1038
|
-
*
|
|
1039
|
-
*
|
|
1040
|
-
*
|
|
1041
|
-
*
|
|
1042
|
-
*
|
|
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
|
|
1043
1268
|
* `evaluateStaticTemplate` draws in the compiler, and drawing it identically is what keeps a
|
|
1044
1269
|
* prerendered page and the same document rendered live in agreement.
|
|
1045
1270
|
*
|
|
1046
1271
|
* @param {string} str - Template source
|
|
1047
1272
|
* @param {JxScope} state - Reactive scope
|
|
1048
|
-
* @returns {
|
|
1273
|
+
* @returns {JxScopeValue} The expression's value, or the interpolated string
|
|
1049
1274
|
*/
|
|
1050
|
-
function
|
|
1275
|
+
function evaluateValueTemplate(str: string, state: JxScope): JxScopeValue {
|
|
1051
1276
|
if (!isSingleExpression(str)) {
|
|
1052
1277
|
return evaluateTemplate(str, state);
|
|
1053
1278
|
}
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
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));
|
|
1062
1291
|
}
|
|
1063
1292
|
|
|
1064
1293
|
// ─── Step 2b: Function resolution (Shape 4) ─────────────────────────────────
|
|
1065
1294
|
|
|
1066
|
-
/**
|
|
1067
|
-
|
|
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;
|
|
1068
1326
|
|
|
1069
|
-
/**
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
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
|
+
}
|
|
1371
|
+
|
|
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;
|
|
1074
1382
|
}
|
|
1075
1383
|
|
|
1076
1384
|
/** Module cache for $src imports (shared with external class resolution). */
|
|
1077
1385
|
const _moduleCache = new Map<string, ImportedModule>();
|
|
1078
1386
|
|
|
1079
1387
|
/** A module the host will import on first use, keyed as the document spells its `$src`. */
|
|
1080
|
-
type ModuleLoader = () => Promise<
|
|
1388
|
+
type ModuleLoader = () => Promise<ImportedModule>;
|
|
1081
1389
|
|
|
1082
1390
|
/**
|
|
1083
1391
|
* Lazy registrations: a loader until it runs, then the in-flight load, so two entries resolving the
|
|
@@ -1109,21 +1417,18 @@ const _hostSeeded = new Set<string>();
|
|
|
1109
1417
|
* specifier not registered here, the runtime calls the host's `/__jx_server__` proxy instead.
|
|
1110
1418
|
*
|
|
1111
1419
|
* @param {string} specifier - The `$src` value as written in the document
|
|
1112
|
-
* @param {
|
|
1113
|
-
*
|
|
1420
|
+
* @param {ImportedModule | ModuleLoader} mod - The imported module namespace, or a loader that
|
|
1421
|
+
* imports it on first use
|
|
1114
1422
|
*/
|
|
1115
|
-
export function preloadModule(
|
|
1116
|
-
specifier: string,
|
|
1117
|
-
mod: Record<string, unknown> | ModuleLoader,
|
|
1118
|
-
): void {
|
|
1423
|
+
export function preloadModule(specifier: string, mod: ImportedModule | ModuleLoader): void {
|
|
1119
1424
|
_hostSeeded.add(specifier);
|
|
1120
|
-
if (
|
|
1425
|
+
if (isFunction(mod)) {
|
|
1121
1426
|
_moduleCache.delete(specifier);
|
|
1122
1427
|
_moduleLoaders.set(specifier, mod);
|
|
1123
1428
|
return;
|
|
1124
1429
|
}
|
|
1125
1430
|
_moduleLoaders.delete(specifier);
|
|
1126
|
-
_moduleCache.set(specifier, mod
|
|
1431
|
+
_moduleCache.set(specifier, mod);
|
|
1127
1432
|
}
|
|
1128
1433
|
|
|
1129
1434
|
/**
|
|
@@ -1148,28 +1453,37 @@ function seededModule(src: string): ImportedModule | Promise<ImportedModule> | u
|
|
|
1148
1453
|
if (!pending) {
|
|
1149
1454
|
return undefined;
|
|
1150
1455
|
}
|
|
1151
|
-
if (
|
|
1456
|
+
if (!isFunction(pending)) {
|
|
1152
1457
|
return pending;
|
|
1153
1458
|
}
|
|
1154
|
-
/*
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
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);
|
|
1159
1467
|
_moduleLoaders.delete(src);
|
|
1160
|
-
return mod
|
|
1161
|
-
}
|
|
1162
|
-
(error: unknown) => {
|
|
1468
|
+
return mod;
|
|
1469
|
+
} catch (error) {
|
|
1163
1470
|
/* A failed load — a chunk the network dropped — puts the loader back, so the next document to
|
|
1164
1471
|
name the specifier tries again rather than inheriting this rejection for good. */
|
|
1165
1472
|
_moduleLoaders.set(src, pending);
|
|
1166
1473
|
throw error;
|
|
1167
|
-
}
|
|
1168
|
-
);
|
|
1474
|
+
}
|
|
1475
|
+
})();
|
|
1169
1476
|
_moduleLoaders.set(src, load);
|
|
1170
1477
|
return load;
|
|
1171
1478
|
}
|
|
1172
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
|
+
|
|
1173
1487
|
/**
|
|
1174
1488
|
* Resolve a $prototype: "Function" entry into a function or computed.
|
|
1175
1489
|
*
|
|
@@ -1180,7 +1494,7 @@ function seededModule(src: string): ImportedModule | Promise<ImportedModule> | u
|
|
|
1180
1494
|
* @param {JxScope} state - Reactive scope proxy
|
|
1181
1495
|
* @param {string} key - Def key name
|
|
1182
1496
|
* @param {string} [base] - Base URL for resolving $src imports
|
|
1183
|
-
* @returns {Promise<
|
|
1497
|
+
* @returns {Promise<JxScopeValue>}
|
|
1184
1498
|
*/
|
|
1185
1499
|
async function resolveFunction(def: JxFunctionDef, state: JxScope, key: string, base?: string) {
|
|
1186
1500
|
if (def.body && def.$src) {
|
|
@@ -1196,11 +1510,13 @@ async function resolveFunction(def: JxFunctionDef, state: JxScope, key: string,
|
|
|
1196
1510
|
return noop;
|
|
1197
1511
|
}
|
|
1198
1512
|
|
|
1199
|
-
let fn:
|
|
1513
|
+
let fn: ScopeFunction;
|
|
1200
1514
|
|
|
1201
|
-
if (
|
|
1515
|
+
if (isString(def.body)) {
|
|
1202
1516
|
const params = resolveParamNames(def);
|
|
1203
|
-
|
|
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;
|
|
1204
1520
|
Object.defineProperty(fn, "name", {
|
|
1205
1521
|
configurable: true,
|
|
1206
1522
|
value: def.name ?? key,
|
|
@@ -1219,23 +1535,16 @@ async function resolveFunction(def: JxFunctionDef, state: JxScope, key: string,
|
|
|
1219
1535
|
} else if (seeded) {
|
|
1220
1536
|
mod = seeded;
|
|
1221
1537
|
} else {
|
|
1222
|
-
|
|
1223
|
-
const resolvedSrc = new URL(src, base).href;
|
|
1224
|
-
try {
|
|
1225
|
-
mod = (await import(resolvedSrc)) as ImportedModule;
|
|
1226
|
-
} catch {
|
|
1227
|
-
mod = (await import(src)) as ImportedModule;
|
|
1228
|
-
}
|
|
1229
|
-
} else {
|
|
1230
|
-
mod = (await import(src)) as ImportedModule;
|
|
1231
|
-
}
|
|
1538
|
+
mod = await importSource(src, base);
|
|
1232
1539
|
_moduleCache.set(src, mod);
|
|
1233
1540
|
}
|
|
1234
1541
|
const candidate = mod[exportName] ?? mod.default?.[exportName];
|
|
1235
|
-
if (
|
|
1542
|
+
if (!isFunction(candidate)) {
|
|
1236
1543
|
throw new TypeError(`Jx: export "${exportName}" not found or not a function in "${src}"`);
|
|
1237
1544
|
}
|
|
1238
|
-
|
|
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;
|
|
1239
1548
|
}
|
|
1240
1549
|
|
|
1241
1550
|
// Detect computed: body contains a return statement, or $src function introspection.
|
|
@@ -1243,11 +1552,9 @@ async function resolveFunction(def: JxFunctionDef, state: JxScope, key: string,
|
|
|
1243
1552
|
const hasParams = (def.parameters ?? def.arguments ?? []).length > 0;
|
|
1244
1553
|
let isComputed = false;
|
|
1245
1554
|
if (!hasParams) {
|
|
1246
|
-
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
isComputed = fn.length <= 1 && bodyReturnsValue(fn.toString());
|
|
1250
|
-
}
|
|
1555
|
+
isComputed = isString(def.body)
|
|
1556
|
+
? bodyReturnsValue(def.body)
|
|
1557
|
+
: fn.length <= 1 && bodyReturnsValue(fn.toString());
|
|
1251
1558
|
}
|
|
1252
1559
|
if (isComputed) {
|
|
1253
1560
|
return computed(() => fn(state));
|
|
@@ -1311,6 +1618,72 @@ export const RESERVED_KEYS = new Set([
|
|
|
1311
1618
|
"observedAttributes",
|
|
1312
1619
|
]);
|
|
1313
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
|
+
|
|
1314
1687
|
/**
|
|
1315
1688
|
* Recursively render a Jx element definition into a DOM element.
|
|
1316
1689
|
*
|
|
@@ -1356,12 +1729,18 @@ export function renderNode(
|
|
|
1356
1729
|
const path = options?._path ?? [];
|
|
1357
1730
|
|
|
1358
1731
|
// Text node children: bare strings/numbers/booleans produce DOM Text nodes
|
|
1359
|
-
if (
|
|
1732
|
+
if (isString(def) || isNumber(def) || isBoolean(def)) {
|
|
1360
1733
|
const textNode = document.createTextNode(String(def));
|
|
1361
|
-
if (
|
|
1362
|
-
|
|
1363
|
-
|
|
1364
|
-
|
|
1734
|
+
if (isTemplateString(def)) {
|
|
1735
|
+
bindTemplate(
|
|
1736
|
+
def,
|
|
1737
|
+
() => {
|
|
1738
|
+
textNode.textContent = evaluateTemplate(def, state);
|
|
1739
|
+
},
|
|
1740
|
+
() => {
|
|
1741
|
+
textNode.textContent = "";
|
|
1742
|
+
},
|
|
1743
|
+
);
|
|
1365
1744
|
}
|
|
1366
1745
|
return textNode;
|
|
1367
1746
|
}
|
|
@@ -1371,9 +1750,9 @@ export function renderNode(
|
|
|
1371
1750
|
for (const [key, val] of Object.entries(def)) {
|
|
1372
1751
|
if (key.startsWith("$") && !RESERVED_KEYS.has(key)) {
|
|
1373
1752
|
if (localState === state) {
|
|
1374
|
-
localState =
|
|
1753
|
+
localState = inheritScope(state);
|
|
1375
1754
|
}
|
|
1376
|
-
localState[key] =
|
|
1755
|
+
localState[key] = isRefValue(val) ? resolveRef(val.$ref, state) : val;
|
|
1377
1756
|
}
|
|
1378
1757
|
}
|
|
1379
1758
|
|
|
@@ -1383,7 +1762,7 @@ export function renderNode(
|
|
|
1383
1762
|
* the supported mechanism is $elements + a custom-element tag (see spec §13).
|
|
1384
1763
|
*/
|
|
1385
1764
|
if ("$ref" in def && !def.tagName) {
|
|
1386
|
-
const refTarget = String(
|
|
1765
|
+
const refTarget = String(def.$ref ?? "");
|
|
1387
1766
|
if (!warnedRefChildren.has(refTarget)) {
|
|
1388
1767
|
warnedRefChildren.add(refTarget);
|
|
1389
1768
|
console.warn(
|
|
@@ -1436,13 +1815,12 @@ export function renderNode(
|
|
|
1436
1815
|
* DESCENDANTS are HTML, so the element and its children carry different namespaces.
|
|
1437
1816
|
*/
|
|
1438
1817
|
const childNs = tagName === "foreignObject" ? null : ns;
|
|
1439
|
-
/*
|
|
1440
|
-
|
|
1441
|
-
|
|
1442
|
-
|
|
1443
|
-
|
|
1444
|
-
|
|
1445
|
-
: 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);
|
|
1446
1824
|
|
|
1447
1825
|
if (options?.onNodeCreated) {
|
|
1448
1826
|
options.onNodeCreated(el, path, def, localState);
|
|
@@ -1455,12 +1833,7 @@ export function renderNode(
|
|
|
1455
1833
|
if (def.style && tagName.includes("-")) {
|
|
1456
1834
|
callSiteStyles.set(el, def.style);
|
|
1457
1835
|
}
|
|
1458
|
-
applyStyle(
|
|
1459
|
-
el,
|
|
1460
|
-
def.style ?? {},
|
|
1461
|
-
(localState["$media"] as Record<string, string>) ?? {},
|
|
1462
|
-
localState,
|
|
1463
|
-
);
|
|
1836
|
+
applyStyle(el, def.style ?? {}, scopeMedia(localState), localState);
|
|
1464
1837
|
applyAttributes(el, def.attributes ?? {}, localState);
|
|
1465
1838
|
|
|
1466
1839
|
const kids = def.children;
|
|
@@ -1528,18 +1901,22 @@ function applyProperties(el: HTMLElement, def: JxElement, state: JxScope) {
|
|
|
1528
1901
|
*
|
|
1529
1902
|
* @param {HTMLElement} el
|
|
1530
1903
|
* @param {string} key The `on`-prefixed key
|
|
1531
|
-
* @param {
|
|
1904
|
+
* @param {JxDocumentValue | undefined} val
|
|
1532
1905
|
* @param {JxScope} state
|
|
1533
1906
|
* @returns {boolean} Whether a listener was attached
|
|
1534
1907
|
*/
|
|
1535
|
-
function bindHandler(
|
|
1908
|
+
function bindHandler(
|
|
1909
|
+
el: HTMLElement,
|
|
1910
|
+
key: string,
|
|
1911
|
+
val: JxDocumentValue | undefined,
|
|
1912
|
+
state: JxScope,
|
|
1913
|
+
): boolean {
|
|
1536
1914
|
const type = key.slice(2);
|
|
1537
1915
|
const scope = state;
|
|
1538
|
-
if (
|
|
1916
|
+
if (isRefValue(val)) {
|
|
1539
1917
|
const handler = resolveRef(val.$ref, state);
|
|
1540
|
-
if (
|
|
1541
|
-
|
|
1542
|
-
el.addEventListener(type, (e) => handlerFn(scope, e));
|
|
1918
|
+
if (isEventHandler(handler)) {
|
|
1919
|
+
el.addEventListener(type, (e) => handler(scope, e));
|
|
1543
1920
|
}
|
|
1544
1921
|
return true;
|
|
1545
1922
|
}
|
|
@@ -1550,9 +1927,11 @@ function bindHandler(el: HTMLElement, key: string, val: unknown, state: JxScope)
|
|
|
1550
1927
|
});
|
|
1551
1928
|
return true;
|
|
1552
1929
|
}
|
|
1553
|
-
if (isFunctionDef(val) &&
|
|
1930
|
+
if (isFunctionDef(val) && isString(val.body)) {
|
|
1554
1931
|
const params = resolveParamNames(val);
|
|
1555
|
-
|
|
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;
|
|
1556
1935
|
el.addEventListener(type, (e) => fn(scope, e));
|
|
1557
1936
|
return true;
|
|
1558
1937
|
}
|
|
@@ -1585,11 +1964,16 @@ function bindDefinitionHandlers(host: HTMLElement, def: JxElement, state: JxScop
|
|
|
1585
1964
|
/**
|
|
1586
1965
|
* @param {HTMLElement} el
|
|
1587
1966
|
* @param {string} key
|
|
1588
|
-
* @param {
|
|
1967
|
+
* @param {JxDocumentValue | undefined} val
|
|
1589
1968
|
* @param {JxScope} state
|
|
1590
1969
|
*/
|
|
1591
|
-
function bindProperty(
|
|
1592
|
-
|
|
1970
|
+
function bindProperty(
|
|
1971
|
+
el: HTMLElement,
|
|
1972
|
+
key: string,
|
|
1973
|
+
val: JxDocumentValue | undefined,
|
|
1974
|
+
state: JxScope,
|
|
1975
|
+
) {
|
|
1976
|
+
const target = elementProperties(el);
|
|
1593
1977
|
const boundProp = boundPropKey(key, val, state);
|
|
1594
1978
|
if (boundProp) {
|
|
1595
1979
|
el.dataset.jxBoundProp = boundProp;
|
|
@@ -1597,15 +1981,16 @@ function bindProperty(el: HTMLElement, key: string, val: unknown, state: JxScope
|
|
|
1597
1981
|
/* An `<img>` in a document says `{"tagName": "img", "src": "/hero.jpg"}`, so `src` arrives HERE
|
|
1598
1982
|
as a top-level key and is written as a DOM PROPERTY — `attributes: { src }` is the rarer
|
|
1599
1983
|
spelling. Both are the same reference and both need the same resolution. */
|
|
1600
|
-
const asAsset = (resolved:
|
|
1601
|
-
|
|
1984
|
+
const asAsset = (resolved: JxScopeValue): JxScopeValue =>
|
|
1985
|
+
isString(resolved) ? canvasAssetValue(el.tagName, key, resolved) : resolved;
|
|
1602
1986
|
/*
|
|
1603
1987
|
* `className` on an SVGElement is a read-only SVGAnimatedString, so assigning it throws in the
|
|
1604
1988
|
* strict mode an ES module always runs under. It is the most common Jx element property, so the
|
|
1605
1989
|
* attribute form is the only one that works on both.
|
|
1606
1990
|
*/
|
|
1607
|
-
const write = (resolved:
|
|
1608
|
-
|
|
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;
|
|
1609
1994
|
if (key === "className" && !(node instanceof HTMLElement)) {
|
|
1610
1995
|
node.setAttribute("class", resolved == null ? "" : String(resolved));
|
|
1611
1996
|
return;
|
|
@@ -1619,9 +2004,11 @@ function bindProperty(el: HTMLElement, key: string, val: unknown, state: JxScope
|
|
|
1619
2004
|
}
|
|
1620
2005
|
target[key] = resolved;
|
|
1621
2006
|
};
|
|
1622
|
-
if (
|
|
1623
|
-
const refVal = val
|
|
2007
|
+
if (isRefValue(val)) {
|
|
2008
|
+
const refVal = val;
|
|
1624
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. */
|
|
1625
2012
|
target[key] = resolveRef(refVal.$ref, state) as string;
|
|
1626
2013
|
return;
|
|
1627
2014
|
}
|
|
@@ -1633,8 +2020,9 @@ function bindProperty(el: HTMLElement, key: string, val: unknown, state: JxScope
|
|
|
1633
2020
|
|
|
1634
2021
|
// Universal ${} reactivity — template strings in element properties
|
|
1635
2022
|
if (isTemplateString(val)) {
|
|
1636
|
-
|
|
1637
|
-
|
|
2023
|
+
const template = val;
|
|
2024
|
+
bindTemplate(template, () => {
|
|
2025
|
+
write(asAsset(evaluateTemplate(template, state)));
|
|
1638
2026
|
});
|
|
1639
2027
|
return;
|
|
1640
2028
|
}
|
|
@@ -1808,7 +2196,7 @@ function textSink(tag: HTMLStyleElement): StyleSink {
|
|
|
1808
2196
|
write();
|
|
1809
2197
|
},
|
|
1810
2198
|
text() {
|
|
1811
|
-
return tag.textContent
|
|
2199
|
+
return tag.textContent;
|
|
1812
2200
|
},
|
|
1813
2201
|
};
|
|
1814
2202
|
}
|
|
@@ -1824,9 +2212,10 @@ function textSink(tag: HTMLStyleElement): StyleSink {
|
|
|
1824
2212
|
* @returns {StyleSink}
|
|
1825
2213
|
*/
|
|
1826
2214
|
function openSink(doc: Document): StyleSink {
|
|
1827
|
-
|
|
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;
|
|
1828
2217
|
const SheetConstructor = view?.CSSStyleSheet;
|
|
1829
|
-
if (
|
|
2218
|
+
if (isFunction(SheetConstructor) && Array.isArray(doc.adoptedStyleSheets)) {
|
|
1830
2219
|
try {
|
|
1831
2220
|
const sheet = new SheetConstructor();
|
|
1832
2221
|
doc.adoptedStyleSheets = [...doc.adoptedStyleSheets, sheet];
|
|
@@ -1839,10 +2228,21 @@ function openSink(doc: Document): StyleSink {
|
|
|
1839
2228
|
}
|
|
1840
2229
|
const tag = doc.createElement("style");
|
|
1841
2230
|
tag.dataset.jxSheet = "";
|
|
1842
|
-
(doc
|
|
2231
|
+
(documentHead(doc) ?? doc.documentElement).append(tag);
|
|
1843
2232
|
return tag.sheet ? cssomSink(tag.sheet, () => tag.remove()) : textSink(tag);
|
|
1844
2233
|
}
|
|
1845
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
|
+
|
|
1846
2246
|
/** One interned rule set: the rules as written, and how many elements point at them. */
|
|
1847
2247
|
interface InternEntry {
|
|
1848
2248
|
inserted: InsertedRules;
|
|
@@ -2104,7 +2504,7 @@ function declaresDisplay(style: JxStyle | undefined): boolean {
|
|
|
2104
2504
|
`@media` block as "the author supplied one", so the element stayed `inline` at rest and
|
|
2105
2505
|
became a block on hover — a live trap rather than a subtlety. An author who wants the
|
|
2106
2506
|
platform's own default writes `display: revert` in the base block, which this sees. */
|
|
2107
|
-
return
|
|
2507
|
+
return isNonNullObject(style) && "display" in style;
|
|
2108
2508
|
}
|
|
2109
2509
|
|
|
2110
2510
|
/**
|
|
@@ -2129,19 +2529,20 @@ function mergeStyle(base: JxStyle, over: JxStyle | undefined): JxStyle {
|
|
|
2129
2529
|
if (!over) {
|
|
2130
2530
|
return base;
|
|
2131
2531
|
}
|
|
2132
|
-
const out:
|
|
2133
|
-
for (const [key, value] of Object.entries(over
|
|
2532
|
+
const out: JxStyle = { ...base };
|
|
2533
|
+
for (const [key, value] of Object.entries(over)) {
|
|
2134
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. */
|
|
2135
2537
|
out[key] =
|
|
2136
|
-
|
|
2137
|
-
|
|
2138
|
-
|
|
2139
|
-
|
|
2140
|
-
|
|
2141
|
-
? 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
|
+
)
|
|
2142
2543
|
: value;
|
|
2143
2544
|
}
|
|
2144
|
-
return out
|
|
2545
|
+
return out;
|
|
2145
2546
|
}
|
|
2146
2547
|
|
|
2147
2548
|
/**
|
|
@@ -2212,7 +2613,7 @@ function applyStyleInto(
|
|
|
2212
2613
|
live: boolean,
|
|
2213
2614
|
) {
|
|
2214
2615
|
releaseElementStyles(el);
|
|
2215
|
-
const doc = el.ownerDocument
|
|
2616
|
+
const doc = el.ownerDocument;
|
|
2216
2617
|
const sheetState = sheetStateFor(doc);
|
|
2217
2618
|
|
|
2218
2619
|
/* One gate for the whole call, so every recursion answers alike. Off in production and in
|
|
@@ -2235,8 +2636,9 @@ function applyStyleInto(
|
|
|
2235
2636
|
variable inherits, which is correct, and the declaration stays in the rule where `:hover` can
|
|
2236
2637
|
override it. The serial keeps two elements' variables apart: a descendant rule reads `var()`
|
|
2237
2638
|
from the nearest ancestor that set it, and without it a styled child would shadow its parent's
|
|
2238
|
-
`--jx-r0` with its own.
|
|
2239
|
-
|
|
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. */
|
|
2240
2642
|
let serial = -1;
|
|
2241
2643
|
let indirect = 0;
|
|
2242
2644
|
const sources: { name: string; value: string | JxRef }[] = [];
|
|
@@ -2258,15 +2660,26 @@ function applyStyleInto(
|
|
|
2258
2660
|
sources.push({ name: property, value });
|
|
2259
2661
|
return null;
|
|
2260
2662
|
}
|
|
2261
|
-
if (serial < 0) {
|
|
2262
|
-
({ serial } = sheetState);
|
|
2263
|
-
sheetState.serial += 1;
|
|
2264
|
-
}
|
|
2265
2663
|
/* Counted separately from `sources`, so an inline custom property before it cannot shift the
|
|
2266
2664
|
name of an indirected one. The name is IN the rule text, and the interning handle is a
|
|
2267
2665
|
hash of that text — a number that moved with unrelated declarations would give two
|
|
2268
2666
|
otherwise identical styles two rules. */
|
|
2269
|
-
|
|
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
|
+
}
|
|
2270
2683
|
indirect += 1;
|
|
2271
2684
|
sources.push({ name, value });
|
|
2272
2685
|
return `var(${name})`;
|
|
@@ -2318,7 +2731,7 @@ function applyStyleInto(
|
|
|
2318
2731
|
scope.run(() => {
|
|
2319
2732
|
for (const { name, value } of sources) {
|
|
2320
2733
|
effect(() => {
|
|
2321
|
-
const resolved =
|
|
2734
|
+
const resolved = isRefValue(value)
|
|
2322
2735
|
? resolveRef(value.$ref, state)
|
|
2323
2736
|
: evaluateTemplate(value, state);
|
|
2324
2737
|
el.style.setProperty(name, canvasStyleValue(String(resolved ?? "")));
|
|
@@ -2411,7 +2824,7 @@ function applyAttributes(el: HTMLElement, attrs: Record<string, JxAttributeValue
|
|
|
2411
2824
|
const attr = canvasAttrName(el, k);
|
|
2412
2825
|
/* Inside the effects, not before them. A `$ref` or `${…}` value is not a string until the
|
|
2413
2826
|
effect runs — which is exactly why the document walk this replaced could never see it. */
|
|
2414
|
-
const write = (resolved:
|
|
2827
|
+
const write = (resolved: JxScopeValue) => {
|
|
2415
2828
|
if (resolved === null || resolved === undefined) {
|
|
2416
2829
|
/* Nothing is an attribute the element does not have. A binding that resolves to `null`
|
|
2417
2830
|
has to take the attribute WITH it, for the same reason a `false` does below — and an
|
|
@@ -2419,7 +2832,7 @@ function applyAttributes(el: HTMLElement, attrs: Record<string, JxAttributeValue
|
|
|
2419
2832
|
el.removeAttribute(attr);
|
|
2420
2833
|
return;
|
|
2421
2834
|
}
|
|
2422
|
-
if (
|
|
2835
|
+
if (isBoolean(resolved)) {
|
|
2423
2836
|
const text = booleanAttrValue(attr, resolved);
|
|
2424
2837
|
/* `removeAttribute`, not `setAttribute(attr, "false")`. A binding that flips back has to
|
|
2425
2838
|
take the attribute WITH it: the element is being re-used, so anything left behind is the
|
|
@@ -2434,10 +2847,10 @@ function applyAttributes(el: HTMLElement, attrs: Record<string, JxAttributeValue
|
|
|
2434
2847
|
}
|
|
2435
2848
|
el.setAttribute(attr, canvasAssetValue(el.tagName, k, String(resolved)));
|
|
2436
2849
|
};
|
|
2437
|
-
if (
|
|
2850
|
+
if (isRefValue(v)) {
|
|
2438
2851
|
effect(() => write(resolveRef(v.$ref, state)));
|
|
2439
2852
|
} else if (isTemplateString(v)) {
|
|
2440
|
-
|
|
2853
|
+
bindTemplate(v, () => write(evaluateValueTemplate(v, state)));
|
|
2441
2854
|
} else {
|
|
2442
2855
|
write(v);
|
|
2443
2856
|
}
|
|
@@ -2483,7 +2896,7 @@ function renderMappedArrayInto(
|
|
|
2483
2896
|
const anchor = document.createComment("jx-array");
|
|
2484
2897
|
parentEl.append(anchor);
|
|
2485
2898
|
const { items: itemsSrc, map: mapDef, filter: filterRef, sort: sortRef, key: keyRef } = arrayDef;
|
|
2486
|
-
const keyPointer =
|
|
2899
|
+
const keyPointer = isRefValue(keyRef) ? keyRef.$ref : null;
|
|
2487
2900
|
/* The instantiation chain the list belongs to (spec.md §16.9), for the same reason
|
|
2488
2901
|
{@link renderSwitch} keeps one: a row that arrives with new data is inserted from an effect
|
|
2489
2902
|
re-run, and the instance it holds must connect under the chain of the definition that drew
|
|
@@ -2501,14 +2914,22 @@ function renderMappedArrayInto(
|
|
|
2501
2914
|
/** The rows currently rendered, in DOM order. */
|
|
2502
2915
|
let rows: MappedRow[] = [];
|
|
2503
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`. */
|
|
2504
2921
|
const teardown = (row: MappedRow): void => {
|
|
2505
|
-
row.scope.stop();
|
|
2506
2922
|
row.node.remove();
|
|
2923
|
+
row.scope.stop();
|
|
2507
2924
|
};
|
|
2508
2925
|
|
|
2509
|
-
const createRow = (
|
|
2926
|
+
const createRow = (
|
|
2927
|
+
key: MappedRow["key"],
|
|
2928
|
+
item: MappedRow["map"]["item"],
|
|
2929
|
+
index: number,
|
|
2930
|
+
): MappedRow => {
|
|
2510
2931
|
const map = shallowReactive({ index, item });
|
|
2511
|
-
const child =
|
|
2932
|
+
const child = inheritScope(state);
|
|
2512
2933
|
child.$map = map;
|
|
2513
2934
|
// The flat aliases stay readable by legacy readers, and follow the reactive context.
|
|
2514
2935
|
Object.defineProperty(child, "$map/item", { enumerable: true, get: () => map.item });
|
|
@@ -2519,8 +2940,8 @@ function renderMappedArrayInto(
|
|
|
2519
2940
|
return { key, map, node, scope };
|
|
2520
2941
|
};
|
|
2521
2942
|
|
|
2522
|
-
const reconcile = (list:
|
|
2523
|
-
const previous = new Map<
|
|
2943
|
+
const reconcile = (list: readonly JxScopeValue[], keys: readonly MappedRow["key"][]): void => {
|
|
2944
|
+
const previous = new Map<MappedRow["key"], MappedRow>();
|
|
2524
2945
|
for (const row of rows) {
|
|
2525
2946
|
previous.set(row.key, row);
|
|
2526
2947
|
}
|
|
@@ -2563,24 +2984,30 @@ function renderMappedArrayInto(
|
|
|
2563
2984
|
};
|
|
2564
2985
|
|
|
2565
2986
|
const update = (): void => {
|
|
2566
|
-
let items:
|
|
2567
|
-
if (Array.isArray(items) &&
|
|
2987
|
+
let items: JxScopeValue = isRefValue(itemsSrc) ? resolveRef(itemsSrc.$ref, state) : itemsSrc;
|
|
2988
|
+
if (Array.isArray(items) && isRefValue(filterRef)) {
|
|
2568
2989
|
const fn = resolveRef(filterRef.$ref, state);
|
|
2569
|
-
if (
|
|
2570
|
-
|
|
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);
|
|
2571
2994
|
}
|
|
2572
2995
|
}
|
|
2573
|
-
if (Array.isArray(items) &&
|
|
2996
|
+
if (Array.isArray(items) && isRefValue(sortRef)) {
|
|
2574
2997
|
const fn = resolveRef(sortRef.$ref, state);
|
|
2575
|
-
if (
|
|
2576
|
-
|
|
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);
|
|
2577
3004
|
}
|
|
2578
3005
|
}
|
|
2579
|
-
const list = Array.isArray(items) && mapDef ?
|
|
3006
|
+
const list: readonly JxScopeValue[] = Array.isArray(items) && mapDef ? items : [];
|
|
2580
3007
|
|
|
2581
3008
|
// Keys are read HERE, tracked, so a key that changes reconciles the list.
|
|
2582
3009
|
const keys = list.map((item, index) => mappedRowKey(keyPointer, item, index, warnOnce));
|
|
2583
|
-
const seen = new Set<
|
|
3010
|
+
const seen = new Set<MappedRow["key"]>();
|
|
2584
3011
|
for (let i = 0; i < keys.length; i++) {
|
|
2585
3012
|
if (seen.has(keys[i])) {
|
|
2586
3013
|
warnOnce(
|
|
@@ -2634,9 +3061,9 @@ function renderMappedArrayInto(
|
|
|
2634
3061
|
|
|
2635
3062
|
/** A rendered row of a mapped array. */
|
|
2636
3063
|
interface MappedRow {
|
|
2637
|
-
key:
|
|
3064
|
+
key: JxScopeValue;
|
|
2638
3065
|
/** The row's `$map` context; `item` and `index` are written in place when the row is reused. */
|
|
2639
|
-
map:
|
|
3066
|
+
map: JxRowContext;
|
|
2640
3067
|
node: HTMLElement | Text;
|
|
2641
3068
|
scope: EffectScope;
|
|
2642
3069
|
}
|
|
@@ -2648,10 +3075,10 @@ interface MappedRow {
|
|
|
2648
3075
|
*/
|
|
2649
3076
|
function mappedRowKey(
|
|
2650
3077
|
pointer: string | null,
|
|
2651
|
-
item:
|
|
3078
|
+
item: MappedRow["map"]["item"],
|
|
2652
3079
|
index: number,
|
|
2653
3080
|
warnOnce: (message: string) => void,
|
|
2654
|
-
):
|
|
3081
|
+
): MappedRow["key"] {
|
|
2655
3082
|
if (pointer === null) {
|
|
2656
3083
|
return index;
|
|
2657
3084
|
}
|
|
@@ -2677,12 +3104,12 @@ function mappedRowKey(
|
|
|
2677
3104
|
* schema already held to the tag-name pattern — the property that lets the compiler enumerate the
|
|
2678
3105
|
* candidates and lets `createElement` be called without a second thought here.
|
|
2679
3106
|
*
|
|
2680
|
-
* @param {
|
|
3107
|
+
* @param {TagNameSlot} tagName The element's declared tag.
|
|
2681
3108
|
* @param {JxScope} scope The state the discriminant reads.
|
|
2682
3109
|
* @returns {string} A literal tag name; `"div"` when nothing is declared.
|
|
2683
3110
|
*/
|
|
2684
|
-
export function resolveTagName(tagName:
|
|
2685
|
-
if (
|
|
3111
|
+
export function resolveTagName(tagName: TagNameSlot, scope: JxScope): string {
|
|
3112
|
+
if (isString(tagName)) {
|
|
2686
3113
|
return tagName;
|
|
2687
3114
|
}
|
|
2688
3115
|
if (!isTagExpression(tagName)) {
|
|
@@ -2720,7 +3147,7 @@ function renderSwitch(def: JxElement, state: JxScope, options?: JxRenderOptions)
|
|
|
2720
3147
|
}
|
|
2721
3148
|
|
|
2722
3149
|
applyProperties(container, def, state);
|
|
2723
|
-
applyStyle(container, def.style ?? {}, (state
|
|
3150
|
+
applyStyle(container, def.style ?? {}, scopeMedia(state), state);
|
|
2724
3151
|
applyAttributes(container, def.attributes ?? {}, state);
|
|
2725
3152
|
/* Every case renders in a detached scope of its own, stopped before the next case renders and
|
|
2726
3153
|
when the container's own scope stops. A generation counter names the case a pending external
|
|
@@ -2739,9 +3166,10 @@ function renderSwitch(def: JxElement, state: JxScope, options?: JxRenderOptions)
|
|
|
2739
3166
|
};
|
|
2740
3167
|
|
|
2741
3168
|
effect(() => {
|
|
2742
|
-
if (!
|
|
2743
|
-
|
|
3169
|
+
if (!isRefValue(def.$switch)) {
|
|
3170
|
+
// Detached before it is stopped, for the reason `teardown` in `renderMappedArray` gives.
|
|
2744
3171
|
container.replaceChildren();
|
|
3172
|
+
retire();
|
|
2745
3173
|
generation += 1;
|
|
2746
3174
|
return;
|
|
2747
3175
|
}
|
|
@@ -2750,12 +3178,13 @@ function renderSwitch(def: JxElement, state: JxScope, options?: JxRenderOptions)
|
|
|
2750
3178
|
return;
|
|
2751
3179
|
}
|
|
2752
3180
|
renderedKey = key;
|
|
2753
|
-
retire();
|
|
2754
3181
|
/* `replaceChildren()` rather than `innerHTML = ""`: identical semantics, and it is not a
|
|
2755
3182
|
Trusted Types injection sink — under `require-trusted-types-for 'script'` an innerHTML write
|
|
2756
3183
|
needs a policy even when the string is empty. Four sinks that were never injecting anything
|
|
2757
|
-
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. */
|
|
2758
3186
|
container.replaceChildren();
|
|
3187
|
+
retire();
|
|
2759
3188
|
generation += 1;
|
|
2760
3189
|
const caseDef = def.cases?.[key];
|
|
2761
3190
|
if (!caseDef) {
|
|
@@ -2766,7 +3195,7 @@ function renderSwitch(def: JxElement, state: JxScope, options?: JxRenderOptions)
|
|
|
2766
3195
|
live = scope;
|
|
2767
3196
|
const childOpts = options ? { ...options, _path: [...path, "cases", key] } : undefined;
|
|
2768
3197
|
|
|
2769
|
-
if (
|
|
3198
|
+
if (isRefValue(caseDef)) {
|
|
2770
3199
|
// External $ref — fetch and render asynchronously, against the mount's base so a bundled
|
|
2771
3200
|
// Document need not live at the page URL.
|
|
2772
3201
|
const { href } = new URL(caseDef.$ref, options?._ctx?.base ?? location.href);
|
|
@@ -2784,7 +3213,7 @@ function renderSwitch(def: JxElement, state: JxScope, options?: JxRenderOptions)
|
|
|
2784
3213
|
scope.run(() => container.append(renderNode(doc, childScope, childOpts))),
|
|
2785
3214
|
);
|
|
2786
3215
|
})
|
|
2787
|
-
.catch((error
|
|
3216
|
+
.catch((error) =>
|
|
2788
3217
|
console.error("Jx $switch: failed to load external case", caseDef.$ref, error),
|
|
2789
3218
|
);
|
|
2790
3219
|
return;
|
|
@@ -2809,6 +3238,15 @@ function renderSwitch(def: JxElement, state: JxScope, options?: JxRenderOptions)
|
|
|
2809
3238
|
|
|
2810
3239
|
// ─── Prototype namespaces (Shape 5) ──────────────────────────────────────────
|
|
2811
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
|
+
|
|
2812
3250
|
/**
|
|
2813
3251
|
* Resolve a $prototype definition into a value for the reactive scope.
|
|
2814
3252
|
*
|
|
@@ -2819,7 +3257,7 @@ function renderSwitch(def: JxElement, state: JxScope, options?: JxRenderOptions)
|
|
|
2819
3257
|
* @param {JxScope} state - Reactive scope proxy
|
|
2820
3258
|
* @param {string} key - Def key (for diagnostics)
|
|
2821
3259
|
* @param {string} [base] - Base URL for resolving $src imports
|
|
2822
|
-
* @returns {Promise<
|
|
3260
|
+
* @returns {Promise<JxScopeValue>}
|
|
2823
3261
|
*/
|
|
2824
3262
|
export async function resolvePrototype(
|
|
2825
3263
|
def: JxPrototypeDef,
|
|
@@ -2835,7 +3273,7 @@ export async function resolvePrototype(
|
|
|
2835
3273
|
|
|
2836
3274
|
switch (def.$prototype) {
|
|
2837
3275
|
case "Request": {
|
|
2838
|
-
const s: Ref<
|
|
3276
|
+
const s: Ref<JxScopeValue> = ref(null);
|
|
2839
3277
|
const debounceMs = def.debounce ?? 0;
|
|
2840
3278
|
let debounceTimer: ReturnType<typeof setTimeout> | null = null;
|
|
2841
3279
|
|
|
@@ -2865,14 +3303,14 @@ export async function resolvePrototype(
|
|
|
2865
3303
|
signal: controller.signal,
|
|
2866
3304
|
...(def.headers && { headers: def.headers }),
|
|
2867
3305
|
...(def.body && {
|
|
2868
|
-
body:
|
|
3306
|
+
body: isNonNullObject(def.body) ? JSON.stringify(def.body) : def.body,
|
|
2869
3307
|
}),
|
|
2870
3308
|
})
|
|
2871
|
-
.then((r) => (r.ok ? r.json() : Promise.reject(r.statusText)))
|
|
3309
|
+
.then((r): Promise<JsonValue> => (r.ok ? r.json() : Promise.reject(r.statusText)))
|
|
2872
3310
|
.then((d) => {
|
|
2873
3311
|
s.value = d;
|
|
2874
3312
|
})
|
|
2875
|
-
.catch((error
|
|
3313
|
+
.catch((error) => {
|
|
2876
3314
|
if (!(error instanceof Error && error.name === "AbortError")) {
|
|
2877
3315
|
s.value = { error: String(error) };
|
|
2878
3316
|
}
|
|
@@ -2894,8 +3332,11 @@ export async function resolvePrototype(
|
|
|
2894
3332
|
const p: Record<string, string> = {};
|
|
2895
3333
|
for (const [k, v] of Object.entries(def)) {
|
|
2896
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. */
|
|
2897
3338
|
p[k] = (
|
|
2898
|
-
|
|
3339
|
+
isRefValue(v)
|
|
2899
3340
|
? resolveRef(v.$ref, state)
|
|
2900
3341
|
: isTemplateString(v)
|
|
2901
3342
|
? evaluateTemplate(v, state)
|
|
@@ -2911,14 +3352,16 @@ export async function resolvePrototype(
|
|
|
2911
3352
|
case "SessionStorage": {
|
|
2912
3353
|
const store = def.$prototype === "LocalStorage" ? localStorage : sessionStorage;
|
|
2913
3354
|
const k = def.key ?? key;
|
|
2914
|
-
let init:
|
|
3355
|
+
let init: JxScopeValue;
|
|
2915
3356
|
try {
|
|
2916
3357
|
const s = store.getItem(k);
|
|
2917
|
-
|
|
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);
|
|
2918
3361
|
} catch {
|
|
2919
3362
|
init = def.default ?? null;
|
|
2920
3363
|
}
|
|
2921
|
-
const storageState
|
|
3364
|
+
const storageState = scopeRef(init);
|
|
2922
3365
|
// Persist on change
|
|
2923
3366
|
effect(() => {
|
|
2924
3367
|
const v = storageState.value;
|
|
@@ -2937,28 +3380,33 @@ export async function resolvePrototype(
|
|
|
2937
3380
|
|
|
2938
3381
|
case "Cookie": {
|
|
2939
3382
|
const name = def.name ?? key;
|
|
2940
|
-
const read = () => {
|
|
3383
|
+
const read = (): JsonValue => {
|
|
2941
3384
|
const raw = readCookie(document.cookie, name);
|
|
2942
3385
|
if (raw === null) {
|
|
2943
3386
|
return null;
|
|
2944
3387
|
}
|
|
2945
3388
|
try {
|
|
2946
|
-
|
|
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;
|
|
2947
3392
|
} catch {
|
|
2948
3393
|
return raw;
|
|
2949
3394
|
}
|
|
2950
3395
|
};
|
|
2951
|
-
const cookieState
|
|
3396
|
+
const cookieState = scopeRef(read() ?? def.default ?? null);
|
|
2952
3397
|
// Persist on change
|
|
2953
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. */
|
|
2954
3402
|
// oxlint-disable-next-line unicorn/no-document-cookie -- the Cookie $prototype IS the cookie store binding
|
|
2955
|
-
document.cookie = serializeCookie(name, cookieState.value, def);
|
|
3403
|
+
document.cookie = serializeCookie(name, cookieState.value as JsonValue, def);
|
|
2956
3404
|
});
|
|
2957
3405
|
return cookieState;
|
|
2958
3406
|
}
|
|
2959
3407
|
|
|
2960
3408
|
case "IndexedDB": {
|
|
2961
|
-
const idbState: Ref<
|
|
3409
|
+
const idbState: Ref<JxScopeValue> = ref(null);
|
|
2962
3410
|
const {
|
|
2963
3411
|
database,
|
|
2964
3412
|
store,
|
|
@@ -2972,6 +3420,7 @@ export async function resolvePrototype(
|
|
|
2972
3420
|
}
|
|
2973
3421
|
const req = indexedDB.open(database, version);
|
|
2974
3422
|
req.addEventListener("upgradeneeded", (e) => {
|
|
3423
|
+
// SAFETY: an open request's events are dispatched at the request itself.
|
|
2975
3424
|
const db: IDBDatabase = (e.target as IDBOpenDBRequest).result;
|
|
2976
3425
|
if (!db.objectStoreNames.contains(store)) {
|
|
2977
3426
|
const os = db.createObjectStore(store, { autoIncrement, keyPath });
|
|
@@ -2981,6 +3430,7 @@ export async function resolvePrototype(
|
|
|
2981
3430
|
}
|
|
2982
3431
|
});
|
|
2983
3432
|
req.addEventListener("success", (e) => {
|
|
3433
|
+
// SAFETY: an open request's events are dispatched at the request itself.
|
|
2984
3434
|
const db: IDBDatabase = (e.target as IDBOpenDBRequest).result;
|
|
2985
3435
|
idbState.value = {
|
|
2986
3436
|
database,
|
|
@@ -3008,14 +3458,19 @@ export async function resolvePrototype(
|
|
|
3008
3458
|
case "FormData": {
|
|
3009
3459
|
const fd = new FormData();
|
|
3010
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. */
|
|
3011
3463
|
fd.append(k, v as string);
|
|
3012
3464
|
}
|
|
3013
3465
|
return fd;
|
|
3014
3466
|
}
|
|
3015
3467
|
|
|
3016
3468
|
case "Blob": {
|
|
3017
|
-
|
|
3018
|
-
|
|
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",
|
|
3019
3474
|
});
|
|
3020
3475
|
}
|
|
3021
3476
|
|
|
@@ -3048,6 +3503,28 @@ const EXTERNAL_RESERVED = new Set([
|
|
|
3048
3503
|
"name",
|
|
3049
3504
|
]);
|
|
3050
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
|
+
|
|
3051
3528
|
/**
|
|
3052
3529
|
* Resolve an external class prototype via $src.
|
|
3053
3530
|
*
|
|
@@ -3055,7 +3532,7 @@ const EXTERNAL_RESERVED = new Set([
|
|
|
3055
3532
|
* @param {JxScope} state
|
|
3056
3533
|
* @param {string} key
|
|
3057
3534
|
* @param {string} [base]
|
|
3058
|
-
* @returns {Promise<
|
|
3535
|
+
* @returns {Promise<Ref<JxScopeValue>>}
|
|
3059
3536
|
*/
|
|
3060
3537
|
async function resolveExternalPrototype(
|
|
3061
3538
|
def: JxPrototypeDef,
|
|
@@ -3080,13 +3557,18 @@ async function resolveExternalPrototype(
|
|
|
3080
3557
|
* Import a JS module and instantiate a class from it. Internal helper used by resolveClassJson for
|
|
3081
3558
|
* $implementation.
|
|
3082
3559
|
*
|
|
3083
|
-
* @param {
|
|
3560
|
+
* @param {JxPrototypeDef} def - Original state entry (for config extraction)
|
|
3084
3561
|
* @param {string} src - JS module URL to import
|
|
3085
3562
|
* @param {string} exportName - Export name to look up
|
|
3086
3563
|
* @param {string} [base] - Base URL for resolution
|
|
3087
|
-
* @returns {Promise<
|
|
3564
|
+
* @returns {Promise<Ref<JxScopeValue>>}
|
|
3088
3565
|
*/
|
|
3089
|
-
async function importAndInstantiate(
|
|
3566
|
+
async function importAndInstantiate(
|
|
3567
|
+
def: JxPrototypeDef,
|
|
3568
|
+
src: string,
|
|
3569
|
+
exportName: string,
|
|
3570
|
+
base?: string,
|
|
3571
|
+
) {
|
|
3090
3572
|
let mod: ImportedModule;
|
|
3091
3573
|
const seeded = seededModule(src);
|
|
3092
3574
|
if (seeded instanceof Promise) {
|
|
@@ -3095,11 +3577,11 @@ async function importAndInstantiate(def: JxScope, src: string, exportName: strin
|
|
|
3095
3577
|
mod = seeded;
|
|
3096
3578
|
} else {
|
|
3097
3579
|
try {
|
|
3098
|
-
mod =
|
|
3580
|
+
mod = await importModule(src);
|
|
3099
3581
|
} catch {
|
|
3100
3582
|
if (base) {
|
|
3101
3583
|
const resolvedSrc = new URL(src, base).href;
|
|
3102
|
-
mod =
|
|
3584
|
+
mod = await importModule(resolvedSrc);
|
|
3103
3585
|
} else {
|
|
3104
3586
|
throw new Error(`Failed to import "${src}"`);
|
|
3105
3587
|
}
|
|
@@ -3111,33 +3593,27 @@ async function importAndInstantiate(def: JxScope, src: string, exportName: strin
|
|
|
3111
3593
|
if (!ExportedClass) {
|
|
3112
3594
|
throw new Error(`Jx: export "${exportName}" not found in "${src}"`);
|
|
3113
3595
|
}
|
|
3114
|
-
if (
|
|
3596
|
+
if (!isFunction(ExportedClass)) {
|
|
3115
3597
|
throw new TypeError(`Jx: "${exportName}" from "${src}" is not a class`);
|
|
3116
3598
|
}
|
|
3117
3599
|
|
|
3118
|
-
|
|
3119
|
-
|
|
3120
|
-
|
|
3121
|
-
|
|
3122
|
-
|
|
3123
|
-
|
|
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));
|
|
3124
3606
|
|
|
3125
|
-
const
|
|
3126
|
-
|
|
3127
|
-
|
|
3128
|
-
|
|
3129
|
-
|
|
3130
|
-
value = await instance.resolve();
|
|
3131
|
-
} else if ("value" in instance) {
|
|
3132
|
-
({ value } = instance);
|
|
3133
|
-
} else {
|
|
3134
|
-
value = instance;
|
|
3135
|
-
}
|
|
3607
|
+
const value = isFunction(instance.resolve)
|
|
3608
|
+
? await instance.resolve()
|
|
3609
|
+
: "value" in instance
|
|
3610
|
+
? instance.value
|
|
3611
|
+
: instance;
|
|
3136
3612
|
|
|
3137
3613
|
// Always wrap in ref for reactivity with external classes
|
|
3138
|
-
const s
|
|
3139
|
-
if (
|
|
3140
|
-
instance.subscribe((newVal
|
|
3614
|
+
const s = scopeRef(value);
|
|
3615
|
+
if (isFunction(instance.subscribe)) {
|
|
3616
|
+
instance.subscribe((newVal) => {
|
|
3141
3617
|
s.value = newVal;
|
|
3142
3618
|
});
|
|
3143
3619
|
}
|
|
@@ -3155,7 +3631,7 @@ async function importAndInstantiate(def: JxScope, src: string, exportName: strin
|
|
|
3155
3631
|
* @param {JxScope} state
|
|
3156
3632
|
* @param {string} key
|
|
3157
3633
|
* @param {string} [base]
|
|
3158
|
-
* @returns {Promise<
|
|
3634
|
+
* @returns {Promise<Ref<JxScopeValue>>}
|
|
3159
3635
|
*/
|
|
3160
3636
|
async function resolveClassJson(def: JxPrototypeDef, state: JxScope, key: string, base?: string) {
|
|
3161
3637
|
const src = def.$src!;
|
|
@@ -3179,7 +3655,9 @@ async function resolveClassJson(def: JxPrototypeDef, state: JxScope, key: string
|
|
|
3179
3655
|
if (!res.ok) {
|
|
3180
3656
|
throw new Error(`HTTP ${res.status}`);
|
|
3181
3657
|
}
|
|
3182
|
-
|
|
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. */
|
|
3183
3661
|
classDef = (await res.json()) as JxClassDef;
|
|
3184
3662
|
} catch {
|
|
3185
3663
|
// Fall back to dev proxy (server will handle .class.json resolution)
|
|
@@ -3206,71 +3684,81 @@ async function resolveClassJson(def: JxPrototypeDef, state: JxScope, key: string
|
|
|
3206
3684
|
|
|
3207
3685
|
// Self-contained: construct class dynamically from schema
|
|
3208
3686
|
const DynClass = classFromSchema(classDef);
|
|
3209
|
-
|
|
3210
|
-
|
|
3211
|
-
|
|
3212
|
-
|
|
3213
|
-
}
|
|
3214
|
-
}
|
|
3215
|
-
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;
|
|
3216
3691
|
|
|
3217
|
-
|
|
3218
|
-
|
|
3219
|
-
value
|
|
3220
|
-
|
|
3221
|
-
|
|
3222
|
-
} else {
|
|
3223
|
-
value = instance;
|
|
3224
|
-
}
|
|
3692
|
+
const value = isFunction(instance.resolve)
|
|
3693
|
+
? await instance.resolve()
|
|
3694
|
+
: "value" in instance
|
|
3695
|
+
? instance.value
|
|
3696
|
+
: instance;
|
|
3225
3697
|
|
|
3226
3698
|
// Always wrap in ref for reactivity
|
|
3227
|
-
const s
|
|
3228
|
-
if (
|
|
3229
|
-
instance.subscribe((newVal
|
|
3699
|
+
const s = scopeRef(value);
|
|
3700
|
+
if (isFunction(instance.subscribe)) {
|
|
3701
|
+
instance.subscribe((newVal) => {
|
|
3230
3702
|
s.value = newVal;
|
|
3231
3703
|
});
|
|
3232
3704
|
}
|
|
3233
3705
|
return s;
|
|
3234
3706
|
}
|
|
3235
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
|
+
|
|
3236
3720
|
/**
|
|
3237
3721
|
* Dynamically construct a class from a .class.json schema definition. Browser-side: maps private
|
|
3238
3722
|
* fields to _-prefixed public fields.
|
|
3239
3723
|
*
|
|
3240
3724
|
* @param {import("@jxsuite/schema/types").JxClassDef} classDef
|
|
3241
|
-
* @returns {
|
|
3725
|
+
* @returns {typeof DynClass} A class whose instances hold the schema's fields by name
|
|
3242
3726
|
*/
|
|
3243
3727
|
function classFromSchema(classDef: JxClassDef) {
|
|
3244
3728
|
const fields = classDef.$defs?.fields ?? {};
|
|
3245
3729
|
// JSON objects inherit Object.prototype.constructor — only an own object value counts.
|
|
3246
3730
|
const rawCtor = classDef.$defs?.constructor;
|
|
3247
|
-
const ctor =
|
|
3731
|
+
const ctor = isCtorDef(rawCtor) ? rawCtor : undefined;
|
|
3248
3732
|
const methods = classDef.$defs?.methods ?? {};
|
|
3249
3733
|
|
|
3250
3734
|
// oxlint-disable-next-line typescript/no-extraneous-class -- methods are attached to the prototype dynamically below
|
|
3251
3735
|
class DynClass {
|
|
3252
|
-
|
|
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 = {}) {
|
|
3253
3742
|
for (const [key, typedField] of Object.entries(fields)) {
|
|
3254
3743
|
const id = typedField.identifier ?? key;
|
|
3255
3744
|
const propName = typedField.access === "private" ? `_${id}` : id;
|
|
3256
3745
|
if (config[id] !== undefined) {
|
|
3257
|
-
|
|
3746
|
+
this[propName] = config[id];
|
|
3258
3747
|
} else if (typedField.initializer !== undefined) {
|
|
3259
|
-
|
|
3748
|
+
this[propName] = typedField.initializer;
|
|
3260
3749
|
} else if (typedField.default !== undefined) {
|
|
3261
|
-
|
|
3750
|
+
this[propName] = structuredClone(typedField.default);
|
|
3262
3751
|
} else {
|
|
3263
|
-
|
|
3752
|
+
this[propName] = null;
|
|
3264
3753
|
}
|
|
3265
3754
|
}
|
|
3266
3755
|
if (ctor?.body) {
|
|
3267
3756
|
const bodyStr = Array.isArray(ctor.body) ? ctor.body.join("\n") : ctor.body;
|
|
3268
|
-
|
|
3269
|
-
|
|
3270
|
-
|
|
3271
|
-
|
|
3272
|
-
|
|
3273
|
-
).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);
|
|
3274
3762
|
}
|
|
3275
3763
|
}
|
|
3276
3764
|
}
|
|
@@ -3279,10 +3767,11 @@ function classFromSchema(classDef: JxClassDef) {
|
|
|
3279
3767
|
const name = typedMethod.identifier ?? key;
|
|
3280
3768
|
const params = (typedMethod.parameters ?? []).map((p) => {
|
|
3281
3769
|
if (p.$ref) {
|
|
3282
|
-
|
|
3770
|
+
// `split` always yields at least one segment, so `pop` always finds one.
|
|
3771
|
+
return p.$ref.split("/").pop() ?? "";
|
|
3283
3772
|
}
|
|
3284
3773
|
const n = p.identifier ?? p.name;
|
|
3285
|
-
return
|
|
3774
|
+
return isString(n) ? n : "arg";
|
|
3286
3775
|
});
|
|
3287
3776
|
const bodyStr = Array.isArray(typedMethod.body)
|
|
3288
3777
|
? typedMethod.body.join("\n")
|
|
@@ -3291,22 +3780,28 @@ function classFromSchema(classDef: JxClassDef) {
|
|
|
3291
3780
|
if (typedMethod.role === "accessor") {
|
|
3292
3781
|
const descriptor: PropertyDescriptor = {};
|
|
3293
3782
|
if (typedMethod.getter) {
|
|
3294
|
-
|
|
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;
|
|
3295
3786
|
}
|
|
3296
3787
|
if (typedMethod.setter) {
|
|
3297
3788
|
const sp = (typedMethod.setter.parameters ?? []).map(
|
|
3298
3789
|
(p) => p.$ref?.split("/").pop() ?? "v",
|
|
3299
3790
|
);
|
|
3300
|
-
|
|
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;
|
|
3301
3796
|
}
|
|
3302
3797
|
Object.defineProperty(DynClass.prototype, name, {
|
|
3303
3798
|
...descriptor,
|
|
3304
3799
|
configurable: true,
|
|
3305
3800
|
});
|
|
3306
3801
|
} else if (typedMethod.scope === "static") {
|
|
3307
|
-
|
|
3802
|
+
DynClass[name] = new Function(...params, bodyStr);
|
|
3308
3803
|
} else {
|
|
3309
|
-
|
|
3804
|
+
DynClass.prototype[name] = new Function(...params, bodyStr);
|
|
3310
3805
|
}
|
|
3311
3806
|
}
|
|
3312
3807
|
|
|
@@ -3314,8 +3809,7 @@ function classFromSchema(classDef: JxClassDef) {
|
|
|
3314
3809
|
configurable: true,
|
|
3315
3810
|
value: classDef.title,
|
|
3316
3811
|
});
|
|
3317
|
-
|
|
3318
|
-
return dynCtor;
|
|
3812
|
+
return DynClass;
|
|
3319
3813
|
}
|
|
3320
3814
|
|
|
3321
3815
|
/**
|
|
@@ -3327,17 +3821,12 @@ function classFromSchema(classDef: JxClassDef) {
|
|
|
3327
3821
|
* @param {JxScope} state
|
|
3328
3822
|
* @param {string} key
|
|
3329
3823
|
* @param {string} [base]
|
|
3330
|
-
* @returns {Promise<
|
|
3824
|
+
* @returns {Promise<Ref<JxScopeValue>>}
|
|
3331
3825
|
*/
|
|
3332
3826
|
async function resolveViaDevProxy(def: JxPrototypeDef, state: JxScope, key: string, base?: string) {
|
|
3333
|
-
const config
|
|
3334
|
-
for (const [k, v] of Object.entries(def)) {
|
|
3335
|
-
if (!EXTERNAL_RESERVED.has(k)) {
|
|
3336
|
-
config[k] = v;
|
|
3337
|
-
}
|
|
3338
|
-
}
|
|
3827
|
+
const config = externalConfig(def);
|
|
3339
3828
|
|
|
3340
|
-
const hasTemplates = Object.values(config).some((v
|
|
3829
|
+
const hasTemplates = Object.values(config).some((v) => isTemplateString(v));
|
|
3341
3830
|
|
|
3342
3831
|
/** @param {JxScope} resolvedConfig */
|
|
3343
3832
|
const doResolve = (resolvedConfig: JxScope) =>
|
|
@@ -3351,15 +3840,16 @@ async function resolveViaDevProxy(def: JxPrototypeDef, state: JxScope, key: stri
|
|
|
3351
3840
|
}),
|
|
3352
3841
|
headers: { "Content-Type": "application/json" },
|
|
3353
3842
|
method: "POST",
|
|
3354
|
-
}).then((r) => {
|
|
3843
|
+
}).then((r): Promise<JsonValue> => {
|
|
3355
3844
|
if (!r.ok) {
|
|
3356
3845
|
throw new Error(`Jx dev proxy ${r.status} for "${key}"`);
|
|
3357
3846
|
}
|
|
3847
|
+
// The dev proxy answers with the resolved value serialized as JSON.
|
|
3358
3848
|
return r.json();
|
|
3359
3849
|
});
|
|
3360
3850
|
|
|
3361
3851
|
// Always wrap in ref for reactivity
|
|
3362
|
-
const s: Ref<
|
|
3852
|
+
const s: Ref<JxScopeValue> = ref(null);
|
|
3363
3853
|
if (hasTemplates) {
|
|
3364
3854
|
effect(() => {
|
|
3365
3855
|
const resolvedConfig: JxScope = {};
|
|
@@ -3367,17 +3857,17 @@ async function resolveViaDevProxy(def: JxPrototypeDef, state: JxScope, key: stri
|
|
|
3367
3857
|
resolvedConfig[k] = isTemplateString(v) ? evaluateTemplate(v, state) : v;
|
|
3368
3858
|
}
|
|
3369
3859
|
doResolve(resolvedConfig)
|
|
3370
|
-
.then((value
|
|
3860
|
+
.then((value) => {
|
|
3371
3861
|
s.value = value;
|
|
3372
3862
|
})
|
|
3373
|
-
.catch((error
|
|
3863
|
+
.catch((error) => console.error("Jx dev proxy:", error));
|
|
3374
3864
|
});
|
|
3375
3865
|
} else {
|
|
3376
3866
|
doResolve(config)
|
|
3377
|
-
.then((value
|
|
3867
|
+
.then((value) => {
|
|
3378
3868
|
s.value = value;
|
|
3379
3869
|
})
|
|
3380
|
-
.catch((error
|
|
3870
|
+
.catch((error) => console.error("Jx dev proxy:", error));
|
|
3381
3871
|
}
|
|
3382
3872
|
return s;
|
|
3383
3873
|
}
|
|
@@ -3393,15 +3883,15 @@ async function resolveViaDevProxy(def: JxPrototypeDef, state: JxScope, key: stri
|
|
|
3393
3883
|
* write started it. The proxy and a host-registered module share it, so a reactive entry's calls
|
|
3394
3884
|
* are ordered and reported the same way on either path.
|
|
3395
3885
|
*
|
|
3396
|
-
* @param {Ref<
|
|
3397
|
-
* @param {
|
|
3398
|
-
*
|
|
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
|
|
3399
3889
|
* @param {string} label - The `console.error` prefix a failed call is reported under
|
|
3400
3890
|
* @returns {(args: JxScope) => void} Starts a call
|
|
3401
3891
|
*/
|
|
3402
3892
|
function latestServerCall(
|
|
3403
|
-
s: Ref<
|
|
3404
|
-
invoke:
|
|
3893
|
+
s: Ref<JxScopeValue>,
|
|
3894
|
+
invoke: ServerCall,
|
|
3405
3895
|
label: string,
|
|
3406
3896
|
): (args: JxScope) => void {
|
|
3407
3897
|
let seq = 0;
|
|
@@ -3410,15 +3900,15 @@ function latestServerCall(
|
|
|
3410
3900
|
const n = seq;
|
|
3411
3901
|
/* The executor runs before the constructor returns, so the call still starts synchronously,
|
|
3412
3902
|
and a throw inside it rejects this promise instead of escaping to the caller. */
|
|
3413
|
-
new Promise<
|
|
3903
|
+
new Promise<JsonValue>((settle) => {
|
|
3414
3904
|
settle(invoke(args));
|
|
3415
3905
|
})
|
|
3416
|
-
.then((result
|
|
3906
|
+
.then((result) => {
|
|
3417
3907
|
if (n === seq) {
|
|
3418
3908
|
s.value = result;
|
|
3419
3909
|
}
|
|
3420
3910
|
})
|
|
3421
|
-
.catch((error
|
|
3911
|
+
.catch((error) => {
|
|
3422
3912
|
if (n === seq) {
|
|
3423
3913
|
console.error(label, error);
|
|
3424
3914
|
}
|
|
@@ -3426,6 +3916,13 @@ function latestServerCall(
|
|
|
3426
3916
|
};
|
|
3427
3917
|
}
|
|
3428
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
|
+
|
|
3429
3926
|
/**
|
|
3430
3927
|
* Resolve a timing: "server" entry. The runtime never imports a server module itself: every call is
|
|
3431
3928
|
* a POST to the host's `/__jx_server__` proxy, which runs the function server-side with `env`, so
|
|
@@ -3438,7 +3935,7 @@ function latestServerCall(
|
|
|
3438
3935
|
* @param {JxScope} state
|
|
3439
3936
|
* @param {string} key
|
|
3440
3937
|
* @param {string} [base]
|
|
3441
|
-
* @returns {Promise<
|
|
3938
|
+
* @returns {Promise<Ref<JxScopeValue>>}
|
|
3442
3939
|
*/
|
|
3443
3940
|
async function resolveServerFunction(
|
|
3444
3941
|
def: JxServerFnDef,
|
|
@@ -3461,23 +3958,26 @@ async function resolveServerFunction(
|
|
|
3461
3958
|
if (!candidate) {
|
|
3462
3959
|
throw new Error(`Jx: export "${exportName}" not found in "${src}" for "${key}"`);
|
|
3463
3960
|
}
|
|
3464
|
-
if (
|
|
3961
|
+
if (!isFunction(candidate)) {
|
|
3465
3962
|
throw new TypeError(`Jx: "${exportName}" from "${src}" is not a function`);
|
|
3466
3963
|
}
|
|
3467
|
-
|
|
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;
|
|
3468
3968
|
|
|
3469
3969
|
const rawArgs = def.arguments ?? {};
|
|
3470
|
-
const hasReactiveArg = Object.values(rawArgs).some((v
|
|
3970
|
+
const hasReactiveArg = Object.values(rawArgs).some((v) => isRefValue(v));
|
|
3471
3971
|
const resolveArgs = () => {
|
|
3472
3972
|
const args: JxScope = {};
|
|
3473
3973
|
for (const [k, v] of Object.entries(rawArgs)) {
|
|
3474
|
-
args[k] =
|
|
3974
|
+
args[k] = isRefValue(v) ? resolveRef(v.$ref, state) : v;
|
|
3475
3975
|
}
|
|
3476
3976
|
return args;
|
|
3477
3977
|
};
|
|
3478
3978
|
|
|
3479
3979
|
// Always wrap in ref for reactivity
|
|
3480
|
-
const s: Ref<
|
|
3980
|
+
const s: Ref<JxScopeValue> = ref(null);
|
|
3481
3981
|
if (hasReactiveArg) {
|
|
3482
3982
|
const call = latestServerCall(s, fn, "Jx server function:");
|
|
3483
3983
|
effect(() => {
|
|
@@ -3499,25 +3999,25 @@ async function resolveServerFunction(
|
|
|
3499
3999
|
* the entry's last value and logs, and the answer to a superseded call is dropped, so a slow answer
|
|
3500
4000
|
* to an earlier call can never overwrite a later one (`latestServerCall`).
|
|
3501
4001
|
*
|
|
3502
|
-
* @param {
|
|
4002
|
+
* @param {JxServerFnDef} def
|
|
3503
4003
|
* @param {JxScope} state
|
|
3504
4004
|
* @param {string} key
|
|
3505
4005
|
* @param {string} [base]
|
|
3506
|
-
* @returns {Promise<
|
|
4006
|
+
* @returns {Promise<Ref<JxScopeValue>>}
|
|
3507
4007
|
*/
|
|
3508
4008
|
async function resolveServerFunctionViaProxy(
|
|
3509
|
-
def:
|
|
4009
|
+
def: JxServerFnDef,
|
|
3510
4010
|
state: JxScope,
|
|
3511
4011
|
key: string,
|
|
3512
4012
|
base?: string,
|
|
3513
4013
|
) {
|
|
3514
4014
|
const rawArgs = def.arguments ?? {};
|
|
3515
|
-
const hasReactiveArg = Object.values(rawArgs).some((v
|
|
4015
|
+
const hasReactiveArg = Object.values(rawArgs).some((v) => isRefValue(v));
|
|
3516
4016
|
|
|
3517
4017
|
const resolveArgs = () => {
|
|
3518
4018
|
const args: JxScope = {};
|
|
3519
4019
|
for (const [k, v] of Object.entries(rawArgs)) {
|
|
3520
|
-
args[k] =
|
|
4020
|
+
args[k] = isRefValue(v) ? resolveRef(v.$ref, state) : v;
|
|
3521
4021
|
}
|
|
3522
4022
|
return args;
|
|
3523
4023
|
};
|
|
@@ -3533,15 +4033,16 @@ async function resolveServerFunctionViaProxy(
|
|
|
3533
4033
|
}),
|
|
3534
4034
|
headers: { "Content-Type": "application/json" },
|
|
3535
4035
|
method: "POST",
|
|
3536
|
-
}).then((r) => {
|
|
4036
|
+
}).then((r): Promise<JsonValue> => {
|
|
3537
4037
|
if (!r.ok) {
|
|
3538
4038
|
throw new Error(`Jx server proxy ${r.status} for "${key}"`);
|
|
3539
4039
|
}
|
|
4040
|
+
// The proxy answers with the function's result serialized as JSON.
|
|
3540
4041
|
return r.json();
|
|
3541
4042
|
});
|
|
3542
4043
|
|
|
3543
4044
|
// Always wrap in ref for reactivity
|
|
3544
|
-
const s: Ref<
|
|
4045
|
+
const s: Ref<JxScopeValue> = ref(null);
|
|
3545
4046
|
const call = latestServerCall(s, doResolve, "Jx server proxy:");
|
|
3546
4047
|
if (hasReactiveArg) {
|
|
3547
4048
|
effect(() => {
|
|
@@ -3563,10 +4064,11 @@ async function resolveServerFunctionViaProxy(
|
|
|
3563
4064
|
*
|
|
3564
4065
|
* @param {string} ref
|
|
3565
4066
|
* @param {JxScope} state - Reactive scope proxy (or child scope)
|
|
3566
|
-
* @returns {
|
|
4067
|
+
* @returns {JxScopeValue}
|
|
3567
4068
|
*/
|
|
3568
4069
|
export function resolveRef(refPath: string, state: JxScope) {
|
|
3569
|
-
|
|
4070
|
+
// A `$ref` read from a document is not schema-validated, so a non-string one passes through.
|
|
4071
|
+
if (!isString(refPath)) {
|
|
3570
4072
|
return refPath;
|
|
3571
4073
|
}
|
|
3572
4074
|
if (refPath.startsWith("$map/") || refPath.startsWith("#/$map/")) {
|
|
@@ -3575,9 +4077,11 @@ export function resolveRef(refPath: string, state: JxScope) {
|
|
|
3575
4077
|
rendered ZERO rows with no warning. */
|
|
3576
4078
|
const parts = (refPath.startsWith("#/") ? refPath.slice(2) : refPath).split("/");
|
|
3577
4079
|
const [, key] = parts; // "item" or "index"
|
|
3578
|
-
|
|
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;
|
|
3579
4083
|
const base = map?.[key!] ?? state[`$map/${key}`];
|
|
3580
|
-
return parts.length > 2 ?
|
|
4084
|
+
return parts.length > 2 ? readPath(base, parts.slice(2).join("/")) : base;
|
|
3581
4085
|
}
|
|
3582
4086
|
if (refPath.startsWith("#/state/")) {
|
|
3583
4087
|
// One call, not a hand-split leading token: slicing at the first `/` skipped unescaping it, so
|
|
@@ -3592,10 +4096,10 @@ export function resolveRef(refPath: string, state: JxScope) {
|
|
|
3592
4096
|
return readPath(state, refPath.slice("parent#/".length));
|
|
3593
4097
|
}
|
|
3594
4098
|
if (refPath.startsWith("window#/")) {
|
|
3595
|
-
return
|
|
4099
|
+
return readPath(globalThis.window, refPath.slice("window#/".length));
|
|
3596
4100
|
}
|
|
3597
4101
|
if (refPath.startsWith("document#/")) {
|
|
3598
|
-
return
|
|
4102
|
+
return readPath(globalThis.document, refPath.slice("document#/".length));
|
|
3599
4103
|
}
|
|
3600
4104
|
return readPath(state, refPath) ?? null;
|
|
3601
4105
|
}
|
|
@@ -3606,32 +4110,12 @@ export function resolveRef(refPath: string, state: JxScope) {
|
|
|
3606
4110
|
* Check if v is a Vue ref (including computed).
|
|
3607
4111
|
*
|
|
3608
4112
|
* @param {unknown} v
|
|
3609
|
-
* @returns {
|
|
4113
|
+
* @returns {v is Ref<unknown>}
|
|
3610
4114
|
*/
|
|
3611
|
-
export function isSignal(v: unknown) {
|
|
4115
|
+
export function isSignal(v: unknown): v is Ref<unknown> {
|
|
3612
4116
|
return isRef(v);
|
|
3613
4117
|
}
|
|
3614
4118
|
|
|
3615
|
-
/**
|
|
3616
|
-
* @param {unknown} v
|
|
3617
|
-
* @returns {v is import("@jxsuite/schema/types").JxRef}
|
|
3618
|
-
*/
|
|
3619
|
-
function isRefObj(v: unknown): v is JxRef {
|
|
3620
|
-
return isRefValue(v);
|
|
3621
|
-
}
|
|
3622
|
-
|
|
3623
|
-
/**
|
|
3624
|
-
* Walk a path off a value. Delegates to the one tokenizer (`pointer.ts`) — this used to split on
|
|
3625
|
-
* `/[./]/`, which no other path in the codebase agreed with.
|
|
3626
|
-
*
|
|
3627
|
-
* @param {unknown} obj
|
|
3628
|
-
* @param {string} path
|
|
3629
|
-
* @returns {unknown}
|
|
3630
|
-
*/
|
|
3631
|
-
function getPath(obj: unknown, path: string) {
|
|
3632
|
-
return readPath(obj, path);
|
|
3633
|
-
}
|
|
3634
|
-
|
|
3635
4119
|
/**
|
|
3636
4120
|
* Write an observed attribute into element state, coerced by the type the state entry already holds
|
|
3637
4121
|
* (spec §16.5): a number parses, a boolean is presence (`"false"` counts as absent), and anything
|
|
@@ -3651,40 +4135,39 @@ function absorbAttribute(
|
|
|
3651
4135
|
state: JxScope,
|
|
3652
4136
|
name: string,
|
|
3653
4137
|
value: string | null,
|
|
3654
|
-
defaults?: ReadonlyMap<string,
|
|
4138
|
+
defaults?: ReadonlyMap<string, JxScopeValue>,
|
|
3655
4139
|
): void {
|
|
3656
4140
|
const camelKey = name.replaceAll(/-([a-z])/g, (_: string, c: string) => c.toUpperCase());
|
|
3657
4141
|
const current = state[camelKey];
|
|
3658
|
-
if (
|
|
4142
|
+
if (isBoolean(current)) {
|
|
3659
4143
|
// Presence IS the value for a boolean, so a removal is already spelled by the same rule.
|
|
3660
4144
|
state[camelKey] = value !== null && value !== "false";
|
|
3661
4145
|
return;
|
|
3662
4146
|
}
|
|
3663
4147
|
if (value === null) {
|
|
3664
4148
|
const declared = defaults?.get(camelKey);
|
|
3665
|
-
state[camelKey] = declared === undefined ? (
|
|
4149
|
+
state[camelKey] = declared === undefined ? (isNumber(current) ? 0 : "") : declared;
|
|
3666
4150
|
return;
|
|
3667
4151
|
}
|
|
3668
|
-
state[camelKey] =
|
|
4152
|
+
state[camelKey] = isNumber(current) ? Number(value) : value;
|
|
3669
4153
|
}
|
|
3670
4154
|
|
|
3671
4155
|
/**
|
|
3672
4156
|
* The declared default of every state entry that has one, by state key.
|
|
3673
4157
|
*
|
|
3674
4158
|
* @param def The element definition
|
|
3675
|
-
* @returns {Map<string,
|
|
4159
|
+
* @returns {Map<string, JxScopeValue>} Key → default
|
|
3676
4160
|
*/
|
|
3677
|
-
function declaredDefaults(def: JxElement): Map<string,
|
|
3678
|
-
const out = new Map<string,
|
|
4161
|
+
function declaredDefaults(def: JxElement): Map<string, JxScopeValue> {
|
|
4162
|
+
const out = new Map<string, JxScopeValue>();
|
|
3679
4163
|
for (const [key, entry] of Object.entries(def.state ?? {})) {
|
|
3680
|
-
if (entry
|
|
3681
|
-
|
|
3682
|
-
|
|
3683
|
-
out.set(key, obj["default"]);
|
|
4164
|
+
if (isNonNullObject(entry)) {
|
|
4165
|
+
if ("default" in entry) {
|
|
4166
|
+
out.set(key, entry.default);
|
|
3684
4167
|
continue;
|
|
3685
4168
|
}
|
|
3686
4169
|
// A computed entry has no default: its value is produced, and a removal leaves it alone.
|
|
3687
|
-
if (COMPUTED_STATE_KEYS.some((marker) => marker in
|
|
4170
|
+
if (COMPUTED_STATE_KEYS.some((marker) => marker in entry)) {
|
|
3688
4171
|
continue;
|
|
3689
4172
|
}
|
|
3690
4173
|
}
|
|
@@ -3745,6 +4228,27 @@ function instanceSupplies(el: HTMLElement, key: string): boolean {
|
|
|
3745
4228
|
return kebab !== key && el.hasAttribute(kebab);
|
|
3746
4229
|
}
|
|
3747
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
|
+
|
|
3748
4252
|
function refusePrivateProp(key: string, where?: string): boolean {
|
|
3749
4253
|
if (!isPrivateStateKey(key)) {
|
|
3750
4254
|
return false;
|
|
@@ -3772,12 +4276,12 @@ export function _resetPrivatePropWarnings(): void {
|
|
|
3772
4276
|
* @returns {JxScope}
|
|
3773
4277
|
*/
|
|
3774
4278
|
function mergeProps(def: JxElement, parentState: JxScope): JxScope {
|
|
3775
|
-
const child =
|
|
4279
|
+
const child = inheritScope(parentState);
|
|
3776
4280
|
for (const [k, v] of Object.entries(def.$props ?? {})) {
|
|
3777
4281
|
if (refusePrivateProp(k, "$props")) {
|
|
3778
4282
|
continue;
|
|
3779
4283
|
}
|
|
3780
|
-
child[k] =
|
|
4284
|
+
child[k] = isRefValue(v) ? resolveRef(v.$ref, parentState) : v;
|
|
3781
4285
|
}
|
|
3782
4286
|
return child;
|
|
3783
4287
|
}
|
|
@@ -3818,12 +4322,12 @@ export type { CssBuildOptions, CssKeyframeBlock, CssRule, CssRuleTarget } from "
|
|
|
3818
4322
|
* custom property survives it: `--fooBar` and `--foo-bar` are two different properties, and
|
|
3819
4323
|
* kebab-casing one renames it out from under the `var()` that reads it.
|
|
3820
4324
|
*
|
|
3821
|
-
* @param {
|
|
4325
|
+
* @param {JxStyle} rules
|
|
3822
4326
|
* @returns {string}
|
|
3823
4327
|
*/
|
|
3824
|
-
export function toCSSText(rules:
|
|
4328
|
+
export function toCSSText(rules: JxStyle) {
|
|
3825
4329
|
return Object.entries(rules)
|
|
3826
|
-
.filter(([k, v]) => !isNestedSelectorKey(k) && (v
|
|
4330
|
+
.filter(([k, v]) => !isNestedSelectorKey(k) && !isNonNullObject(v))
|
|
3827
4331
|
.map(([p, v]) => `${cssPropertyName(p)}: ${canvasStyleValue(String(v))}`)
|
|
3828
4332
|
.join("; ");
|
|
3829
4333
|
}
|
|
@@ -3891,9 +4395,9 @@ export async function redefineElement(doc: JxDocument, baseUrl?: string): Promis
|
|
|
3891
4395
|
* before `buildScope`, and re-set it every render so a stale map from a previous document cannot
|
|
3892
4396
|
* leak.
|
|
3893
4397
|
*
|
|
3894
|
-
* @param {Record<string, string>} map
|
|
4398
|
+
* @param {Record<string, string> | null} [map] - Absent or null clears it
|
|
3895
4399
|
*/
|
|
3896
|
-
export function setRootMedia(map
|
|
4400
|
+
export function setRootMedia(map?: Record<string, string> | null): void {
|
|
3897
4401
|
_rootMedia = map ?? {};
|
|
3898
4402
|
}
|
|
3899
4403
|
|
|
@@ -3911,7 +4415,7 @@ async function registerElements(
|
|
|
3911
4415
|
) {
|
|
3912
4416
|
for (const entry of elements) {
|
|
3913
4417
|
// Bare string: npm package side-effect import (registers custom elements)
|
|
3914
|
-
if (
|
|
4418
|
+
if (isString(entry)) {
|
|
3915
4419
|
try {
|
|
3916
4420
|
// Bare specifiers need a URL path for the browser; the dev server resolves
|
|
3917
4421
|
// /node_modules/<pkg> to the package entry point via exports/module/main.
|
|
@@ -3923,7 +4427,7 @@ async function registerElements(
|
|
|
3923
4427
|
}
|
|
3924
4428
|
continue;
|
|
3925
4429
|
}
|
|
3926
|
-
if (!
|
|
4430
|
+
if (!isRefValue(entry)) {
|
|
3927
4431
|
continue;
|
|
3928
4432
|
}
|
|
3929
4433
|
const { href } = new URL(entry.$ref, base);
|
|
@@ -3951,7 +4455,7 @@ async function registerElements(
|
|
|
3951
4455
|
* @param {import("@jxsuite/schema/types").JxHeadEntry[]} entries
|
|
3952
4456
|
* @param {string} _base - Document base URL for resolving relative paths
|
|
3953
4457
|
*/
|
|
3954
|
-
function injectHead(entries: JxHeadEntry[], _base: string) {
|
|
4458
|
+
function injectHead(entries: readonly (JxHeadEntry | null | undefined)[], _base: string) {
|
|
3955
4459
|
for (const entry of entries) {
|
|
3956
4460
|
if (!entry || !entry.tagName) {
|
|
3957
4461
|
continue;
|
|
@@ -3961,7 +4465,7 @@ function injectHead(entries: JxHeadEntry[], _base: string) {
|
|
|
3961
4465
|
// Resolve href/src: bare npm specifiers -> /node_modules/ path
|
|
3962
4466
|
for (const key of ["href", "src"] as const) {
|
|
3963
4467
|
const v = attrs[key];
|
|
3964
|
-
if (
|
|
4468
|
+
if (!isString(v) || v === "") {
|
|
3965
4469
|
continue;
|
|
3966
4470
|
}
|
|
3967
4471
|
if (!v.startsWith("/") && !v.startsWith(".") && !v.startsWith("http")) {
|
|
@@ -3987,10 +4491,9 @@ function injectHead(entries: JxHeadEntry[], _base: string) {
|
|
|
3987
4491
|
if (entry.textContent) {
|
|
3988
4492
|
// An object is a structured data block (JSON-LD, site-architecture.md §8.5). The compiler
|
|
3989
4493
|
// Serializes it the same way, so the interpreted page and the built page agree.
|
|
3990
|
-
el.textContent =
|
|
3991
|
-
|
|
3992
|
-
|
|
3993
|
-
: entry.textContent;
|
|
4494
|
+
el.textContent = isNonNullObject(entry.textContent)
|
|
4495
|
+
? JSON.stringify(entry.textContent, null, 2)
|
|
4496
|
+
: entry.textContent;
|
|
3994
4497
|
}
|
|
3995
4498
|
document.head.append(el);
|
|
3996
4499
|
}
|
|
@@ -3999,18 +4502,18 @@ function injectHead(entries: JxHeadEntry[], _base: string) {
|
|
|
3999
4502
|
// ─── Instantiation chain (spec.md §16.9) ─────────────────────────────────────
|
|
4000
4503
|
|
|
4001
4504
|
/**
|
|
4002
|
-
* How deep component nesting may go before an instance refuses to render
|
|
4003
|
-
*
|
|
4004
|
-
*
|
|
4005
|
-
*
|
|
4006
|
-
*
|
|
4007
|
-
*
|
|
4008
|
-
* may not bottom out. The seen-set cannot tell those apart, so this cap is what turns a
|
|
4009
|
-
* stop into a diagnostic instead of a page that never settles
|
|
4010
|
-
*
|
|
4011
|
-
*
|
|
4012
|
-
*
|
|
4013
|
-
*
|
|
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.
|
|
4014
4517
|
*/
|
|
4015
4518
|
export const MAX_COMPONENT_NESTING = 32;
|
|
4016
4519
|
|
|
@@ -4018,7 +4521,7 @@ export const MAX_COMPONENT_NESTING = 32;
|
|
|
4018
4521
|
interface InstanceFrame {
|
|
4019
4522
|
tag: string;
|
|
4020
4523
|
/** Every value the instance supplied that its definition's state read (§16.9). */
|
|
4021
|
-
props:
|
|
4524
|
+
props: JxScope;
|
|
4022
4525
|
}
|
|
4023
4526
|
|
|
4024
4527
|
/**
|
|
@@ -4071,6 +4574,30 @@ const ROOT_INSTANCE_PATH: InstancePath = { expansion: 0, frames: [] };
|
|
|
4071
4574
|
*/
|
|
4072
4575
|
let _instancePath: InstancePath = ROOT_INSTANCE_PATH;
|
|
4073
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
|
+
|
|
4074
4601
|
/**
|
|
4075
4602
|
* Run `fn` with `path` as the chain, so every instance that CONNECTS during it reads that chain.
|
|
4076
4603
|
*
|
|
@@ -4119,11 +4646,11 @@ function reexpand(path: InstancePath): InstancePath {
|
|
|
4119
4646
|
* ended. Inside plain data the comparison IS the JSON form, so a member JSON cannot see (a
|
|
4120
4647
|
* function, a `Map` two levels down) is not seen here either; §16.9 says so.
|
|
4121
4648
|
*
|
|
4122
|
-
* @param {
|
|
4123
|
-
* @param {
|
|
4649
|
+
* @param {JxScopeValue} a
|
|
4650
|
+
* @param {JxScopeValue} b
|
|
4124
4651
|
* @returns {boolean}
|
|
4125
4652
|
*/
|
|
4126
|
-
function samePropValue(a:
|
|
4653
|
+
function samePropValue(a: JxScopeValue, b: JxScopeValue): boolean {
|
|
4127
4654
|
if (Object.is(a, b)) {
|
|
4128
4655
|
return true;
|
|
4129
4656
|
}
|
|
@@ -4140,11 +4667,11 @@ function samePropValue(a: unknown, b: unknown): boolean {
|
|
|
4140
4667
|
/**
|
|
4141
4668
|
* Whether a value is an array or a plain object — the shapes whose JSON form is the whole value.
|
|
4142
4669
|
*
|
|
4143
|
-
* @param {
|
|
4144
|
-
* @returns {
|
|
4670
|
+
* @param {JxScopeValue} value
|
|
4671
|
+
* @returns {boolean}
|
|
4145
4672
|
*/
|
|
4146
|
-
function isPlainData(value:
|
|
4147
|
-
if (
|
|
4673
|
+
function isPlainData(value: JxScopeValue): boolean {
|
|
4674
|
+
if (!isNonNullObject(value)) {
|
|
4148
4675
|
return false;
|
|
4149
4676
|
}
|
|
4150
4677
|
const raw: unknown = toRaw(value);
|
|
@@ -4220,19 +4747,19 @@ const _definedSources = new Set<string>();
|
|
|
4220
4747
|
|
|
4221
4748
|
export async function defineElement(source: string | JxDocument, baseUrl?: string) {
|
|
4222
4749
|
let base = baseUrl;
|
|
4223
|
-
let
|
|
4224
|
-
if (
|
|
4750
|
+
let source_: JxDocument;
|
|
4751
|
+
if (isString(source)) {
|
|
4225
4752
|
base = new URL(source, base ?? location.href).href;
|
|
4226
4753
|
if (_definedSources.has(base)) {
|
|
4227
4754
|
return;
|
|
4228
4755
|
}
|
|
4229
4756
|
_definedSources.add(base);
|
|
4230
|
-
|
|
4757
|
+
source_ = await resolve(source);
|
|
4758
|
+
} else {
|
|
4759
|
+
source_ = source;
|
|
4231
4760
|
}
|
|
4232
4761
|
base ??= location.href;
|
|
4233
4762
|
|
|
4234
|
-
const source_: JxDocument = doc as JxDocument;
|
|
4235
|
-
|
|
4236
4763
|
const { tagName } = source_;
|
|
4237
4764
|
if (!tagName || !tagName.includes("-")) {
|
|
4238
4765
|
throw new Error(`Jx defineElement: tagName "${tagName}" must contain a hyphen`);
|
|
@@ -4284,7 +4811,7 @@ export async function defineElement(source: string | JxDocument, baseUrl?: strin
|
|
|
4284
4811
|
the same values render the same thing, forever. Only a value the definition's state READS
|
|
4285
4812
|
is recorded — a `$props` key no state entry declares changes nothing about the render, so
|
|
4286
4813
|
a definition that "varies" one at each level is still rendering itself. */
|
|
4287
|
-
const supplied:
|
|
4814
|
+
const supplied: JxScope = {};
|
|
4288
4815
|
|
|
4289
4816
|
// The definition as it stands NOW — a redefinition since this class was made is honoured.
|
|
4290
4817
|
const live = _elementDefs.get(tagName) ?? { base: initialBase, doc: initialDef };
|
|
@@ -4312,8 +4839,13 @@ export async function defineElement(source: string | JxDocument, baseUrl?: strin
|
|
|
4312
4839
|
const propsAttr = this.dataset.jxProps;
|
|
4313
4840
|
if (propsAttr) {
|
|
4314
4841
|
try {
|
|
4315
|
-
|
|
4316
|
-
|
|
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) {
|
|
4317
4849
|
if (refusePrivateProp(key, "data-jx-props")) {
|
|
4318
4850
|
continue;
|
|
4319
4851
|
}
|
|
@@ -4351,13 +4883,10 @@ export async function defineElement(source: string | JxDocument, baseUrl?: strin
|
|
|
4351
4883
|
if (isPrivateStateKey(key)) {
|
|
4352
4884
|
continue;
|
|
4353
4885
|
}
|
|
4354
|
-
|
|
4355
|
-
|
|
4356
|
-
|
|
4357
|
-
|
|
4358
|
-
) {
|
|
4359
|
-
state[key] = (this as Record<string, unknown>)[key];
|
|
4360
|
-
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];
|
|
4361
4890
|
}
|
|
4362
4891
|
}
|
|
4363
4892
|
|
|
@@ -4386,18 +4915,7 @@ export async function defineElement(source: string | JxDocument, baseUrl?: strin
|
|
|
4386
4915
|
ownPath = extendInstancePath(enclosingPath, { props: supplied, tag: tagName });
|
|
4387
4916
|
} catch (error) {
|
|
4388
4917
|
this.replaceChildren();
|
|
4389
|
-
|
|
4390
|
-
return;
|
|
4391
|
-
}
|
|
4392
|
-
console.error(`Jx: <${tagName}> was not rendered:`, error);
|
|
4393
|
-
this.dispatchEvent(
|
|
4394
|
-
new ErrorEvent("jx-error", {
|
|
4395
|
-
bubbles: true,
|
|
4396
|
-
composed: true,
|
|
4397
|
-
error,
|
|
4398
|
-
message: error instanceof Error ? error.message : String(error),
|
|
4399
|
-
}),
|
|
4400
|
-
);
|
|
4918
|
+
reportUnrendered(this, tagName, error instanceof Error ? error : new Error(String(error)));
|
|
4401
4919
|
return;
|
|
4402
4920
|
}
|
|
4403
4921
|
/*
|
|
@@ -4409,21 +4927,26 @@ export async function defineElement(source: string | JxDocument, baseUrl?: strin
|
|
|
4409
4927
|
if (isPrivateStateKey(key)) {
|
|
4410
4928
|
continue;
|
|
4411
4929
|
}
|
|
4412
|
-
|
|
4413
|
-
|
|
4414
|
-
|
|
4415
|
-
|
|
4416
|
-
|
|
4417
|
-
|
|
4418
|
-
|
|
4419
|
-
|
|
4420
|
-
|
|
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
|
+
});
|
|
4421
4944
|
}
|
|
4422
4945
|
|
|
4423
4946
|
this._state = state;
|
|
4424
4947
|
|
|
4425
4948
|
// Capture light DOM children (for slot distribution) before rendering
|
|
4426
|
-
const slottedChildren =
|
|
4949
|
+
const slottedChildren = captureSlotted(this);
|
|
4427
4950
|
this.replaceChildren();
|
|
4428
4951
|
|
|
4429
4952
|
/* Custom elements default to `display: inline`; a Jx container behaves like a `<div>`, so
|
|
@@ -4432,14 +4955,16 @@ export async function defineElement(source: string | JxDocument, baseUrl?: strin
|
|
|
4432
4955
|
without one. At (0,1,0) a consumer's rule, a cascade layer or a parent's descendant rule
|
|
4433
4956
|
all win normally, and the definition's own `display` still wins because it is written into
|
|
4434
4957
|
the same rule after this. */
|
|
4435
|
-
const
|
|
4958
|
+
const ownStyle = def.style;
|
|
4959
|
+
const defStyle =
|
|
4960
|
+
ownStyle && declaresDisplay(ownStyle) ? ownStyle : { display: "block", ...ownStyle };
|
|
4436
4961
|
/* Definition first, call site second, so the call site wins at equal specificity by source
|
|
4437
4962
|
order — the promise §9.2 already makes for base-before-nested. Merged into ONE call
|
|
4438
4963
|
because each `applyStyle` releases what the last one interned. */
|
|
4439
|
-
const hostStyle = mergeStyle(defStyle
|
|
4964
|
+
const hostStyle = mergeStyle(defStyle, callSiteStyles.get(this));
|
|
4440
4965
|
|
|
4441
4966
|
// Render template into light DOM (once, not in effect — inner effects handle reactivity)
|
|
4442
|
-
applyStyle(this, hostStyle, (state
|
|
4967
|
+
applyStyle(this, hostStyle, scopeMedia(state), state);
|
|
4443
4968
|
applyAttributes(this, def.attributes ?? {}, state);
|
|
4444
4969
|
bindDefinitionHandlers(this, def, state);
|
|
4445
4970
|
|
|
@@ -4452,46 +4977,122 @@ export async function defineElement(source: string | JxDocument, baseUrl?: strin
|
|
|
4452
4977
|
bindProperty(this, "textContent", def.textContent, state);
|
|
4453
4978
|
}
|
|
4454
4979
|
const children = Array.isArray(def.children) ? def.children : [];
|
|
4455
|
-
/*
|
|
4456
|
-
|
|
4457
|
-
|
|
4458
|
-
|
|
4459
|
-
|
|
4460
|
-
|
|
4461
|
-
|
|
4462
|
-
|
|
4463
|
-
|
|
4464
|
-
|
|
4465
|
-
|
|
4466
|
-
|
|
4467
|
-
|
|
4468
|
-
|
|
4469
|
-
|
|
4470
|
-
|
|
4471
|
-
|
|
4472
|
-
|
|
4473
|
-
|
|
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
|
+
};
|
|
4474
5008
|
|
|
4475
5009
|
/* Lifecycle: onMount, with the HOST as its second argument.
|
|
4476
5010
|
A sidecar that must reach the element it belongs to had no way to: the runtime handed it
|
|
4477
5011
|
the scope alone, so elements resorted to dispatching a `jx-ready` event at themselves and
|
|
4478
5012
|
catching it with a root handler purely to learn `currentTarget`. The host is what the
|
|
4479
|
-
callback needed; the event round trip was the workaround.
|
|
4480
|
-
|
|
4481
|
-
|
|
4482
|
-
|
|
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;
|
|
4483
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
|
+
);
|
|
4484
5082
|
}
|
|
4485
5083
|
|
|
4486
5084
|
disconnectedCallback() {
|
|
4487
|
-
|
|
4488
|
-
|
|
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);
|
|
4489
5089
|
}
|
|
4490
5090
|
}
|
|
4491
5091
|
|
|
4492
5092
|
adoptedCallback() {
|
|
4493
|
-
|
|
4494
|
-
|
|
5093
|
+
const state = this._state;
|
|
5094
|
+
if (state && isScopeHook(state.onAdopted)) {
|
|
5095
|
+
state.onAdopted(state);
|
|
4495
5096
|
}
|
|
4496
5097
|
}
|
|
4497
5098
|
|
|
@@ -4543,11 +5144,25 @@ function renderCustomElementWithProps(
|
|
|
4543
5144
|
element is an asset reference authored at the USAGE site, so it belongs to the document that
|
|
4544
5145
|
wrote it — a media prop the component forwards into its own `<img>` is resolved separately,
|
|
4545
5146
|
inside the component's render, against the same context. */
|
|
4546
|
-
const write = (resolved:
|
|
4547
|
-
|
|
4548
|
-
|
|
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
|
+
}
|
|
4549
5164
|
};
|
|
4550
|
-
if (
|
|
5165
|
+
if (isRefValue(val)) {
|
|
4551
5166
|
const refVal = val;
|
|
4552
5167
|
write(resolveRef(refVal.$ref, state));
|
|
4553
5168
|
// Reactive forwarding: re-set the property when the source changes
|
|
@@ -4555,7 +5170,7 @@ function renderCustomElementWithProps(
|
|
|
4555
5170
|
write(resolveRef(refVal.$ref, state));
|
|
4556
5171
|
});
|
|
4557
5172
|
} else if (isTemplateString(val)) {
|
|
4558
|
-
|
|
5173
|
+
bindTemplate(val, () => {
|
|
4559
5174
|
write(evaluateTemplate(val, state));
|
|
4560
5175
|
});
|
|
4561
5176
|
} else {
|
|
@@ -4571,7 +5186,7 @@ function renderCustomElementWithProps(
|
|
|
4571
5186
|
if (def.style) {
|
|
4572
5187
|
callSiteStyles.set(el, def.style);
|
|
4573
5188
|
}
|
|
4574
|
-
applyStyle(el, def.style ?? {}, (state
|
|
5189
|
+
applyStyle(el, def.style ?? {}, scopeMedia(state), state);
|
|
4575
5190
|
applyAttributes(el, def.attributes ?? {}, state);
|
|
4576
5191
|
/* And its ordinary properties and handlers — `id`, `hidden`, `className`, an `onselect` — exactly
|
|
4577
5192
|
as an ordinary element gets them. A custom element used in a document is still an element in
|
|
@@ -4596,47 +5211,184 @@ function renderCustomElementWithProps(
|
|
|
4596
5211
|
}
|
|
4597
5212
|
|
|
4598
5213
|
/**
|
|
4599
|
-
*
|
|
5214
|
+
* Whether a node is an element, by its `nodeType` (which, unlike `instanceof`, holds across
|
|
5215
|
+
* realms).
|
|
4600
5216
|
*
|
|
4601
|
-
* @param {
|
|
4602
|
-
* @
|
|
4603
|
-
*/
|
|
4604
|
-
function
|
|
4605
|
-
|
|
4606
|
-
|
|
4607
|
-
|
|
4608
|
-
|
|
4609
|
-
|
|
4610
|
-
|
|
4611
|
-
|
|
4612
|
-
|
|
4613
|
-
|
|
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) ─────────────────────────────────────────
|
|
5245
|
+
|
|
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;
|
|
4614
5252
|
|
|
4615
|
-
|
|
4616
|
-
|
|
5253
|
+
/** The word that opens a prerendered slot group's comment bracket: `<!--jx-slot n name-->`. */
|
|
5254
|
+
export const SLOT_MARK = "jx-slot";
|
|
4617
5255
|
|
|
4618
|
-
|
|
4619
|
-
|
|
4620
|
-
|
|
4621
|
-
|
|
4622
|
-
|
|
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
|
+
})();
|
|
5277
|
+
|
|
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;
|
|
4623
5313
|
}
|
|
4624
|
-
|
|
4625
|
-
|
|
4626
|
-
unnamed.push(child);
|
|
5314
|
+
slotRecord.set(n, name);
|
|
5315
|
+
captured.push(n);
|
|
4627
5316
|
}
|
|
4628
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
|
+
}
|
|
4629
5331
|
|
|
4630
|
-
|
|
4631
|
-
|
|
4632
|
-
|
|
4633
|
-
|
|
4634
|
-
|
|
4635
|
-
|
|
4636
|
-
|
|
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>();
|
|
4637
5377
|
for (const slot of slots) {
|
|
4638
|
-
const name = slot.getAttribute("name");
|
|
4639
|
-
|
|
4640
|
-
|
|
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);
|
|
4641
5393
|
}
|
|
4642
5394
|
}
|