@jxsuite/compiler 5.1.2 → 6.0.1

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 +218 -346
  2. package/dist/compiler.js +130 -258
  3. package/package.json +11 -10
  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 +693 -329
  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 +16 -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 +421 -279
  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 +156 -111
  37. package/src/targets/compile-element.ts +227 -198
  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,7 +9,12 @@
9
9
  import {
10
10
  COLOR_SCHEME_ATTR,
11
11
  COLOR_SCHEME_STORAGE_KEY,
12
+ MAX_COMPONENT_NESTING,
12
13
  RESERVED_KEYS,
14
+ SLOT_ATTR,
15
+ SLOT_MARK,
16
+ SLOT_RECORD_KEY,
17
+ SLOT_WHITESPACE,
13
18
  booleanAttrValue,
14
19
  buildStyleRules,
15
20
  enumeratedAttrNames,
@@ -26,30 +31,39 @@ import {
26
31
  childrenContainArray,
27
32
  hasStructuredBody,
28
33
  isExpressionDef,
34
+ isBoolean,
35
+ isFunction,
29
36
  isFunctionDef,
30
37
  isMappedArray,
31
38
  isNamedFormulaDef,
39
+ isNodeObject,
40
+ isNonNullObject,
41
+ isNumber,
32
42
  isPrototypeDef,
33
43
  isRef,
34
- isSchemaOnlyDef as isSchemaOnly,
44
+ isSchemaOnlyDef,
35
45
  isServerFnDef,
46
+ isString,
36
47
  isTagExpression,
37
48
  isTemplateString,
38
49
  paramNames,
39
50
  tagNameCandidates,
40
51
  } from "@jxsuite/schema/guards";
52
+ import type { TagNameSlot } from "@jxsuite/schema/guards";
41
53
  import { resolveShadowMode, styleScopePrefix } from "./shadow.ts";
42
54
  import type { ShadowMode } from "./shadow.ts";
43
- import type { ExpressionNode } from "@jxsuite/runtime/expression";
44
55
  import { readPath } from "@jxsuite/runtime/pointer";
56
+ import type { JxScope } from "@jxsuite/runtime/types";
45
57
  import type {
58
+ CemParameter,
46
59
  JsonValue,
60
+ JxDocumentValue,
47
61
  JxElement,
62
+ JxMappedArray,
48
63
  JxMutableNode,
49
64
  JxPrototypeDef,
50
65
  JxRef,
51
66
  JxStateDefinition,
52
- JxStateObject,
53
67
  JxStyle,
54
68
  ProjectConfig,
55
69
  } from "@jxsuite/schema/types";
@@ -80,22 +94,51 @@ export { compileStatements, runStatements } from "@jxsuite/runtime/statements";
80
94
  * declared parameter names (honoring CemParameter defaults) as the `_args` object the compiled
81
95
  * body's `$args/` refs read from. Call sites stay positional, matching the interpreter.
82
96
  */
83
- export function emitFormulaFn(def: { parameters?: unknown[] }, compiledBody: string): string {
97
+ export function emitFormulaFn(
98
+ def: { parameters?: readonly FormulaParameter[] | undefined },
99
+ compiledBody: string,
100
+ ): string {
84
101
  const entries: string[] = [];
85
102
  for (const [i, p] of (def.parameters ?? []).entries()) {
86
- const name = typeof p === "string" ? p : ((p as { name?: string } | null)?.name ?? "");
103
+ const name = formulaParameterName(p);
87
104
  if (!name) {
88
105
  continue;
89
106
  }
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}]`
107
+ const arg = hasParameterDefault(p)
108
+ ? `_a[${i}] === undefined ? ${JSON.stringify(p.default)} : _a[${i}]`
93
109
  : `_a[${i}]`;
94
110
  entries.push(`${JSON.stringify(name)}: ${arg}`);
95
111
  }
96
112
  return `(..._a) => { const _args = { ${entries.join(", ")} }; return ${compiledBody}; }`;
97
113
  }
98
114
 
115
+ /**
116
+ * One declared formula parameter as a document holds it: a bare name or a CEM parameter object. A
117
+ * hand-edited list may also carry a `null` hole, which both the emitted and the build-time callable
118
+ * skip like an unnamed entry.
119
+ */
120
+ type FormulaParameter = string | CemParameter | null;
121
+
122
+ /**
123
+ * The name a formula parameter binds under `$args/`, or "" for an entry that names nothing.
124
+ *
125
+ * @param {FormulaParameter} p
126
+ * @returns {string}
127
+ */
128
+ function formulaParameterName(p: FormulaParameter): string {
129
+ return isString(p) ? p : (p?.name ?? "");
130
+ }
131
+
132
+ /**
133
+ * Whether a formula parameter declares a `default`, which fills an argument the call site omits.
134
+ *
135
+ * @param {FormulaParameter} p
136
+ * @returns {boolean}
137
+ */
138
+ function hasParameterDefault(p: FormulaParameter): p is CemParameter {
139
+ return isNonNullObject(p) && "default" in p;
140
+ }
141
+
99
142
  /**
100
143
  * Emit the import-clause binding for one Function-def `$src` entry.
101
144
  *
@@ -109,10 +152,10 @@ export function emitFormulaFn(def: { parameters?: unknown[] }, compiledBody: str
109
152
  * only reachable as `default as key`.
110
153
  *
111
154
  * @param {string} key - State key, and the local binding name
112
- * @param {unknown} def - The Function definition
155
+ * @param {SrcExportOwner} def - The Function definition
113
156
  * @returns {string}
114
157
  */
115
- export function srcImportBinding(key: string, def: unknown) {
158
+ export function srcImportBinding(key: string, def: SrcExportOwner) {
116
159
  const exportName = srcExportName(key, def);
117
160
  return exportName === key ? key : `${exportName} as ${key}`;
118
161
  }
@@ -125,14 +168,23 @@ export function srcImportBinding(key: string, def: unknown) {
125
168
  * to, where the local name is chosen by the generated code and only the export name is fixed.
126
169
  *
127
170
  * @param {string} key - State key
128
- * @param {unknown} def - The Function definition
171
+ * @param {SrcExportOwner} def - The Function definition
129
172
  * @returns {string}
130
173
  */
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;
174
+ export function srcExportName(key: string, def: SrcExportOwner): string {
175
+ const declared = def?.$export;
176
+ return isString(declared) && declared !== "" ? declared : key;
134
177
  }
135
178
 
179
+ /**
180
+ * The Function definition a `$src` export is named on, as the document holds it. `$export` is read
181
+ * unchecked from document data, so anything but a non-empty string falls back to the state key.
182
+ */
183
+ type SrcExportOwner =
184
+ | { $export?: JxDocumentValue | undefined; [key: string]: JxDocumentValue | undefined }
185
+ | null
186
+ | undefined;
187
+
136
188
  /**
137
189
  * Emit the auto-fetch `effect()` for a `$prototype: "Request"` state entry.
138
190
  *
@@ -173,7 +225,7 @@ export function emitRequestFetch(
173
225
 
174
226
  if (isTemplateUrl) {
175
227
  lines.push(
176
- ` const url = \`${withStatePrefix(url as string, statePrefix)}\`;`,
228
+ ` const url = \`${withStatePrefix(url, statePrefix)}\`;`,
177
229
  ' if (!url || url === "undefined" || url.includes("undefined")) return;',
178
230
  );
179
231
  } else {
@@ -188,10 +240,9 @@ export function emitRequestFetch(
188
240
  fetchOpts.push(`headers: ${JSON.stringify(def.headers)}`);
189
241
  }
190
242
  if (def.body) {
191
- const bodyStr =
192
- typeof def.body === "object"
193
- ? JSON.stringify(JSON.stringify(def.body))
194
- : JSON.stringify(def.body);
243
+ const bodyStr = isNonNullObject(def.body)
244
+ ? JSON.stringify(JSON.stringify(def.body))
245
+ : JSON.stringify(def.body);
195
246
  fetchOpts.push(`body: ${bodyStr}`);
196
247
  }
197
248
 
@@ -229,7 +280,6 @@ function withStatePrefix(str: string, statePrefix: string) {
229
280
  `\${${expr.replaceAll(/(?<!this\.)\bstate\./g, `${statePrefix}.`)}}`,
230
281
  );
231
282
  }
232
- export type { ExpressionNode } from "@jxsuite/runtime/expression";
233
283
 
234
284
  /**
235
285
  * Non-enumerable marker on a build-time scope listing the state keys that hold no build-time value.
@@ -267,7 +317,7 @@ export function isClassJsonSrc(src?: unknown): src is string {
267
317
  * @returns {boolean}
268
318
  */
