@jxsuite/compiler 5.1.2 → 5.1.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. package/dist/cli.js +220 -351
  2. package/dist/compiler.js +126 -257
  3. package/package.json +4 -4
  4. package/src/cli.ts +12 -14
  5. package/src/compiler.ts +45 -23
  6. package/src/shadow.ts +9 -8
  7. package/src/shared.ts +350 -296
  8. package/src/site/asset-mounts.ts +22 -12
  9. package/src/site/bundler.ts +38 -12
  10. package/src/site/client-runtime.ts +31 -17
  11. package/src/site/csp.ts +20 -11
  12. package/src/site/db-push.ts +38 -24
  13. package/src/site/dev-command.ts +3 -3
  14. package/src/site/format-host.ts +56 -45
  15. package/src/site/headers-emitter.ts +7 -4
  16. package/src/site/i18n.ts +20 -19
  17. package/src/site/image-cache.ts +16 -3
  18. package/src/site/image-optimizer.ts +19 -5
  19. package/src/site/image-transform.ts +56 -60
  20. package/src/site/img-loading.ts +11 -5
  21. package/src/site/layout-resolver.ts +7 -6
  22. package/src/site/link-relations.ts +10 -6
  23. package/src/site/locale-negotiation.ts +1 -1
  24. package/src/site/pages-discovery.ts +65 -47
  25. package/src/site/preview-server.ts +22 -19
  26. package/src/site/project-sections.ts +12 -3
  27. package/src/site/prototype-resolver.ts +94 -42
  28. package/src/site/schema-command.ts +26 -13
  29. package/src/site/service-worker.ts +2 -2
  30. package/src/site/site-build.ts +379 -262
  31. package/src/site/site-loader.ts +6 -3
  32. package/src/site/static-host.ts +3 -3
  33. package/src/site/validate-command.ts +64 -34
  34. package/src/site/well-known.ts +57 -12
  35. package/src/targets/compile-class.ts +17 -5
  36. package/src/targets/compile-client.ts +141 -106
  37. package/src/targets/compile-element.ts +157 -140
  38. package/src/targets/compile-server.ts +11 -5
  39. package/src/targets/compile-static.ts +57 -63
  40. package/src/types.ts +5 -6
