@jxsuite/compiler 1.4.0 → 2.0.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.
@@ -16,21 +16,26 @@ import {
16
16
  DEFAULT_LIT_HTML_SRC,
17
17
  DEFAULT_REACTIVITY_SRC,
18
18
  buildAttrs,
19
+ childSeparator,
19
20
  colorSchemePrePaintScript,
20
21
  compileExpression,
21
22
  compileStatements,
22
23
  compileStyles,
23
24
  createCompileContext,
24
25
  emitFormulaFn,
26
+ emitRequestFetch,
25
27
  escapeHtml,
26
28
  isMutating,
27
29
  isRefObject,
28
30
  isSchemaOnly,
29
31
  isTemplateString,
32
+ PREFORMATTED_TAGS,
30
33
  pureSchemeOf,
31
34
  resolveStaticValue,
35
+ srcImportBinding,
32
36
  } from "../shared.ts";
33
37
  import {
38
+ bodyReturnsValue,
34
39
  hasStructuredBody,
35
40
  isExpandedSignal,
36
41
  isExpressionDef,
@@ -38,16 +43,24 @@ import {
38
43
  isMappedArray,
39
44
  isNamedFormulaDef,
40
45
  isPrototypeDef,
46
+ isTagExpression,
41
47
  paramNames,
48
+ tagNameCandidates,
42
49
  } from "@jxsuite/schema/guards";
43
50
  import type { CompileContext, ExpressionNode } from "../shared.ts";
51
+ import {
52
+ escapeToken,
53
+ objectKey,
54
+ refAccessor,
55
+ refBindingKey,
56
+ refSegments,
57
+ } from "@jxsuite/runtime/pointer";
44
58
  import type {
45
59
  JsonObject,
46
60
  JsonValue,
47
61
  JxDocument,
48
62
  JxMappedArray,
49
63
  JxMutableNode,
50
- JxPrototypeDef,
51
64
  JxStyle,
52
65
  } from "@jxsuite/schema/types";
53
66
 
@@ -161,11 +174,11 @@ export function compileClient(
161
174
  if (!srcImportMap.has(def.$src)) {
162
175
  srcImportMap.set(def.$src, new Set());
163
176
  }
164
- (srcImportMap.get(def.$src) as Set<string>).add(key);
177
+ (srcImportMap.get(def.$src) as Set<string>).add(srcImportBinding(key, def));
165
178
 
166
179
  // $src functions always produce computed entries (they return values)
167
180
  computedEntries.push([key, `() => { return ${key}(state); }`]);
168
- } else if (typeof def.body === "string" && def.body.includes("return")) {
181
+ } else if (typeof def.body === "string" && bodyReturnsValue(def.body)) {
169
182
  // Body contains return → computed
170
183
  computedEntries.push([key, `() => { ${def.body} }`]);
171
184
  } else {
@@ -200,7 +213,7 @@ export function compileClient(
200
213
  // $prototype: "Request"
201
214
  if (def.$prototype === "Request") {
202
215
  stateEntries.push([key, null]);
203
- initBlocks.push(emitRequestInit(key, def));
216
+ initBlocks.push(emitRequestFetch(key, def));
204
217
  continue;
205
218
  }
206
219
 
@@ -334,7 +347,22 @@ function buildClientNode(
334
347
  raw?.$media ?? context.media,
335
348
  );
336
349
 
350
+ /* REFUSED, LOUDLY. A chosen tag has no implementation in this target: the element's identity is
351
+ fixed in the prerendered markup, so there is nothing here to re-choose at hydration the way the
352
+ `$switch` binding below re-chooses a subtree. Emitting a silently wrong element would be an
353
+ instance of the class the tagName pattern was added to end, so a dynamic page that needs a
354
+ chosen tag is told to put the element in a component (which compiles through `compile-element`,
355
+ where the construct is implemented) rather than shipping markup with the wrong tag. */
356
+ if (isTagExpression(def.tagName)) {
357
+ throw new Error(
358
+ "A tag chosen at creation is not supported on a dynamic page yet (candidates: " +
359
+ `${tagNameCandidates(def.tagName).join(", ")}). Move the element into a component, or ` +
360
+ "give it a literal tagName.",
361
+ );
362
+ }
337
363
  const tag = def.tagName ?? "div";
364
+ // `white-space` inherits, so once inside a <pre> the whole subtree stays whitespace-significant.
365
+ nextContext.preformatted = context.preformatted === true || PREFORMATTED_TAGS.has(tag);
338
366
  const bindAttrs = [];
339
367
  let needsBind = false;
340
368
 
@@ -478,6 +506,33 @@ function buildClientNode(
478
506
  // Inner content
479
507
  let inner = "";
480
508
  const source = raw ?? def;
509
+ if (source.$switch) {
510
+ // ─── $switch → lit-html render binding ───
511
+ // Without this branch the node fell through to the generic element path, which emitted a
512
+ // Container and then looked for `children` to recurse into — `cases` is not `children`, so the
513
+ // Subtree was never visited and the page shipped with the content missing, no error (#127).
514
+ // The container is emitted empty for the same reason the mapped-array branch below is: lit's
515
+ // `render` appends into the container rather than replacing its existing children, so any
516
+ // Prerendered branch would survive alongside the rendered one.
517
+ counter.needsLit = true;
518
+ const swKey = `_sw${counter.sw}`;
519
+ counter.sw += 1;
520
+ const discriminant = isRefObject(source.$switch)
521
+ ? `state.${refToBindingKey((source.$switch as { $ref: string }).$ref)}`
522
+ : `\`${String(source.$switch)}\``;
523
+ const cases = (source.cases ?? {}) as Record<string, JxMutableNode>;
524
+ const caseEntries = Object.entries(cases)
525
+ // An external `$ref` case cannot be fetched at compile time, exactly as in the static target.
526
+ .filter(([, caseDef]) => caseDef && !isRefObject(caseDef))
527
+ .map(
528
+ ([key, caseDef]) =>
529
+ `${JSON.stringify(key)}: html\`${emitChildLit(caseDef, nextContext.preformatted)}\``,
530
+ );
531
+ bindings.set(swKey, `() => ({${caseEntries.join(", ")}})[String(${discriminant})] ?? html\`\``);
532
+ bindAttrs.push(`:render="${swKey}"`);
533
+ const bindAttrStrSwitch = ` ${bindAttrs.join(" ")}`;
534
+ return `<${tag}${staticAttrs} data-bind${bindAttrStrSwitch}></${tag}>`;
535
+ }
481
536
  if (source.textContent !== undefined && !needsBind) {
482
537
  const value = resolveStaticValue(source.textContent, nextContext.scope);
483
538
  inner = value == null ? "" : escapeHtml(String(value));
@@ -530,9 +585,14 @@ function buildClientNode(
530
585
  const listKey = `_children${counter.l}`;
531
586
  counter.l += 1;
532
587
  const childrenTpl = source.children
533
- .map((c) => emitChildLit(c as JxMutableNode))
534
- .join("\n ");
535
- bindings.set(listKey, `() => html\`\n ${childrenTpl}\n \``);
588
+ .map((c) => emitChildLit(c as JxMutableNode, nextContext.preformatted))
589
+ .join(childSeparator(nextContext.preformatted, " "));
590
+ bindings.set(
591
+ listKey,
592
+ nextContext.preformatted
593
+ ? `() => html\`${childrenTpl}\``
594
+ : `() => html\`\n ${childrenTpl}\n \``,
595
+ );
536
596
  bindAttrs.push(`:render="${listKey}"`);
537
597
  needsBind = true;
538
598
  const bindAttrStr2 = ` ${bindAttrs.join(" ")}`;
@@ -551,7 +611,7 @@ function buildClientNode(
551
611
  counter,
552
612
  );
553
613
  })
554
- .join("\n ");
614
+ .join(childSeparator(nextContext.preformatted));
555
615
  }
556
616
 
557
617
  // Self-closing tags
@@ -570,13 +630,25 @@ function buildClientNode(
570
630
  * index.
571
631
  *
572
632
  * @param {JxMutableNode} def
633
+ * @param {boolean} [preformatted]
573
634
  * @returns {string}
574
635
  */
575
- function emitLitMapTemplate(def: JxMutableNode | undefined) {
636
+ function emitLitMapTemplate(def: JxMutableNode | undefined, preformatted = false) {
576
637
  if (!def) {
577
638
  return "";
578
639
  }
640
+ // Same refusal as `buildClientNode` above, for the repeater's item template — this target has no
641
+ // Branch construct, and a silently wrong element is what the tagName pattern exists to prevent.
642
+ if (isTagExpression(def.tagName)) {
643
+ throw new Error(
644
+ "A tag chosen at creation is not supported inside a dynamic page's repeater yet " +
645
+ `(candidates: ${tagNameCandidates(def.tagName).join(", ")}). Move the item into a ` +
646
+ "component, or give it a literal tagName.",
647
+ );
648
+ }
579
649
  const tag = def.tagName ?? "div";
650
+ // `white-space` inherits, so once inside a <pre> the whole subtree stays whitespace-significant.
651
+ const inPre = preformatted || PREFORMATTED_TAGS.has(tag);
580
652
  let attrs = "";
581
653
 
582
654
  if (def.id) {
@@ -586,6 +658,20 @@ function emitLitMapTemplate(def: JxMutableNode | undefined) {
586
658
  attrs += ` class="${mapRefsToLit(def.className)}"`;
587
659
  }
588
660
 
661
+ // $props → lit property bindings. The element target emits these; the client target emitted
662
+ // Nothing, so a component in a list silently received none of its per-item props.
663
+ if (def.$props && typeof def.$props === "object") {
664
+ for (const [k, v] of Object.entries(def.$props)) {
665
+ if (isRefObject(v)) {
666
+ attrs += ` .${k}="\${${mapRefToClientExpr((v as { $ref: string }).$ref)}}"`;
667
+ } else if (typeof v === "string" && isTemplateString(v)) {
668
+ attrs += ` .${k}="${mapRefsToLit(v)}"`;
669
+ } else {
670
+ attrs += ` .${k}="\${${JSON.stringify(v)}}"`;
671
+ }
672
+ }
673
+ }
674
+
589
675
  // Attributes object
590
676
  if (def.attributes && typeof def.attributes === "object") {
591
677
  for (const [k, v] of Object.entries(def.attributes)) {
@@ -674,7 +760,8 @@ function emitLitMapTemplate(def: JxMutableNode | undefined) {
674
760
  // Legacy whole-children repeater nested inside a map template.
675
761
  inner = `\n ${emitArrayHole(def.children)}\n `;
676
762
  } else if (Array.isArray(def.children)) {
677
- inner = `\n ${def.children.map((c) => emitChildLit(c)).join("\n ")}\n `;
763
+ const rendered = def.children.map((c) => emitChildLit(c, inPre));
764
+ inner = inPre ? rendered.join("") : `\n ${rendered.join("\n ")}\n `;
678
765
  }
679
766
 
680
767
  const voidTags = new Set(["input", "br", "hr", "img", "meta", "link"]);
@@ -689,9 +776,10 @@ function emitLitMapTemplate(def: JxMutableNode | undefined) {
689
776
  * via a `.map()` hole), or a nested element.
690
777
  *
691
778
  * @param {JxMutableNode | string} c
779
+ * @param {boolean} [preformatted]
692
780
  * @returns {string}
693
781
  */
694
- function emitChildLit(c: JxMutableNode | string): string {
782
+ function emitChildLit(c: JxMutableNode | string, preformatted = false): string {
695
783
  if (typeof c === "string") {
696
784
  return isTemplateString(c) ? mapRefsToLit(c) : escapeHtml(c);
697
785
  }
@@ -701,7 +789,7 @@ function emitChildLit(c: JxMutableNode | string): string {
701
789
  if (isMappedArray(c)) {
702
790
  return emitArrayHole(c);
703
791
  }
704
- return emitLitMapTemplate(c);
792
+ return emitLitMapTemplate(c, preformatted);
705
793
  }
706
794
 
707
795
  /**
@@ -798,7 +886,11 @@ function emitClientModule(
798
886
  // State — reactive state
799
887
  lines.push("", "const state = reactive({");
800
888
  for (const [key, val] of stateEntries) {
801
- lines.push(` ${key}: ${JSON.stringify(val)},`);
889
+ /*
890
+ * State keys come from the document, so `{"user.name": 1}` is legal input. Pasting one raw as
891
+ * an identifier emitted `user.name: 1,` — a SyntaxError the build reported as a success.
892
+ */
893
+ lines.push(` ${objectKey(key)}: ${JSON.stringify(val)},`);
802
894
  }
803
895
  lines.push("});", "");
804
896
 
@@ -813,7 +905,7 @@ function emitClientModule(
813
905
  // Computed signals on state
814
906
  if (computedEntries.length > 0) {
815
907
  for (const [key, expr] of computedEntries) {
816
- lines.push(`state.${key} = computed(${expr});`);
908
+ lines.push(`${refAccessor("state", escapeToken(key))} = computed(${expr});`);
817
909
  }
818
910
  lines.push("");
819
911
  }
@@ -822,7 +914,7 @@ function emitClientModule(
822
914
  if (bindEntries.length > 0) {
823
915
  lines.push("const bind = {");
824
916
  for (const [key, expr] of bindEntries) {
825
- lines.push(` ${key}: ${expr},`);
917
+ lines.push(` ${objectKey(key)}: ${expr},`);
826
918
  }
827
919
  lines.push("};");
828
920
  } else {
@@ -837,7 +929,7 @@ function emitClientModule(
837
929
  const argNames = def.args ?? ["state"];
838
930
  const callArgs = argNames.map((a: string) => (a === "state" ? "state" : "e")).join(", ");
839
931
  lines.push(
840
- ` ${key}: (e) => { const fn = (${argNames.join(", ")}) => { ${def.body} }; fn(${
932
+ ` ${objectKey(key)}: (e) => { const fn = (${argNames.join(", ")}) => { ${def.body} }; fn(${
841
933
  callArgs
842
934
  }); },`,
843
935
  );
@@ -888,61 +980,8 @@ function emitClientModule(
888
980
  }
889
981
 
890
982
  // ─── Prototype init emitters ─────────────────────────────────────────────────
891
-
892
- /**
893
- * @param {string} key
894
- * @param {JxMutableNode} def
895
- * @returns {string}
896
- */
897
- function emitRequestInit(key: string, def: JxPrototypeDef) {
898
- const { url } = def;
899
- const method = def.method ?? "GET";
900
- const isTemplateUrl = isTemplateString(url);
901
-
902
- if (def.manual) {
903
- return `// ${key}: manual Request — fetch triggered by user action`;
904
- }
905
-
906
- const lines: string[] = [
907
- `// ${key}: auto-fetch from ${isTemplateUrl ? "(dynamic URL)" : url}`,
908
- "effect(() => {",
909
- ];
910
-
911
- if (isTemplateUrl) {
912
- lines.push(
913
- ` const url = \`${url}\`;`,
914
- ' if (!url || url === "undefined" || url.includes("undefined")) return;',
915
- );
916
- } else {
917
- lines.push(` const url = ${JSON.stringify(url)};`);
918
- }
919
-
920
- const fetchOpts: string[] = [];
921
- if (method !== "GET") {
922
- fetchOpts.push(`method: ${JSON.stringify(method)}`);
923
- }
924
- if (def.headers) {
925
- fetchOpts.push(`headers: ${JSON.stringify(def.headers)}`);
926
- }
927
- if (def.body) {
928
- const bodyStr =
929
- typeof def.body === "object"
930
- ? JSON.stringify(JSON.stringify(def.body))
931
- : JSON.stringify(def.body);
932
- fetchOpts.push(`body: ${bodyStr}`);
933
- }
934
-
935
- const optsStr = fetchOpts.length > 0 ? `, { ${fetchOpts.join(", ")} }` : "";
936
- lines.push(
937
- ` fetch(url${optsStr})`,
938
- " .then(r => r.ok ? r.json() : Promise.reject(r.statusText))",
939
- ` .then(d => { state.${key} = d; })`,
940
- ` .catch(e => { state.${key} = { error: String(e) }; });`,
941
- "});",
942
- );
943
-
944
- return lines.join("\n");
945
- }
983
+ // Request auto-fetch lives in shared.ts (`emitRequestFetch`) so the element target emits the same
984
+ // Effect against `this.state`.
946
985
 
947
986
  /**
948
987
  * @param {string} key
@@ -995,11 +1034,38 @@ function emitCookieInit(key: string, cookieName: string, defaultVal: unknown) {
995
1034
  * @param {string} ref
996
1035
  * @returns {string}
997
1036
  */
998
- function refToBindingKey(ref: string) {
1037
+ /**
1038
+ * Lower a `$ref` to the JS expression that reads it inside a map callback — the mirror of the
1039
+ * element target's `refToExpr`/`mapRefToExpr`. Distinct from `refToBindingKey` below, which
1040
+ * flattens `/` to `_` to name a binding and would produce nonsense (`state.$map_item`) as an
1041
+ * expression.
1042
+ *
1043
+ * @param {string} ref
1044
+ * @returns {string}
1045
+ */
1046
+ function mapRefToClientExpr(ref: string) {
1047
+ if (ref.startsWith("$map/")) {
1048
+ // The loop variable is an emitted identifier, so its first segment is the base, not a member.
1049
+ const [head, ...rest] = refSegments(ref.slice("$map/".length));
1050
+ return refAccessor(head ?? "", rest.map((seg) => escapeToken(seg)).join("/"));
1051
+ }
999
1052
  if (ref.startsWith("#/state/")) {
1000
- return ref.slice("#/state/".length).replaceAll("/", "_");
1053
+ return refAccessor("state", ref.slice("#/state/".length));
1001
1054
  }
1002
- return ref.replaceAll("/", "_");
1055
+ return refAccessor("state", ref);
1056
+ }
1057
+
1058
+ /**
1059
+ * The `bind`/`on` map key for a ref. It is emitted both as an object-literal key and as a
1060
+ * `data-bind` attribute value, so it has to survive being a JavaScript identifier and an HTML
1061
+ * attribute at once. Stripping the `#/state/` prefix first keeps every pure-slash ref on the key it
1062
+ * already had — `#/state/user/name` is still `user_name` — so this fix changes no shipped output.
1063
+ *
1064
+ * @param {string} ref
1065
+ * @returns {string}
1066
+ */
1067
+ function refToBindingKey(ref: string) {
1068
+ return refBindingKey(ref.startsWith("#/state/") ? ref.slice("#/state/".length) : ref);
1003
1069
  }
1004
1070
 
1005
1071
  /**
@@ -1011,11 +1077,8 @@ function addRefBinding(bindings: Map<string, string>, key: string, ref: string)
1011
1077
  if (bindings.has(key)) {
1012
1078
  return;
1013
1079
  }
1014
- if (ref.startsWith("#/state/")) {
1015
- const path = ref.slice("#/state/".length);
1016
- const parts = path.split("/");
1017
- bindings.set(key, `() => state.${parts.join(".")}`);
1018
- } else {
1019
- bindings.set(key, `() => state.${ref}`);
1020
- }
1080
+ bindings.set(
1081
+ key,
1082
+ `() => ${refAccessor("state", ref.startsWith("#/state/") ? ref.slice("#/state/".length) : ref)}`,
1083
+ );
1021
1084
  }