@jxsuite/compiler 1.5.0 → 2.0.2

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.
@@ -7,15 +7,16 @@
7
7
 
8
8
  import { RESERVED_KEYS, camelToKebab } from "@jxsuite/runtime";
9
9
  import {
10
+ PREFORMATTED_TAGS,
10
11
  collectStyles,
11
12
  compileExpression,
13
+ compileOperandSource,
12
14
  compileStatements,
13
15
  emitFormulaFn,
14
16
  emitRequestFetch,
15
17
  escapeHtml,
16
18
  isMutating,
17
19
  isSchemaOnly,
18
- PREFORMATTED_TAGS,
19
20
  srcImportBinding,
20
21
  tagNameToClassName,
21
22
  } from "../shared.ts";
@@ -27,11 +28,15 @@ import {
27
28
  isMappedArray,
28
29
  isNamedFormulaDef,
29
30
  isRef,
31
+ isTagExpression,
30
32
  paramNames,
31
33
  } from "@jxsuite/schema/guards";
32
34
  import { parseJxDocument } from "@jxsuite/schema/parse";
33
35
  import type { ExpressionNode } from "../shared.ts";
36
+ import { resolveShadowMode } from "../shadow.ts";
37
+ import type { ShadowMode } from "../shadow.ts";
34
38
  import type { FormatRegistry } from "@jxsuite/schema/format-registry";