package/src/shared.ts CHANGED
@@ -9,6 +9,7 @@
9
9
  import {
10
10
  COLOR_SCHEME_ATTR,
11
11
  COLOR_SCHEME_STORAGE_KEY,
12
+ MAX_COMPONENT_NESTING,
12
13
  RESERVED_KEYS,
13
14
  booleanAttrValue,
14
15
  buildStyleRules,
@@ -26,30 +27,37 @@ import {
26
27
  childrenContainArray,
27
28
  hasStructuredBody,
28
29
  isExpressionDef,
30
+ isBoolean,
31
+ isFunction,
29
32
  isFunctionDef,
30
33
  isMappedArray,
31
34
  isNamedFormulaDef,
35
+ isNonNullObject,
36
+ isNumber,
32
37
  isPrototypeDef,
33
38
  isRef,
34
- isSchemaOnlyDef as isSchemaOnly,
39
+ isSchemaOnlyDef,
35
40
  isServerFnDef,
41
+ isString,
36
42
  isTagExpression,
37
43
  isTemplateString,
38
44
  paramNames,
39
45
  tagNameCandidates,
40
46
  } from "@jxsuite/schema/guards";
47
+ import type { TagNameSlot } from "@jxsuite/schema/guards";
41
48
  import { resolveShadowMode, styleScopePrefix } from "./shadow.ts";
42
49
  import type { ShadowMode } from "./shadow.ts";
43
- import type { ExpressionNode } from "@jxsuite/runtime/expression";
44
50
  import { readPath } from "@jxsuite/runtime/pointer";
51
+ import type { JxScope } from "@jxsuite/runtime/types";
45
52
  import type {
53
+ CemParameter,
46
54
  JsonValue,
55
+ JxDocumentValue,
47
56
  JxElement,
48
57
  JxMutableNode,
49
58
  JxPrototypeDef,
50
59
  JxRef,
51
60
  JxStateDefinition,
52
- JxStateObject,
53
61
  JxStyle,
54
62
  ProjectConfig,
55
63
  } from "@jxsuite/schema/types";
@@ -80,22 +88,51 @@ export { compileStatements, runStatements } from "@jxsuite/runtime/statements";
80
88
  * declared parameter names (honoring CemParameter defaults) as the `_args` object the compiled
81
89
  * body's `$args/` refs read from. Call sites stay positional, matching the interpreter.
82
90
  */
83
- export function emitFormulaFn(def: { parameters?: unknown[] }, compiledBody: string): string {
91
+ export function emitFormulaFn(
92
+ def: { parameters?: readonly FormulaParameter[] | undefined },
93
+ compiledBody: string,
94
+ ): string {
84
95
  const entries: string[] = [];
85
96
  for (const [i, p] of (def.parameters ?? []).entries()) {
86
- const name = typeof p === "string" ? p : ((p as { name?: string } | null)?.name ?? "");
97
+ const name = formulaParameterName(p);
87
98
  if (!name) {
88
99
  continue;
89
100
  }
90
- const hasDefault = typeof p === "object" && p !== null && "default" in p;
91
- const arg = hasDefault
92
- ? `_a[${i}] === undefined ? ${JSON.stringify((p as { default?: unknown }).default)} : _a[${i}]`
101
+ const arg = hasParameterDefault(p)
102
+ ? `_a[${i}] === undefined ? ${JSON.stringify(p.default)} : _a[${i}]`
93
103
  : `_a[${i}]`;
94
104
  entries.push(`${JSON.stringify(name)}: ${arg}`);
95
105
  }
96
106
  return `(..._a) => { const _args = { ${entries.join(", ")} }; return ${compiledBody}; }`;
97
107
  }
98
108
 
109
+ /**
110
+ * One declared formula parameter as a document holds it: a bare name or a CEM parameter object. A
111
+ * hand-edited list may also carry a `null` hole, which both the emitted and the build-time callable
112
+ * skip like an unnamed entry.
113
+ */
114
+ type FormulaParameter = string | CemParameter | null;
115
+
116
+ /**
117
+ * The name a formula parameter binds under `$args/`, or "" for an entry that names nothing.
118
+ *
119
+ * @param {FormulaParameter} p
120
+ * @returns {string}
121
+ */
122
+ function formulaParameterName(p: FormulaParameter): string {
123
+ return isString(p) ? p : (p?.name ?? "");
124
+ }
125
+
126
+ /**
127
+ * Whether a formula parameter declares a `default`, which fills an argument the call site omits.
128
+ *
129
+ * @param {FormulaParameter} p
130
+ * @returns {boolean}
131
+ */
132
+ function hasParameterDefault(p: FormulaParameter): p is CemParameter {
133
+ return isNonNullObject(p) && "default" in p;
134
+ }
135
+
99
136
  /**
100
137
  * Emit the import-clause binding for one Function-def `$src` entry.
101
138
  *
@@ -109,10 +146,10 @@ export function emitFormulaFn(def: { parameters?: unknown[] }, compiledBody: str
109
146
  * only reachable as `default as key`.
110
147
  *
111
148
  * @param {string} key - State key, and the local binding name
112
- * @param {unknown} def - The Function definition
149
+ * @param {SrcExportOwner} def - The Function definition
113
150
  * @returns {string}
114
151
  */
115
- export function srcImportBinding(key: string, def: unknown) {
152
+ export function srcImportBinding(key: string, def: SrcExportOwner) {
116
153
  const exportName = srcExportName(key, def);
117
154
  return exportName === key ? key : `${exportName} as ${key}`;
118
155
  }
@@ -125,14 +162,23 @@ export function srcImportBinding(key: string, def: unknown) {
125
162
  * to, where the local name is chosen by the generated code and only the export name is fixed.
126
163
  *
127
164
  * @param {string} key - State key
128
- * @param {unknown} def - The Function definition
165
+ * @param {SrcExportOwner} def - The Function definition
129
166
  * @returns {string}
130
167
  */
131
- export function srcExportName(key: string, def: unknown): string {
132
- const declared = (def as { $export?: unknown } | null | undefined)?.$export;
133
- return typeof declared === "string" && declared !== "" ? declared : key;
168
+ export function srcExportName(key: string, def: SrcExportOwner): string {
169
+ const declared = def?.$export;
170
+ return isString(declared) && declared !== "" ? declared : key;
134
171
  }
135
172
 
173
+ /**
174
+ * The Function definition a `$src` export is named on, as the document holds it. `$export` is read
175
+ * unchecked from document data, so anything but a non-empty string falls back to the state key.
176
+ */
177
+ type SrcExportOwner =
178
+ | { $export?: JxDocumentValue | undefined; [key: string]: JxDocumentValue | undefined }
179
+ | null
180
+ | undefined;
181
+
136
182
  /**
137
183
  * Emit the auto-fetch `effect()` for a `$prototype: "Request"` state entry.
138
184
  *
@@ -173,7 +219,7 @@ export function emitRequestFetch(
173
219
 
174
220
  if (isTemplateUrl) {
175
221
  lines.push(
176
- ` const url = \`${withStatePrefix(url as string, statePrefix)}\`;`,
222
+ ` const url = \`${withStatePrefix(url, statePrefix)}\`;`,
177
223
  ' if (!url || url === "undefined" || url.includes("undefined")) return;',
178
224
  );
179
225
  } else {
@@ -188,10 +234,9 @@ export function emitRequestFetch(
188
234
  fetchOpts.push(`headers: ${JSON.stringify(def.headers)}`);
189
235
  }
190
236
  if (def.body) {
191
- const bodyStr =
192
- typeof def.body === "object"
193
- ? JSON.stringify(JSON.stringify(def.body))
194
- : JSON.stringify(def.body);
237
+ const bodyStr = isNonNullObject(def.body)
238
+ ? JSON.stringify(JSON.stringify(def.body))
239
+ : JSON.stringify(def.body);
195
240
  fetchOpts.push(`body: ${bodyStr}`);
196
241
  }
197
242
 
@@ -229,7 +274,6 @@ function withStatePrefix(str: string, statePrefix: string) {
229
274
  `\${${expr.replaceAll(/(?<!this\.)\bstate\./g, `${statePrefix}.`)}}`,
230
275
  );
231
276
  }
232
- export type { ExpressionNode } from "@jxsuite/runtime/expression";
233
277
 
234
278
  /**
235
279
  * Non-enumerable marker on a build-time scope listing the state keys that hold no build-time value.
@@ -267,7 +311,7 @@ export function isClassJsonSrc(src?: unknown): src is string {
267
311
  * @returns {boolean}
268
312
  */
269
313
  export function isDynamic(def: JxElement | JxMutableNode | string) {
270
- if (!def || typeof def !== "object") {
314
+ if (!isNonNullObject(def)) {
271
315
  return false;
272
316
  }
273
317
 
@@ -277,25 +321,20 @@ export function isDynamic(def: JxElement | JxMutableNode | string) {
277
321
  if (k === "$site" || k === "$page") {
278
322
  continue;
279
323
  }
324
+ if (!isNonNullObject(d) || Array.isArray(d)) {
325
+ return true;
326
+ }
280
327
  // Skip timing: "compiler" entries — resolved at build time, baked into static HTML
281
- if (
282
- d &&
283
- typeof d === "object" &&
284
- !Array.isArray(d) &&
285
- (d as JxPrototypeDef).timing === "compiler"
286
- ) {
328
+ if (d.timing === "compiler") {
287
329
  continue;
288
330
  }
289
- if (typeof d !== "object" || d === null || Array.isArray(d)) {
290
- return true;
291
- }
292
- if ((d as JxPrototypeDef).$prototype) {
331
+ if (d.$prototype) {
293
332
  return true;
294
333
  }
295
- if ("default" in /** @type {object} */ d) {
334
+ if ("default" in d) {
296
335
  return true;
297
336
  }
298
- if (isSchemaOnly(d)) {
337
+ if (isSchemaOnlyDef(d)) {
299
338
  continue;
300
339
  }
301
340
  return true;
@@ -317,11 +356,7 @@ export function isDynamic(def: JxElement | JxMutableNode | string) {
317
356
  if (RESERVED_KEYS.has(key)) {
318
357
  continue;
319
358
  }
320
- if (
321
- val !== null &&
322
- typeof val === "object" &&
323
- typeof (val as JxMutableNode).$ref === "string"
324
- ) {
359
+ if (isRef(val)) {
325
360
  return true;
326
361
  }
327
362
  if (isTemplateString(val)) {
@@ -329,7 +364,7 @@ export function isDynamic(def: JxElement | JxMutableNode | string) {
329
364
  }
330
365
  }
331
366
 
332
- if (def.style && typeof def.style === "object") {
367
+ if (isNonNullObject(def.style)) {
333
368
  for (const val of Object.values(def.style)) {
334
369
  if (isTemplateString(val)) {
335
370
  return true;
@@ -337,7 +372,7 @@ export function isDynamic(def: JxElement | JxMutableNode | string) {
337
372
  }
338
373
  }
339
374
 
340
- if (def.attributes && typeof def.attributes === "object") {
375
+ if (isNonNullObject(def.attributes)) {
341
376
  for (const val of Object.values(def.attributes)) {
342
377
  if (isTemplateString(val)) {
343
378
  return true;
@@ -355,7 +390,7 @@ export function isDynamic(def: JxElement | JxMutableNode | string) {
355
390
  * @returns {boolean}
356
391
  */
357
392
  export function isNodeDynamic(def: JxElement | JxMutableNode | string) {
358
- if (!def || typeof def !== "object") {
393
+ if (!isNonNullObject(def)) {
359
394
  return false;
360
395
  }
361
396
 
@@ -370,11 +405,7 @@ export function isNodeDynamic(def: JxElement | JxMutableNode | string) {
370
405
  if (RESERVED_KEYS.has(key)) {
371
406
  continue;
372
407
  }
373
- if (
374
- val !== null &&
375
- typeof val === "object" &&
376
- typeof (val as JxMutableNode).$ref === "string"
377
- ) {
408
+ if (isRef(val)) {
378
409
  return true;
379
410
  }
380
411
  if (isTemplateString(val)) {
@@ -382,7 +413,7 @@ export function isNodeDynamic(def: JxElement | JxMutableNode | string) {
382
413
  }
383
414
  }
384
415
 
385
- if (def.style && typeof def.style === "object") {
416
+ if (isNonNullObject(def.style)) {
386
417
  for (const val of Object.values(def.style)) {
387
418
  if (isTemplateString(val)) {
388
419
  return true;
@@ -390,7 +421,7 @@ export function isNodeDynamic(def: JxElement | JxMutableNode | string) {
390
421
  }
391
422
  }
392
423
 
393
- if (def.attributes && typeof def.attributes === "object") {
424
+ if (isNonNullObject(def.attributes)) {
394
425
  for (const val of Object.values(def.attributes)) {
395
426
  if (isTemplateString(val)) {
396
427
  return true;
@@ -408,7 +439,7 @@ export function isNodeDynamic(def: JxElement | JxMutableNode | string) {
408
439
  * @returns {boolean}
409
440
  */
410
441
  export function hasAnyIsland(def: JxElement | JxMutableNode | string): boolean {
411
- if (!def || typeof def !== "object") {
442
+ if (!isNonNullObject(def)) {
412
443
  return false;
413
444
  }
414
445
  if (isDynamic(def)) {
@@ -422,33 +453,60 @@ export function hasAnyIsland(def: JxElement | JxMutableNode | string): boolean {
422
453
 
423
454
  // ─── Scope / value resolution ─────────────────────────────────────────────────
424
455
 
425
- /**
426
- * @param {JxElement | JxMutableNode | null} raw
427
- * @param {Record<string, unknown> | null} [parentScope]
428
- * @param {Record<string, unknown>} [scopeDefs]
429
- * @param {Record<string, string>} [media]
430
- * @returns {CompileContext}
431
- */
456
+ /** What a compile target threads down the tree: the scope in effect, its definitions and `$media`. */
432
457
  export interface CompileContext {
433
- scope: Record<string, unknown>;
434
- scopeDefs: Record<string, unknown>;
458
+ scope: JxScope;
459
+ /** The `state` definitions the scope was built from. */
460
+ scopeDefs: Record<string, JxStateDefinition>;
435
461
  media: Record<string, string>;
436
462
  /** True inside a `<pre>`-like subtree, where emitters must not indent nested children. */
437
463
  preformatted?: boolean;
438
464
  }
439
465
 
466
+ /**
467
+ * @param {JxElement | JxMutableNode | null} raw
468
+ * @param {JxScope | null} [parentScope]
469
+ * @param {Record<string, JxStateDefinition>} [scopeDefs]
470
+ * @param {Record<string, string>} [media]
471
+ * @returns {CompileContext}
472
+ */
440
473
  export function createCompileContext(
441
474
  raw: JxElement | JxMutableNode | null,
442
- parentScope: Record<string, unknown> | null = null,
443
- scopeDefs: Record<string, unknown> = {},
475
+ parentScope: JxScope | null = null,
476
+ scopeDefs: Record<string, JxStateDefinition> = {},
444
477
  media: Record<string, string> = {},
445
478
  ): CompileContext {
446
- const scope: Record<string, unknown> = raw?.state
479
+ const scope = raw?.state
447
480
  ? buildInitialScope(raw.state, parentScope)
448
- : (parentScope ?? (Object.create(null) as Record<string, unknown>));
481
+ : (parentScope ?? emptyScope());
449
482
  return { media, scope, scopeDefs };
450
483
  }
451
484
 
485
+ /**
486
+ * A fresh scope inheriting from `parent`, or from nothing at all, so no `Object.prototype` member
487
+ * can answer for an undeclared state key.
488
+ *
489
+ * @param {JxScope | null} [parent]
490
+ * @returns {JxScope}
491
+ */
492
+ function emptyScope(parent: JxScope | null = null): JxScope {
493
+ /* SAFETY: `Object.create` returns a new object with no own keys whose prototype is `parent` (a
494
+ scope) or null, so every key it answers is a scope member or absent — exactly a JxScope. */
495
+ return Object.create(parent) as JxScope;
496
+ }
497
+
498
+ /**
499
+ * The runtime-only marks `buildInitialScope` attached to `scope` or to a scope it inherits from.
500
+ *
501
+ * @param {JxScope} scope
502
+ * @returns {Set<string> | undefined}
503
+ */
504
+ function runtimeOnlyKeysOf(scope: JxScope): Set<string> | undefined {
505
+ /* SAFETY: `RUNTIME_ONLY_KEYS` is module-private and `buildInitialScope` is the only writer, which
506
+ always stores a `Set<string>` under it; a scope built anywhere else answers undefined. */
507
+ return (scope as { [RUNTIME_ONLY_KEYS]?: Set<string> })[RUNTIME_ONLY_KEYS];
508
+ }
509
+
452
510
  /**
453
511
  * `state.x = …`, `state.x += …`, `state.x++` — every form a string handler body can use to write a
454
512
  * state entry. `===`/`!==`/`<=`/`>=` are excluded by the `(?!=)` guard and by the operator set.
@@ -469,36 +527,43 @@ const STATE_MUTATING_METHOD_RE =
469
527
  * distinguishes it from a constant — but if a handler assigns to it, interpolating it at build time
470
528
  * replaces the template in the emitted output and the element is dead for the life of the page.
471
529
  *
472
- * @param {unknown} node
530
+ * @param {JxDocumentValue | undefined} node
473
531
  * @param {Set<string>} into
474
532
  */
475
- function collectAssignedStateKeys(node: unknown, into: Set<string>) {
476
- if (typeof node === "string") {
533
+ function collectAssignedStateKeys(node: JxDocumentValue | undefined, into: Set<string>) {
534
+ if (isString(node)) {
477
535
  for (const m of node.matchAll(STATE_ASSIGNMENT_RE)) {
536
+ // SAFETY: the pattern's one capture group is outside any optional part, so a match has it.
478
537
  into.add(m[1] as string);
479
538
  }
480
539
  for (const m of node.matchAll(STATE_MUTATING_METHOD_RE)) {
540
+ // SAFETY: the pattern's one capture group is outside any optional part, so a match has it.
481
541
  into.add(m[1] as string);
482
542
  }
483
543
  return;
484
544
  }
485
- if (!node || typeof node !== "object") {
486
- return;
487
- }
488
545
  if (Array.isArray(node)) {
489
546
  for (const entry of node) {
490
547
  collectAssignedStateKeys(entry, into);
491
548
  }
492
549
  return;
493
550
  }
494
- const rec = node as Record<string, unknown>;
551
+ if (!isNonNullObject(node)) {
552
+ return;
553
+ }
554
+ /* SAFETY: `node` is a non-array object inside a handler body or an expression, so it is a
555
+ document object, and every member of a document object is itself a document value. */
556
+ const rec = node as Record<string, JxDocumentValue | undefined>;
495
557
  // A mutating expression names its destination as `target: { $ref: "#/state/x" }`.
496
- const op = rec.operator;
497
- if (typeof op === "string" && isMutating(op)) {
498
- const target = rec.target as { $ref?: string } | undefined;
499
- if (target && typeof target.$ref === "string" && target.$ref.startsWith("#/state/")) {
500
- into.add(target.$ref.slice("#/state/".length).split("/")[0] as string);
501
- }
558
+ const { operator, target } = rec;
559
+ if (
560
+ isString(operator) &&
561
+ isMutating(operator) &&
562
+ isRef(target) &&
563
+ target.$ref.startsWith("#/state/")
564
+ ) {
565
+ // SAFETY: `String.prototype.split` always returns at least one element.
566
+ into.add(target.$ref.slice("#/state/".length).split("/")[0] as string);
502
567
  }
503
568
  for (const value of Object.values(rec)) {
504
569
  collectAssignedStateKeys(value, into);
@@ -507,18 +572,18 @@ function collectAssignedStateKeys(node: unknown, into: Set<string>) {
507
572
 
508
573
  /**
509
574
  * @param {Record<string, JxStateDefinition>} [defs]
510
- * @param {Record<string, unknown> | null} [parentScope]
511
- * @returns {Record<string, unknown>}
575
+ * @param {JxScope | null} [parentScope]
576
+ * @returns {JxScope}
512
577
  */
513
578
  export function buildInitialScope(
514
579
  defs: Record<string, JxStateDefinition> = {},
515
- parentScope: Record<string, unknown> | null = null,
580
+ parentScope: JxScope | null = null,
516
581
  ) {
517
- const scope = Object.create(parentScope ?? null) as Record<string, unknown>;
582
+ const scope = emptyScope(parentScope);
518
583
  // Keys whose real value only exists after hydration, so prerender must not interpolate them —
519
584
  // See `readsRuntimeOnlyState`. Seeded from the parent so nested scopes inherit the marks.
520
- const inherited = (parentScope as Record<PropertyKey, unknown> | null)?.[RUNTIME_ONLY_KEYS];
521
- const runtimeOnly = new Set<string>(inherited instanceof Set ? (inherited as Set<string>) : []);
585
+ const inherited = parentScope ? runtimeOnlyKeysOf(parentScope) : undefined;
586
+ const runtimeOnly = new Set<string>(inherited);
522
587
  // Attached up front, and mutated in place by the passes below, so the template pass at the end can
523
588
  // Consult the marks the earlier passes added.
524
589
  Object.defineProperty(scope, RUNTIME_ONLY_KEYS, {
@@ -534,12 +599,8 @@ export function buildInitialScope(
534
599
  // Builder cannot open, so a write that only happens there is still missed (issue #125).
535
600
  const assigned = new Set<string>();
536
601
  for (const def of Object.values(defs)) {
537
- if (def && typeof def === "object" && (isFunctionDef(def) || isExpressionDef(def))) {
538
- collectAssignedStateKeys(
539
- (def as { body?: unknown; $expression?: unknown }).body ??
540
- (def as { $expression?: unknown }).$expression,
541
- assigned,
542
- );
602
+ if (isFunctionDef(def) || isExpressionDef(def)) {
603
+ collectAssignedStateKeys(def.body ?? def.$expression, assigned);
543
604
  }
544
605
  }
545
606
  for (const key of assigned) {
@@ -549,17 +610,16 @@ export function buildInitialScope(
549
610
  }
550
611
 
551
612
  for (const [key, def] of Object.entries(defs)) {
552
- if (typeof def !== "object" || def === null || Array.isArray(def)) {
613
+ if (!isNonNullObject(def) || Array.isArray(def)) {
553
614
  setOwnScopeValue(scope, key, cloneValue(def));
554
615
  continue;
555
616
  }
556
- const d = def as JxStateObject & JxPrototypeDef;
557
- if ("default" in d) {
558
- setOwnScopeValue(scope, key, cloneValue(d.default));
617
+ if ("default" in def) {
618
+ setOwnScopeValue(scope, key, cloneValue(def.default));
559
619
  continue;
560
620
  }
561
- if (!d.$prototype && !("$expression" in d) && !isSchemaOnly(d)) {
562
- setOwnScopeValue(scope, key, cloneValue(d));
621
+ if (!def.$prototype && !("$expression" in def) && !isSchemaOnlyDef(def)) {
622
+ setOwnScopeValue(scope, key, cloneValue(def));
563
623
  }
564
624
  }
565
625
 
@@ -572,34 +632,32 @@ export function buildInitialScope(
572
632
  const computedBodies: [string, string][] = [];
573
633
 
574
634
  for (const [key, def] of Object.entries(defs)) {
575
- if (typeof def === "string" && isTemplateString(def)) {
635
+ if (isTemplateString(def)) {
576
636
  templateDefs.push([key, def]);
577
637
  continue;
578
638
  }
579
- if (!def || typeof def !== "object") {
639
+ if (!isNonNullObject(def)) {
580
640
  continue;
581
641
  }
582
642
  if (isExpressionDef(def)) {
583
- const node = def.$expression as ExpressionNode;
643
+ const node = def.$expression;
584
644
  if (isNamedFormulaDef(def)) {
585
645
  // Named formula: callable, positional args mapped onto its declared parameters.
586
- const params = def.parameters as (string | { name?: string; default?: unknown })[];
587
- setOwnScopeValue(scope, key, (...argValues: unknown[]) => {
588
- const args: Record<string, unknown> = {};
646
+ const params: readonly FormulaParameter[] = def.parameters;
647
+ setOwnScopeValue(scope, key, (...argValues: JxScope[string][]) => {
648
+ const args: JxScope = {};
589
649
  for (const [i, p] of params.entries()) {
590
- const name = typeof p === "string" ? p : (p?.name ?? "");
650
+ const name = formulaParameterName(p);
591
651
  if (!name) {
592
652
  continue;
593
653
  }
594
654
  args[name] =
595
- argValues[i] === undefined && typeof p === "object" && p !== null && "default" in p
596
- ? p.default
597
- : argValues[i];
655
+ argValues[i] === undefined && hasParameterDefault(p) ? p.default : argValues[i];
598
656
  }
599
657
  return evaluateExpression(node, scope, null, { args });
600
658
  });
601
659
  } else if (isMutating(node.operator)) {
602
- setOwnScopeValue(scope, key, (s: Record<string, unknown>, event: Event) =>
660
+ setOwnScopeValue(scope, key, (s: JxScope, event: Event) =>
603
661
  evaluateExpression(node, s, event),
604
662
  );
605
663
  } else {
@@ -609,12 +667,13 @@ export function buildInitialScope(
609
667
  }
610
668
  if (isFunctionDef(def)) {
611
669
  if (hasStructuredBody(def)) {
612
- // Structured body (spec §20): a side-effecting handler in the build-time scope.
670
+ // Structured body (spec §20): a side-effecting handler in the build-time scope. Build-time
671
+ // Code may call it bare, so the scope it runs against falls back to this one.
613
672
  const { body } = def;
614
- setOwnScopeValue(scope, key, (s: Record<string, unknown>, event?: Event) => {
673
+ setOwnScopeValue(scope, key, (s?: JxScope, event?: Event) => {
615
674
  void runStatements(body, s ?? scope, event ?? null);
616
675
  });
617
- } else if (typeof def.body === "string") {
676
+ } else if (isString(def.body)) {
618
677
  const declared = def.parameters ? paramNames(def.parameters) : (def.arguments ?? []);
619
678
  const { body } = def;
620
679
  // Declared names bind by name, not by position (spec.md §5.3 4d): a parameter literally
@@ -623,8 +682,13 @@ export function buildInitialScope(
623
682
  // The older behaviour here — produced a duplicate parameter for the `["state", "event"]`
624
683
  // Form that examples/components/task-manager.json uses, leaving `state` undefined.
625
684
  const params = declared.includes("state") ? declared : ["state", ...declared];
626
- const fn = new Function(...params, body) as (...args: unknown[]) => unknown;
627
- const invoke = (state: Record<string, unknown>, event?: unknown) =>
685
+ /* SAFETY: `new Function` returns an ordinary function of exactly `params`; `invoke` below is
686
+ its only caller and passes the scope or the event in each position, and what the body
687
+ returns is one more scope member. */
688
+ const fn = new Function(...params, body) as (
689
+ ...args: (JxScope | Event | undefined)[]
690
+ ) => JxScope[string];
691
+ const invoke = (state: JxScope, event?: Event) =>
628
692
  fn(...params.map((name) => (name === "state" ? state : event)));
629
693
  if (bodyReturnsValue(body)) {
630
694
  defineLazyScopeValue(scope, key, () => invoke(scope));
@@ -677,11 +741,11 @@ export function buildInitialScope(
677
741
  }
678
742
 
679
743
  /**
680
- * @param {Record<string, unknown>} scope
744
+ * @param {JxScope} scope
681
745
  * @param {string} key
682
- * @param {unknown} value
746
+ * @param {JxScope[string]} value - Any scope member: document data or a build-time callable
683
747
  */
684
- export function setOwnScopeValue(scope: Record<string, unknown>, key: string, value: unknown) {
748
+ export function setOwnScopeValue(scope: JxScope, key: string, value: JxScope[string]) {
685
749
  Object.defineProperty(scope, key, {
686
750
  configurable: true,
687
751
  enumerable: true,
@@ -691,15 +755,11 @@ export function setOwnScopeValue(scope: Record<string, unknown>, key: string, va
691
755
  }
692
756
 
693
757
  /**
694
- * @param {Record<string, unknown>} scope
758
+ * @param {JxScope} scope
695
759
  * @param {string} key
696
- * @param {() => unknown} getter
760
+ * @param {() => JxScope[string]} getter - Computes the member on read
697
761
  */
698
- export function defineLazyScopeValue(
699
- scope: Record<string, unknown>,
700
- key: string,
701
- getter: () => unknown,
702
- ) {
762
+ export function defineLazyScopeValue(scope: JxScope, key: string, getter: () => JxScope[string]) {
703
763
  Object.defineProperty(scope, key, {
704
764
  configurable: true,
705
765
  enumerable: true,
@@ -708,16 +768,17 @@ export function defineLazyScopeValue(
708
768
  }
709
769
 
710
770
  /**
711
- * @param {unknown} value
712
- * @param {Record<string, unknown>} scope
771
+ * @param {JxDocumentValue | undefined} value - A bindable document value: a `$ref`, a template
772
+ * string, or a literal returned as is
773
+ * @param {JxScope | null} scope
713
774
  * @returns {unknown}
714
775
  */
715
- export function resolveStaticValue(value: unknown, scope: Record<string, unknown> | null) {
716
- if (isRefObject(value)) {
717
- return resolveRefValue((value as JxMutableNode).$ref, scope!);
776
+ export function resolveStaticValue(value: JxDocumentValue | undefined, scope: JxScope | null) {
777
+ if (isRef(value)) {
778
+ return resolveRefValue(value.$ref, scope!);
718
779
  }
719
780
  if (isTemplateString(value)) {
720
- return evaluateStaticTemplate(value as string, scope!);
781
+ return evaluateStaticTemplate(value, scope);
721
782
  }
722
783
  return value;
723
784
  }
@@ -731,18 +792,22 @@ export function isRefObject(value: unknown): value is JxRef {
731
792
  }
732
793
 
733
794
  /**
734
- * @param {unknown} refValue
735
- * @param {Record<string, unknown>} scope
795
+ * @param {JsonValue | undefined} refValue - A `$ref` member as the document holds it; anything but
796
+ * a string is returned as is
797
+ * @param {JxScope} scope
736
798
  * @returns {unknown}
737
799
  */
738
- export function resolveRefValue(refValue: unknown, scope: Record<string, unknown>) {
739
- if (typeof refValue !== "string") {
800
+ export function resolveRefValue(refValue: JsonValue | undefined, scope: JxScope) {
801
+ if (!isString(refValue)) {
740
802
  return refValue;
741
803
  }
742
804
  if (refValue.startsWith("$map/")) {
743
805
  const parts = refValue.split("/");
744
806
  const key = parts[1]!;
745
- const base = (scope.$map as Record<string, unknown> | undefined)?.[key] ?? scope[`$map/${key}`];
807
+ /* SAFETY: `?.` stops at null and undefined, and every other value answers a member read by
808
+ name; the member comes back as a scope member, so the view claims nothing about its type. */
809
+ const row = scope.$map as JxScope | null | undefined;
810
+ const base = row?.[key] ?? scope[`$map/${key}`];
746
811
  return parts.length > 2 ? getPathValue(base, parts.slice(2).join("/")) : base;
747
812
  }
748
813
  if (refValue.startsWith("#/state/")) {
@@ -770,31 +835,32 @@ export function resolveRefValue(refValue: unknown, scope: Record<string, unknown
770
835
  * formulas (spec.md §19.4c) are meant to be used during prerender.
771
836
  *
772
837
  * @param {string} str
773
- * @param {Record<string, unknown>} scope
838
+ * @param {JxScope | null} scope
774
839
  * @returns {boolean}
775
840
  */
776
- function readsRuntimeOnlyState(str: string, scope: Record<string, unknown>) {
841
+ function readsRuntimeOnlyState(str: string, scope: JxScope | null) {
777
842
  if (!scope) {
778
843
  return false;
779
844
  }
780
- const runtimeOnly = (scope as Record<PropertyKey, unknown>)[RUNTIME_ONLY_KEYS];
845
+ const runtimeOnly = runtimeOnlyKeysOf(scope);
781
846
  for (const match of str.matchAll(/\bstate\.([A-Za-z_$][\w$]*)\s*(\()?/g)) {
782
847
  if (match[2]) {
783
848
  continue;
784
849
  }
850
+ // SAFETY: capture group 1 is outside any optional part of the pattern, so a match has it.
785
851
  const key = match[1] as string;
786
- if (runtimeOnly instanceof Set && runtimeOnly.has(key)) {
852
+ if (runtimeOnly?.has(key)) {
787
853
  return true;
788
854
  }
789
855
  // Reading the key can run a computed's getter, which is free to throw on partial build-time
790
856
  // Data. A throw says nothing about whether the entry is runtime-only, so it is not a mark.
791
- let value: unknown;
857
+ let value: JxScope[string];
792
858
  try {
793
859
  value = scope[key];
794
860
  } catch {
795
861
  continue;
796
862
  }
797
- if (typeof value === "function") {
863
+ if (isFunction(value)) {
798
864
  return true;
799
865
  }
800
866
  }
@@ -803,10 +869,10 @@ function readsRuntimeOnlyState(str: string, scope: Record<string, unknown>) {
803
869
 
804
870
  /**
805
871
  * @param {string} str
806
- * @param {Record<string, unknown>} scope
872
+ * @param {JxScope | null} scope
807
873
  * @returns {unknown}
808
874
  */
809
- export function evaluateStaticTemplate(str: string, scope: Record<string, unknown>) {
875
+ export function evaluateStaticTemplate(str: string, scope: JxScope | null) {
810
876
  if (readsRuntimeOnlyState(str, scope)) {
811
877
  return null;
812
878
  }
@@ -822,7 +888,9 @@ export function evaluateStaticTemplate(str: string, scope: Record<string, unknow
822
888
  const values = [scope, scope?.$map, scope?.$site, scope?.$page];
823
889
  try {
824
890
  const body = isSingleExpression(str) ? `return (${str.slice(2, -1)})` : `return \`${str}\``;
825
- const fn = new Function(...args, body) as (...a: unknown[]) => unknown;
891
+ /* SAFETY: `new Function` compiles exactly the four parameters in `args`, and its body returns a
892
+ template's value, which is whatever the scope it reads holds: one more scope member. */
893
+ const fn = new Function(...args, body) as (...a: JxScope[string][]) => JxScope[string];
826
894
  return fn(...values);
827
895
  } catch {
828
896
  return null;
@@ -830,20 +898,24 @@ export function evaluateStaticTemplate(str: string, scope: Record<string, unknow
830
898
  }
831
899
 
832
900
  /**
833
- * @param {unknown} base
901
+ * @param {JxScope[string]} base - A scope, or any member read from one
834
902
  * @param {string} path
835
903
  * @returns {unknown}
836
904
  */
837
- export function getPathValue(base: unknown, path: string) {
905
+ export function getPathValue(base: JxScope[string], path: string) {
838
906
  return readPath(base, path);
839
907
  }
840
908
 
841
909
  /**
842
- * @param {unknown} value
843
- * @returns {unknown}
910
+ * A deep copy of an object, so a scope never aliases the document it was built from; anything else
911
+ * is returned as is.
912
+ *
913
+ * @template T
914
+ * @param {T} value
915
+ * @returns {T}
844
916
  */
845
- export function cloneValue(value: unknown) {
846
- if (value === null || typeof value !== "object") {
917
+ export function cloneValue<T>(value: T): T {
918
+ if (!isNonNullObject(value)) {
847
919
  return value;
848
920
  }
849
921
  return structuredClone(value);
@@ -857,12 +929,12 @@ export function cloneValue(value: unknown) {
857
929
  * class handle), so only a per-instance value is written on the element.
858
930
  *
859
931
  * @param {JxStyle | null | undefined} style
860
- * @param {Record<string, unknown> | null} scope
932
+ * @param {JxScope | null} scope
861
933
  * @returns {string} `prop: value; prop: value`, or "" when nothing resolves
862
934
  */
863
935
  export function inlineStyleDeclarations(
864
936
  style: JxStyle | null | undefined,
865
- scope: Record<string, unknown> | null,
937
+ scope: JxScope | null,
866
938
  ): string {
867
939
  if (!style || !scope) {
868
940
  return "";
@@ -875,9 +947,6 @@ export function inlineStyleDeclarations(
875
947
  !k.startsWith("&") &&
876
948
  !k.startsWith("[") &&
877
949
  !k.startsWith("@") &&
878
- v !== null &&
879
- typeof v !== "object" &&
880
- typeof v === "string" &&
881
950
  isTemplateString(v),
882
951
  )
883
952
  .map(([k, v]) => {
@@ -892,18 +961,14 @@ export function inlineStyleDeclarations(
892
961
  * Build an HTML attribute string from a static element definition.
893
962
  *
894
963
  * @param {JxElement | JxMutableNode} def
895
- * @param {Record<string, unknown>} scope
964
+ * @param {JxScope | null} scope
896
965
  * @param {string} [hostStyle] - Declarations resolved by the caller against ANOTHER scope, folded
897
966
  * into the same `style` attribute after the node's own. A nested component instance is the one
898
967
  * caller: its own `style` resolves against the parent's scope, while the definition's host style
899
968
  * resolves against the instance's, and an element cannot carry two `style` attributes.
900
969
  * @returns {string}
901
970
  */
902
- export function buildAttrs(
903
- def: JxElement | JxMutableNode,
904
- scope: Record<string, unknown> | null,
905
- hostStyle = "",
906
- ) {
971
+ export function buildAttrs(def: JxElement | JxMutableNode, scope: JxScope | null, hostStyle = "") {
907
972
  let out = "";
908
973
 
909
974
  const id = resolveStaticValue(def.id, scope);
@@ -971,18 +1036,14 @@ export function buildAttrs(
971
1036
  * Sidebar's own not-the-current-page marker, and second-guessing it would be this same defect
972
1037
  * Pointed the other way.
973
1038
  */
974
- if (typeof value === "boolean") {
1039
+ if (isBoolean(value)) {
975
1040
  const text = booleanAttrValue(k, value);
976
1041
  if (text !== null) {
977
1042
  out += text === "" ? ` ${k}` : ` ${k}="${escapeHtml(text)}"`;
978
1043
  }
979
1044
  continue;
980
1045
  }
981
- if (
982
- value !== null &&
983
- value !== undefined &&
984
- (typeof value === "string" || typeof value === "number")
985
- ) {
1046
+ if (isString(value) || isNumber(value)) {
986
1047
  out += ` ${k}="${escapeHtml(String(value))}"`;
987
1048
  }
988
1049
  }
@@ -1017,30 +1078,34 @@ export function childSeparator(preformatted: boolean | undefined, indent = " ")
1017
1078
  return preformatted ? "" : `\n${indent}`;
1018
1079
  }
1019
1080
 
1081
+ /**
1082
+ * A {@link CompileContext} as a static emitter hands it to a node, before that node's own scope is
1083
+ * known: the scope in effect may still be none.
1084
+ */
1085
+ export type InnerCompileContext = Omit<CompileContext, "scope"> & { scope: JxScope | null };
1086
+
1020
1087
  /**
1021
1088
  * Build the inner HTML (textContent or children) for a node.
1022
1089
  *
1023
1090
  * @param {JxElement} def
1024
1091
  * @param {JxElement | null} raw
1025
- * @param {{
1026
- * scope: Record<string, unknown>;
1027
- * scopeDefs: Record<string, unknown>;
1028
- * media: Record<string, string>;
1029
- * preformatted?: boolean;
1030
- * }} context
1031
- * @param {(def: unknown, raw: unknown, context: unknown) => string} childCompiler
1092
+ * @param {InnerCompileContext} context
1093
+ * @param {(
1094
+ * def: JxElement | string,
1095
+ * raw: JxElement | string,
1096
+ * context: InnerCompileContext,
1097
+ * ) => string} childCompiler
1032
1098
  * @returns {string}
1033
1099
  */
1034
1100
  export function buildInner(
1035
1101
  def: JxElement,
1036
1102
  raw: JxElement | null,
1037
- context: {
1038
- scope: Record<string, unknown> | null;
1039
- scopeDefs: Record<string, unknown>;
1040
- media: Record<string, string>;
1041
- preformatted?: boolean;
1042
- },
1043
- childCompiler: (def: unknown, raw: unknown, context: unknown) => string,
1103
+ context: InnerCompileContext,
1104
+ childCompiler: (
1105
+ def: JxElement | string,
1106
+ raw: JxElement | string,
1107
+ context: InnerCompileContext,
1108
+ ) => string,
1044
1109
  ) {
1045
1110
  const source = raw ?? def;
1046
1111
 
@@ -1049,13 +1114,14 @@ export function buildInner(
1049
1114
  return value == null ? "" : escapeHtml(String(value));
1050
1115
  }
1051
1116
  if (source.innerHTML) {
1052
- return (resolveStaticValue(source.innerHTML, context.scope) as string) ?? "";
1117
+ const value = resolveStaticValue(source.innerHTML, context.scope);
1118
+ return value == null ? "" : String(value);
1053
1119
  }
1054
1120
  if (Array.isArray(source.children)) {
1055
1121
  const rawChildren = raw?.children;
1056
1122
  return source.children
1057
- .map((c: JxElement | JxMutableNode | string, i: number) => {
1058
- const childRaw = (rawChildren as (JxElement | string)[] | undefined)?.[i] ?? c;
1123
+ .map((c, i) => {
1124
+ const childRaw = (Array.isArray(rawChildren) ? rawChildren[i] : undefined) ?? c;
1059
1125
  return childCompiler(c, childRaw, context);
1060
1126
  })
1061
1127
  .join(childSeparator(context.preformatted));
@@ -1127,7 +1193,7 @@ function recordDroppedReactive(
1127
1193
  value: string | JxRef,
1128
1194
  selector: string | null,
1129
1195
  ): null {
1130
- const source = typeof value === "string" ? value : value.$ref;
1196
+ const source = isString(value) ? value : value.$ref;
1131
1197
  _droppedReactive.push({ property, selector, source });
1132
1198
  return null;
1133
1199
  }
@@ -1230,7 +1296,7 @@ export function compileStyles(
1230
1296
  `color-scheme` triplet a scheme query in `$media` still calls for, which is exactly what a
1231
1297
  page with a scheme query and no project style has always carried. */
1232
1298
  const projectSheet = buildSiteStyleCSS(
1233
- projectStyle && typeof projectStyle === "object" ? projectStyle : {},
1299
+ isNonNullObject(projectStyle) ? projectStyle : {},
1234
1300
  mediaQueries,
1235
1301
  (value) => value,
1236
1302
  recordDroppedReactive,
@@ -1276,7 +1342,7 @@ export function collectStyles(
1276
1342
  prefix = "jx",
1277
1343
  ) {
1278
1344
  const counter = counterArg ?? { n: 0 };
1279
- if (!def || typeof def !== "object") {
1345
+ if (!isNonNullObject(def)) {
1280
1346
  return;
1281
1347
  }
1282
1348
 
@@ -1307,16 +1373,9 @@ export function collectStyles(
1307
1373
  }
1308
1374
 
1309
1375
  // $switch case nodes render in place of the container's content — collect their styles too
1310
- if (def.cases && typeof def.cases === "object") {
1311
- for (const c of Object.values(def.cases)) {
1312
- collectStyles(
1313
- c as JxElement | JxMutableNode | string,
1314
- rules,
1315
- mediaQueries,
1316
- selector,
1317
- counter,
1318
- prefix,
1319
- );
1376
+ if (isNonNullObject(def.cases)) {
1377
+ for (const c of Object.values<JxElement | JxMutableNode>(def.cases)) {
1378
+ collectStyles(c, rules, mediaQueries, selector, counter, prefix);
1320
1379
  }
1321
1380
  }
1322
1381
  }
@@ -1380,18 +1439,13 @@ export function collectSrcImports(doc: JxElement) {
1380
1439
  * @param {Set<string>} srcs
1381
1440
  */
1382
1441
  function _walkSrc(def: JxElement | JxMutableNode | string, srcs: Set<string>) {
1383
- if (!def || typeof def !== "object") {
1442
+ if (!isNonNullObject(def)) {
1384
1443
  return;
1385
1444
  }
1386
1445
  if (def.state) {
1387
1446
  for (const d of Object.values(def.state)) {
1388
- if (
1389
- d &&
1390
- typeof d === "object" &&
1391
- (d as JxMutableNode).$prototype === "Function" &&
1392
- (d as JxMutableNode).$src
1393
- ) {
1394
- srcs.add((d as JxMutableNode).$src as string);
1447
+ if (isFunctionDef(d) && d.$src) {
1448
+ srcs.add(d.$src);
1395
1449
  }
1396
1450
  }
1397
1451
  }
@@ -1422,7 +1476,7 @@ function _walkServerEntries(
1422
1476
  def: JxElement | JxMutableNode | string,
1423
1477
  entries: Map<string, { key: string; exportName: string; src: string }>,
1424
1478
  ) {
1425
- if (!def || typeof def !== "object") {
1479
+ if (!isNonNullObject(def)) {
1426
1480
  return;
1427
1481
  }
1428
1482
  if (def.state) {
@@ -1461,15 +1515,12 @@ export const SELF_CLOSING = new Set<string>([
1461
1515
  * precisely because the old `${…}` form was left as text and then re-resolved against the page
1462
1516
  * scope, where a component's own state is undefined.
1463
1517
  *
1464
- * @param {unknown} tagName
1465
- * @param {Record<string, unknown>} scope
1518
+ * @param {TagNameSlot} tagName
1519
+ * @param {JxScope | null} scope
1466
1520
  * @returns {string}
1467
1521
  */
1468
- export function resolveStaticTagName(
1469
- tagName: unknown,
1470
- scope: Record<string, unknown> | null,
1471
- ): string {
1472
- if (typeof tagName === "string") {
1522
+ export function resolveStaticTagName(tagName: TagNameSlot, scope: JxScope | null): string {
1523
+ if (isString(tagName)) {
1473
1524
  return tagName;
1474
1525
  }
1475
1526
  const candidates = tagNameCandidates(tagName);
@@ -1480,10 +1531,14 @@ export function resolveStaticTagName(
1480
1531
  return candidates[0]!;
1481
1532
  }
1482
1533
  const expression = tagName.$expression;
1534
+ /* SAFETY: a tag expression is parsed from a document, so its `target` is a document value (a
1535
+ `$ref`, a template string or a literal); the schema leaves it untyped only because any operand
1536
+ may stand there. */
1537
+ const target = expression.target as JxDocumentValue | undefined;
1483
1538
  if (expression.operator === "?:") {
1484
- return resolveStaticValue(expression.target, scope) ? expression.value : expression.initial;
1539
+ return resolveStaticValue(target, scope) ? expression.value : expression.initial;
1485
1540
  }
1486
- const key = resolveStaticValue(expression.target, scope);
1541
+ const key = resolveStaticValue(target, scope);
1487
1542
  return expression.cases[String(key)] ?? expression.default;
1488
1543
  }
1489
1544
 
@@ -1515,17 +1570,13 @@ interface PrerenderFrame {
1515
1570
  key: string;
1516
1571
  }
1517
1572
 
1518
- /**
1519
- * How deep component nesting may go before the build refuses to continue.
1520
- *
1521
- * The cycle check below catches a definition that renders ITSELF — same tag, same props — which is
1522
- * the only shape that can never terminate. A self-reference whose props change at every level
1523
- * (`depth: "${state.depth + 1}"`) is data-driven recursion, legitimate when a `$switch` bottoms it
1524
- * out and unbounded when the author forgot to. The seen-set cannot tell those apart, so this cap is
1525
- * what turns the second into a diagnostic instead of a stack overflow. Thirty-two is far beyond any
1526
- * component library's real depth and far below the call stack's.
1527
- */
1528
- export const MAX_COMPONENT_NESTING = 32;
1573
+ /** What {@link liftPropsAttributes} splits an instance's attributes into. */
1574
+ export interface LiftedPropsAttributes {
1575
+ /** The `props.*` values keyed by prop name, or null when the instance wrote none. */
1576
+ lifted: Record<string, JsonValue> | null;
1577
+ /** Every other attribute, unchanged. */
1578
+ rest: NonNullable<JxElement["attributes"]>;
1579
+ }
1529
1580
 
1530
1581
  /**
1531
1582
  * Lift literal `props.*` attribute keys off an instance's attributes.
@@ -1538,20 +1589,17 @@ export const MAX_COMPONENT_NESTING = 32;
1538
1589
  * definition every page shares.
1539
1590
  *
1540
1591
  * @param {Record<string, JxAttributeValue> | undefined} attributes
1541
- * @returns {{ lifted: Record<string, JsonValue> | null; rest: Record<string, JxAttributeValue> }}
1542
- * The lifted props (null when there were none) and the attributes that remain
1592
+ * @returns {LiftedPropsAttributes} The lifted props (null when there were none) and the attributes
1593
+ * that remain
1543
1594
  */
1544
- export function liftPropsAttributes(attributes: JxElement["attributes"]): {
1545
- lifted: Record<string, JsonValue> | null;
1546
- rest: NonNullable<JxElement["attributes"]>;
1547
- } {
1595
+ export function liftPropsAttributes(attributes: JxElement["attributes"]): LiftedPropsAttributes {
1548
1596
  let lifted: Record<string, JsonValue> | null = null;
1549
1597
  const rest: NonNullable<JxElement["attributes"]> = {};
1550
1598
  for (const [key, value] of Object.entries(attributes ?? {})) {
1551
1599
  if (key.startsWith("props.") && key.length > "props.".length) {
1552
1600
  lifted ??= {};
1553
- // JxAttributeValue is JSON-representable (primitives or a $ref object), so the narrowing to
1554
- // JsonValue is sound.
1601
+ /* SAFETY: a JxAttributeValue is a JSON primitive or a `{ $ref }` object with one string
1602
+ member, so it is a JsonValue; only the interface's missing index signature hides that. */
1555
1603
  lifted[key.slice("props.".length)] = value as JsonValue;
1556
1604
  } else {
1557
1605
  rest[key] = value;
@@ -1571,33 +1619,32 @@ export function liftPropsAttributes(attributes: JxElement["attributes"]): {
1571
1619
  *
1572
1620
  * @param {JxElement} doc - Component definition
1573
1621
  * @param {Record<string, JsonValue> | null} props - Instance-specific prop values
1574
- * @returns {Record<string, unknown>}
1622
+ * @returns {JxScope}
1575
1623
  */
1576
1624
  export function buildInstanceScope(
1577
1625
  doc: JxElement,
1578
1626
  props: Record<string, JsonValue> | null,
1579
- ): Record<string, unknown> {
1580
- let stateDefs: Record<string, JxStateDefinition> = doc.state ?? {};
1581
- if (props) {
1582
- stateDefs = { ...stateDefs };
1583
- for (const [key, value] of Object.entries(props)) {
1584
- if (key in stateDefs) {
1585
- const existing = stateDefs[key];
1586
- stateDefs[key] =
1587
- existing &&
1588
- typeof existing === "object" &&
1589
- !Array.isArray(existing) &&
1590
- "default" in existing
1591
- ? { .../** @type {JxStateObject} */ existing, default: value }
1592
- : (value as JxStateDefinition);
1593
- } else {
1594
- stateDefs[key] = value as JxStateDefinition;
1595
- }
1596
- }
1627
+ ): JxScope {
1628
+ if (!props) {
1629
+ return buildInitialScope(doc.state ?? {}, null);
1630
+ }
1631
+ const stateDefs = { ...doc.state };
1632
+ for (const [key, value] of Object.entries(props)) {
1633
+ const existing = stateDefs[key];
1634
+ stateDefs[key] =
1635
+ isNonNullObject(existing) && !Array.isArray(existing) && "default" in existing
1636
+ ? { ...existing, default: value }
1637
+ : value;
1597
1638
  }
1598
1639
  return buildInitialScope(stateDefs, null);
1599
1640
  }
1600
1641
 
1642
+ /**
1643
+ * Resolved host declarations by (camelCase) property: each is what its template read from the
1644
+ * instance's scope.
1645
+ */
1646
+ export type ResolvedHostStyle = Record<string, JxScope[string]>;
1647
+
1601
1648
  /**
1602
1649
  * The definition's host `style` entries that depend on the instance, resolved against it.
1603
1650
  *
@@ -1607,26 +1654,22 @@ export function buildInstanceScope(
1607
1654
  * one.
1608
1655
  *
1609
1656
  * @param {JxStyle | null | undefined} style - The definition's `style`
1610
- * @param {Record<string, unknown>} scope - The instance's scope (`buildInstanceScope`)
1611
- * @returns {Record<string, unknown>} Resolved values by (camelCase) property
1657
+ * @param {JxScope} scope - The instance's scope (`buildInstanceScope`)
1658
+ * @returns {ResolvedHostStyle}
1612
1659
  */
1613
1660
  export function resolveHostStyle(
1614
1661
  style: JxStyle | null | undefined,
1615
- scope: Record<string, unknown>,
1616
- ): Record<string, unknown> {
1617
- const resolved: Record<string, unknown> = {};
1618
- if (!style) {
1619
- return resolved;
1620
- }
1621
- for (const [prop, value] of Object.entries(style)) {
1622
- if (typeof value === "string" && isTemplateString(value)) {
1623
- const val = resolveStaticValue(value, scope);
1624
- if (val != null) {
1625
- resolved[prop] = val;
1662
+ scope: JxScope,
1663
+ ): ResolvedHostStyle {
1664
+ return Object.fromEntries(
1665
+ Object.entries(style ?? {}).flatMap(([prop, value]) => {
1666
+ if (!isTemplateString(value)) {
1667
+ return [];
1626
1668
  }
1627
- }
1628
- }
1629
- return resolved;
1669
+ const val = resolveStaticValue(value, scope);
1670
+ return val == null ? [] : [[prop, val] as const];
1671
+ }),
1672
+ );
1630
1673
  }
1631
1674
 
1632
1675
  /**
@@ -1638,14 +1681,14 @@ export function resolveHostStyle(
1638
1681
  * the shape spec.md §17.2 recommends when every child is a bare string — prerendered to nothing.
1639
1682
  *
1640
1683
  * @param {JxElement | JxMutableNode} node
1641
- * @param {Record<string, unknown> | null} scope
1684
+ * @param {JxScope | null} scope
1642
1685
  * @param {string | null} slotContent
1643
1686
  * @param {ComponentPrerenderContext | null} context
1644
1687
  * @returns {string}
1645
1688
  */
1646
1689
  function renderInner(
1647
1690
  node: JxElement | JxMutableNode,
1648
- scope: Record<string, unknown> | null,
1691
+ scope: JxScope | null,
1649
1692
  slotContent: string | null,
1650
1693
  context: ComponentPrerenderContext | null,
1651
1694
  ): string {
@@ -1655,7 +1698,7 @@ function renderInner(
1655
1698
  }
1656
1699
  if (node.innerHTML) {
1657
1700
  const val = resolveStaticValue(node.innerHTML, scope);
1658
- return val != null ? String(val) : (node.innerHTML as string);
1701
+ return val != null ? String(val) : node.innerHTML;
1659
1702
  }
1660
1703
  if (Array.isArray(node.children)) {
1661
1704
  return node.children
@@ -1667,40 +1710,50 @@ function renderInner(
1667
1710
  return "";
1668
1711
  }
1669
1712
 
1713
+ /**
1714
+ * A child slot as the static renderer meets it: a node or a text string, plus the JSON scalars,
1715
+ * holes and nested lists a hand-written `children` array may also carry.
1716
+ */
1717
+ export type StaticChild =
1718
+ | JxElement
1719
+ | JxMutableNode
1720
+ | string
1721
+ | number
1722
+ | boolean
1723
+ | null
1724
+ | undefined
1725
+ | StaticChild[];
1726
+
1670
1727
  /**
1671
1728
  * Recursively render a Jx node tree to static HTML for pre-rendering.
1672
1729
  *
1673
- * @param {JxElement | JxMutableNode | string} node
1674
- * @param {Record<string, unknown>} scope
1730
+ * @param {StaticChild} node
1731
+ * @param {JxScope | null} scope
1675
1732
  * @param {string | null} [slotContent] - HTML to substitute for `<slot>` elements
1676
1733
  * @param {ComponentPrerenderContext | null} [context] - The component registry, when a custom
1677
1734
  * element tag met here should be expanded rather than emitted bare
1678
1735
  * @returns {string}
1679
1736
  */
1680
1737
  export function renderStaticNode(
1681
- node: JxElement | JxMutableNode | string,
1682
- scope: Record<string, unknown> | null,
1738
+ node: StaticChild,
1739
+ scope: JxScope | null,
1683
1740
  slotContent: string | null = null,
1684
1741
  context: ComponentPrerenderContext | null = null,
1685
1742
  ): string {
1686
- if (typeof node === "string") {
1743
+ if (isString(node)) {
1687
1744
  if (isTemplateString(node) && scope) {
1688
1745
  const val = evaluateStaticTemplate(node, scope);
1689
1746
  return val != null ? escapeHtml(String(val)) : escapeHtml(node);
1690
1747
  }
1691
1748
  return escapeHtml(node);
1692
1749
  }
1693
- if (typeof node === "number" || typeof node === "boolean") {
1750
+ if (isNumber(node) || isBoolean(node)) {
1694
1751
  return escapeHtml(String(node));
1695
1752
  }
1696
1753
  if (Array.isArray(node)) {
1697
- return (node as (JxElement | JxMutableNode | string)[])
1698
- .map((c: JxElement | JxMutableNode | string): string =>
1699
- renderStaticNode(c, scope, slotContent, context),
1700
- )
1701
- .join("\n");
1754
+ return node.map((c): string => renderStaticNode(c, scope, slotContent, context)).join("\n");
1702
1755
  }
1703
- if (!node || typeof node !== "object") {
1756
+ if (!isNonNullObject(node)) {
1704
1757
  return "";
1705
1758
  }
1706
1759
 
@@ -1716,10 +1769,7 @@ export function renderStaticNode(
1716
1769
  const switchTag = node.tagName ?? "div";
1717
1770
  const attrs = buildAttrs(node, scope);
1718
1771
  const key = resolveStaticValue(node.$switch, scope);
1719
- const caseDef =
1720
- key == null
1721
- ? undefined
1722
- : (node.cases as Record<string, JxElement | string> | undefined)?.[String(key)];
1772
+ const caseDef = key == null ? undefined : node.cases?.[String(key)];
1723
1773
  const inner =
1724
1774
  caseDef !== undefined && !isRefObject(caseDef)
1725
1775
  ? renderStaticNode(caseDef, scope, slotContent, context)
@@ -1775,7 +1825,7 @@ export function renderStaticNode(
1775
1825
  *
1776
1826
  * @param {JxElement | JxMutableNode} node - The instance as written in the parent's tree
1777
1827
  * @param {JxElement} def - The registered definition for its tag
1778
- * @param {Record<string, unknown> | null} scope - The parent's scope
1828
+ * @param {JxScope | null} scope - The parent's scope
1779
1829
  * @param {string | null} slotContent - The parent's slot content
1780
1830
  * @param {ComponentPrerenderContext} context
1781
1831
  * @returns {string}
@@ -1784,15 +1834,20 @@ export function renderStaticNode(
1784
1834
  function renderComponentInstance(
1785
1835
  node: JxElement | JxMutableNode,
1786
1836
  def: JxElement,
1787
- scope: Record<string, unknown> | null,
1837
+ scope: JxScope | null,
1788
1838
  slotContent: string | null,
1789
1839
  context: ComponentPrerenderContext,
1790
1840
  ): string {
1841
+ /* SAFETY: the caller looked `def` up in `componentDefs` by the node's resolved string tag, and
1842
+ that registry is keyed by each definition's own `tagName`, so this is that same string. */
1791
1843
  const tag = def.tagName as string;
1792
1844
  const { lifted, rest: attributes } = liftPropsAttributes(node.attributes);
1793
1845
  const written = { ...lifted, ...node.$props };
1794
1846
  const props: Record<string, JsonValue> = {};
1795
1847
  for (const [key, value] of Object.entries(written)) {
1848
+ /* SAFETY: a prop resolves against the parent's build-time scope, where what a `$ref` or template
1849
+ may read is JSON state, and the props travel on as JSON either way (`data-jx-props` below), as
1850
+ the page walk serializes a page-level instance's. */
1796
1851
  props[key] = (resolveStaticValue(value, scope) as JsonValue | null) ?? value;
1797
1852
  }
1798
1853
  const hasProps = Object.keys(props).length > 0;
@@ -1840,7 +1895,7 @@ function renderComponentInstance(
1840
1895
 
1841
1896
  // The element as the page walk would leave it: props consumed, children rendered, stamped. A
1842
1897
  // Copy, because `node` is a definition every page shares.
1843
- const instance: JxElement = { ...(node as JxElement) };
1898
+ const instance = { ...node };
1844
1899
  delete instance.$props;
1845
1900
  delete instance.children;
1846
1901
  instance.attributes =
@@ -1891,7 +1946,7 @@ export function isComponentFullyStatic(doc: JxElement) {
1891
1946
  * @returns {boolean}
1892
1947
  */
1893
1948
  function _isStaticNode(node: JxElement | string | (JxElement | string)[]): boolean {
1894
- if (!node || typeof node !== "object") {
1949
+ if (!isNonNullObject(node)) {
1895
1950
  return true;
1896
1951
  }
1897
1952
  if (Array.isArray(node)) {
@@ -1910,14 +1965,13 @@ function _isStaticNode(node: JxElement | string | (JxElement | string)[]): boole
1910
1965
  // Check state entries
1911
1966
  if (node.state) {
1912
1967
  for (const def of Object.values(node.state)) {
1913
- if (!def || typeof def !== "object") {
1968
+ if (!isNonNullObject(def) || Array.isArray(def)) {
1914
1969
  continue;
1915
1970
  }
1916
- const d = def as JxMutableNode;
1917
- if (d.$prototype) {
1971
+ if (def.$prototype) {
1918
1972
  return false;
1919
1973
  }
1920
- if (d.$ref) {
1974
+ if (def.$ref) {
1921
1975
  return false;
1922
1976
  }
1923
1977
  }
@@ -1934,8 +1988,8 @@ function _isStaticNode(node: JxElement | string | (JxElement | string)[]): boole
1934
1988
  if (Array.isArray(node.children)) {
1935
1989
  return node.children.every((c) => _isStaticNode(c));
1936
1990
  }
1937
- // Children descriptor object ($prototype: "Array", etc.)
1938
- if (node.children && typeof node.children === "object" && node.children.$prototype) {
1991
+ // The legacy bare children slot: a mapped array builds its children at runtime.
1992
+ if (isMappedArray(node.children)) {
1939
1993
  return false;
1940
1994
  }
1941
1995
 
@@ -2015,7 +2069,7 @@ export function buildComponentCSS(
2015
2069
  */
2016
2070
  const scope = styleScopePrefix(tagName, shadow);
2017
2071
 
2018
- if (styleDef && typeof styleDef === "object") {
2072
+ if (isNonNullObject(styleDef)) {
2019
2073
  /* The top level is walked here rather than handed straight to the builder, because ONE of its
2020
2074
  keys does not mean what `resolveNestedSelector` would make of it: `:host` and `:host(.foo)`
2021
2075
  are TRANSLATED per {@link resolveSelector} so a style object means the same thing in both
@@ -2030,8 +2084,8 @@ export function buildComponentCSS(
2030
2084
  not an at-rule, not a nested selector — and was dropped without reaching the builder at
2031
2085
  all, so the one emitter that could have reported it never saw it. */
2032
2086
  if (isRef(value)) {
2033
- own[prop] = value as never;
2034
- } else if (value !== null && typeof value === "object" && !Array.isArray(value)) {
2087
+ own[prop] = value;
2088
+ } else if (isNonNullObject(value) && !Array.isArray(value)) {
2035
2089
  blocks.push([prop, value]);
2036
2090
  } else if (!prop.startsWith("@") && !isNestedSelectorKey(prop)) {
2037
2091
  own[prop] = value;