@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
@@ -28,30 +28,46 @@ import {
28
28
  bodyReturnsValue,
29
29
  hasStructuredBody,
30
30
  isExpressionDef,
31
+ isBoolean,
31
32
  isFunctionDef,
32
33
  isMappedArray,
33
34
  isNamedFormulaDef,
35
+ isNonNullObject,
36
+ isNumber,
37
+ isPrototypeDef,
34
38
  isRef,
39
+ isString,
35
40
  isTagExpression,
36
41
  paramNames,
37
42
  } from "@jxsuite/schema/guards";
38
43
  import { parseJxDocument } from "@jxsuite/schema/parse";
39
- import type { ExpressionNode } from "../shared.ts";
40
44
  import { resolveShadowMode } from "../shadow.ts";
41
45
  import type { ShadowMode } from "../shadow.ts";
42
46
  import type { FormatRegistry } from "@jxsuite/schema/format-registry";
43
47
  import { escapeToken, objectKey, refAccessor, refSegments } from "@jxsuite/runtime/pointer";
44
48
  import type {
49
+ JsonPrimitive,
45
50
  JxDocument,
51
+ JxDocumentValue,
46
52
  JxExpressionDef,
53
+ JxExpressionNode,
47
54
  JxFunctionDef,
48
55
  JxMappedArray,
49
56
  JxMutableNode,
50
57
  JxPrototypeDef,
58
+ JxRef,
59
+ JxStateDefinition,
51
60
  JxStyle,
52
61
  ProjectConfig,
53
62
  } from "@jxsuite/schema/types";
54
63
 
64
+ /**
65
+ * A member of a node's `children` (or a `$switch` case) as a parsed document holds it: an element,
66
+ * a text string, or — since the document is JSON — a number, boolean or `null`, rendered as text or
67
+ * skipped.
68
+ */
69
+ type LitChild = JxMutableNode | JsonPrimitive;
70
+
55
71
  /** Options accepted by compileElement. */