39
+ import { escapeToken, objectKey, refAccessor, refSegments } from "@jxsuite/runtime/pointer";
35
40
  import type {
36
41
  JxDocument,
37
42
  JxExpressionDef,
@@ -40,6 +45,7 @@ import type {
40
45
  JxMutableNode,
41
46
  JxPrototypeDef,
42
47
  JxStyle,
48
+ ProjectConfig,
43
49
  } from "@jxsuite/schema/types";
44
50
 
45
51
  /** Options accepted by compileElement. */
@@ -55,6 +61,8 @@ export interface CompileElementOptions {
55
61
  $media?: Record<string, string>;
56
62
  formats?: FormatRegistry;
57
63
  basePath?: string | null;
64
+ /** Project `defaults`, read for `defaults.shadow` (spec.md §16.6). */
65
+ defaults?: ProjectConfig["defaults"];
58
66
  [key: string]: unknown;
59
67
  }
60
68
 
@@ -152,6 +160,7 @@ export async function compileElement(
152
160
  className,
153
161
  elementImports,
154
162
  rewriteSrc ? (spec: string) => rewriteSrc(spec, currentDir) : undefined,
163
+ resolveShadowMode(doc, opts.defaults),
155
164
  );
156
165
  const outputPath = filePath ? filePath.replace(/\.json$/, ".js") : `${tagName}.js`;
157
166
  files.push({ content: jsContent, path: outputPath, tagName });
@@ -328,6 +337,7 @@ export function emitElementModule(
328
337
  className: string,
329
338
  elementImports: string[],
330
339
  rewriteSrc?: (specifier: string) => string,
340
+ shadow: ShadowMode | null = null,
331
341
  ) {
332
342
  const lines: string[] = ["// Generated by @jxsuite/compiler — do not edit manually"];
333
343
 
@@ -377,6 +387,7 @@ export function emitElementModule(
377
387
  // And a single list keeps every effect on the same lifecycle instead of leaking the ones nobody
378
388
  // Held a handle to.
379
389
  " #effects = [];",
390
+ ...(shadow === null ? [] : [" #root = null;"]),
380
391
  "",
381
392
  // Constructor: build reactive state
382
393
  " constructor() {",
@@ -388,6 +399,8 @@ export function emitElementModule(
388
399
  const functionEntries: [string, JxExpressionDef | JxFunctionDef][] = [];
389
400
 
390
401
  const formulaEntries: [string, JxExpressionDef][] = [];
402
+ /** `"${…}"` state entries — computed, exactly as the runtime treats them. */
403
+ const templateEntries: [string, string][] = [];
391
404
  const callableRefs = collectCallableRefs(doc);
392
405
  for (const [key, def] of Object.entries(defs)) {
393
406
  const d = def as JxMutableNode;
@@ -415,6 +428,20 @@ export function emitElementModule(
415
428
  } else {
416
429
  functionEntries.push([key, d]);
417
430
  }
431
+ } else if (typeof def === "string" && def.includes("${")) {
432
+ /* A TEMPLATE STRING IS A COMPUTED, and the runtime has always said so — `runtime.ts`'s
433
+ second state pass is `if (typeof def === "string" && def.includes("${")) state[key] =
434
+ computed(() => evaluateTemplate(def, state))`, and `StateEntry`'s own schema description
435
+ reads "string with ${} → computed". This branch did not exist, so the compiler fell
436
+ through to `extractInitialValue` and emitted the template as a LITERAL.
437
+
438
+ The consequence is the worst shape a bug can take: the same component behaved one way in
439
+ Studio's canvas, which runs the runtime, and another way on the deployed site, which runs
440
+ this. A real site's `$switch` discriminant (`imageKey: "${state.image ? 'set' : ''}"`)
441
+ evaluated correctly in the editor and shipped as the literal text of its own expression,
442
+ so the case never matched and the image silently never rendered in production. Found by
443
+ building that site and reading the emitted component. */
444
+ templateEntries.push([key, def]);
418
445
  } else {
419
446
  // Use extractInitialValue to get the correct initial value
420
447
  const initVal = extractInitialValue(d);
@@ -427,7 +454,11 @@ export function emitElementModule(
427
454
  // Emit reactive({...}) with initial state values
428
455
  lines.push(" this.state = reactive({");
429
456
  for (const [key, initVal] of stateEntries) {
430
- lines.push(` ${key}: ${initVal},`);
457
+ /*
458
+ * A state key is author data, not an identifier: `{"user.name": 1}` is a legal document, and
459
+ * pasting it raw emitted `user.name: 1,` — a SyntaxError this build reported as a success.
460
+ */
461
+ lines.push(` ${objectKey(key)}: ${initVal},`);
431
462
  }
432
463
  lines.push(" });");
433
464
 
@@ -439,7 +470,9 @@ export function emitElementModule(
439
470
  eventParam: "e",
440
471
  statePrefix: "s",
441
472
  });
442
- lines.push(` this.state.${key} = (s, e) => { ${compiled}; };`);
473
+ lines.push(
474
+ ` ${refAccessor("this.state", escapeToken(key))} = (s, e) => { ${compiled}; };`,
475
+ );
443
476
  } else if (hasStructuredBody(def)) {
444
477
  // Structured body (spec §20) — statements compiled against this.state; dispatch target is
445
478
  // The component instance itself (WHATWG dispatchEvent on the custom element).
@@ -449,7 +482,11 @@ export function emitElementModule(
449
482
  indent: " ",
450
483
  statePrefix: "this.state",
451
484
  });
452
- lines.push(` this.state.${key} = (s, e) => {`, compiled, " };");
485
+ lines.push(
486
+ ` ${refAccessor("this.state", escapeToken(key))} = (s, e) => {`,
487
+ compiled,
488
+ " };",
489
+ );
453
490
  } else {
454
491
  const args = def.parameters ? paramNames(def.parameters) : (def.arguments ?? ["state"]);
455
492
  const paramList = args.join(", ");
@@ -464,17 +501,25 @@ export function emitElementModule(
464
501
  if (args[0] === "state") {
465
502
  if (def.$src) {
466
503
  // $src function — wrap imported function so it receives state
467
- lines.push(` this.state.${key} = (${paramList}) => ${key}(${paramList});`);
504
+ lines.push(
505
+ ` ${refAccessor("this.state", escapeToken(key))} = (${paramList}) => ${key}(${paramList});`,
506
+ );
468
507
  } else {
469
- lines.push(` this.state.${key} = (${paramList}) => {`, ` ${body}`, " };");
508
+ lines.push(
509
+ ` ${refAccessor("this.state", escapeToken(key))} = (${paramList}) => {`,
510
+ ` ${body}`,
511
+ " };",
512
+ );
470
513
  }
471
514
  } else {
472
515
  const callArgs = args.map((a: string) => (a === "state" ? "state" : "e")).join(", ");
473
516
  if (def.$src) {
474
- lines.push(` this.state.${key} = (state, e) => ${key}(${callArgs});`);
517
+ lines.push(
518
+ ` ${refAccessor("this.state", escapeToken(key))} = (state, e) => ${key}(${callArgs});`,
519
+ );
475
520
  } else {
476
521
  lines.push(
477
- ` this.state.${key} = (state, e) => {`,
522
+ ` ${refAccessor("this.state", escapeToken(key))} = (state, e) => {`,
478
523
  ` const _fn = (${paramList}) => {`,
479
524
  ` ${body}`,
480
525
  " };",
@@ -493,7 +538,19 @@ export function emitElementModule(
493
538
  eventParam: "e",
494
539
  statePrefix: "this.state",
495
540
  });
496
- lines.push(` this.state.${key} = ${emitFormulaFn(def, compiled)};`);
541
+ lines.push(
542
+ ` ${refAccessor("this.state", escapeToken(key))} = ${emitFormulaFn(def, compiled)};`,
543
+ );
544
+ }
545
+
546
+ /* Emitted with the same `state.` → `this.state.` rewrite the inline-body path below uses, into a
547
+ template literal — which is what `evaluateTemplate` is, so the result is a string on both
548
+ sides rather than a string here and a raw value there. */
549
+ for (const [key, template] of templateEntries) {
550
+ lines.push(
551
+ "",
552
+ ` ${refAccessor("this.state", escapeToken(key))} = computed(() => \`${template.replaceAll("state.", "this.state.")}\`);`,
553
+ );
497
554
  }
498
555
 
499
556
  // Emit computed signals — $src or inline body
@@ -504,11 +561,15 @@ export function emitElementModule(
504
561
  eventParam: "e",
505
562
  statePrefix: "this.state",
506
563
  });
507
- lines.push(` this.state.${key} = computed(() => ${compiled});`);
564
+ lines.push(
565
+ ` ${refAccessor("this.state", escapeToken(key))} = computed(() => ${compiled});`,
566
+ );
508
567
  } else if (def.$src) {
509
- lines.push(` this.state.${key} = computed(() => ${key}(this.state));`);
568
+ lines.push(
569
+ ` ${refAccessor("this.state", escapeToken(key))} = computed(() => ${key}(this.state));`,
570
+ );
510
571
  } else {
511
- lines.push(` this.state.${key} = computed(() => {`);
572
+ lines.push(` ${refAccessor("this.state", escapeToken(key))} = computed(() => {`);
512
573
  const body = (typeof def.body === "string" ? def.body : "").replaceAll(
513
574
  "state.",
514
575
  "this.state.",
@@ -528,23 +589,61 @@ export function emitElementModule(
528
589
  }
529
590
  }
530
591
 
592
+ // Subtrees a branching construct would otherwise repeat are lifted into `const _cN = html`…``
593
+ // Declarations emitted inside `template()`, above the `return` — inside it, so they are rebuilt
594
+ // Per render and read the same `s` the template does.
595
+ const hoistScope = createHoistScope();
596
+ const templateBody = emitLitChildren(doc.children, doc.style, " ", false, false, hoistScope);
597
+
531
598
  lines.push(
532
599
  // Template method
533
600
  " template() {",
534
601
  " const s = this.state;",
602
+ ...hoistScope.decls,
535
603
  " return html`",
536
- emitLitChildren(doc.children, doc.style, " "),
604
+ templateBody,
537
605
  " `;",
538
606
  " }",
539
607
  "",
608
+ ...(shadow === null
609
+ ? []
610
+ : [
611
+ /*
612
+ * The hydration contract. A declarative shadow root the parser already materialized
613
+ * MUST be adopted rather than replaced — calling `attachShadow` over one throws, and
614
+ * even where it did not, replacing it would discard the server-rendered markup this
615
+ * whole feature exists to ship.
616
+ *
617
+ * An `open` root is on the element. A `closed` one is not, by definition, and
618
+ * `ElementInternals` is the standard's only way back to it — which is why the two modes
619
+ * need different code rather than a mode string in one call.
620
+ */
621
+ " #renderRoot() {",
622
+ " if (this.#root) return this.#root;",
623
+ shadow === "closed"
624
+ ? " this.#root = this.attachInternals().shadowRoot ?? this.attachShadow({ mode: 'closed' });"
625
+ : " this.#root = this.shadowRoot ?? this.attachShadow({ mode: 'open' });",
626
+ " return this.#root;",
627
+ " }",
628
+ "",
629
+ ]),
540
630
  // ConnectedCallback
541
631
  " connectedCallback() {",
632
+ /*
633
+ * Each of the three absorption paths below skips a `#`-prefixed key: private state is not part
634
+ * of the component's interface and is never settable through $props (spec.md §5.6). Emitted as
635
+ * a runtime check rather than resolved at build time because all three read keys that arrive
636
+ * with the instance — an attribute payload, an attribute name, a property a parent set — none
637
+ * of which the compiler can see. The interpreter enforces the same rule in `runtime.ts`; the
638
+ * two tiers agreeing is the point, since a document is authored against whichever runs.
639
+ */
542
640
  // Read $props from data-jx-props attribute (set by compiler for pre-rendered instances)
543
641
  " const _pa = this.getAttribute('data-jx-props');",
544
642
  " if (_pa) {",
545
643
  " try {",
546
644
  " const _p = JSON.parse(_pa);",
547
645
  " for (const [k, v] of Object.entries(_p)) {",
646
+ " if (k.startsWith('#')) continue;",
548
647
  " if (k in this.state) this.state[k] = v;",
549
648
  " }",
550
649
  " } catch {}",
@@ -556,6 +655,7 @@ export function emitElementModule(
556
655
  " const _pn = this.getAttributeNames().filter(n => n.startsWith('props.') && n.length > 6);",
557
656
  " for (const _n of _pn) {",
558
657
  " const _k = _n.slice(6);",
658
+ " if (_k.startsWith('#')) { this.removeAttribute(_n); continue; }",
559
659
  " if (_k in this.state) {",
560
660
  " this.state[_k] = this.getAttribute(_n);",
561
661
  " this.removeAttribute(_n);",
@@ -564,6 +664,7 @@ export function emitElementModule(
564
664
  // Merge JS properties set before connection (by parent runtime).
565
665
  // Only check own properties to avoid inherited DOM properties like `title`.
566
666
  " for (const key of Object.keys(this.state)) {",
667
+ " if (key.startsWith('#')) continue;",
567
668
  " if (this.hasOwnProperty(key) && this[key] !== undefined) {",
568
669
  " this.state[key] = this[key];",
569
670
  " }",
@@ -616,7 +717,14 @@ export function emitElementModule(
616
717
  }
617
718
  }
618
719
  const hasSlot = treeHasSlot(doc.children);
619
- if (hasSlot) {
720
+ /*
721
+ * The slot emulation is a light-DOM construction: save the host's children, render over them,
722
+ * then splice them back where the literal `<slot>` element landed. Inside a shadow root that
723
+ * would be actively wrong — the light children ARE the slotted content and must stay in the
724
+ * light tree, where a real `<slot>` distributes them. This is the single largest behavioural
725
+ * difference between the two modes, and the reason shadow cannot be the default.
726
+ */
727
+ if (hasSlot && shadow === null) {
620
728
  // Save light DOM children (slotted content) before clearing
621
729
  lines.push(
622
730
  " const _slotted = Array.from(this.childNodes).filter(n => n.nodeType === 1 || (n.nodeType === 3 && n.textContent.trim()));",
@@ -626,10 +734,42 @@ export function emitElementModule(
626
734
  " if (this.hasAttribute('data-jx-prerendered')) {",
627
735
  " this.removeAttribute('data-jx-prerendered');",
628
736
  " }",
629
- " this.innerHTML = '';",
630
- " this.#effects.push(effect(() => render(this.template(), this)));",
631
737
  );
632
- if (hasSlot) {
738
+ if (shadow === null) {
739
+ lines.push(
740
+ /*
741
+ * `replaceChildren()`, not `innerHTML = ''`: identical semantics for clearing an element, and
742
+ * it is not a DOM injection sink. The runtime made the same change for the same reason — a
743
+ * write that was never injecting anything still costs a page a Trusted Types policy under
744
+ * `require-trusted-types-for 'script'`, and this one is emitted into every light-DOM element
745
+ * module a site ships.
746
+ */
747
+ " this.replaceChildren();",
748
+ " this.#effects.push(effect(() => render(this.template(), this)));",
749
+ );
750
+ } else {
751
+ /*
752
+ * Nothing clears the LIGHT DOM here: those children are the slotted content, and a real
753
+ * `<slot>` distributes them.
754
+ *
755
+ * The shadow root is cleared, and for the same reason light mode clears `innerHTML`: lit
756
+ * renders into a container by appending its own tree, so leaving the declarative markup in
757
+ * place shows the component twice. A declarative shadow root is a first paint, not a
758
+ * hydration target — Jx does not use lit-ssr's `hydrate`, and the light path discards its
759
+ * prerender too.
760
+ *
761
+ * The stylesheet link is the one thing that survives: it is what styles this root, the
762
+ * document's head cannot reach in here, and re-adding it would refetch it.
763
+ */
764
+ lines.push(
765
+ " const _root = this.#renderRoot();",
766
+ " for (const _n of [..._root.childNodes]) {",
767
+ " if (!(_n.nodeType === 1 && _n.tagName === 'LINK')) _n.remove();",
768
+ " }",
769
+ " this.#effects.push(effect(() => render(this.template(), _root)));",
770
+ );
771
+ }
772
+ if (hasSlot && shadow === null) {
633
773
  // Replace <slot> placeholder with saved slotted content
634
774
  lines.push(
635
775
  " const _slot = this.querySelector('slot');",
@@ -661,6 +801,46 @@ export function emitElementModule(
661
801
  return lines.join("\n");
662
802
  }
663
803
 
804
+ /**
805
+ * Declarations that must be emitted _beside_ a lit template rather than inside it.
806
+ *
807
+ * `emitLitNode` writes one template string, so a construct with N branches recursed once per branch
808
+ * and wrote the whole subtree N times into the bundle — the authored document contains it once. A
809
+ * scope collects `const _cN = html`…`;` declarations for subtrees that would otherwise repeat, and
810
+ * the branches reference those instead.
811
+ *
812
+ * One scope per template body: `template()` has its own, and each map callback gets a fresh one
813
+ * because its declarations close over that callback's `item`/`index`.
814
+ */
815
+ interface HoistScope {
816
+ decls: string[];
817
+ byText: Map<string, string>;
818
+ }
819
+
820
+ function createHoistScope(): HoistScope {
821
+ return { byText: new Map(), decls: [] };
822
+ }
823
+
824
+ /**
825
+ * Hoist an emitted subtree into a `const` and return the `${_cN}` expression referencing it.
826
+ * Identical subtrees collapse onto a single declaration.
827
+ *
828
+ * @param {HoistScope} scope
829
+ * @param {string} text
830
+ * @returns {string}
831
+ */
832
+ function hoistSubtree(scope: HoistScope, text: string): string {
833
+ const existing = scope.byText.get(text);
834
+ if (existing) {
835
+ return existing;
836
+ }
837
+ const name = `_c${scope.decls.length}`;
838
+ scope.decls.push(` const ${name} = html\`\n${text}\n \`;`);
839
+ const ref = `\${${name}}`;
840
+ scope.byText.set(text, ref);
841
+ return ref;
842
+ }
843
+
664
844
  /**
665
845
  * Convert Jx children to lit-html template content.
666
846
  *
@@ -676,6 +856,7 @@ function emitLitChildren(
676
856
  indent: string,
677
857
  preformatted = false,
678
858
  inMap = false,
859
+ scope: HoistScope | null = null,
679
860
  ) {
680
861
  if (!children) {
681
862
  return "";
@@ -695,7 +876,7 @@ function emitLitChildren(
695
876
  .map((child: JxMutableNode | string) =>
696
877
  isMappedArray(child)
697
878
  ? emitMappedArray(child, indent)
698
- : emitLitNode(child, preformatted ? "" : indent, preformatted, inMap),
879
+ : emitLitNode(child, preformatted ? "" : indent, preformatted, inMap, scope),
699
880
  )
700
881
  .join(preformatted ? "" : "\n");
701
882
  }
@@ -711,7 +892,10 @@ function emitLitNode(
711
892
  indent: string,
712
893
  preformatted = false,
713
894
  inMap = false,
714
- ) {
895
+ scope: HoistScope | null = null,
896
+ /** Pre-emitted inner content, so a branching construct can share one subtree across branches. */
897
+ innerOverride: string | null = null,
898
+ ): string {
715
899
  // String children are text nodes
716
900
  if (typeof def === "string") {
717
901
  if (def.includes("${")) {
@@ -726,6 +910,67 @@ function emitLitNode(
726
910
  return "";
727
911
  }
728
912
 
913
+ /* A CHOSEN TAG BECOMES ONE TEMPLATE PER CANDIDATE, keyed by the discriminant — the same shape
914
+ this function already emits for `$switch` twenty lines down, because it is the same problem:
915
+ lit cannot bind a tag name, and the alternative (`lit-html/static.js`'s `unsafeStatic`) is an
916
+ HTML-injection primitive with an unbounded template cache, deliberately avoided in this repo.
917
+
918
+ The candidates are literal `TagName`s by schema, so this terminates and every branch is a legal
919
+ element. The subtree is written once in the DOCUMENT, and — since the hoist scope below lifts it
920
+ into a single `const` the branches share — once in the BUNDLE too. */
921
+ if (isTagExpression(def.tagName)) {
922
+ const expression = def.tagName.$expression;
923
+ // The same two shapes `$switch` accepts: a pointer, or an expression node.
924
+ // `compileOperandSource` — the compiler's counterpart to the runtime's `evaluateOperand`, so
925
+ // Both sides read the discriminant the same way.
926
+ const discriminant = compileOperandSource(expression.target, {
927
+ eventParam: "e",
928
+ statePrefix: "s",
929
+ });
930
+ const branches =
931
+ expression.operator === "?:"
932
+ ? [
933
+ [true, expression.value],
934
+ [false, expression.initial],
935
+ ]
936
+ : [...Object.entries(expression.cases), ["__default__", expression.default]];
937
+ // The inner content is identical across candidates — only the tag differs — so it is emitted
938
+ // Once and every branch references it. Skipped when the candidates disagree about being
939
+ // Preformatted, because `white-space` decides how the subtree itself is indented.
940
+ const candidateTags = branches.map(([, candidate]) => String(candidate));
941
+ const sharedPre = candidateTags.map((t) => preformatted || PREFORMATTED_TAGS.has(t));
942
+ let innerRef: string | null = null;
943
+ if (scope && sharedPre.every((p) => p === sharedPre[0])) {
944
+ const sharedInner = emitNodeInner(def, `${indent} `, sharedPre[0] === true, inMap, scope);
945
+ if (sharedInner.trim() !== "") {
946
+ innerRef = hoistSubtree(scope, sharedInner);
947
+ }
948
+ }
949
+ const rendered: [string | boolean | undefined, string][] = branches.map(([key, candidate]) => {
950
+ const asLiteral = { ...def, tagName: candidate as string };
951
+ return [key, emitLitNode(asLiteral, `${indent} `, preformatted, inMap, scope, innerRef)];
952
+ });
953
+ if (expression.operator === "?:") {
954
+ const [, yes] = rendered[0]!;
955
+ const [, no] = rendered[1]!;
956
+ return `${indent}\${${discriminant}
957
+ ? html\`
958
+ ${yes}
959
+ \`
960
+ : html\`
961
+ ${no}
962
+ \`}`;
963
+ }
964
+ const entries = rendered
965
+ .filter(([key]) => key !== "__default__")
966
+ .map(([key, text]) => ` ${JSON.stringify(key)}: html\`\n${text}\n \``);
967
+ const [, fallback] = rendered.at(-1)!;
968
+ return (
969
+ `${indent}${"$"}{{\n${entries.join(",\n")}\n}` +
970
+ `[String(${discriminant})] ?? html\`\n${fallback}\n\`}`
971
+ );
972
+ }
973
+
729
974
  const tag = def.tagName ?? "div";
730
975
  // `white-space` inherits, so once inside a <pre> the whole subtree stays whitespace-significant.
731
976
  const inPre = preformatted || PREFORMATTED_TAGS.has(tag);
@@ -775,6 +1020,12 @@ function emitLitNode(
775
1020
  parts.push(`.${key}="\${${refToExpr((val as JxMutableNode).$ref as string)}}"`);
776
1021
  } else if (typeof val === "string" && val.includes("${")) {
777
1022
  parts.push(`.${key}="${toLitExpr(val)}"`);
1023
+ } else {
1024
+ // A literal IDL value (`value: "a"`, `checked: true`) is still a property the author asked
1025
+ // For. Without this branch it was dropped outright — including every `${…}` a build-time map
1026
+ // Expansion had already resolved to a constant, which is how an unrolled `<option>` lost its
1027
+ // Value and fell back to using its own label as its key.
1028
+ parts.push(`.${key}="\${${JSON.stringify(val)}}"`);
778
1029
  }
779
1030
  }
780
1031
 
@@ -834,34 +1085,70 @@ function emitLitNode(
834
1085
  return `${indent}<${tag}${attrsStr}\n${indent}>`;
835
1086
  }
836
1087
 
837
- let inner = "";
1088
+ const inner = innerOverride ?? emitNodeInner(def, indent, inPre, inMap, scope);
1089
+
1090
+ // Inside a <pre>, the newline before `>` would land in the element's own text.
1091
+ return inPre
1092
+ ? `<${tag}${attrsStr}>${inner}</${tag}>`
1093
+ : `${indent}<${tag}${attrsStr}\n${indent}>${inner}</${tag}>`;
1094
+ }
1095
+
1096
+ /**
1097
+ * Emit an element's inner content — the `$switch` lookup, text, raw HTML, or children.
1098
+ *
1099
+ * Split out of `emitLitNode` so a chosen `tagName` can emit it once and share it across candidates
1100
+ * instead of recursing into the same subtree per branch.
1101
+ *
1102
+ * @param {JxMutableNode} def
1103
+ * @param {string} indent
1104
+ * @param {boolean} inPre
1105
+ * @param {boolean} inMap
1106
+ * @param {HoistScope | null} scope
1107
+ * @returns {string}
1108
+ */
1109
+ function emitNodeInner(
1110
+ def: JxMutableNode,
1111
+ indent: string,
1112
+ inPre: boolean,
1113
+ inMap: boolean,
1114
+ scope: HoistScope | null,
1115
+ ): string {
838
1116
  if (def.$switch) {
839
1117
  const switchRef = isRef(def.$switch) ? def.$switch.$ref : (def.$switch as string);
840
1118
  const switchExpr = refToExpr(switchRef);
841
1119
  const cases = (def.cases ?? {}) as Record<string, JxMutableNode>;
842
- const caseEntries: string[] = [];
1120
+ const rendered: [string, string][] = [];
843
1121
  for (const [key, caseDef] of Object.entries(cases)) {
844
1122
  if (!caseDef || isRef(caseDef)) {
845
1123
  continue;
846
1124
  }
847
- const renderedCase = emitLitNode(caseDef, `${indent} `, false, inMap);
848
- caseEntries.push(` ${JSON.stringify(key)}: html\`\n${renderedCase}\n \``);
1125
+ rendered.push([key, emitLitNode(caseDef, `${indent} `, false, inMap, scope)]);
849
1126
  }
850
- inner = `\${{\n${caseEntries.join(",\n")}\n}[${switchExpr}]}`;
851
- } else if (def.textContent !== undefined) {
852
- inner = toLitTextContent(def.textContent);
853
- } else if (def.innerHTML !== undefined) {
854
- inner = def.innerHTML;
855
- } else if (def.children) {
856
- inner = inPre
857
- ? emitLitChildren(def.children, def.style, "", true, inMap)
858
- : `\n${emitLitChildren(def.children, def.style, `${indent} `, false, inMap)}\n${indent}`;
1127
+ // Cases that emit byte-identical subtrees share one hoisted `const` rather than repeating it
1128
+ // Once per case — the authored document names the subtree once (#126).
1129
+ const repeated = new Set(
1130
+ rendered
1131
+ .map(([, text]) => text)
1132
+ .filter((text, i, all) => all.indexOf(text) !== i && text.trim() !== ""),
1133
+ );
1134
+ const caseEntries = rendered.map(([key, text]) => {
1135
+ const body = scope && repeated.has(text) ? hoistSubtree(scope, text) : text;
1136
+ return ` ${JSON.stringify(key)}: html\`\n${body}\n \``;
1137
+ });
1138
+ return `\${{\n${caseEntries.join(",\n")}\n}[${switchExpr}]}`;
859
1139
  }
860
-
861
- // Inside a <pre>, the newline before `>` would land in the element's own text.
862
- return inPre
863
- ? `<${tag}${attrsStr}>${inner}</${tag}>`
864
- : `${indent}<${tag}${attrsStr}\n${indent}>${inner}</${tag}>`;
1140
+ if (def.textContent !== undefined) {
1141
+ return toLitTextContent(def.textContent);
1142
+ }
1143
+ if (def.innerHTML !== undefined) {
1144
+ return def.innerHTML;
1145
+ }
1146
+ if (def.children) {
1147
+ return inPre
1148
+ ? emitLitChildren(def.children, def.style, "", true, inMap, scope)
1149
+ : `\n${emitLitChildren(def.children, def.style, `${indent} `, false, inMap, scope)}\n${indent}`;
1150
+ }
1151
+ return "";
865
1152
  }
866
1153
 
867
1154
  /**
@@ -901,6 +1188,36 @@ function emitMappedArray(arrayDef: JxMappedArray, indent: string) {
901
1188
  parts.push(`class="${toLitExpr(String(mapDef.className))}"`);
902
1189
  }
903
1190
 
1191
+ // IDL properties (`value`, `checked`, `selected`, …) go straight on the element, exactly as they
1192
+ // Do outside a map (emitLitNode above). Without this loop the map body silently dropped them, so
1193
+ // An `<option value="${$map.item.v}">` emitted no value at all and every option fell back to its
1194
+ // Own label — a change handler then received a display string instead of the key.
1195
+ for (const [key, val] of Object.entries(mapDef)) {
1196
+ if (
1197
+ RESERVED_KEYS.has(key) ||
1198
+ key.startsWith("$") ||
1199
+ key.startsWith("on") ||
1200
+ key === "tagName" ||
1201
+ key === "id" ||
1202
+ key === "className" ||
1203
+ key === "style" ||
1204
+ key === "children" ||
1205
+ key === "textContent" ||
1206
+ key === "innerHTML" ||
1207
+ key === "attributes"
1208
+ ) {
1209
+ continue;
1210
+ }
1211
+
1212
+ if (val && typeof val === "object" && (val as JxMutableNode).$ref) {
1213
+ parts.push(`.${key}="\${${mapRefToExpr((val as JxMutableNode).$ref as string)}}"`);
1214
+ } else if (typeof val === "string" && val.includes("${")) {
1215
+ parts.push(`.${key}="${toLitExpr(val)}"`);
1216
+ } else {
1217
+ parts.push(`.${key}="\${${JSON.stringify(val)}}"`);
1218
+ }
1219
+ }
1220
+
904
1221
  if (mapDef.$props) {
905
1222
  for (const [key, val] of Object.entries(mapDef.$props)) {
906
1223
  if (isRef(val)) {
@@ -938,11 +1255,14 @@ function emitMappedArray(arrayDef: JxMappedArray, indent: string) {
938
1255
 
939
1256
  const attrsStr = parts.length > 0 ? `\n${indent} ${parts.join(`\n${indent} `)}` : "";
940
1257
 
1258
+ // The map body gets its own hoist scope: a subtree lifted out of a branch here closes over this
1259
+ // Callback's `item`/`index`, so its declaration belongs inside the callback, not in `template()`.
1260
+ const mapScope = createHoistScope();
941
1261
  let inner = "";
942
1262
  if (mapDef.textContent !== undefined) {
943
1263
  inner = toLitTextContent(mapDef.textContent);
944
1264
  } else if (mapDef.children) {
945
- inner = `\n${emitLitChildren(mapDef.children, null, `${indent} `, false, true)}\n${indent} `;
1265
+ inner = `\n${emitLitChildren(mapDef.children, null, `${indent} `, false, true, mapScope)}\n${indent} `;
946
1266
  }
947
1267
 
948
1268
  const body = `html\`\n${indent} <${tag}${attrsStr}\n${indent} >${inner}</${tag}>\n${indent}\``;
@@ -955,8 +1275,11 @@ function emitMappedArray(arrayDef: JxMappedArray, indent: string) {
955
1275
  // `$map["item"]` — and nests correctly, since an inner map shadows the outer binding exactly as
956
1276
  // The interpreter's child scope does. Emitted only when referenced, so output is otherwise
957
1277
  // Unchanged.
958
- const callback = body.includes("$map")
959
- ? `(item, index) => { const $map = { item, index }; return ${body}; }`
1278
+ const prelude =
1279
+ (body.includes("$map") ? `const $map = { item, index }; ` : "") +
1280
+ mapScope.decls.map((d) => `${d.trim()} `).join("");
1281
+ const callback = prelude
1282
+ ? `(item, index) => { ${prelude}return ${body}; }`
960
1283
  : `(item, index) => ${body}`;
961
1284
 
962
1285
  return `${indent}\${${itemsExpr}.map(${callback})}`;
@@ -970,14 +1293,38 @@ function emitMappedArray(arrayDef: JxMappedArray, indent: string) {
970
1293
  */
971
1294
  function refToExpr(ref: string) {
972
1295
  if (ref.startsWith("#/state/")) {
973
- const path = ref.slice("#/state/".length);
974
- return `s.${path.replaceAll("/", ".")}`;
1296
+ return refAccessor("s", ref.slice("#/state/".length));
975
1297
  }
976
1298
  if (ref.startsWith("$map/")) {
977
- const path = ref.slice("$map/".length);
978
- return path.replaceAll("/", ".");
1299
+ return mapVarAccessor(ref.slice("$map/".length));
1300
+ }
1301
+ /*
1302
+ * The remaining schemes, mirroring `compileRef` in @jxsuite/runtime. They used to fall through to
1303
+ * the state branch, so `parent#/user` emitted `s.parent#/user` — a syntax error. A prop reaches
1304
+ * this element through `state`, so `parent#/` reads from `s` exactly as `#/state/` does.
1305
+ */
1306
+ if (ref.startsWith("parent#/")) {
1307
+ return refAccessor("s", ref.slice("parent#/".length));
1308
+ }
1309
+ if (ref.startsWith("window#/")) {
1310
+ return refAccessor("window", ref.slice("window#/".length));
979
1311
  }
980
- return `s.${ref}`;
1312
+ if (ref.startsWith("document#/")) {
1313
+ return refAccessor("document", ref.slice("document#/".length));
1314
+ }
1315
+ return refAccessor("s", ref);
1316
+ }
1317
+
1318
+ /**
1319
+ * `$map/item/title` reads the loop variable `item`, so its first segment is an emitted identifier
1320
+ * rather than a member of some receiver — the one ref form whose base is not fixed.
1321
+ *
1322
+ * @param {string} path
1323
+ * @returns {string}
1324
+ */
1325
+ function mapVarAccessor(path: string) {
1326
+ const [head, ...rest] = refSegments(path);
1327
+ return refAccessor(head ?? "", rest.map((seg) => escapeToken(seg)).join("/"));
981
1328
  }
982
1329
 
983
1330
  /**
@@ -986,7 +1333,7 @@ function refToExpr(ref: string) {
986
1333
  */
987
1334
  function mapRefToExpr(ref: string) {
988
1335
  if (ref.startsWith("$map/")) {
989
- return ref.slice("$map/".length).replaceAll("/", ".");
1336
+ return mapVarAccessor(ref.slice("$map/".length));
990
1337
  }
991
1338
  return refToExpr(ref);
992
1339
  }