@jxsuite/compiler 1.4.0 → 1.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -11,12 +11,16 @@ import {
11
11
  compileExpression,
12
12
  compileStatements,
13
13
  emitFormulaFn,
14
+ emitRequestFetch,
14
15
  escapeHtml,
15
16
  isMutating,
16
17
  isSchemaOnly,
18
+ PREFORMATTED_TAGS,
19
+ srcImportBinding,
17
20
  tagNameToClassName,
18
21
  } from "../shared.ts";
19
22
  import {
23
+ bodyReturnsValue,
20
24
  hasStructuredBody,
21
25
  isExpressionDef,
22
26
  isFunctionDef,
@@ -34,6 +38,7 @@ import type {
34
38
  JxFunctionDef,
35
39
  JxMappedArray,
36
40
  JxMutableNode,
41
+ JxPrototypeDef,
37
42
  JxStyle,
38
43
  } from "@jxsuite/schema/types";
39
44
 
@@ -248,6 +253,68 @@ function extractInitialValue(def: JxMutableNode) {
248
253
  return JSON.stringify(def);
249
254
  }
250
255
 
256
+ /** Lifecycle hooks the generated element calls itself, so they are never `$ref`'d (spec.md §16.4). */
257
+ const LIFECYCLE_KEYS = new Set(["onMount", "onUnmount", "onAdopted"]);
258
+
259
+ /**
260
+ * Collect the state keys a document uses as a _callable_, at any depth: bound to an `on*` event,
261
+ * invoked by an `$expression` `call` node, or called as `state.key(…)` from a template string or
262
+ * another function's body.
263
+ *
264
+ * A bodyless `$src` Function carries no body to classify by, and unlike the interpreter — which
265
+ * introspects the imported function — a compiler only has the document to go on. Anything not used
266
+ * as a callable has its return value read reactively, and so becomes a computed (spec.md §5.3 4b).
267
+ * Defaulting the other way round would turn a called helper into a value and break its call site.
268
+ *
269
+ * @param {JxDocument} doc
270
+ * @returns {Set<string>}
271
+ */
272
+ function collectCallableRefs(doc: JxDocument) {
273
+ const keys = new Set<string>();
274
+ const seen = new Set<object>();
275
+
276
+ const addStateRef = (ref: unknown) => {
277
+ if (typeof ref === "string" && ref.startsWith("#/state/")) {
278
+ keys.add(ref.slice("#/state/".length).split("/")[0] as string);
279
+ }
280
+ };
281
+
282
+ const visit = (node: unknown) => {
283
+ if (typeof node === "string") {
284
+ // `${state.formatDate(d)}` in a template, or `state.helper(x)` in another function's body.
285
+ for (const call of node.matchAll(/\bstate\.([A-Za-z_$][\w$]*)\s*\(/g)) {
286
+ keys.add(call[1] as string);
287
+ }
288
+ return;
289
+ }
290
+ if (!node || typeof node !== "object" || seen.has(node)) {
291
+ return;
292
+ }
293
+ seen.add(node);
294
+ if (Array.isArray(node)) {
295
+ for (const item of node) {
296
+ visit(item);
297
+ }
298
+ return;
299
+ }
300
+ const record = node as Record<string, unknown>;
301
+ // An `$expression` `call` node names its callee in `target` (spec.md §19.4c).
302
+ if (record.operator === "call" && isRef(record.target)) {
303
+ addStateRef((record.target as { $ref: string }).$ref);
304
+ }
305
+ for (const [key, val] of Object.entries(record)) {
306
+ if (key.startsWith("on") && key !== "observedAttributes" && isRef(val)) {
307
+ addStateRef(val.$ref);
308
+ }
309
+ visit(val);
310
+ }
311
+ };
312
+
313
+ visit(doc.children);
314
+ visit(doc.state);
315
+ return keys;
316
+ }
317
+
251
318
  /**
252
319
  * Generate a complete ES module string for a custom element.
253
320
  *
@@ -282,7 +349,7 @@ export function emitElementModule(
282
349
  if (!srcImportMap.has(srcPath)) {
283
350
  srcImportMap.set(srcPath, []);
284
351
  }
285
- (srcImportMap.get(srcPath) as string[]).push(key);
352
+ (srcImportMap.get(srcPath) as string[]).push(srcImportBinding(key, d));
286
353
  }
287
354
  }
288
355
  for (const [srcPath, names] of srcImportMap) {
@@ -292,12 +359,24 @@ export function emitElementModule(
292
359
  lines.push(`import { ${names.join(", ")} } from '${importPath}';`);
293
360
  }
294
361
 
362
+ // `$prototype: "Request"` entries each get an auto-fetch effect in connectedCallback, plus the
363
+ // Machinery to stop those effects again on disconnect. Both are emitted only when one exists, so
364
+ // Documents without a Request produce exactly the same module as before.
365
+ const requestEntries = Object.entries(defs).filter(([, def]) => {
366
+ const d = def as JxMutableNode;
367
+ return Boolean(d) && typeof d === "object" && !Array.isArray(d) && d.$prototype === "Request";
368
+ });
369
+
295
370
  lines.push(
296
- `import { reactive, computed, effect } from '@vue/reactivity';`,
371
+ `import { reactive, computed, effect, stop } from '@vue/reactivity';`,
297
372
  `import { render, html } from 'lit-html';`,
298
373
  "",
299
374
  `class ${className} extends HTMLElement {`,
300
- " #dispose = null;",
375
+ // One registry for every effect the element creates — render, dynamic host styles, Request
376
+ // Auto-fetches. Calling an @vue/reactivity runner re-runs it, so teardown has to be `stop()`,
377
+ // And a single list keeps every effect on the same lifecycle instead of leaking the ones nobody
378
+ // Held a handle to.
379
+ " #effects = [];",
301
380
  "",
302
381
  // Constructor: build reactive state
303
382
  " constructor() {",
@@ -309,6 +388,7 @@ export function emitElementModule(
309
388
  const functionEntries: [string, JxExpressionDef | JxFunctionDef][] = [];
310
389
 
311
390
  const formulaEntries: [string, JxExpressionDef][] = [];
391
+ const callableRefs = collectCallableRefs(doc);
312
392
  for (const [key, def] of Object.entries(defs)) {
313
393
  const d = def as JxMutableNode;
314
394
  if (isExpressionDef(d)) {
@@ -321,7 +401,16 @@ export function emitElementModule(
321
401
  computedEntries.push([key, d]);
322
402
  }
323
403
  } else if (isFunctionDef(d)) {
324
- if (typeof d.body === "string" && d.body.includes("return")) {
404
+ if (d.$src && typeof d.body !== "string") {
405
+ // Bodyless `$src` entry — classify by document usage, not by an absent body. `body` and
406
+ // `$src` are mutually exclusive (spec.md §5.3 4d), so this is the only shape a
407
+ // Spec-conformant external Function takes.
408
+ if (callableRefs.has(key) || LIFECYCLE_KEYS.has(key)) {
409
+ functionEntries.push([key, d]);
410
+ } else {
411
+ computedEntries.push([key, d]);
412
+ }
413
+ } else if (typeof d.body === "string" && bodyReturnsValue(d.body)) {
325
414
  computedEntries.push([key, d]);
326
415
  } else {
327
416
  functionEntries.push([key, d]);
@@ -364,15 +453,35 @@ export function emitElementModule(
364
453
  } else {
365
454
  const args = def.parameters ? paramNames(def.parameters) : (def.arguments ?? ["state"]);
366
455
  const paramList = args.join(", ");
367
- if (def.$src) {
368
- // $src function — wrap imported function so it receives state
369
- lines.push(` this.state.${key} = (${paramList}) => ${key}(${paramList});`);
456
+ const body = typeof def.body === "string" ? def.body : "";
457
+ // Every call site invokes a state function as `(state, event)` — the template's
458
+ // `s.fn(s, e)`, `onMount`, `onUnmount`. Declared names that already start with `state` line
459
+ // Up positionally, so they are emitted directly. Any other declaration — including the
460
+ // `"parameters": ["event"]` form that examples/components/{todo-app,fetch-demo}.json use — is
461
+ // Mapped onto the arguments by name, the way the client target does it
462
+ // (compile-client.ts), with `state` bound as the outer parameter so a body referencing bare
463
+ // `state` resolves regardless of the declared arity.
464
+ if (args[0] === "state") {
465
+ if (def.$src) {
466
+ // $src function — wrap imported function so it receives state
467
+ lines.push(` this.state.${key} = (${paramList}) => ${key}(${paramList});`);
468
+ } else {
469
+ lines.push(` this.state.${key} = (${paramList}) => {`, ` ${body}`, " };");
470
+ }
370
471
  } else {
371
- lines.push(
372
- ` this.state.${key} = (${paramList}) => {`,
373
- ` ${typeof def.body === "string" ? def.body : ""}`,
374
- " };",
375
- );
472
+ const callArgs = args.map((a: string) => (a === "state" ? "state" : "e")).join(", ");
473
+ if (def.$src) {
474
+ lines.push(` this.state.${key} = (state, e) => ${key}(${callArgs});`);
475
+ } else {
476
+ lines.push(
477
+ ` this.state.${key} = (state, e) => {`,
478
+ ` const _fn = (${paramList}) => {`,
479
+ ` ${body}`,
480
+ " };",
481
+ ` return _fn(${callArgs});`,
482
+ " };",
483
+ );
484
+ }
376
485
  }
377
486
  }
378
487
  }
@@ -441,6 +550,17 @@ export function emitElementModule(
441
550
  " } catch {}",
442
551
  " this.removeAttribute('data-jx-props');",
443
552
  " }",
553
+ // Literal `props.*` attributes — the live-render mirror of the build-time lift in site-build.
554
+ // String values only: HTML lowercases attribute names, so a state key must be lowercase to match.
555
+ // Names are collected first because removing while iterating the live NamedNodeMap skips entries.
556
+ " const _pn = this.getAttributeNames().filter(n => n.startsWith('props.') && n.length > 6);",
557
+ " for (const _n of _pn) {",
558
+ " const _k = _n.slice(6);",
559
+ " if (_k in this.state) {",
560
+ " this.state[_k] = this.getAttribute(_n);",
561
+ " this.removeAttribute(_n);",
562
+ " }",
563
+ " }",
444
564
  // Merge JS properties set before connection (by parent runtime).
445
565
  // Only check own properties to avoid inherited DOM properties like `title`.
446
566
  " for (const key of Object.keys(this.state)) {",
@@ -449,6 +569,20 @@ export function emitElementModule(
449
569
  " }",
450
570
  " }",
451
571
  );
572
+
573
+ // `$prototype: "Request"` entries fetch on connect — after the `$props`/property merge above, so
574
+ // A template `url` interpolates the values the parent passed in rather than the initial ones.
575
+ // Runners are collected so disconnectedCallback can stop them: a reactive URL would otherwise
576
+ // Keep fetching after removal, and every re-insertion would stack another effect on top.
577
+ for (const [key, def] of requestEntries) {
578
+ lines.push(
579
+ emitRequestFetch(key, def as unknown as JxPrototypeDef, {
580
+ collect: "this.#effects",
581
+ indent: " ",
582
+ statePrefix: "this.state",
583
+ }),
584
+ );
585
+ }
452
586
  if (doc.style && typeof doc.style === "object") {
453
587
  const dynamicStyles: [string, string][] = [];
454
588
  for (const [prop, value] of Object.entries(doc.style)) {
@@ -470,7 +604,7 @@ export function emitElementModule(
470
604
  }
471
605
  }
472
606
  if (dynamicStyles.length > 0) {
473
- lines.push(" effect(() => {");
607
+ lines.push(" this.#effects.push(effect(() => {");
474
608
  for (const [cssProp, value] of dynamicStyles) {
475
609
  const expr = value.replaceAll(
476
610
  /\$\{([^}]+)\}/g,
@@ -478,7 +612,7 @@ export function emitElementModule(
478
612
  );
479
613
  lines.push(` this.style['${cssProp}'] = \`${expr}\`;`);
480
614
  }
481
- lines.push(" });");
615
+ lines.push(" }));");
482
616
  }
483
617
  }
484
618
  const hasSlot = treeHasSlot(doc.children);
@@ -493,7 +627,7 @@ export function emitElementModule(
493
627
  " this.removeAttribute('data-jx-prerendered');",
494
628
  " }",
495
629
  " this.innerHTML = '';",
496
- " this.#dispose = effect(() => render(this.template(), this));",
630
+ " this.#effects.push(effect(() => render(this.template(), this)));",
497
631
  );
498
632
  if (hasSlot) {
499
633
  // Replace <slot> placeholder with saved slotted content
@@ -514,7 +648,8 @@ export function emitElementModule(
514
648
  "",
515
649
  // DisconnectedCallback
516
650
  " disconnectedCallback() {",
517
- " if (this.#dispose) { this.#dispose(); this.#dispose = null; }",
651
+ " for (const _e of this.#effects) { stop(_e); }",
652
+ " this.#effects.length = 0;",
518
653
  " if (typeof this.state.onUnmount === 'function') { this.state.onUnmount(this.state); }",
519
654
  " }",
520
655
  "}",
@@ -532,12 +667,15 @@ export function emitElementModule(
532
667
  * @param {JxMutableNode["children"]} children
533
668
  * @param {JxStyle | null | undefined} parentStyle
534
669
  * @param {string} indent
670
+ * @param {boolean} [preformatted]
535
671
  * @returns {string}
536
672
  */
537
673
  function emitLitChildren(
538
674
  children: JxMutableNode["children"],
539
675
  _parentStyle: JxStyle | null | undefined,
540
676
  indent: string,
677
+ preformatted = false,
678
+ inMap = false,
541
679
  ) {
542
680
  if (!children) {
543
681
  return "";
@@ -555,17 +693,25 @@ function emitLitChildren(
555
693
  // Mixed children: elements/text plus array pseudo-elements expanded inline among siblings.
556
694
  return children
557
695
  .map((child: JxMutableNode | string) =>
558
- isMappedArray(child) ? emitMappedArray(child, indent) : emitLitNode(child, indent),
696
+ isMappedArray(child)
697
+ ? emitMappedArray(child, indent)
698
+ : emitLitNode(child, preformatted ? "" : indent, preformatted, inMap),
559
699
  )
560
- .join("\n");
700
+ .join(preformatted ? "" : "\n");
561
701
  }
562
702
 
563
703
  /**
564
704
  * @param {JxMutableNode | string} def
565
705
  * @param {string} indent
706
+ * @param {boolean} [preformatted]
566
707
  * @returns {string}
567
708
  */
568
- function emitLitNode(def: JxMutableNode | string, indent: string) {
709
+ function emitLitNode(
710
+ def: JxMutableNode | string,
711
+ indent: string,
712
+ preformatted = false,
713
+ inMap = false,
714
+ ) {
569
715
  // String children are text nodes
570
716
  if (typeof def === "string") {
571
717
  if (def.includes("${")) {
@@ -581,6 +727,8 @@ function emitLitNode(def: JxMutableNode | string, indent: string) {
581
727
  }
582
728
 
583
729
  const tag = def.tagName ?? "div";
730
+ // `white-space` inherits, so once inside a <pre> the whole subtree stays whitespace-significant.
731
+ const inPre = preformatted || PREFORMATTED_TAGS.has(tag);
584
732
 
585
733
  const parts: string[] = [];
586
734
 
@@ -596,11 +744,14 @@ function emitLitNode(def: JxMutableNode | string, indent: string) {
596
744
  }
597
745
  }
598
746
 
747
+ // `id`/`className` take the same template rewriting as every other attribute — inside a map
748
+ // These routinely carry `${$map.item…}`/`${$map.index}`, which emitMappedArray already rewrites
749
+ // On the map root but nothing rewrote on its descendants.
599
750
  if (def.id) {
600
- parts.push(`id="${def.id}"`);
751
+ parts.push(`id="${toLitExpr(String(def.id))}"`);
601
752
  }
602
753
  if (def.className) {
603
- parts.push(`class="${def.className}"`);
754
+ parts.push(`class="${toLitExpr(String(def.className))}"`);
604
755
  }
605
756
 
606
757
  for (const [key, val] of Object.entries(def)) {
@@ -631,12 +782,21 @@ function emitLitNode(def: JxMutableNode | string, indent: string) {
631
782
  for (const [key, val] of Object.entries(def.$props)) {
632
783
  if (val && typeof val === "object" && (val as JxMutableNode).$ref) {
633
784
  parts.push(`.${key}="\${${refToExpr((val as JxMutableNode).$ref as string)}}"`);
785
+ } else if (typeof val === "string" && val.includes("${")) {
786
+ // A template value is a binding, not text. JSON-quoting it emitted the template's own source
787
+ // As the prop value (`.label="${"${state.x}"}"`), so the component received the literal
788
+ // String `${state.x}` forever.
789
+ parts.push(`.${key}="${toLitExpr(val)}"`);
634
790
  } else {
635
791
  parts.push(`.${key}="\${${JSON.stringify(val)}}"`);
636
792
  }
637
793
  }
638
794
  }
639
795
 
796
+ // A handler bound inside a map publishes its iteration to state before running, matching the
797
+ // Interpreter's child scope and the client target's handler wrapper. Bodies read it as
798
+ // `state.$map.index` / `state.$map.item`.
799
+ const mapCtx = inMap ? "s.$map = $map; " : "";
640
800
  for (const [key, val] of Object.entries(def)) {
641
801
  if (!key.startsWith("on") || key === "observedAttributes") {
642
802
  continue;
@@ -644,7 +804,9 @@ function emitLitNode(def: JxMutableNode | string, indent: string) {
644
804
  const eventName = key.slice(2).toLowerCase();
645
805
  if (val && typeof val === "object" && (val as JxMutableNode).$ref) {
646
806
  parts.push(
647
- `@${eventName}="\${(e) => ${refToExpr((val as JxMutableNode).$ref as string)}(s, e)}"`,
807
+ mapCtx
808
+ ? `@${eventName}="\${(e) => { ${mapCtx}${refToExpr((val as JxMutableNode).$ref as string)}(s, e); }}"`
809
+ : `@${eventName}="\${(e) => ${refToExpr((val as JxMutableNode).$ref as string)}(s, e)}"`,
648
810
  );
649
811
  } else if (val && typeof val === "object" && "$expression" in /** @type {any} */ val) {
650
812
  const compiled = compileExpression(
@@ -654,9 +816,9 @@ function emitLitNode(def: JxMutableNode | string, indent: string) {
654
816
  statePrefix: "s",
655
817
  },
656
818
  );
657
- parts.push(`@${eventName}="\${(e) => { ${compiled}; }}"`);
819
+ parts.push(`@${eventName}="\${(e) => { ${mapCtx}${compiled}; }}"`);
658
820
  } else if (isFunctionDef(val)) {
659
- parts.push(`@${eventName}="\${(e) => { ${inlineHandlerBody(val)} }}"`);
821
+ parts.push(`@${eventName}="\${(e) => { ${mapCtx}${inlineHandlerBody(val)} }}"`);
660
822
  }
661
823
  }
662
824
 
@@ -682,7 +844,7 @@ function emitLitNode(def: JxMutableNode | string, indent: string) {
682
844
  if (!caseDef || isRef(caseDef)) {
683
845
  continue;
684
846
  }
685
- const renderedCase = emitLitNode(caseDef, `${indent} `);
847
+ const renderedCase = emitLitNode(caseDef, `${indent} `, false, inMap);
686
848
  caseEntries.push(` ${JSON.stringify(key)}: html\`\n${renderedCase}\n \``);
687
849
  }
688
850
  inner = `\${{\n${caseEntries.join(",\n")}\n}[${switchExpr}]}`;
@@ -691,10 +853,15 @@ function emitLitNode(def: JxMutableNode | string, indent: string) {
691
853
  } else if (def.innerHTML !== undefined) {
692
854
  inner = def.innerHTML;
693
855
  } else if (def.children) {
694
- inner = `\n${emitLitChildren(def.children, def.style, `${indent} `)}\n${indent}`;
856
+ inner = inPre
857
+ ? emitLitChildren(def.children, def.style, "", true, inMap)
858
+ : `\n${emitLitChildren(def.children, def.style, `${indent} `, false, inMap)}\n${indent}`;
695
859
  }
696
860
 
697
- return `${indent}<${tag}${attrsStr}\n${indent}>${inner}</${tag}>`;
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}>`;
698
865
  }
699
866
 
700
867
  /**
@@ -713,10 +880,35 @@ function emitMappedArray(arrayDef: JxMappedArray, indent: string) {
713
880
  const tag = mapDef.tagName ?? "div";
714
881
  const parts: string[] = [];
715
882
 
883
+ // The map root gets the same attribute treatment as any other element (emitLitNode above);
884
+ // `${item…}`/`${index…}` templates resolve against the map callback's own parameters.
885
+ if (mapDef.attributes) {
886
+ for (const [key, val] of Object.entries(mapDef.attributes)) {
887
+ if (val && typeof val === "object" && isRef(val)) {
888
+ parts.push(`${key}="\${${mapRefToExpr(val.$ref)}}"`);
889
+ } else if (typeof val === "string" && val.includes("${")) {
890
+ parts.push(`${key}="${toLitExpr(val)}"`);
891
+ } else {
892
+ parts.push(`${key}="${val}"`);
893
+ }
894
+ }
895
+ }
896
+
897
+ if (mapDef.id) {
898
+ parts.push(`id="${toLitExpr(String(mapDef.id))}"`);
899
+ }
900
+ if (mapDef.className) {
901
+ parts.push(`class="${toLitExpr(String(mapDef.className))}"`);
902
+ }
903
+
716
904
  if (mapDef.$props) {
717
905
  for (const [key, val] of Object.entries(mapDef.$props)) {
718
906
  if (isRef(val)) {
719
907
  parts.push(`.${key}="\${${mapRefToExpr(val.$ref)}}"`);
908
+ } else if (typeof val === "string" && val.includes("${")) {
909
+ // `$props: { label: "${$map.item.name}" }` is the ordinary way to pass per-item data to a
910
+ // Component in a list; JSON-quoting it handed over the template source instead.
911
+ parts.push(`.${key}="${toLitExpr(val)}"`);
720
912
  } else {
721
913
  parts.push(`.${key}="\${${JSON.stringify(val)}}"`);
722
914
  }
@@ -734,13 +926,13 @@ function emitMappedArray(arrayDef: JxMappedArray, indent: string) {
734
926
  }
735
927
  const eventName = key.slice(2).toLowerCase();
736
928
  if (isRef(val)) {
737
- parts.push(`@${eventName}="\${(e) => ${refToExpr(val.$ref)}(s, e)}"`);
929
+ parts.push(`@${eventName}="\${(e) => { s.$map = $map; ${refToExpr(val.$ref)}(s, e); }}"`);
738
930
  } else if (isExpressionDef(val)) {
739
931
  const compiled = compileExpression(val.$expression as ExpressionNode, {
740
932
  eventParam: "e",
741
933
  statePrefix: "s",
742
934
  });
743
- parts.push(`@${eventName}="\${(e) => { ${compiled}; }}"`);
935
+ parts.push(`@${eventName}="\${(e) => { s.$map = $map; ${compiled}; }}"`);
744
936
  }
745
937
  }
746
938
 
@@ -750,10 +942,24 @@ function emitMappedArray(arrayDef: JxMappedArray, indent: string) {
750
942
  if (mapDef.textContent !== undefined) {
751
943
  inner = toLitTextContent(mapDef.textContent);
752
944
  } else if (mapDef.children) {
753
- inner = `\n${emitLitChildren(mapDef.children, null, `${indent} `)}\n${indent} `;
945
+ inner = `\n${emitLitChildren(mapDef.children, null, `${indent} `, false, true)}\n${indent} `;
754
946
  }
755
947
 
756
- return `${indent}\${${itemsExpr}.map((item, index) => html\`\n${indent} <${tag}${attrsStr}\n${indent} >${inner}</${tag}>\n${indent}\`)}`;
948
+ const body = `html\`\n${indent} <${tag}${attrsStr}\n${indent} >${inner}</${tag}>\n${indent}\``;
949
+
950
+ // `${$map.item…}` and `${$map.index}` are spec-sanctioned template forms: spec.md §6.6 names
951
+ // `$map` alongside `item`/`index` as an iteration binding, and the interpreter passes it into the
952
+ // Template evaluator as a real parameter. The compiled callback bound only `item`/`index`, so
953
+ // Every such template survived into the module as a dead `$map` reference and threw at render.
954
+ // Binding the same object here resolves every access form — `$map.item`, `$map?.item`,
955
+ // `$map["item"]` — and nests correctly, since an inner map shadows the outer binding exactly as
956
+ // The interpreter's child scope does. Emitted only when referenced, so output is otherwise
957
+ // Unchanged.
958
+ const callback = body.includes("$map")
959
+ ? `(item, index) => { const $map = { item, index }; return ${body}; }`
960
+ : `(item, index) => ${body}`;
961
+
962
+ return `${indent}\${${itemsExpr}.map(${callback})}`;
757
963
  }
758
964
 
759
965
  /**
@@ -828,6 +1034,15 @@ function inlineHandlerBody(def: JxFunctionDef) {
828
1034
  });
829
1035
  }
830
1036
  const body = typeof def.body === "string" ? def.body : "";
1037
+ const declared = def.parameters ? paramNames(def.parameters) : (def.arguments ?? []);
1038
+ if (declared.length > 0) {
1039
+ // The call site supplies only `(e)`, so map the declared names onto the arguments the handler
1040
+ // Actually has — `state` to the `s` alias, anything else to the event. Binding by name means the
1041
+ // Body needs no textual rewriting, and a declared `event` stops silently resolving to the
1042
+ // Deprecated `window.event` global (any other declared name threw ReferenceError outright).
1043
+ const args = declared.map((name: string) => (name === "state" ? "s" : "e")).join(", ");
1044
+ return `((${declared.join(", ")}) => { ${body} })(${args});`;
1045
+ }
831
1046
  return body.replaceAll(/(?<!this\.)state\./g, "s.").replaceAll(/(?<!this\.)state(?!\.)/g, "s");
832
1047
  }
833
1048
 
@@ -7,11 +7,13 @@
7
7
 
8
8
  import {
9
9
  buildAttrs,
10
+ childSeparator,
10
11
  colorSchemePrePaintScript,
11
12
  compileStyles,
12
13
  createCompileContext,
13
14
  escapeHtml,
14
15
  isNodeDynamic,
16
+ PREFORMATTED_TAGS,
15
17
  pureSchemeOf,
16
18
  resolveStaticValue,
17
19
  SELF_CLOSING,
@@ -127,6 +129,7 @@ function compileNode(
127
129
  scope: Record<string, unknown> | null;
128
130
  scopeDefs: Record<string, unknown>;
129
131
  media: Record<string, string>;
132
+ preformatted?: boolean;
130
133
  },
131
134
  islands: { def: JxMutableNode; tagName: string; className: string }[],
132
135
  ): string {
@@ -166,7 +169,9 @@ function compileNode(
166
169
  return `<${tag}${attrs}>`;
167
170
  }
168
171
 
169
- const inner = buildInnerWithIslands(def, raw, nextContext, islands);
172
+ // `white-space` inherits, so once inside a <pre> the whole subtree stays whitespace-significant.
173
+ const preformatted = context.preformatted === true || PREFORMATTED_TAGS.has(tag);
174
+ const inner = buildInnerWithIslands(def, raw, { ...nextContext, preformatted }, islands);
170
175
 
171
176
  return `<${tag}${attrs}>${inner}</${tag}>`;
172
177
  }
@@ -188,6 +193,7 @@ function buildInnerWithIslands(
188
193
  scope: Record<string, unknown> | null;
189
194
  scopeDefs: Record<string, unknown>;
190
195
  media: Record<string, string>;
196
+ preformatted?: boolean;
191
197
  },
192
198
  islands: { def: JxMutableNode; tagName: string; className: string }[],
193
199
  ): string {
@@ -209,7 +215,7 @@ function buildInnerWithIslands(
209
215
  const childRaw = (rawChildren?.[i] ?? c) as JxMutableNode;
210
216
  return compileNode(child, childDynamic, childRaw, context, islands);
211
217
  })
212
- .join("\n ");
218
+ .join(childSeparator(context.preformatted));
213
219
  }
214
220
  return "";
215
221
  }