56
72
  export interface CompileElementOptions {
57
73
  /** Resolve an `$elements` ref path to its emitted JS module path. */
@@ -67,7 +83,6 @@ export interface CompileElementOptions {
67
83
  basePath?: string | null;
68
84
  /** Project `defaults`, read for `defaults.shadow` (spec.md §16.6). */
69
85
  defaults?: ProjectConfig["defaults"];
70
- [key: string]: unknown;
71
86
  }
72
87
 
73
88
  /**
@@ -93,7 +108,7 @@ export async function compileElement(
93
108
  async function processElement(srcPath: string | JxDocument, parentDir: string | null) {
94
109
  let doc: JxDocument;
95
110
  let filePath: string | null;
96
- if (typeof srcPath === "string") {
111
+ if (isString(srcPath)) {
97
112
  const { readFileSync } = await import("node:fs");
98
113
  const { resolve } = await import("node:path");
99
114
  filePath = parentDir ? resolve(parentDir, srcPath) : resolve(srcPath);
@@ -106,13 +121,12 @@ export async function compileElement(
106
121
  } else {
107
122
  const { extname } = await import("node:path");
108
123
  const ext = extname(filePath).toLowerCase();
109
- const entry = formats?.byExtension?.(ext, "parse");
124
+ const entry = formats?.byExtension(ext, "parse");
110
125
  if (!entry) {
111
126
  const { unknownFormatError } = await import("../site/format-host.ts");
112
127
  throw unknownFormatError(filePath, ext);
113
128
  }
114
- // Format plugins contractually parse source text into a Jx document.
115
- doc = (await entry.call("parse", readFileSync(filePath, "utf8"))) as JxDocument;
129
+ doc = await entry.parseDocument(readFileSync(filePath, "utf8"));
116
130
  }
117
131
  } else {
118
132
  doc = srcPath;
@@ -138,7 +152,7 @@ export async function compileElement(
138
152
  if (Array.isArray(doc.$elements)) {
139
153
  for (const elRef of doc.$elements) {
140
154
  const refPath = isRef(elRef) ? elRef.$ref : elRef;
141
- if (typeof refPath !== "string") {
155
+ if (!isString(refPath)) {
142
156
  continue;
143
157
  }
144
158
 
@@ -174,12 +188,19 @@ export async function compileElement(
174
188
  return { files };
175
189
  }
176
190
 
191
+ /** Options accepted by compileElementPage: the page shell's, plus everything compileElement takes. */
192
+ export interface CompileElementPageOptions extends CompileElementOptions {
193
+ title?: string;
194
+ reactivitySrc?: string;
195
+ litHtmlSrc?: string;
196
+ }
197
+
177
198
  /**
178
199
  * Compile a Jx custom element document to a complete HTML page with an import map for CDN
179
200
  * dependencies.
180
201
  *
181
- * @param {string | any} sourcePath
182
- * @param {Record<string, unknown>} [opts]
202
+ * @param {string | JxDocument} sourcePath
203
+ * @param {CompileElementPageOptions} [opts]
183
204
  * @returns {Promise<{
184
205
  * html: string;
185
206
  * files: { path: string; content: string; tagName: string }[];
@@ -187,12 +208,7 @@ export async function compileElement(
187
208
  */
188
209
  export async function compileElementPage(
189
210
  sourcePath: string | JxDocument,
190
- opts: {
191
- title?: string;
192
- reactivitySrc?: string;
193
- litHtmlSrc?: string;
194
- [key: string]: unknown;
195
- } = {},
211
+ opts: CompileElementPageOptions = {},
196
212
  ) {
197
213
  const {
198
214
  title = "Jx App",
@@ -240,11 +256,11 @@ export async function compileElementPage(
240
256
  * like { type, default, description } now correctly extract the `default` value instead of dumping
241
257
  * the whole object.
242
258
  *
243
- * @param {JxMutableNode} def
259
+ * @param {JxStateDefinition} def
244
260
  * @returns {string | undefined}
245
261
  */
246
- function extractInitialValue(def: JxMutableNode) {
247
- if (def === null || typeof def !== "object" || Array.isArray(def)) {
262
+ function extractInitialValue(def: JxStateDefinition) {
263
+ if (!isNonNullObject(def) || Array.isArray(def)) {
248
264
  return JSON.stringify(def);
249
265
  }
250
266
  // Expanded signal with explicit default
@@ -286,21 +302,25 @@ function collectCallableRefs(doc: JxDocument) {
286
302
  const keys = new Set<string>();
287
303
  const seen = new Set<object>();
288
304
 
289
- const addStateRef = (ref: unknown) => {
290
- if (typeof ref === "string" && ref.startsWith("#/state/")) {
305
+ const addStateRef = (ref: string) => {
306
+ if (ref.startsWith("#/state/")) {
307
+ /* SAFETY: `String.prototype.split` always returns at least one element (the whole string
308
+ when the separator is absent), so index 0 is present. */
291
309
  keys.add(ref.slice("#/state/".length).split("/")[0] as string);
292
310
  }
293
311
  };
294
312
 
295
- const visit = (node: unknown) => {
296
- if (typeof node === "string") {
313
+ const visit = (node: JxDocumentValue | undefined) => {
314
+ if (isString(node)) {
297
315
  // `${state.formatDate(d)}` in a template, or `state.helper(x)` in another function's body.
298
316
  for (const call of node.matchAll(/\bstate\.([A-Za-z_$][\w$]*)\s*\(/g)) {
317
+ /* SAFETY: the pattern's one capture group is not optional, so every match fills
318
+ index 1 with the called member's name. */
299
319
  keys.add(call[1] as string);
300
320
  }
301
321
  return;
302
322
  }
303
- if (!node || typeof node !== "object" || seen.has(node)) {
323
+ if (!isNonNullObject(node) || seen.has(node)) {
304
324
  return;
305
325
  }
306
326
  seen.add(node);
@@ -310,10 +330,13 @@ function collectCallableRefs(doc: JxDocument) {
310
330
  }
311
331
  return;
312
332
  }
313
- const record = node as Record<string, unknown>;
333
+ /* SAFETY: `node` is a non-null, non-array object reached from `doc.children` or `doc.state`,
334
+ and every such object in a parsed Jx document is a JSON object whose members are document
335
+ values; the view only reads members, and each is checked before use. */
336
+ const record = node as Readonly<Record<string, JxDocumentValue | undefined>>;
314
337
  // An `$expression` `call` node names its callee in `target` (spec.md §19.4c).
315
338
  if (record.operator === "call" && isRef(record.target)) {
316
- addStateRef((record.target as { $ref: string }).$ref);
339
+ addStateRef(record.target.$ref);
317
340
  }
318
341
  for (const [key, val] of Object.entries(record)) {
319
342
  if (key.startsWith("on") && key !== "observedAttributes" && isRef(val)) {
@@ -360,10 +383,9 @@ export function emitElementModule(
360
383
  const defs = doc.state ?? {};
361
384
  const callableRefs = collectCallableRefs(doc);
362
385
  for (const [key, def] of Object.entries(defs)) {
363
- const d = def as JxMutableNode;
364
- if (d && typeof d === "object" && !Array.isArray(d) && d.$prototype === "Function" && d.$src) {
365
- const srcPath = d.$src;
366
- if (d.$lazy === true) {
386
+ if (isFunctionDef(def) && def.$src) {
387
+ const srcPath = def.$src;
388
+ if (def.$lazy === true) {
367
389
  /*
368
390
  * A computed reads its value synchronously, and a lazy binding can only ever hand back a
369
391
  * promise. Silently producing `computed(() => Promise)` would render "[object Promise]" in
@@ -376,19 +398,20 @@ export function emitElementModule(
376
398
  `Remove "$lazy", or call ${key}() from a handler instead of binding its value.`,
377
399
  );
378
400
  }
379
- if (!lazySrcMap.has(srcPath)) {
380
- lazySrcMap.set(srcPath, []);
401
+ let lazyBindings = lazySrcMap.get(srcPath);
402
+ if (!lazyBindings) {
403
+ lazyBindings = [];
404
+ lazySrcMap.set(srcPath, lazyBindings);
381
405
  }
382
- (lazySrcMap.get(srcPath) as { binding: string; exportName: string }[]).push({
383
- binding: key,
384
- exportName: srcExportName(key, d),
385
- });
406
+ lazyBindings.push({ binding: key, exportName: srcExportName(key, def) });
386
407
  continue;
387
408
  }
388
- if (!srcImportMap.has(srcPath)) {
389
- srcImportMap.set(srcPath, []);
409
+ let bindings = srcImportMap.get(srcPath);
410
+ if (!bindings) {
411
+ bindings = [];
412
+ srcImportMap.set(srcPath, bindings);
390
413
  }
391
- (srcImportMap.get(srcPath) as string[]).push(srcImportBinding(key, d));
414
+ bindings.push(srcImportBinding(key, def));
392
415
  }
393
416
  }
394
417
  for (const [srcPath, names] of srcImportMap) {
@@ -401,10 +424,9 @@ export function emitElementModule(
401
424
  // `$prototype: "Request"` entries each get an auto-fetch effect in connectedCallback, plus the
402
425
  // Machinery to stop those effects again on disconnect. Both are emitted only when one exists, so
403
426
  // Documents without a Request produce exactly the same module as before.
404
- const requestEntries = Object.entries(defs).filter(([, def]) => {
405
- const d = def as JxMutableNode;
406
- return Boolean(d) && typeof d === "object" && !Array.isArray(d) && d.$prototype === "Request";
407
- });
427
+ const requestEntries = Object.entries(defs).flatMap(([key, def]): [string, JxPrototypeDef][] =>
428
+ isPrototypeDef(def) && def.$prototype === "Request" ? [[key, def]] : [],
429
+ );
408
430
 
409
431
  lines.push(
410
432
  `import { reactive, computed, effect, stop } from '@vue/reactivity';`,
@@ -460,32 +482,30 @@ export function emitElementModule(
460
482
  /** `"${…}"` state entries — computed, exactly as the runtime treats them. */
461
483
  const templateEntries: [string, string][] = [];
462
484
  for (const [key, def] of Object.entries(defs)) {
463
- const d = def as JxMutableNode;
464
- if (isExpressionDef(d)) {
465
- const node = d.$expression as ExpressionNode;
466
- if (isNamedFormulaDef(d)) {
467
- formulaEntries.push([key, d]);
468
- } else if (isMutating(node.operator)) {
469
- functionEntries.push([key, d]);
485
+ if (isExpressionDef(def)) {
486
+ if (isNamedFormulaDef(def)) {
487
+ formulaEntries.push([key, def]);
488
+ } else if (isMutating(def.$expression.operator)) {
489
+ functionEntries.push([key, def]);
470
490
  } else {
471
- computedEntries.push([key, d]);
491
+ computedEntries.push([key, def]);
472
492
  }
473
- } else if (isFunctionDef(d)) {
474
- if (d.$src && typeof d.body !== "string") {
493
+ } else if (isFunctionDef(def)) {
494
+ if (def.$src && !isString(def.body)) {
475
495
  // Bodyless `$src` entry — classify by document usage, not by an absent body. `body` and
476
496
  // `$src` are mutually exclusive (spec.md §5.3 4d), so this is the only shape a
477
497
  // Spec-conformant external Function takes.
478
498
  if (callableRefs.has(key) || LIFECYCLE_KEYS.has(key)) {
479
- functionEntries.push([key, d]);
499
+ functionEntries.push([key, def]);
480
500
  } else {
481
- computedEntries.push([key, d]);
501
+ computedEntries.push([key, def]);
482
502
  }
483
- } else if (typeof d.body === "string" && bodyReturnsValue(d.body)) {
484
- computedEntries.push([key, d]);
503
+ } else if (isString(def.body) && bodyReturnsValue(def.body)) {
504
+ computedEntries.push([key, def]);
485
505
  } else {
486
- functionEntries.push([key, d]);
506
+ functionEntries.push([key, def]);
487
507
  }
488
- } else if (typeof def === "string" && def.includes("${")) {
508
+ } else if (isString(def) && def.includes("${")) {
489
509
  /* A TEMPLATE STRING IS A COMPUTED, and the runtime has always said so — `runtime.ts`'s
490
510
  second state pass is `if (typeof def === "string" && def.includes("${")) state[key] =
491
511
  computed(() => evaluateTemplate(def, state))`, and `StateEntry`'s own schema description
@@ -501,7 +521,7 @@ export function emitElementModule(
501
521
  templateEntries.push([key, def]);
502
522
  } else {
503
523
  // Use extractInitialValue to get the correct initial value
504
- const initVal = extractInitialValue(d);
524
+ const initVal = extractInitialValue(def);
505
525
  if (initVal !== undefined) {
506
526
  stateEntries.push([key, initVal]);
507
527
  }
@@ -523,7 +543,7 @@ export function emitElementModule(
523
543
  for (const [key, def] of functionEntries) {
524
544
  lines.push("");
525
545
  if (isExpressionDef(def)) {
526
- const compiled = compileExpression(def.$expression as ExpressionNode, {
546
+ const compiled = compileExpression(def.$expression, {
527
547
  eventParam: "e",
528
548
  statePrefix: "s",
529
549
  });
@@ -547,7 +567,7 @@ export function emitElementModule(
547
567
  } else {
548
568
  const args = def.parameters ? paramNames(def.parameters) : (def.arguments ?? ["state"]);
549
569
  const paramList = args.join(", ");
550
- const body = typeof def.body === "string" ? def.body : "";
570
+ const body = isString(def.body) ? def.body : "";
551
571
  // Every call site invokes a state function as `(state, event)` — the template's
552
572
  // `s.fn(s, e)`, `onMount`, `onUnmount`. Declared names that already start with `state` line
553
573
  // Up positionally, so they are emitted directly. Any other declaration — including the
@@ -591,7 +611,7 @@ export function emitElementModule(
591
611
  // Emit named formulas — scope callables mapping positional args onto declared parameters
592
612
  for (const [key, def] of formulaEntries) {
593
613
  lines.push("");
594
- const compiled = compileExpression(def.$expression as ExpressionNode, {
614
+ const compiled = compileExpression(def.$expression, {
595
615
  eventParam: "e",
596
616
  statePrefix: "this.state",
597
617
  });
@@ -614,7 +634,7 @@ export function emitElementModule(
614
634
  for (const [key, def] of computedEntries) {
615
635
  lines.push("");
616
636
  if (isExpressionDef(def)) {
617
- const compiled = compileExpression(def.$expression as ExpressionNode, {
637
+ const compiled = compileExpression(def.$expression, {
618
638
  eventParam: "e",
619
639
  statePrefix: "this.state",
620
640
  });
@@ -627,10 +647,7 @@ export function emitElementModule(
627
647
  );
628
648
  } else {
629
649
  lines.push(` ${refAccessor("this.state", escapeToken(key))} = computed(() => {`);
630
- const body = (typeof def.body === "string" ? def.body : "").replaceAll(
631
- "state.",
632
- "this.state.",
633
- );
650
+ const body = (isString(def.body) ? def.body : "").replaceAll("state.", "this.state.");
634
651
  lines.push(` ${body}`, " });");
635
652
  }
636
653
  }
@@ -742,14 +759,14 @@ export function emitElementModule(
742
759
  // Keep fetching after removal, and every re-insertion would stack another effect on top.
743
760
  for (const [key, def] of requestEntries) {
744
761
  lines.push(
745
- emitRequestFetch(key, def as unknown as JxPrototypeDef, {
762
+ emitRequestFetch(key, def, {
746
763
  collect: "this.#effects",
747
764
  indent: " ",
748
765
  statePrefix: "this.state",
749
766
  }),
750
767
  );
751
768
  }
752
- if (doc.style && typeof doc.style === "object") {
769
+ if (isNonNullObject(doc.style)) {
753
770
  const dynamicStyles: [string, string][] = [];
754
771
  for (const [prop, value] of Object.entries(doc.style)) {
755
772
  if (
@@ -761,12 +778,9 @@ export function emitElementModule(
761
778
  ) {
762
779
  continue;
763
780
  }
764
- if (value === null || typeof value === "object") {
765
- continue;
766
- }
767
- const cssProp = camelToKebab(prop);
768
- if (typeof value === "string" && value.includes("${")) {
769
- dynamicStyles.push([cssProp, value]);
781
+ // Only a template declaration is dynamic; nested blocks and literal values are static CSS.
782
+ if (isString(value) && value.includes("${")) {
783
+ dynamicStyles.push([camelToKebab(prop), value]);
770
784
  }
771
785
  }
772
786
  if (dynamicStyles.length > 0) {
@@ -938,7 +952,7 @@ function emitLitChildren(
938
952
 
939
953
  // Mixed children: elements/text plus array pseudo-elements expanded inline among siblings.
940
954
  return children
941
- .map((child: JxMutableNode | string) =>
955
+ .map((child) =>
942
956
  isMappedArray(child)
943
957
  ? emitMappedArray(child, indent)
944
958
  : emitLitNode(child, preformatted ? "" : indent, preformatted, inMap, scope),
@@ -947,13 +961,13 @@ function emitLitChildren(
947
961
  }
948
962
 
949
963
  /**
950
- * @param {JxMutableNode | string} def
964
+ * @param {LitChild} def
951
965
  * @param {string} indent
952
966
  * @param {boolean} [preformatted]
953
967
  * @returns {string}
954
968
  */
955
969
  function emitLitNode(
956
- def: JxMutableNode | string,
970
+ def: LitChild,
957
971
  indent: string,
958
972
  preformatted = false,
959
973
  inMap = false,
@@ -962,16 +976,16 @@ function emitLitNode(
962
976
  innerOverride: string | null = null,
963
977
  ): string {
964
978
  // String children are text nodes
965
- if (typeof def === "string") {
979
+ if (isString(def)) {
966
980
  if (def.includes("${")) {
967
981
  return `${indent}${toLitTextContent(def)}`;
968
982
  }
969
983
  return `${indent}${escapeHtml(def)}`;
970
984
  }
971
- if (typeof def === "number" || typeof def === "boolean") {
985
+ if (isNumber(def) || isBoolean(def)) {
972
986
  return `${indent}${escapeHtml(String(def))}`;
973
987
  }
974
- if (!def || typeof def !== "object") {
988
+ if (!isNonNullObject(def)) {
975
989
  return "";
976
990
  }
977
991
 
@@ -992,7 +1006,7 @@ function emitLitNode(
992
1006
  eventParam: "e",
993
1007
  statePrefix: "s",
994
1008
  });
995
- const branches =
1009
+ const branches: [string | boolean, string][] =
996
1010
  expression.operator === "?:"
997
1011
  ? [
998
1012
  [true, expression.value],
@@ -1002,7 +1016,7 @@ function emitLitNode(
1002
1016
  // The inner content is identical across candidates — only the tag differs — so it is emitted
1003
1017
  // Once and every branch references it. Skipped when the candidates disagree about being
1004
1018
  // Preformatted, because `white-space` decides how the subtree itself is indented.
1005
- const candidateTags = branches.map(([, candidate]) => String(candidate));
1019
+ const candidateTags = branches.map(([, candidate]) => candidate);
1006
1020
  const sharedPre = candidateTags.map((t) => preformatted || PREFORMATTED_TAGS.has(t));
1007
1021
  let innerRef: string | null = null;
1008
1022
  if (scope && sharedPre.every((p) => p === sharedPre[0])) {
@@ -1011,8 +1025,8 @@ function emitLitNode(
1011
1025
  innerRef = hoistSubtree(scope, sharedInner);
1012
1026
  }
1013
1027
  }
1014
- const rendered: [string | boolean | undefined, string][] = branches.map(([key, candidate]) => {
1015
- const asLiteral = { ...def, tagName: candidate as string };
1028
+ const rendered: [string | boolean, string][] = branches.map(([key, candidate]) => {
1029
+ const asLiteral = { ...def, tagName: candidate };
1016
1030
  return [key, emitLitNode(asLiteral, `${indent} `, preformatted, inMap, scope, innerRef)];
1017
1031
  });
1018
1032
  if (expression.operator === "?:") {
@@ -1026,9 +1040,9 @@ ${yes}
1026
1040
  ${no}
1027
1041
  \`}`;
1028
1042
  }
1029
- const entries = rendered
1030
- .filter(([key]) => key !== "__default__")
1031
- .map(([key, text]) => ` ${JSON.stringify(key)}: html\`\n${text}\n \``);
1043
+ const entries = rendered.flatMap(([key, text]) =>
1044
+ key === "__default__" ? [] : [` ${JSON.stringify(key)}: html\`\n${text}\n \``],
1045
+ );
1032
1046
  const [, fallback] = rendered.at(-1)!;
1033
1047
  return (
1034
1048
  `${indent}${"$"}{{\n${entries.join(",\n")}\n}` +
@@ -1049,17 +1063,17 @@ ${no}
1049
1063
  §8.3). A LITERAL boolean is decided now; a BOUND one cannot be, because its resolved type
1050
1064
  is not known until the effect runs — so it goes through the emitted helper, whose `null`
1051
1065
  becomes lit's `nothing` and takes the attribute off. */
1052
- if (val && typeof val === "object" && isRef(val)) {
1066
+ if (isRef(val)) {
1053
1067
  parts.push(`${key}="\${${ATTR_HELPER}('${key}', ${refToExpr(val.$ref)}) ?? nothing}"`);
1054
- } else if (typeof val === "string" && isWholeTemplate(val)) {
1068
+ } else if (isString(val) && isWholeTemplate(val)) {
1055
1069
  parts.push(
1056
1070
  `${key}="\${${ATTR_HELPER}('${key}', ${toLitExpr(val).slice(2, -1)}) ?? nothing}"`,
1057
1071
  );
1058
- } else if (typeof val === "string" && val.includes("${")) {
1072
+ } else if (isString(val) && val.includes("${")) {
1059
1073
  /* A template with literal text around it — `"page ${n}"` — is a string by construction, so
1060
1074
  there is no boolean to decide and wrapping it would only add a call. */
1061
1075
  parts.push(`${key}="${toLitExpr(val)}"`);
1062
- } else if (typeof val === "boolean") {
1076
+ } else if (isBoolean(val)) {
1063
1077
  const text = booleanAttrValue(key, val);
1064
1078
  if (text !== null) {
1065
1079
  parts.push(text === "" ? key : `${key}="${text}"`);
@@ -1097,9 +1111,9 @@ ${no}
1097
1111
  continue;
1098
1112
  }
1099
1113
 
1100
- if (val && typeof val === "object" && (val as JxMutableNode).$ref) {
1101
- parts.push(`.${key}="\${${refToExpr((val as JxMutableNode).$ref as string)}}"`);
1102
- } else if (typeof val === "string" && val.includes("${")) {
1114
+ if (isBoundRef(val)) {
1115
+ parts.push(`.${key}="\${${refToExpr(val.$ref)}}"`);
1116
+ } else if (isString(val) && val.includes("${")) {
1103
1117
  parts.push(`.${key}="${toLitExpr(val)}"`);
1104
1118
  } else {
1105
1119
  // A literal IDL value (`value: "a"`, `checked: true`) is still a property the author asked
@@ -1112,9 +1126,9 @@ ${no}
1112
1126
 
1113
1127
  if (def.$props) {
1114
1128
  for (const [key, val] of Object.entries(def.$props)) {
1115
- if (val && typeof val === "object" && (val as JxMutableNode).$ref) {
1116
- parts.push(`.${key}="\${${refToExpr((val as JxMutableNode).$ref as string)}}"`);
1117
- } else if (typeof val === "string" && val.includes("${")) {
1129
+ if (isBoundRef(val)) {
1130
+ parts.push(`.${key}="\${${refToExpr(val.$ref)}}"`);
1131
+ } else if (isString(val) && val.includes("${")) {
1118
1132
  // A template value is a binding, not text. JSON-quoting it emitted the template's own source
1119
1133
  // As the prop value (`.label="${"${state.x}"}"`), so the component received the literal
1120
1134
  // String `${state.x}` forever.
@@ -1134,20 +1148,20 @@ ${no}
1134
1148
  continue;
1135
1149
  }
1136
1150
  const eventName = key.slice(2).toLowerCase();
1137
- if (val && typeof val === "object" && (val as JxMutableNode).$ref) {
1151
+ if (isBoundRef(val)) {
1138
1152
  parts.push(
1139
1153
  mapCtx
1140
- ? `@${eventName}="\${(e) => { ${mapCtx}${refToExpr((val as JxMutableNode).$ref as string)}(s, e); }}"`
1141
- : `@${eventName}="\${(e) => ${refToExpr((val as JxMutableNode).$ref as string)}(s, e)}"`,
1142
- );
1143
- } else if (val && typeof val === "object" && "$expression" in /** @type {any} */ val) {
1144
- const compiled = compileExpression(
1145
- (val as Record<string, unknown>).$expression as ExpressionNode,
1146
- {
1147
- eventParam: "e",
1148
- statePrefix: "s",
1149
- },
1154
+ ? `@${eventName}="\${(e) => { ${mapCtx}${refToExpr(val.$ref)}(s, e); }}"`
1155
+ : `@${eventName}="\${(e) => ${refToExpr(val.$ref)}(s, e)}"`,
1150
1156
  );
1157
+ } else if (isNonNullObject(val) && "$expression" in val) {
1158
+ /* SAFETY: an `on*` binding object carrying `$expression` is a declarative handler, and the
1159
+ document schema types that member as an expression node (spec.md §19). It is passed on
1160
+ unvalidated, exactly as authored, so a malformed node fails inside compileExpression. */
1161
+ const compiled = compileExpression(val.$expression as JxExpressionNode, {
1162
+ eventParam: "e",
1163
+ statePrefix: "s",
1164
+ });
1151
1165
  parts.push(`@${eventName}="\${(e) => { ${mapCtx}${compiled}; }}"`);
1152
1166
  } else if (isFunctionDef(val)) {
1153
1167
  parts.push(`@${eventName}="\${(e) => { ${mapCtx}${inlineHandlerBody(val)} }}"`);
@@ -1195,11 +1209,11 @@ function emitNodeInner(
1195
1209
  scope: HoistScope | null,
1196
1210
  ): string {
1197
1211
  if (def.$switch) {
1198
- const switchRef = isRef(def.$switch) ? def.$switch.$ref : (def.$switch as string);
1212
+ const switchRef = isRef(def.$switch) ? def.$switch.$ref : def.$switch;
1199
1213
  const switchExpr = refToExpr(switchRef);
1200
- const cases = (def.cases ?? {}) as Record<string, JxMutableNode>;
1201
1214
  const rendered: [string, string][] = [];
1202
- for (const [key, caseDef] of Object.entries(cases)) {
1215
+ // A case value is document data, so a `null` or empty one can reach here and is skipped.
1216
+ for (const [key, caseDef] of Object.entries<LitChild>(def.cases ?? {})) {
1203
1217
  if (!caseDef || isRef(caseDef)) {
1204
1218
  continue;
1205
1219
  }
@@ -1207,10 +1221,9 @@ function emitNodeInner(
1207
1221
  }
1208
1222
  // Cases that emit byte-identical subtrees share one hoisted `const` rather than repeating it
1209
1223
  // Once per case — the authored document names the subtree once (#126).
1224
+ const texts = rendered.map(([, text]) => text);
1210
1225
  const repeated = new Set(
1211
- rendered
1212
- .map(([, text]) => text)
1213
- .filter((text, i, all) => all.indexOf(text) !== i && text.trim() !== ""),
1226
+ texts.filter((text, i) => texts.indexOf(text) !== i && text.trim() !== ""),
1214
1227
  );
1215
1228
  const caseEntries = rendered.map(([key, text]) => {
1216
1229
  const body = scope && repeated.has(text) ? hoistSubtree(scope, text) : text;
@@ -1252,9 +1265,9 @@ function emitMappedArray(arrayDef: JxMappedArray, indent: string) {
1252
1265
  // `${item…}`/`${index…}` templates resolve against the map callback's own parameters.
1253
1266
  if (mapDef.attributes) {
1254
1267
  for (const [key, val] of Object.entries(mapDef.attributes)) {
1255
- if (val && typeof val === "object" && isRef(val)) {
1268
+ if (isRef(val)) {
1256
1269
  parts.push(`${key}="\${${mapRefToExpr(val.$ref)}}"`);
1257
- } else if (typeof val === "string" && val.includes("${")) {
1270
+ } else if (isString(val) && val.includes("${")) {
1258
1271
  parts.push(`${key}="${toLitExpr(val)}"`);
1259
1272
  } else {
1260
1273
  parts.push(`${key}="${val}"`);
@@ -1290,9 +1303,9 @@ function emitMappedArray(arrayDef: JxMappedArray, indent: string) {
1290
1303
  continue;
1291
1304
  }
1292
1305
 
1293
- if (val && typeof val === "object" && (val as JxMutableNode).$ref) {
1294
- parts.push(`.${key}="\${${mapRefToExpr((val as JxMutableNode).$ref as string)}}"`);
1295
- } else if (typeof val === "string" && val.includes("${")) {
1306
+ if (isBoundRef(val)) {
1307
+ parts.push(`.${key}="\${${mapRefToExpr(val.$ref)}}"`);
1308
+ } else if (isString(val) && val.includes("${")) {
1296
1309
  parts.push(`.${key}="${toLitExpr(val)}"`);
1297
1310
  } else {
1298
1311
  parts.push(`.${key}="\${${JSON.stringify(val)}}"`);
@@ -1303,7 +1316,7 @@ function emitMappedArray(arrayDef: JxMappedArray, indent: string) {
1303
1316
  for (const [key, val] of Object.entries(mapDef.$props)) {
1304
1317
  if (isRef(val)) {
1305
1318
  parts.push(`.${key}="\${${mapRefToExpr(val.$ref)}}"`);
1306
- } else if (typeof val === "string" && val.includes("${")) {
1319
+ } else if (isString(val) && val.includes("${")) {
1307
1320
  // `$props: { label: "${$map.item.name}" }` is the ordinary way to pass per-item data to a
1308
1321
  // Component in a list; JSON-quoting it handed over the template source instead.
1309
1322
  parts.push(`.${key}="${toLitExpr(val)}"`);
@@ -1326,7 +1339,7 @@ function emitMappedArray(arrayDef: JxMappedArray, indent: string) {
1326
1339
  if (isRef(val)) {
1327
1340
  parts.push(`@${eventName}="\${(e) => { s.$map = $map; ${refToExpr(val.$ref)}(s, e); }}"`);
1328
1341
  } else if (isExpressionDef(val)) {
1329
- const compiled = compileExpression(val.$expression as ExpressionNode, {
1342
+ const compiled = compileExpression(val.$expression, {
1330
1343
  eventParam: "e",
1331
1344
  statePrefix: "s",
1332
1345
  });
@@ -1366,6 +1379,17 @@ function emitMappedArray(arrayDef: JxMappedArray, indent: string) {
1366
1379
  return `${indent}\${${itemsExpr}.map(${callback})}`;
1367
1380
  }
1368
1381
 
1382
+ /**
1383
+ * Whether a document value binds through a non-empty `$ref` pointer. An empty pointer names no
1384
+ * state, so the emitters treat that object as a literal value rather than a binding.
1385
+ *
1386
+ * @param {JxDocumentValue | undefined} value
1387
+ * @returns {boolean}
1388
+ */
1389
+ function isBoundRef(value: JxDocumentValue | undefined): value is JxRef {
1390
+ return isRef(value) && value.$ref !== "";
1391
+ }
1392
+
1369
1393
  /**
1370
1394
  * Convert a $ref string to a JS expression using `s` (this.state alias).
1371
1395
  *
@@ -1445,19 +1469,15 @@ function isWholeTemplate(str: string): boolean {
1445
1469
  * Convert textContent value to lit-html text content. Bug fix: handles $ref objects, which
1446
1470
  * previously produced [object Object].
1447
1471
  *
1448
- * @param {unknown} value
1472
+ * @param {string | null | JxRef} value
1449
1473
  * @returns {string}
1450
1474
  */
1451
- function toLitTextContent(value: unknown) {
1475
+ function toLitTextContent(value: string | null | JxRef) {
1452
1476
  // Handle $ref objects → emit as lit expression
1453
- if (
1454
- value !== null &&
1455
- typeof value === "object" &&
1456
- typeof (value as JxMutableNode).$ref === "string"
1457
- ) {
1458
- return `\${${refToExpr((value as JxMutableNode).$ref as string)}}`;
1459
- }
1460
- if (typeof value === "string" && value.includes("${")) {
1477
+ if (isRef(value)) {
1478
+ return `\${${refToExpr(value.$ref)}}`;
1479
+ }
1480
+ if (isString(value) && value.includes("${")) {
1461
1481
  return toLitExpr(value);
1462
1482
  }
1463
1483
  return String(value);
@@ -1475,7 +1495,7 @@ function inlineHandlerBody(def: JxFunctionDef) {
1475
1495
  statePrefix: "s",
1476
1496
  });
1477
1497
  }
1478
- const body = typeof def.body === "string" ? def.body : "";
1498
+ const body = isString(def.body) ? def.body : "";
1479
1499
  const declared = def.parameters ? paramNames(def.parameters) : (def.arguments ?? []);
1480
1500
  if (declared.length > 0) {
1481
1501
  // The call site supplies only `(e)`, so map the declared names onto the arguments the handler
@@ -1493,7 +1513,7 @@ function inlineHandlerBody(def: JxFunctionDef) {
1493
1513
  * @returns {string}
1494
1514
  */
1495
1515
  function emitStyleString(styleDef: JxStyle | null | undefined) {
1496
- if (!styleDef || typeof styleDef !== "object") {
1516
+ if (!isNonNullObject(styleDef)) {
1497
1517
  return "";
1498
1518
  }
1499
1519
 
@@ -1509,11 +1529,8 @@ function emitStyleString(styleDef: JxStyle | null | undefined) {
1509
1529
  continue;
1510
1530
  }
1511
1531
 
1512
- if (value === null || typeof value === "object") {
1513
- continue;
1514
- }
1515
-
1516
- if (typeof value === "string" && value.includes("${")) {
1532
+ // Only a template declaration is dynamic; nested blocks and literal values are static CSS.
1533
+ if (isString(value) && value.includes("${")) {
1517
1534
  const cssProp = camelToKebab(prop);
1518
1535
  parts.push(`${cssProp}: ${toLitExpr(value)}`);
1519
1536
  }
@@ -1533,7 +1550,7 @@ function treeHasSlot(children: JxMutableNode["children"]): boolean {
1533
1550
  return false;
1534
1551
  }
1535
1552
  for (const child of children) {
1536
- if (!child || typeof child !== "object") {
1553
+ if (!isNonNullObject(child)) {
1537
1554
  continue;
1538
1555
  }
1539
1556
  if (child.tagName === "slot") {