269
319
  export function isDynamic(def: JxElement | JxMutableNode | string) {
270
- if (!def || typeof def !== "object") {
320
+ if (!isNonNullObject(def)) {
271
321
  return false;
272
322
  }
273
323
 
@@ -277,25 +327,20 @@ export function isDynamic(def: JxElement | JxMutableNode | string) {
277
327
  if (k === "$site" || k === "$page") {
278
328
  continue;
279
329
  }
330
+ if (!isNonNullObject(d) || Array.isArray(d)) {
331
+ return true;
332
+ }
280
333
  // 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
- ) {
334
+ if (d.timing === "compiler") {
287
335
  continue;
288
336
  }
289
- if (typeof d !== "object" || d === null || Array.isArray(d)) {
290
- return true;
291
- }
292
- if ((d as JxPrototypeDef).$prototype) {
337
+ if (d.$prototype) {
293
338
  return true;
294
339
  }
295
- if ("default" in /** @type {object} */ d) {
340
+ if ("default" in d) {
296
341
  return true;
297
342
  }
298
- if (isSchemaOnly(d)) {
343
+ if (isSchemaOnlyDef(d)) {
299
344
  continue;
300
345
  }
301
346
  return true;
@@ -317,11 +362,7 @@ export function isDynamic(def: JxElement | JxMutableNode | string) {
317
362
  if (RESERVED_KEYS.has(key)) {
318
363
  continue;
319
364
  }
320
- if (
321
- val !== null &&
322
- typeof val === "object" &&
323
- typeof (val as JxMutableNode).$ref === "string"
324
- ) {
365
+ if (isRef(val)) {
325
366
  return true;
326
367
  }
327
368
  if (isTemplateString(val)) {
@@ -329,7 +370,7 @@ export function isDynamic(def: JxElement | JxMutableNode | string) {
329
370
  }
330
371
  }
331
372
 
332
- if (def.style && typeof def.style === "object") {
373
+ if (isNonNullObject(def.style)) {
333
374
  for (const val of Object.values(def.style)) {
334
375
  if (isTemplateString(val)) {
335
376
  return true;
@@ -337,7 +378,7 @@ export function isDynamic(def: JxElement | JxMutableNode | string) {
337
378
  }
338
379
  }
339
380
 
340
- if (def.attributes && typeof def.attributes === "object") {
381
+ if (isNonNullObject(def.attributes)) {
341
382
  for (const val of Object.values(def.attributes)) {
342
383
  if (isTemplateString(val)) {
343
384
  return true;
@@ -355,7 +396,7 @@ export function isDynamic(def: JxElement | JxMutableNode | string) {
355
396
  * @returns {boolean}
356
397
  */
357
398
  export function isNodeDynamic(def: JxElement | JxMutableNode | string) {
358
- if (!def || typeof def !== "object") {
399
+ if (!isNonNullObject(def)) {
359
400
  return false;
360
401
  }
361
402
 
@@ -370,11 +411,7 @@ export function isNodeDynamic(def: JxElement | JxMutableNode | string) {
370
411
  if (RESERVED_KEYS.has(key)) {
371
412
  continue;
372
413
  }
373
- if (
374
- val !== null &&
375
- typeof val === "object" &&
376
- typeof (val as JxMutableNode).$ref === "string"
377
- ) {
414
+ if (isRef(val)) {
378
415
  return true;
379
416
  }
380
417
  if (isTemplateString(val)) {
@@ -382,7 +419,7 @@ export function isNodeDynamic(def: JxElement | JxMutableNode | string) {
382
419
  }
383
420
  }
384
421
 
385
- if (def.style && typeof def.style === "object") {
422
+ if (isNonNullObject(def.style)) {
386
423
  for (const val of Object.values(def.style)) {
387
424
  if (isTemplateString(val)) {
388
425
  return true;
@@ -390,7 +427,7 @@ export function isNodeDynamic(def: JxElement | JxMutableNode | string) {
390
427
  }
391
428
  }
392
429
 
393
- if (def.attributes && typeof def.attributes === "object") {
430
+ if (isNonNullObject(def.attributes)) {
394
431
  for (const val of Object.values(def.attributes)) {
395
432
  if (isTemplateString(val)) {
396
433
  return true;
@@ -408,7 +445,7 @@ export function isNodeDynamic(def: JxElement | JxMutableNode | string) {
408
445
  * @returns {boolean}
409
446
  */
410
447
  export function hasAnyIsland(def: JxElement | JxMutableNode | string): boolean {
411
- if (!def || typeof def !== "object") {
448
+ if (!isNonNullObject(def)) {
412
449
  return false;
413
450
  }
414
451
  if (isDynamic(def)) {
@@ -422,33 +459,60 @@ export function hasAnyIsland(def: JxElement | JxMutableNode | string): boolean {
422
459
 
423
460
  // ─── Scope / value resolution ─────────────────────────────────────────────────
424
461
 
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
- */
462
+ /** What a compile target threads down the tree: the scope in effect, its definitions and `$media`. */
432
463
  export interface CompileContext {
433
- scope: Record<string, unknown>;
434
- scopeDefs: Record<string, unknown>;
464
+ scope: JxScope;
465
+ /** The `state` definitions the scope was built from. */
466
+ scopeDefs: Record<string, JxStateDefinition>;
435
467
  media: Record<string, string>;
436
468
  /** True inside a `<pre>`-like subtree, where emitters must not indent nested children. */
437
469
  preformatted?: boolean;
438
470
  }
439
471
 
472
+ /**
473
+ * @param {JxElement | JxMutableNode | null} raw
474
+ * @param {JxScope | null} [parentScope]
475
+ * @param {Record<string, JxStateDefinition>} [scopeDefs]
476
+ * @param {Record<string, string>} [media]
477
+ * @returns {CompileContext}
478
+ */
440
479
  export function createCompileContext(
441
480
  raw: JxElement | JxMutableNode | null,
442
- parentScope: Record<string, unknown> | null = null,
443
- scopeDefs: Record<string, unknown> = {},
481
+ parentScope: JxScope | null = null,
482
+ scopeDefs: Record<string, JxStateDefinition> = {},
444
483
  media: Record<string, string> = {},
445
484
  ): CompileContext {
446
- const scope: Record<string, unknown> = raw?.state
485
+ const scope = raw?.state
447
486
  ? buildInitialScope(raw.state, parentScope)
448
- : (parentScope ?? (Object.create(null) as Record<string, unknown>));
487
+ : (parentScope ?? emptyScope());
449
488
  return { media, scope, scopeDefs };
450
489
  }
451
490
 
491
+ /**
492
+ * A fresh scope inheriting from `parent`, or from nothing at all, so no `Object.prototype` member
493
+ * can answer for an undeclared state key.
494
+ *
495
+ * @param {JxScope | null} [parent]
496
+ * @returns {JxScope}
497
+ */
498
+ function emptyScope(parent: JxScope | null = null): JxScope {
499
+ /* SAFETY: `Object.create` returns a new object with no own keys whose prototype is `parent` (a
500
+ scope) or null, so every key it answers is a scope member or absent — exactly a JxScope. */
501
+ return Object.create(parent) as JxScope;
502
+ }
503
+
504
+ /**
505
+ * The runtime-only marks `buildInitialScope` attached to `scope` or to a scope it inherits from.
506
+ *
507
+ * @param {JxScope} scope
508
+ * @returns {Set<string> | undefined}
509
+ */
510
+ function runtimeOnlyKeysOf(scope: JxScope): Set<string> | undefined {
511
+ /* SAFETY: `RUNTIME_ONLY_KEYS` is module-private and `buildInitialScope` is the only writer, which
512
+ always stores a `Set<string>` under it; a scope built anywhere else answers undefined. */
513
+ return (scope as { [RUNTIME_ONLY_KEYS]?: Set<string> })[RUNTIME_ONLY_KEYS];
514
+ }
515
+
452
516
  /**
453
517
  * `state.x = …`, `state.x += …`, `state.x++` — every form a string handler body can use to write a
454
518
  * state entry. `===`/`!==`/`<=`/`>=` are excluded by the `(?!=)` guard and by the operator set.
@@ -469,36 +533,43 @@ const STATE_MUTATING_METHOD_RE =
469
533
  * distinguishes it from a constant — but if a handler assigns to it, interpolating it at build time
470
534
  * replaces the template in the emitted output and the element is dead for the life of the page.
471
535
  *
472
- * @param {unknown} node
536
+ * @param {JxDocumentValue | undefined} node
473
537
  * @param {Set<string>} into
474
538
  */
475
- function collectAssignedStateKeys(node: unknown, into: Set<string>) {
476
- if (typeof node === "string") {
539
+ function collectAssignedStateKeys(node: JxDocumentValue | undefined, into: Set<string>) {
540
+ if (isString(node)) {
477
541
  for (const m of node.matchAll(STATE_ASSIGNMENT_RE)) {
542
+ // SAFETY: the pattern's one capture group is outside any optional part, so a match has it.
478
543
  into.add(m[1] as string);
479
544
  }
480
545
  for (const m of node.matchAll(STATE_MUTATING_METHOD_RE)) {
546
+ // SAFETY: the pattern's one capture group is outside any optional part, so a match has it.
481
547
  into.add(m[1] as string);
482
548
  }
483
549
  return;
484
550
  }
485
- if (!node || typeof node !== "object") {
486
- return;
487
- }
488
551
  if (Array.isArray(node)) {
489
552
  for (const entry of node) {
490
553
  collectAssignedStateKeys(entry, into);
491
554
  }
492
555
  return;
493
556
  }
494
- const rec = node as Record<string, unknown>;
557
+ if (!isNonNullObject(node)) {
558
+ return;
559
+ }
560
+ /* SAFETY: `node` is a non-array object inside a handler body or an expression, so it is a
561
+ document object, and every member of a document object is itself a document value. */
562
+ const rec = node as Record<string, JxDocumentValue | undefined>;
495
563
  // 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
- }
564
+ const { operator, target } = rec;
565
+ if (
566
+ isString(operator) &&
567
+ isMutating(operator) &&
568
+ isRef(target) &&
569
+ target.$ref.startsWith("#/state/")
570
+ ) {
571
+ // SAFETY: `String.prototype.split` always returns at least one element.
572
+ into.add(target.$ref.slice("#/state/".length).split("/")[0] as string);
502
573
  }
503
574
  for (const value of Object.values(rec)) {
504
575
  collectAssignedStateKeys(value, into);
@@ -507,18 +578,18 @@ function collectAssignedStateKeys(node: unknown, into: Set<string>) {
507
578
 
508
579
  /**
509
580
  * @param {Record<string, JxStateDefinition>} [defs]
510
- * @param {Record<string, unknown> | null} [parentScope]
511
- * @returns {Record<string, unknown>}
581
+ * @param {JxScope | null} [parentScope]
582
+ * @returns {JxScope}
512
583
  */
513
584
  export function buildInitialScope(
514
585
  defs: Record<string, JxStateDefinition> = {},
515
- parentScope: Record<string, unknown> | null = null,
586
+ parentScope: JxScope | null = null,
516
587
  ) {
517
- const scope = Object.create(parentScope ?? null) as Record<string, unknown>;
588
+ const scope = emptyScope(parentScope);
518
589
  // Keys whose real value only exists after hydration, so prerender must not interpolate them —
519
590
  // 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>) : []);
591
+ const inherited = parentScope ? runtimeOnlyKeysOf(parentScope) : undefined;
592
+ const runtimeOnly = new Set<string>(inherited);
522
593
  // Attached up front, and mutated in place by the passes below, so the template pass at the end can
523
594
  // Consult the marks the earlier passes added.
524
595
  Object.defineProperty(scope, RUNTIME_ONLY_KEYS, {
@@ -534,12 +605,8 @@ export function buildInitialScope(
534
605
  // Builder cannot open, so a write that only happens there is still missed (issue #125).
535
606
  const assigned = new Set<string>();
536
607
  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
- );
608
+ if (isFunctionDef(def) || isExpressionDef(def)) {
609
+ collectAssignedStateKeys(def.body ?? def.$expression, assigned);
543
610
  }
544
611
  }
545
612
  for (const key of assigned) {
@@ -549,17 +616,16 @@ export function buildInitialScope(
549
616
  }
550
617
 
551
618
  for (const [key, def] of Object.entries(defs)) {
552
- if (typeof def !== "object" || def === null || Array.isArray(def)) {
619
+ if (!isNonNullObject(def) || Array.isArray(def)) {
553
620
  setOwnScopeValue(scope, key, cloneValue(def));
554
621
  continue;
555
622
  }
556
- const d = def as JxStateObject & JxPrototypeDef;
557
- if ("default" in d) {
558
- setOwnScopeValue(scope, key, cloneValue(d.default));
623
+ if ("default" in def) {
624
+ setOwnScopeValue(scope, key, cloneValue(def.default));
559
625
  continue;
560
626
  }
561
- if (!d.$prototype && !("$expression" in d) && !isSchemaOnly(d)) {
562
- setOwnScopeValue(scope, key, cloneValue(d));
627
+ if (!def.$prototype && !("$expression" in def) && !isSchemaOnlyDef(def)) {
628
+ setOwnScopeValue(scope, key, cloneValue(def));
563
629
  }
564
630
  }
565
631
 
@@ -572,34 +638,32 @@ export function buildInitialScope(
572
638
  const computedBodies: [string, string][] = [];
573
639
 
574
640
  for (const [key, def] of Object.entries(defs)) {
575
- if (typeof def === "string" && isTemplateString(def)) {
641
+ if (isTemplateString(def)) {
576
642
  templateDefs.push([key, def]);
577
643
  continue;
578
644
  }
579
- if (!def || typeof def !== "object") {
645
+ if (!isNonNullObject(def)) {
580
646
  continue;
581
647
  }
582
648
  if (isExpressionDef(def)) {
583
- const node = def.$expression as ExpressionNode;
649
+ const node = def.$expression;
584
650
  if (isNamedFormulaDef(def)) {
585
651
  // 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> = {};
652
+ const params: readonly FormulaParameter[] = def.parameters;
653
+ setOwnScopeValue(scope, key, (...argValues: JxScope[string][]) => {
654
+ const args: JxScope = {};
589
655
  for (const [i, p] of params.entries()) {
590
- const name = typeof p === "string" ? p : (p?.name ?? "");
656
+ const name = formulaParameterName(p);
591
657
  if (!name) {
592
658
  continue;
593
659
  }
594
660
  args[name] =
595
- argValues[i] === undefined && typeof p === "object" && p !== null && "default" in p
596
- ? p.default
597
- : argValues[i];
661
+ argValues[i] === undefined && hasParameterDefault(p) ? p.default : argValues[i];
598
662
  }
599
663
  return evaluateExpression(node, scope, null, { args });
600
664
  });
601
665
  } else if (isMutating(node.operator)) {
602
- setOwnScopeValue(scope, key, (s: Record<string, unknown>, event: Event) =>
666
+ setOwnScopeValue(scope, key, (s: JxScope, event: Event) =>
603
667
  evaluateExpression(node, s, event),
604
668
  );
605
669
  } else {
@@ -609,12 +673,13 @@ export function buildInitialScope(
609
673
  }
610
674
  if (isFunctionDef(def)) {
611
675
  if (hasStructuredBody(def)) {
612
- // Structured body (spec §20): a side-effecting handler in the build-time scope.
676
+ // Structured body (spec §20): a side-effecting handler in the build-time scope. Build-time
677
+ // Code may call it bare, so the scope it runs against falls back to this one.
613
678
  const { body } = def;
614
- setOwnScopeValue(scope, key, (s: Record<string, unknown>, event?: Event) => {
679
+ setOwnScopeValue(scope, key, (s?: JxScope, event?: Event) => {
615
680
  void runStatements(body, s ?? scope, event ?? null);
616
681
  });
617
- } else if (typeof def.body === "string") {
682
+ } else if (isString(def.body)) {
618
683
  const declared = def.parameters ? paramNames(def.parameters) : (def.arguments ?? []);
619
684
  const { body } = def;
620
685
  // Declared names bind by name, not by position (spec.md §5.3 4d): a parameter literally
@@ -623,8 +688,13 @@ export function buildInitialScope(
623
688
  // The older behaviour here — produced a duplicate parameter for the `["state", "event"]`
624
689
  // Form that examples/components/task-manager.json uses, leaving `state` undefined.
625
690
  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) =>
691
+ /* SAFETY: `new Function` returns an ordinary function of exactly `params`; `invoke` below is
692
+ its only caller and passes the scope or the event in each position, and what the body
693
+ returns is one more scope member. */
694
+ const fn = new Function(...params, body) as (
695
+ ...args: (JxScope | Event | undefined)[]
696
+ ) => JxScope[string];
697
+ const invoke = (state: JxScope, event?: Event) =>
628
698
  fn(...params.map((name) => (name === "state" ? state : event)));
629
699
  if (bodyReturnsValue(body)) {
630
700
  defineLazyScopeValue(scope, key, () => invoke(scope));
@@ -677,11 +747,11 @@ export function buildInitialScope(
677
747
  }
678
748
 
679
749
  /**
680
- * @param {Record<string, unknown>} scope
750
+ * @param {JxScope} scope
681
751
  * @param {string} key
682
- * @param {unknown} value
752
+ * @param {JxScope[string]} value - Any scope member: document data or a build-time callable
683
753
  */
684
- export function setOwnScopeValue(scope: Record<string, unknown>, key: string, value: unknown) {
754
+ export function setOwnScopeValue(scope: JxScope, key: string, value: JxScope[string]) {
685
755
  Object.defineProperty(scope, key, {
686
756
  configurable: true,
687
757
  enumerable: true,
@@ -691,15 +761,11 @@ export function setOwnScopeValue(scope: Record<string, unknown>, key: string, va
691
761
  }
692
762
 
693
763
  /**
694
- * @param {Record<string, unknown>} scope
764
+ * @param {JxScope} scope
695
765
  * @param {string} key
696
- * @param {() => unknown} getter
766
+ * @param {() => JxScope[string]} getter - Computes the member on read
697
767
  */
698
- export function defineLazyScopeValue(
699
- scope: Record<string, unknown>,
700
- key: string,
701
- getter: () => unknown,
702
- ) {
768
+ export function defineLazyScopeValue(scope: JxScope, key: string, getter: () => JxScope[string]) {
703
769
  Object.defineProperty(scope, key, {
704
770
  configurable: true,
705
771
  enumerable: true,
@@ -708,16 +774,17 @@ export function defineLazyScopeValue(
708
774
  }
709
775
 
710
776
  /**
711
- * @param {unknown} value
712
- * @param {Record<string, unknown>} scope
777
+ * @param {JxDocumentValue | undefined} value - A bindable document value: a `$ref`, a template
778
+ * string, or a literal returned as is
779
+ * @param {JxScope | null} scope
713
780
  * @returns {unknown}
714
781
  */
715
- export function resolveStaticValue(value: unknown, scope: Record<string, unknown> | null) {
716
- if (isRefObject(value)) {
717
- return resolveRefValue((value as JxMutableNode).$ref, scope!);
782
+ export function resolveStaticValue(value: JxDocumentValue | undefined, scope: JxScope | null) {
783
+ if (isRef(value)) {
784
+ return resolveRefValue(value.$ref, scope!);
718
785
  }
719
786
  if (isTemplateString(value)) {
720
- return evaluateStaticTemplate(value as string, scope!);
787
+ return evaluateStaticTemplate(value, scope);
721
788
  }
722
789
  return value;
723
790
  }
@@ -731,18 +798,22 @@ export function isRefObject(value: unknown): value is JxRef {
731
798
  }
732
799
 
733
800
  /**
734
- * @param {unknown} refValue
735
- * @param {Record<string, unknown>} scope
801
+ * @param {JsonValue | undefined} refValue - A `$ref` member as the document holds it; anything but
802
+ * a string is returned as is
803
+ * @param {JxScope} scope
736
804
  * @returns {unknown}
737
805
  */
738
- export function resolveRefValue(refValue: unknown, scope: Record<string, unknown>) {
739
- if (typeof refValue !== "string") {
806
+ export function resolveRefValue(refValue: JsonValue | undefined, scope: JxScope) {
807
+ if (!isString(refValue)) {
740
808
  return refValue;
741
809
  }
742
810
  if (refValue.startsWith("$map/")) {
743
811
  const parts = refValue.split("/");
744
812
  const key = parts[1]!;
745
- const base = (scope.$map as Record<string, unknown> | undefined)?.[key] ?? scope[`$map/${key}`];
813
+ /* SAFETY: `?.` stops at null and undefined, and every other value answers a member read by
814
+ name; the member comes back as a scope member, so the view claims nothing about its type. */
815
+ const row = scope.$map as JxScope | null | undefined;
816
+ const base = row?.[key] ?? scope[`$map/${key}`];
746
817
  return parts.length > 2 ? getPathValue(base, parts.slice(2).join("/")) : base;
747
818
  }
748
819
  if (refValue.startsWith("#/state/")) {
@@ -770,43 +841,58 @@ export function resolveRefValue(refValue: unknown, scope: Record<string, unknown
770
841
  * formulas (spec.md §19.4c) are meant to be used during prerender.
771
842
  *
772
843
  * @param {string} str
773
- * @param {Record<string, unknown>} scope
844
+ * @param {JxScope | null} scope
774
845
  * @returns {boolean}
775
846
  */
776
- function readsRuntimeOnlyState(str: string, scope: Record<string, unknown>) {
847
+ function readsRuntimeOnlyState(str: string, scope: JxScope | null) {
777
848
  if (!scope) {
778
849
  return false;
779
850
  }
780
- const runtimeOnly = (scope as Record<PropertyKey, unknown>)[RUNTIME_ONLY_KEYS];
851
+ const runtimeOnly = runtimeOnlyKeysOf(scope);
781
852
  for (const match of str.matchAll(/\bstate\.([A-Za-z_$][\w$]*)\s*(\()?/g)) {
782
853
  if (match[2]) {
783
854
  continue;
784
855
  }
856
+ // SAFETY: capture group 1 is outside any optional part of the pattern, so a match has it.
785
857
  const key = match[1] as string;
786
- if (runtimeOnly instanceof Set && runtimeOnly.has(key)) {
858
+ if (runtimeOnly?.has(key)) {
787
859
  return true;
788
860
  }
789
861
  // Reading the key can run a computed's getter, which is free to throw on partial build-time
790
862
  // Data. A throw says nothing about whether the entry is runtime-only, so it is not a mark.
791
- let value: unknown;
863
+ let value: JxScope[string];
792
864
  try {
793
865
  value = scope[key];
794
866
  } catch {
795
867
  continue;
796
868
  }
797
- if (typeof value === "function") {
869
+ if (isFunction(value)) {
798
870
  return true;
799
871
  }
800
872
  }
801
873
  return false;
802
874
  }
803
875
 
876
+ /**
877
+ * The JavaScript expression a Shape 3 template string compiles to (spec.md §5.3): the expression
878
+ * itself, parenthesized, when the template is ONE expression, so it keeps its own type, and a
879
+ * template literal otherwise, which is a string by construction. The same line
880
+ * {@link evaluateStaticTemplate} and the runtime draw, so a prerendered page, a hydrated component
881
+ * and Studio's canvas read `"${state.a === state.b}"` as the same boolean.
882
+ *
883
+ * @param {string} template - Template source, already rewritten to the target's state prefix
884
+ * @returns {string} A JavaScript expression
885
+ */
886
+ export function templateValueSource(template: string): string {
887
+ return isSingleExpression(template) ? `(${template.slice(2, -1)})` : `\`${template}\``;
888
+ }
889
+
804
890
  /**
805
891
  * @param {string} str
806
- * @param {Record<string, unknown>} scope
892
+ * @param {JxScope | null} scope
807
893
  * @returns {unknown}
808
894
  */
809
- export function evaluateStaticTemplate(str: string, scope: Record<string, unknown>) {
895
+ export function evaluateStaticTemplate(str: string, scope: JxScope | null) {
810
896
  if (readsRuntimeOnlyState(str, scope)) {
811
897
  return null;
812
898
  }
@@ -822,7 +908,9 @@ export function evaluateStaticTemplate(str: string, scope: Record<string, unknow
822
908
  const values = [scope, scope?.$map, scope?.$site, scope?.$page];
823
909
  try {
824
910
  const body = isSingleExpression(str) ? `return (${str.slice(2, -1)})` : `return \`${str}\``;
825
- const fn = new Function(...args, body) as (...a: unknown[]) => unknown;
911
+ /* SAFETY: `new Function` compiles exactly the four parameters in `args`, and its body returns a
912
+ template's value, which is whatever the scope it reads holds: one more scope member. */
913
+ const fn = new Function(...args, body) as (...a: JxScope[string][]) => JxScope[string];
826
914
  return fn(...values);
827
915
  } catch {
828
916
  return null;
@@ -830,20 +918,24 @@ export function evaluateStaticTemplate(str: string, scope: Record<string, unknow
830
918
  }
831
919
 
832
920
  /**
833
- * @param {unknown} base
921
+ * @param {JxScope[string]} base - A scope, or any member read from one
834
922
  * @param {string} path
835
923
  * @returns {unknown}
836
924
  */
837
- export function getPathValue(base: unknown, path: string) {
925
+ export function getPathValue(base: JxScope[string], path: string) {
838
926
  return readPath(base, path);
839
927
  }
840
928
 
841
929
  /**
842
- * @param {unknown} value
843
- * @returns {unknown}
930
+ * A deep copy of an object, so a scope never aliases the document it was built from; anything else
931
+ * is returned as is.
932
+ *
933
+ * @template T
934
+ * @param {T} value
935
+ * @returns {T}
844
936
  */
845
- export function cloneValue(value: unknown) {
846
- if (value === null || typeof value !== "object") {
937
+ export function cloneValue<T>(value: T): T {
938
+ if (!isNonNullObject(value)) {
847
939
  return value;
848
940
  }
849
941
  return structuredClone(value);
@@ -857,12 +949,12 @@ export function cloneValue(value: unknown) {
857
949
  * class handle), so only a per-instance value is written on the element.
858
950
  *
859
951
  * @param {JxStyle | null | undefined} style
860
- * @param {Record<string, unknown> | null} scope
952
+ * @param {JxScope | null} scope
861
953
  * @returns {string} `prop: value; prop: value`, or "" when nothing resolves
862
954
  */
863
955
  export function inlineStyleDeclarations(
864
956
  style: JxStyle | null | undefined,
865
- scope: Record<string, unknown> | null,
957
+ scope: JxScope | null,
866
958
  ): string {
867
959
  if (!style || !scope) {
868
960
  return "";
@@ -875,9 +967,6 @@ export function inlineStyleDeclarations(
875
967
  !k.startsWith("&") &&
876
968
  !k.startsWith("[") &&
877
969
  !k.startsWith("@") &&
878
- v !== null &&
879
- typeof v !== "object" &&
880
- typeof v === "string" &&
881
970
  isTemplateString(v),
882
971
  )
883
972
  .map(([k, v]) => {
@@ -892,18 +981,14 @@ export function inlineStyleDeclarations(
892
981
  * Build an HTML attribute string from a static element definition.
893
982
  *
894
983
  * @param {JxElement | JxMutableNode} def
895
- * @param {Record<string, unknown>} scope
984
+ * @param {JxScope | null} scope
896
985
  * @param {string} [hostStyle] - Declarations resolved by the caller against ANOTHER scope, folded
897
986
  * into the same `style` attribute after the node's own. A nested component instance is the one
898
987
  * caller: its own `style` resolves against the parent's scope, while the definition's host style
899
988
  * resolves against the instance's, and an element cannot carry two `style` attributes.
900
989
  * @returns {string}
901
990
  */
902
- export function buildAttrs(
903
- def: JxElement | JxMutableNode,
904
- scope: Record<string, unknown> | null,
905
- hostStyle = "",
906
- ) {
991
+ export function buildAttrs(def: JxElement | JxMutableNode, scope: JxScope | null, hostStyle = "") {
907
992
  let out = "";
908
993
 
909
994
  const id = resolveStaticValue(def.id, scope);
@@ -971,18 +1056,14 @@ export function buildAttrs(
971
1056
  * Sidebar's own not-the-current-page marker, and second-guessing it would be this same defect
972
1057
  * Pointed the other way.
973
1058
  */
974
- if (typeof value === "boolean") {
1059
+ if (isBoolean(value)) {
975
1060
  const text = booleanAttrValue(k, value);
976
1061
  if (text !== null) {
977
1062
  out += text === "" ? ` ${k}` : ` ${k}="${escapeHtml(text)}"`;
978
1063
  }
979
1064
  continue;
980
1065
  }
981
- if (
982
- value !== null &&
983
- value !== undefined &&
984
- (typeof value === "string" || typeof value === "number")
985
- ) {
1066
+ if (isString(value) || isNumber(value)) {
986
1067
  out += ` ${k}="${escapeHtml(String(value))}"`;
987
1068
  }
988
1069
  }
@@ -1017,30 +1098,34 @@ export function childSeparator(preformatted: boolean | undefined, indent = " ")
1017
1098
  return preformatted ? "" : `\n${indent}`;
1018
1099
  }
1019
1100
 
1101
+ /**
1102
+ * A {@link CompileContext} as a static emitter hands it to a node, before that node's own scope is
1103
+ * known: the scope in effect may still be none.
1104
+ */
1105
+ export type InnerCompileContext = Omit<CompileContext, "scope"> & { scope: JxScope | null };
1106
+
1020
1107
  /**
1021
1108
  * Build the inner HTML (textContent or children) for a node.
1022
1109
  *
1023
1110
  * @param {JxElement} def
1024
1111
  * @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
1112
+ * @param {InnerCompileContext} context
1113
+ * @param {(
1114
+ * def: JxElement | string,
1115
+ * raw: JxElement | string,
1116
+ * context: InnerCompileContext,
1117
+ * ) => string} childCompiler
1032
1118
  * @returns {string}
1033
1119
  */
1034
1120
  export function buildInner(
1035
1121
  def: JxElement,
1036
1122
  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,
1123
+ context: InnerCompileContext,
1124
+ childCompiler: (
1125
+ def: JxElement | string,
1126
+ raw: JxElement | string,
1127
+ context: InnerCompileContext,
1128
+ ) => string,
1044
1129
  ) {
1045
1130
  const source = raw ?? def;
1046
1131
 
@@ -1049,13 +1134,14 @@ export function buildInner(
1049
1134
  return value == null ? "" : escapeHtml(String(value));
1050
1135
  }
1051
1136
  if (source.innerHTML) {
1052
- return (resolveStaticValue(source.innerHTML, context.scope) as string) ?? "";
1137
+ const value = resolveStaticValue(source.innerHTML, context.scope);
1138
+ return value == null ? "" : String(value);
1053
1139
  }
1054
1140
  if (Array.isArray(source.children)) {
1055
1141
  const rawChildren = raw?.children;
1056
1142
  return source.children
1057
- .map((c: JxElement | JxMutableNode | string, i: number) => {
1058
- const childRaw = (rawChildren as (JxElement | string)[] | undefined)?.[i] ?? c;
1143
+ .map((c, i) => {
1144
+ const childRaw = (Array.isArray(rawChildren) ? rawChildren[i] : undefined) ?? c;
1059
1145
  return childCompiler(c, childRaw, context);
1060
1146
  })
1061
1147
  .join(childSeparator(context.preformatted));
@@ -1127,7 +1213,7 @@ function recordDroppedReactive(
1127
1213
  value: string | JxRef,
1128
1214
  selector: string | null,
1129
1215
  ): null {
1130
- const source = typeof value === "string" ? value : value.$ref;
1216
+ const source = isString(value) ? value : value.$ref;
1131
1217
  _droppedReactive.push({ property, selector, source });
1132
1218
  return null;
1133
1219
  }
@@ -1196,6 +1282,154 @@ export function attrHelperSource(): string {
1196
1282
  );
1197
1283
  }
1198
1284
 
1285
+ /** The capture helper a light-DOM component module calls before its first render. */
1286
+ export const SLOT_CAPTURE_HELPER = "__jxCaptureSlotted";
1287
+
1288
+ /** The distribution helper a light-DOM component module calls after its first render. */
1289
+ export const SLOT_DISTRIBUTE_HELPER = "__jxDistributeSlots";
1290
+
1291
+ /**
1292
+ * The slot rule (spec.md §8.5) as source for a generated module: `captureSlotted` and
1293
+ * `distributeSlots` from `@jxsuite/runtime`, inlined for the reason `attrHelperSource` gives, with
1294
+ * the whitespace pattern, the bracket word, the attribute and the record key SERIALIZED from the
1295
+ * runtime's exports rather than retyped. The record lives on `globalThis` under the same registered
1296
+ * symbol, so a group one module (or the interpreter) records is read by the element that captures
1297
+ * next. `slot-distribution-agreement.test.ts` runs this source and the runtime's functions over one
1298
+ * fixture table, which is what keeps the two from drifting.
1299
+ *
1300
+ * @returns {string} Two `function` declarations and their two constants, for a module's preamble.
1301
+ */
1302
+ export function slotHelperSource(): string {
1303
+ const ws = `new RegExp(${JSON.stringify(SLOT_WHITESPACE.source)}, ${JSON.stringify(SLOT_WHITESPACE.flags)})`;
1304
+ const key = `Symbol.for(${JSON.stringify(SLOT_RECORD_KEY)})`;
1305
+ const attr = JSON.stringify(SLOT_ATTR);
1306
+ const mark = JSON.stringify(SLOT_MARK);
1307
+ return [
1308
+ `const __jxSlotWs = ${ws};`,
1309
+ `const __jxSlotRec = globalThis[${key}] || (globalThis[${key}] = new WeakMap());`,
1310
+ `function ${SLOT_CAPTURE_HELPER}(host) {`,
1311
+ ` const id = host.getAttribute(${attr});`,
1312
+ ` if (id === null) return Array.from(host.childNodes);`,
1313
+ ` host.removeAttribute(${attr});`,
1314
+ ` const open = ${mark} + ' ' + id + ' ';`,
1315
+ ` const close = '/' + ${mark} + ' ' + id;`,
1316
+ ` const openers = [];`,
1317
+ ` const w = host.ownerDocument.createTreeWalker(host, 128);`,
1318
+ ` for (let n = w.nextNode(); n; n = w.nextNode()) if (n.data.startsWith(open)) openers.push(n);`,
1319
+ ` const out = [];`,
1320
+ ` for (const o of openers) {`,
1321
+ ` const name = decodeURIComponent(o.data.slice(open.length));`,
1322
+ ` for (let n = o.nextSibling; n; n = n.nextSibling) {`,
1323
+ ` if (n.nodeType === 8 && n.data === close) break;`,
1324
+ ` __jxSlotRec.set(n, name);`,
1325
+ ` out.push(n);`,
1326
+ ` }`,
1327
+ ` }`,
1328
+ ` return out;`,
1329
+ `}`,
1330
+ `function ${SLOT_DISTRIBUTE_HELPER}(host, captured) {`,
1331
+ ` const slots = Array.from(host.querySelectorAll('slot'));`,
1332
+ ` if (slots.length === 0) return;`,
1333
+ ` const groups = new Map();`,
1334
+ ` for (const n of captured) {`,
1335
+ ` if (n.nodeType === 3 && __jxSlotWs.test(n.data)) continue;`,
1336
+ ` const r = __jxSlotRec.get(n);`,
1337
+ ` __jxSlotRec.delete(n);`,
1338
+ ` const name = r !== undefined ? r : n.nodeType === 1 ? n.getAttribute('slot') || '' : '';`,
1339
+ ` let g = groups.get(name);`,
1340
+ ` if (!g) { g = []; groups.set(name, g); }`,
1341
+ ` g.push(n);`,
1342
+ ` }`,
1343
+ ` const taken = new Set();`,
1344
+ ` for (const slot of slots) {`,
1345
+ ` const name = slot.getAttribute('name') || '';`,
1346
+ ` let g = [];`,
1347
+ ` if (!taken.has(name)) { taken.add(name); g = groups.get(name) || []; }`,
1348
+ ` const content = g.some((n) => n.nodeType === 1 || (n.nodeType === 3 && n.data !== ''));`,
1349
+ ` const placed = content ? g : g.concat(Array.from(slot.childNodes));`,
1350
+ ` const p = slot.parentElement;`,
1351
+ ` if (p && p !== host && p.localName.includes('-')) {`,
1352
+ ` const r = __jxSlotRec.get(slot);`,
1353
+ ` const f = r !== undefined ? r : slot.getAttribute('slot') || '';`,
1354
+ ` for (const n of placed) __jxSlotRec.set(n, f);`,
1355
+ ` }`,
1356
+ ` slot.replaceWith(...placed);`,
1357
+ ` }`,
1358
+ `}`,
1359
+ ].join("\n");
1360
+ }
1361
+
1362
+ /**
1363
+ * An empty comment written where a lit child part would otherwise end at a node distribution moves.
1364
+ *
1365
+ * Lit ends a child part at the node that followed its marker when the template was cloned. A `$map`
1366
+ * or `$switch` written directly before a `<slot>` ends at that slot, and once the slot is replaced
1367
+ * the part's next update clears every later sibling of its new parent and throws; a binding among
1368
+ * an instance's children ends at a named sibling, which moves to another group, in the same way. A
1369
+ * comment ends the part instead: it travels with its group, and it affects neither `:empty` nor a
1370
+ * child combinator, so a slot still "leaves no node" in the selector tree §16.6 is about.
1371
+ */
1372
+ export const LIT_PART_GUARD = "<!---->";
1373
+
1374
+ /**
1375
+ * Whether a definition's tree draws a `<slot>` anywhere a render can reach: `children`, a
1376
+ * `$switch`'s `cases`, or a repeater's `map` (a whole-children repeater included).
1377
+ *
1378
+ * @param {JxMutableNode["children"]} children
1379
+ * @returns {boolean}
1380
+ */
1381
+ export function definitionHasSlot(children: SlotChildren): boolean {
1382
+ for (const child of slotChildList(children)) {
1383
+ if (isMappedArray(child)) {
1384
+ if (child.map && definitionHasSlot([child.map])) {
1385
+ return true;
1386
+ }
1387
+ continue;
1388
+ }
1389
+ if (!isNodeObject(child)) {
1390
+ continue;
1391
+ }
1392
+ if (child.tagName === "slot") {
1393
+ return true;
1394
+ }
1395
+ const cases = Object.values(child.cases ?? {}).filter(
1396
+ (c) => isNodeObject(c) && !isRefObject(c),
1397
+ );
1398
+ if (definitionHasSlot(cases) || definitionHasSlot(child.children)) {
1399
+ return true;
1400
+ }
1401
+ }
1402
+ return false;
1403
+ }
1404
+
1405
+ /** A node's `children` as either document shape writes it. */
1406
+ export type SlotChildren =
1407
+ | JxElement["children"]
1408
+ | JxMutableNode["children"]
1409
+ | readonly StaticChild[]
1410
+ | null
1411
+ | undefined;
1412
+
1413
+ /**
1414
+ * `children` as a list: an array as it is, a whole-children repeater as its one member, nothing as
1415
+ * nothing.
1416
+ *
1417
+ * @param {SlotChildren} children
1418
+ * @returns {readonly (StaticChild | JxMappedArray)[]}
1419
+ */
1420
+ function slotChildList(children: SlotChildren): readonly (StaticChild | JxMappedArray)[] {
1421
+ if (isMappedArray(children)) {
1422
+ return [children];
1423
+ }
1424
+ // Document JSON can hold anything here; what is neither a list nor a repeater holds no child.
1425
+ if (!Array.isArray(children)) {
1426
+ return [];
1427
+ }
1428
+ /* SAFETY: every array member of `SlotChildren` is a list of children; `Array.isArray` narrows the
1429
+ union to `any[]`, so the cast only restores the element type it already had. */
1430
+ return children as readonly StaticChild[];
1431
+ }
1432
+
1199
1433
  /**
1200
1434
  * Walk the entire document tree and collect all static nested CSS rules.
1201
1435
  *
@@ -1230,7 +1464,7 @@ export function compileStyles(
1230
1464
  `color-scheme` triplet a scheme query in `$media` still calls for, which is exactly what a
1231
1465
  page with a scheme query and no project style has always carried. */
1232
1466
  const projectSheet = buildSiteStyleCSS(
1233
- projectStyle && typeof projectStyle === "object" ? projectStyle : {},
1467
+ isNonNullObject(projectStyle) ? projectStyle : {},
1234
1468
  mediaQueries,
1235
1469
  (value) => value,
1236
1470
  recordDroppedReactive,
@@ -1276,7 +1510,7 @@ export function collectStyles(
1276
1510
  prefix = "jx",
1277
1511
  ) {
1278
1512
  const counter = counterArg ?? { n: 0 };
1279
- if (!def || typeof def !== "object") {
1513
+ if (!isNonNullObject(def)) {
1280
1514
  return;
1281
1515
  }
1282
1516
 
@@ -1307,16 +1541,9 @@ export function collectStyles(
1307
1541
  }
1308
1542
 
1309
1543
  // $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
- );
1544
+ if (isNonNullObject(def.cases)) {
1545
+ for (const c of Object.values<JxElement | JxMutableNode>(def.cases)) {
1546
+ collectStyles(c, rules, mediaQueries, selector, counter, prefix);
1320
1547
  }
1321
1548
  }
1322
1549
  }
@@ -1380,18 +1607,13 @@ export function collectSrcImports(doc: JxElement) {
1380
1607
  * @param {Set<string>} srcs
1381
1608
  */
1382
1609
  function _walkSrc(def: JxElement | JxMutableNode | string, srcs: Set<string>) {
1383
- if (!def || typeof def !== "object") {
1610
+ if (!isNonNullObject(def)) {
1384
1611
  return;
1385
1612
  }
1386
1613
  if (def.state) {
1387
1614
  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);
1615
+ if (isFunctionDef(d) && d.$src) {
1616
+ srcs.add(d.$src);
1395
1617
  }
1396
1618
  }
1397
1619
  }
@@ -1422,7 +1644,7 @@ function _walkServerEntries(
1422
1644
  def: JxElement | JxMutableNode | string,
1423
1645
  entries: Map<string, { key: string; exportName: string; src: string }>,
1424
1646
  ) {
1425
- if (!def || typeof def !== "object") {
1647
+ if (!isNonNullObject(def)) {
1426
1648
  return;
1427
1649
  }
1428
1650
  if (def.state) {
@@ -1461,15 +1683,12 @@ export const SELF_CLOSING = new Set<string>([
1461
1683
  * precisely because the old `${…}` form was left as text and then re-resolved against the page
1462
1684
  * scope, where a component's own state is undefined.
1463
1685
  *
1464
- * @param {unknown} tagName
1465
- * @param {Record<string, unknown>} scope
1686
+ * @param {TagNameSlot} tagName
1687
+ * @param {JxScope | null} scope
1466
1688
  * @returns {string}
1467
1689
  */
1468
- export function resolveStaticTagName(
1469
- tagName: unknown,
1470
- scope: Record<string, unknown> | null,
1471
- ): string {
1472
- if (typeof tagName === "string") {
1690
+ export function resolveStaticTagName(tagName: TagNameSlot, scope: JxScope | null): string {
1691
+ if (isString(tagName)) {
1473
1692
  return tagName;
1474
1693
  }
1475
1694
  const candidates = tagNameCandidates(tagName);
@@ -1480,10 +1699,14 @@ export function resolveStaticTagName(
1480
1699
  return candidates[0]!;
1481
1700
  }
1482
1701
  const expression = tagName.$expression;
1702
+ /* SAFETY: a tag expression is parsed from a document, so its `target` is a document value (a
1703
+ `$ref`, a template string or a literal); the schema leaves it untyped only because any operand
1704
+ may stand there. */
1705
+ const target = expression.target as JxDocumentValue | undefined;
1483
1706
  if (expression.operator === "?:") {
1484
- return resolveStaticValue(expression.target, scope) ? expression.value : expression.initial;
1707
+ return resolveStaticValue(target, scope) ? expression.value : expression.initial;
1485
1708
  }
1486
- const key = resolveStaticValue(expression.target, scope);
1709
+ const key = resolveStaticValue(target, scope);
1487
1710
  return expression.cases[String(key)] ?? expression.default;
1488
1711
  }
1489
1712
 
@@ -1506,6 +1729,120 @@ export interface ComponentPrerenderContext {
1506
1729
  defaults?: ProjectConfig["defaults"] | undefined;
1507
1730
  /** The instances being expanded, outermost first; empty at a page-level instance. */
1508
1731
  path?: readonly PrerenderFrame[] | undefined;
1732
+ /**
1733
+ * The page's counter for `data-jx-slots` ids. Nested contexts are spread copies, so they share
1734
+ * it; without one (a unit test, a definition preview) no instance is stamped or bracketed.
1735
+ */
1736
+ slotIds?: { n: number } | undefined;
1737
+ }
1738
+
1739
+ /**
1740
+ * An instance's children, grouped by the slot they name, as the static renderer places them
1741
+ * (spec.md §8.5). One render consumes a fill: `taken` records which names a slot already took.
1742
+ */
1743
+ export interface SlotFill {
1744
+ /** Group name (`""` is the unnamed group) → its rendered HTML and whether it holds content. */
1745
+ groups: Map<string, { html: string; content: boolean }>;
1746
+ taken: Set<string>;
1747
+ /** The instance's `data-jx-slots` id, whose brackets wrap each placed group; null for none. */
1748
+ id: number | null;
1749
+ }
1750
+
1751
+ /**
1752
+ * The next `data-jx-slots` id of the page being rendered, or null without a counter.
1753
+ *
1754
+ * @param {ComponentPrerenderContext | null} context
1755
+ * @returns {number | null}
1756
+ */
1757
+ export function nextSlotId(context: ComponentPrerenderContext | null): number | null {
1758
+ if (!context?.slotIds) {
1759
+ return null;
1760
+ }
1761
+ context.slotIds.n += 1;
1762
+ return context.slotIds.n;
1763
+ }
1764
+
1765
+ /**
1766
+ * Group an instance's children by slot name, as definitions, applying the rule the interpreter's
1767
+ * `distributeSlots` applies to nodes: a string or number whose rendered text is only HTML
1768
+ * whitespace is dropped; an element names its group by its resolved `attributes.slot`, else its
1769
+ * resolved node-level `slot` (which is then rendered as the attribute, so a recovered child still
1770
+ * names its group); everything else joins the unnamed group. An element, `$switch` or repeater
1771
+ * child is content; a string or number is when its text is not empty.
1772
+ *
1773
+ * @param {StaticChild} children
1774
+ * @param {JxScope | null} scope - The scope the children resolve in (the parent's)
1775
+ * @param {(child: StaticChild) => string} render - Renders one child
1776
+ * @param {number | null} [id]
1777
+ * @returns {SlotFill}
1778
+ */
1779
+ export function createSlotFill(
1780
+ children: SlotChildren,
1781
+ scope: JxScope | null,
1782
+ render: (child: StaticChild) => string,
1783
+ id: number | null = null,
1784
+ ): SlotFill {
1785
+ const groups: SlotFill["groups"] = new Map();
1786
+ for (const child of slotChildList(children)) {
1787
+ let name = "";
1788
+ let content = true;
1789
+ let html: string;
1790
+ if (isString(child) || isNumber(child) || isBoolean(child)) {
1791
+ html = render(child);
1792
+ if (SLOT_WHITESPACE.test(html)) {
1793
+ continue;
1794
+ }
1795
+ content = html !== "";
1796
+ } else if (isMappedArray(child)) {
1797
+ // A repeater renders nothing statically; it is content all the same, as its rows will be.
1798
+ html = "";
1799
+ } else if (!isNodeObject(child)) {
1800
+ html = render(child);
1801
+ content = html !== "";
1802
+ } else {
1803
+ const fromAttr = resolveStaticValue(child.attributes?.slot, scope);
1804
+ const fromNode = isString(child.slot) ? resolveStaticValue(child.slot, scope) : null;
1805
+ if (isString(fromAttr) && fromAttr !== "") {
1806
+ name = fromAttr;
1807
+ html = render(child);
1808
+ } else if (isString(fromNode) && fromNode !== "") {
1809
+ name = fromNode;
1810
+ const { slot: _slot, ...rest } = child;
1811
+ html = render({ ...rest, attributes: { ...rest.attributes, slot: fromNode } });
1812
+ } else {
1813
+ html = render(child);
1814
+ }
1815
+ }
1816
+ const group = groups.get(name);
1817
+ if (group) {
1818
+ group.html = group.html === "" ? html : `${group.html}\n${html}`;
1819
+ group.content ||= content;
1820
+ } else {
1821
+ groups.set(name, { content, html });
1822
+ }
1823
+ }
1824
+ return { groups, id, taken: new Set() };
1825
+ }
1826
+
1827
+ /**
1828
+ * Mark every slot name inside a fallback that is not rendered as taken, in tree order, so the
1829
+ * prerender's answer matches the DOM tiers', where such a slot is still met by `querySelectorAll`.
1830
+ *
1831
+ * @param {StaticChild} node
1832
+ * @param {JxScope | null} scope
1833
+ * @param {SlotFill} slots
1834
+ */
1835
+ function takeSlotNames(children: SlotChildren, scope: JxScope | null, slots: SlotFill): void {
1836
+ for (const node of slotChildList(children)) {
1837
+ if (isMappedArray(node) || !isNodeObject(node)) {
1838
+ continue;
1839
+ }
1840
+ if (node.tagName === "slot") {
1841
+ const name = resolveStaticValue(node.attributes?.name, scope);
1842
+ slots.taken.add(isString(name) ? name : "");
1843
+ }
1844
+ takeSlotNames(node.children, scope, slots);
1845
+ }
1509
1846
  }
1510
1847
 
1511
1848
  /** One instance on the expansion path: its tag, and its resolved props as an identity. */
@@ -1515,17 +1852,13 @@ interface PrerenderFrame {
1515
1852
  key: string;
1516
1853
  }
1517
1854
 
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;
1855
+ /** What {@link liftPropsAttributes} splits an instance's attributes into. */
1856
+ export interface LiftedPropsAttributes {
1857
+ /** The `props.*` values keyed by prop name, or null when the instance wrote none. */
1858
+ lifted: Record<string, JsonValue> | null;
1859
+ /** Every other attribute, unchanged. */
1860
+ rest: NonNullable<JxElement["attributes"]>;
1861
+ }
1529
1862
 
1530
1863
  /**
1531
1864
  * Lift literal `props.*` attribute keys off an instance's attributes.
@@ -1538,20 +1871,17 @@ export const MAX_COMPONENT_NESTING = 32;
1538
1871
  * definition every page shares.
1539
1872
  *
1540
1873
  * @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
1874
+ * @returns {LiftedPropsAttributes} The lifted props (null when there were none) and the attributes
1875
+ * that remain
1543
1876
  */
1544
- export function liftPropsAttributes(attributes: JxElement["attributes"]): {
1545
- lifted: Record<string, JsonValue> | null;
1546
- rest: NonNullable<JxElement["attributes"]>;
1547
- } {
1877
+ export function liftPropsAttributes(attributes: JxElement["attributes"]): LiftedPropsAttributes {
1548
1878
  let lifted: Record<string, JsonValue> | null = null;
1549
1879
  const rest: NonNullable<JxElement["attributes"]> = {};
1550
1880
  for (const [key, value] of Object.entries(attributes ?? {})) {
1551
1881
  if (key.startsWith("props.") && key.length > "props.".length) {
1552
1882
  lifted ??= {};
1553
- // JxAttributeValue is JSON-representable (primitives or a $ref object), so the narrowing to
1554
- // JsonValue is sound.
1883
+ /* SAFETY: a JxAttributeValue is a JSON primitive or a `{ $ref }` object with one string
1884
+ member, so it is a JsonValue; only the interface's missing index signature hides that. */
1555
1885
  lifted[key.slice("props.".length)] = value as JsonValue;
1556
1886
  } else {
1557
1887
  rest[key] = value;
@@ -1571,33 +1901,32 @@ export function liftPropsAttributes(attributes: JxElement["attributes"]): {
1571
1901
  *
1572
1902
  * @param {JxElement} doc - Component definition
1573
1903
  * @param {Record<string, JsonValue> | null} props - Instance-specific prop values
1574
- * @returns {Record<string, unknown>}
1904
+ * @returns {JxScope}
1575
1905
  */
1576
1906
  export function buildInstanceScope(
1577
1907
  doc: JxElement,
1578
1908
  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
- }
1909
+ ): JxScope {
1910
+ if (!props) {
1911
+ return buildInitialScope(doc.state ?? {}, null);
1912
+ }
1913
+ const stateDefs = { ...doc.state };
1914
+ for (const [key, value] of Object.entries(props)) {
1915
+ const existing = stateDefs[key];
1916
+ stateDefs[key] =
1917
+ isNonNullObject(existing) && !Array.isArray(existing) && "default" in existing
1918
+ ? { ...existing, default: value }
1919
+ : value;
1597
1920
  }
1598
1921
  return buildInitialScope(stateDefs, null);
1599
1922
  }
1600
1923
 
1924
+ /**
1925
+ * Resolved host declarations by (camelCase) property: each is what its template read from the
1926
+ * instance's scope.
1927
+ */
1928
+ export type ResolvedHostStyle = Record<string, JxScope[string]>;
1929
+
1601
1930
  /**
1602
1931
  * The definition's host `style` entries that depend on the instance, resolved against it.
1603
1932
  *
@@ -1607,26 +1936,22 @@ export function buildInstanceScope(
1607
1936
  * one.
1608
1937
  *
1609
1938
  * @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
1939
+ * @param {JxScope} scope - The instance's scope (`buildInstanceScope`)
1940
+ * @returns {ResolvedHostStyle}
1612
1941
  */
1613
1942
  export function resolveHostStyle(
1614
1943
  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;
1944
+ scope: JxScope,
1945
+ ): ResolvedHostStyle {
1946
+ return Object.fromEntries(
1947
+ Object.entries(style ?? {}).flatMap(([prop, value]) => {
1948
+ if (!isTemplateString(value)) {
1949
+ return [];
1626
1950
  }
1627
- }
1628
- }
1629
- return resolved;
1951
+ const val = resolveStaticValue(value, scope);
1952
+ return val == null ? [] : [[prop, val] as const];
1953
+ }),
1954
+ );
1630
1955
  }
1631
1956
 
1632
1957
  /**
@@ -1638,15 +1963,15 @@ export function resolveHostStyle(
1638
1963
  * the shape spec.md §17.2 recommends when every child is a bare string — prerendered to nothing.
1639
1964
  *
1640
1965
  * @param {JxElement | JxMutableNode} node
1641
- * @param {Record<string, unknown> | null} scope
1642
- * @param {string | null} slotContent
1966
+ * @param {JxScope | null} scope
1967
+ * @param {SlotFill | null} slots
1643
1968
  * @param {ComponentPrerenderContext | null} context
1644
1969
  * @returns {string}
1645
1970
  */
1646
1971
  function renderInner(
1647
1972
  node: JxElement | JxMutableNode,
1648
- scope: Record<string, unknown> | null,
1649
- slotContent: string | null,
1973
+ scope: JxScope | null,
1974
+ slots: SlotFill | null,
1650
1975
  context: ComponentPrerenderContext | null,
1651
1976
  ): string {
1652
1977
  if (node.textContent !== undefined) {
@@ -1655,52 +1980,61 @@ function renderInner(
1655
1980
  }
1656
1981
  if (node.innerHTML) {
1657
1982
  const val = resolveStaticValue(node.innerHTML, scope);
1658
- return val != null ? String(val) : (node.innerHTML as string);
1983
+ return val != null ? String(val) : node.innerHTML;
1659
1984
  }
1660
1985
  if (Array.isArray(node.children)) {
1661
1986
  return node.children
1662
- .map((c: JxElement | JxMutableNode | string) =>
1663
- renderStaticNode(c, scope, slotContent, context),
1664
- )
1987
+ .map((c: JxElement | JxMutableNode | string) => renderStaticNode(c, scope, slots, context))
1665
1988
  .join("\n");
1666
1989
  }
1667
1990
  return "";
1668
1991
  }
1669
1992
 
1993
+ /**
1994
+ * A child slot as the static renderer meets it: a node or a text string, plus the JSON scalars,
1995
+ * holes and nested lists a hand-written `children` array may also carry.
1996
+ */
1997
+ export type StaticChild =
1998
+ | JxElement
1999
+ | JxMutableNode
2000
+ | string
2001
+ | number
2002
+ | boolean
2003
+ | null
2004
+ | undefined
2005
+ | StaticChild[];
2006
+
1670
2007
  /**
1671
2008
  * Recursively render a Jx node tree to static HTML for pre-rendering.
1672
2009
  *
1673
- * @param {JxElement | JxMutableNode | string} node
1674
- * @param {Record<string, unknown>} scope
1675
- * @param {string | null} [slotContent] - HTML to substitute for `<slot>` elements
2010
+ * @param {StaticChild} node
2011
+ * @param {JxScope | null} scope
2012
+ * @param {SlotFill | null} [slots] - The instance's children by slot name; `null` leaves a `<slot>`
2013
+ * literal (a shadow root, or a definition rendered with no instance)
1676
2014
  * @param {ComponentPrerenderContext | null} [context] - The component registry, when a custom
1677
2015
  * element tag met here should be expanded rather than emitted bare
1678
2016
  * @returns {string}
1679
2017
  */
1680
2018
  export function renderStaticNode(
1681
- node: JxElement | JxMutableNode | string,
1682
- scope: Record<string, unknown> | null,
1683
- slotContent: string | null = null,
2019
+ node: StaticChild,
2020
+ scope: JxScope | null,
2021
+ slots: SlotFill | null = null,
1684
2022
  context: ComponentPrerenderContext | null = null,
1685
2023
  ): string {
1686
- if (typeof node === "string") {
2024
+ if (isString(node)) {
1687
2025
  if (isTemplateString(node) && scope) {
1688
2026
  const val = evaluateStaticTemplate(node, scope);
1689
2027
  return val != null ? escapeHtml(String(val)) : escapeHtml(node);
1690
2028
  }
1691
2029
  return escapeHtml(node);
1692
2030
  }
1693
- if (typeof node === "number" || typeof node === "boolean") {
2031
+ if (isNumber(node) || isBoolean(node)) {
1694
2032
  return escapeHtml(String(node));
1695
2033
  }
1696
2034
  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");
2035
+ return node.map((c): string => renderStaticNode(c, scope, slots, context)).join("\n");
1702
2036
  }
1703
- if (!node || typeof node !== "object") {
2037
+ if (!isNonNullObject(node)) {
1704
2038
  return "";
1705
2039
  }
1706
2040
 
@@ -1716,13 +2050,10 @@ export function renderStaticNode(
1716
2050
  const switchTag = node.tagName ?? "div";
1717
2051
  const attrs = buildAttrs(node, scope);
1718
2052
  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)];
2053
+ const caseDef = key == null ? undefined : node.cases?.[String(key)];
1723
2054
  const inner =
1724
2055
  caseDef !== undefined && !isRefObject(caseDef)
1725
- ? renderStaticNode(caseDef, scope, slotContent, context)
2056
+ ? renderStaticNode(caseDef, scope, slots, context)
1726
2057
  : "";
1727
2058
  return `<${switchTag}${attrs}>${inner}</${switchTag}>`;
1728
2059
  }
@@ -1733,15 +2064,33 @@ export function renderStaticNode(
1733
2064
  state does not exist, so SSR silently emitted the fallback branch's tag. */
1734
2065
  const tag = resolveStaticTagName(node.tagName, scope);
1735
2066
 
1736
- // Replace <slot> with provided slot content
1737
- if (tag === "slot" && slotContent != null) {
1738
- return slotContent;
2067
+ /* A slot is REPLACED by its group (spec.md §8.5), and leaves no node: the first slot of a name
2068
+ takes the group, and the fallback follows only when the group holds no content. A slot inside
2069
+ a fallback that is not rendered still takes its name, as it does in the DOM tiers. */
2070
+ if (tag === "slot" && slots) {
2071
+ const resolved = resolveStaticValue(node.attributes?.name, scope);
2072
+ const name = isString(resolved) ? resolved : "";
2073
+ let group: { html: string; content: boolean } | undefined;
2074
+ if (!slots.taken.has(name)) {
2075
+ slots.taken.add(name);
2076
+ group = slots.groups.get(name);
2077
+ }
2078
+ let out = group?.html ?? "";
2079
+ if (out !== "" && slots.id !== null) {
2080
+ out = `<!--${SLOT_MARK} ${slots.id} ${encodeURIComponent(name)}-->${out}<!--/${SLOT_MARK} ${slots.id}-->`;
2081
+ }
2082
+ if (group?.content) {
2083
+ takeSlotNames(node.children, scope, slots);
2084
+ return out;
2085
+ }
2086
+ const fallback = renderInner(node, scope, slots, context);
2087
+ return out === "" ? fallback : fallback === "" ? out : `${out}\n${fallback}`;
1739
2088
  }
1740
2089
 
1741
2090
  // A registered component: expanded exactly as the page walk expands one, not emitted bare.
1742
2091
  const def = context?.componentDefs.get(tag);
1743
2092
  if (def && context) {
1744
- return renderComponentInstance(node, def, scope, slotContent, context);
2093
+ return renderComponentInstance(node, def, scope, slots, context);
1745
2094
  }
1746
2095
 
1747
2096
  const attrs = buildAttrs(node, scope);
@@ -1750,7 +2099,7 @@ export function renderStaticNode(
1750
2099
  return `<${tag}${attrs}>`;
1751
2100
  }
1752
2101
 
1753
- return `<${tag}${attrs}>${renderInner(node, scope, slotContent, context)}</${tag}>`;
2102
+ return `<${tag}${attrs}>${renderInner(node, scope, slots, context)}</${tag}>`;
1754
2103
  }
1755
2104
 
1756
2105
  /**
@@ -1761,7 +2110,10 @@ export function renderStaticNode(
1761
2110
  * scope, which is what `resolveDocTemplates` does for a page-level instance before the page walk
1762
2111
  * reaches it.
1763
2112
  * - Slot content: the instance's own children, rendered in the parent's scope and with the parent's
1764
- * slot content, so a `<slot>` written among them passes the grandparent's through.
2113
+ * fill, so a `<slot>` written among them passes the grandparent's group of that name through, and
2114
+ * placed in the definition's slots by name (`createSlotFill`, spec.md §8.5). A live instance
2115
+ * whose definition has a slot carries `data-jx-slots` and brackets each group, so its element
2116
+ * recovers what it was given when it upgrades (`captureSlotted` in the runtime).
1765
2117
  * - Shadow mode: a declarative shadow root with the stylesheet link inside it (spec.md §16.6).
1766
2118
  * - Host style: the definition's template-string entries resolved against the INSTANCE, written on
1767
2119
  * the element — the page walk's `resolvedStyle` in inline form, since there is no page stylesheet
@@ -1775,8 +2127,8 @@ export function renderStaticNode(
1775
2127
  *
1776
2128
  * @param {JxElement | JxMutableNode} node - The instance as written in the parent's tree
1777
2129
  * @param {JxElement} def - The registered definition for its tag
1778
- * @param {Record<string, unknown> | null} scope - The parent's scope
1779
- * @param {string | null} slotContent - The parent's slot content
2130
+ * @param {JxScope | null} scope - The parent's scope
2131
+ * @param {SlotFill | null} parentSlots - The parent's fill
1780
2132
  * @param {ComponentPrerenderContext} context
1781
2133
  * @returns {string}
1782
2134
  * @docs framework/build
@@ -1784,15 +2136,20 @@ export function renderStaticNode(
1784
2136
  function renderComponentInstance(
1785
2137
  node: JxElement | JxMutableNode,
1786
2138
  def: JxElement,
1787
- scope: Record<string, unknown> | null,
1788
- slotContent: string | null,
2139
+ scope: JxScope | null,
2140
+ parentSlots: SlotFill | null,
1789
2141
  context: ComponentPrerenderContext,
1790
2142
  ): string {
2143
+ /* SAFETY: the caller looked `def` up in `componentDefs` by the node's resolved string tag, and
2144
+ that registry is keyed by each definition's own `tagName`, so this is that same string. */
1791
2145
  const tag = def.tagName as string;
1792
2146
  const { lifted, rest: attributes } = liftPropsAttributes(node.attributes);
1793
2147
  const written = { ...lifted, ...node.$props };
1794
2148
  const props: Record<string, JsonValue> = {};
1795
2149
  for (const [key, value] of Object.entries(written)) {
2150
+ /* SAFETY: a prop resolves against the parent's build-time scope, where what a `$ref` or template
2151
+ may read is JSON state, and the props travel on as JSON either way (`data-jx-props` below), as
2152
+ the page walk serializes a page-level instance's. */
1796
2153
  props[key] = (resolveStaticValue(value, scope) as JsonValue | null) ?? value;
1797
2154
  }
1798
2155
  const hasProps = Object.keys(props).length > 0;
@@ -1814,25 +2171,29 @@ function renderComponentInstance(
1814
2171
  }
1815
2172
  const nested: ComponentPrerenderContext = { ...context, path: [...path, frame] };
1816
2173
 
1817
- // The instance's children are the parent's nodes: parent scope, parent slot, parent path.
1818
- const slot =
1819
- Array.isArray(node.children) && node.children.length > 0
1820
- ? node.children
1821
- .map((c: JxElement | JxMutableNode | string) =>
1822
- renderStaticNode(c, scope, slotContent, context),
1823
- )
1824
- .join("\n")
1825
- : null;
2174
+ // The instance's children are the parent's nodes: parent scope, parent fill, parent path.
2175
+ const { children } = node;
2176
+ const renderChild = (c: StaticChild) => renderStaticNode(c, scope, parentSlots, context);
1826
2177
 
1827
2178
  const shadow = resolveShadowMode(def, context.defaults);
1828
2179
  const isStatic = isComponentFullyStatic(def);
1829
2180
  const instanceScope = buildInstanceScope(def, hasProps ? props : null);
1830
- const inner = renderInner(def, instanceScope, shadow ? null : slot, nested);
1831
- const innerHTML = shadow
1832
- ? `<template shadowrootmode="${shadow}">` +
2181
+ let innerHTML: string;
2182
+ let slotId: number | null = null;
2183
+ if (shadow) {
2184
+ // The light children stay siblings of the template, where a real `<slot>` distributes them.
2185
+ const light = Array.isArray(children)
2186
+ ? children.map((c: StaticChild) => renderChild(c)).join("\n")
2187
+ : "";
2188
+ innerHTML =
2189
+ `<template shadowrootmode="${shadow}">` +
1833
2190
  `<link rel="stylesheet" href="/components/${tag}.css">` +
1834
- `${inner}</template>${slot ?? ""}`
1835
- : inner;
2191
+ `${renderInner(def, instanceScope, null, nested)}</template>${light}`;
2192
+ } else {
2193
+ slotId = !isStatic && definitionHasSlot(def.children) ? nextSlotId(context) : null;
2194
+ const fill = createSlotFill(children, scope, renderChild, slotId);
2195
+ innerHTML = renderInner(def, instanceScope, fill, nested);
2196
+ }
1836
2197
 
1837
2198
  const hostStyle = Object.entries(resolveHostStyle(def.style, instanceScope))
1838
2199
  .map(([prop, value]) => `${camelToKebab(prop)}: ${value}`)
@@ -1840,11 +2201,14 @@ function renderComponentInstance(
1840
2201
 
1841
2202
  // The element as the page walk would leave it: props consumed, children rendered, stamped. A
1842
2203
  // Copy, because `node` is a definition every page shares.
1843
- const instance: JxElement = { ...(node as JxElement) };
2204
+ const instance = { ...node };
1844
2205
  delete instance.$props;
1845
2206
  delete instance.children;
1846
2207
  instance.attributes =
1847
2208
  !isStatic && hasProps ? { ...attributes, "data-jx-props": JSON.stringify(props) } : attributes;
2209
+ if (slotId !== null) {
2210
+ instance.attributes = { ...instance.attributes, [SLOT_ATTR]: String(slotId) };
2211
+ }
1848
2212
  if (isStatic) {
1849
2213
  instance.$static = true;
1850
2214
  } else {
@@ -1859,7 +2223,8 @@ function renderComponentInstance(
1859
2223
  * @param {JxElement} doc - Component JSON definition
1860
2224
  * @param {Record<string, JsonValue> | null} [propsOverride] - Instance-specific prop values to
1861
2225
  * merge into state
1862
- * @param {string | null} [slotContent] - HTML to substitute for `<slot>` elements
2226
+ * @param {SlotFill | null} [slots] - The instance's children by slot name (`createSlotFill`);
2227
+ * `null` leaves every `<slot>` literal
1863
2228
  * @param {ComponentPrerenderContext | null} [context] - The component registry; with one, an
1864
2229
  * instance of a registered component inside `doc` is expanded rather than emitted bare
1865
2230
  * @returns {string} The pre-rendered innerHTML
@@ -1867,10 +2232,10 @@ function renderComponentInstance(
1867
2232
  export function preRenderComponentHtml(
1868
2233
  doc: JxElement,
1869
2234
  propsOverride: Record<string, JsonValue> | null = null,
1870
- slotContent: string | null = null,
2235
+ slots: SlotFill | null = null,
1871
2236
  context: ComponentPrerenderContext | null = null,
1872
2237
  ) {
1873
- return renderInner(doc, buildInstanceScope(doc, propsOverride), slotContent, context);
2238
+ return renderInner(doc, buildInstanceScope(doc, propsOverride), slots, context);
1874
2239
  }
1875
2240
 
1876
2241
  /**
@@ -1891,7 +2256,7 @@ export function isComponentFullyStatic(doc: JxElement) {
1891
2256
  * @returns {boolean}
1892
2257
  */
1893
2258
  function _isStaticNode(node: JxElement | string | (JxElement | string)[]): boolean {
1894
- if (!node || typeof node !== "object") {
2259
+ if (!isNonNullObject(node)) {
1895
2260
  return true;
1896
2261
  }
1897
2262
  if (Array.isArray(node)) {
@@ -1910,14 +2275,13 @@ function _isStaticNode(node: JxElement | string | (JxElement | string)[]): boole
1910
2275
  // Check state entries
1911
2276
  if (node.state) {
1912
2277
  for (const def of Object.values(node.state)) {
1913
- if (!def || typeof def !== "object") {
2278
+ if (!isNonNullObject(def) || Array.isArray(def)) {
1914
2279
  continue;
1915
2280
  }
1916
- const d = def as JxMutableNode;
1917
- if (d.$prototype) {
2281
+ if (def.$prototype) {
1918
2282
  return false;
1919
2283
  }
1920
- if (d.$ref) {
2284
+ if (def.$ref) {
1921
2285
  return false;
1922
2286
  }
1923
2287
  }
@@ -1934,8 +2298,8 @@ function _isStaticNode(node: JxElement | string | (JxElement | string)[]): boole
1934
2298
  if (Array.isArray(node.children)) {
1935
2299
  return node.children.every((c) => _isStaticNode(c));
1936
2300
  }
1937
- // Children descriptor object ($prototype: "Array", etc.)
1938
- if (node.children && typeof node.children === "object" && node.children.$prototype) {
2301
+ // The legacy bare children slot: a mapped array builds its children at runtime.
2302
+ if (isMappedArray(node.children)) {
1939
2303
  return false;
1940
2304
  }
1941
2305
 
@@ -2015,7 +2379,7 @@ export function buildComponentCSS(
2015
2379
  */
2016
2380
  const scope = styleScopePrefix(tagName, shadow);
2017
2381
 
2018
- if (styleDef && typeof styleDef === "object") {
2382
+ if (isNonNullObject(styleDef)) {
2019
2383
  /* The top level is walked here rather than handed straight to the builder, because ONE of its
2020
2384
  keys does not mean what `resolveNestedSelector` would make of it: `:host` and `:host(.foo)`
2021
2385
  are TRANSLATED per {@link resolveSelector} so a style object means the same thing in both
@@ -2030,8 +2394,8 @@ export function buildComponentCSS(
2030
2394
  not an at-rule, not a nested selector — and was dropped without reaching the builder at
2031
2395
  all, so the one emitter that could have reported it never saw it. */
2032
2396
  if (isRef(value)) {
2033
- own[prop] = value as never;
2034
- } else if (value !== null && typeof value === "object" && !Array.isArray(value)) {
2397
+ own[prop] = value;
2398
+ } else if (isNonNullObject(value) && !Array.isArray(value)) {
2035
2399
  blocks.push([prop, value]);
2036
2400
  } else if (!prop.startsWith("@") && !isNestedSelectorKey(prop)) {
2037
2401
  own[prop] = value;