@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.
@@ -3,9 +3,11 @@
3
3
  *
4
4
  * Extends the original project.json check into the full compliance walk:
5
5
  *
6
- * 1. `project.json` against the generated entry schema (validateProjectFile);
7
- * 2. The committed entry documents are SELF-CONTAINED (no residual relative `$ref`s — the
8
- * editor-resolution guarantee of the bundled form, specs/extensions.md §5.4);
6
+ * 1. The committed entry documents are SELF-CONTAINED and SINGLE-RESOURCE: every `$ref` is a
7
+ * root-relative JSON Pointer that resolves inside the file — the editor-resolution guarantee of
8
+ * the committed form (specs/extensions.md §5.4). Checked first and fatal, since walks 2–4
9
+ * compile those documents;
10
+ * 2. `project.json` against the generated entry schema (validateProjectFile);
9
11
  * 3. Every document under `components/`, `pages/`, and `layouts/` against the bundled document schema
10
12
  * (core + extension `$paths` union — the same schema the studio consumes);
11
13
  * 4. Every project-local `*.class.json` against the generated class schema;
@@ -94,33 +96,39 @@ export async function validateProjectTree(
94
96
  const issues: ProjectTreeIssue[] = [];
95
97
  let checked = 0;
96
98
 
97
- // 1. project.json against the generated entry schema.
98
- const projectResult = await validateProjectFile(root);
99
- checked += 1;
100
- if (!projectResult.valid) {
101
- issues.push({ errors: projectResult.errors ?? [], file: "project.json" });
102
- }
103
-
104
- // 2. Committed entry documents must be self-contained (the editor-resolution guarantee).
99
+ /* 1. Committed entry documents must be self-contained (the editor-resolution guarantee). This
100
+ runs FIRST because every later walk compiles one of them: an unresolvable ref surfaces from ajv
101
+ as a thrown MissingRefError, so diagnosing it here turns a stack trace into "regenerate with
102
+ `jx schema`". Nothing downstream is meaningful until it holds, hence the early return. */
105
103
  for (const name of ["project.schema.json", "document.schema.json"]) {
106
104
  const path = resolve(root, name);
107
105
  if (!existsSync(path)) {
108
106
  continue;
109
107
  }
110
108
  checked += 1;
111
- const residual: string[] = [];
112
- collectRelativeRefs(JSON.parse(readFileSync(path, "utf8")), residual);
113
- if (residual.length > 0) {
109
+ const entry = JSON.parse(readFileSync(path, "utf8")) as Record<string, unknown>;
110
+ const unresolvable = collectUnresolvableRefs(entry);
111
+ if (unresolvable.length > 0) {
114
112
  issues.push({
115
- errors: residual.map((ref) => ({
113
+ errors: unresolvable.map(({ reason, ref }) => ({
116
114
  message:
117
- `relative $ref "${ref}" — committed entry documents must be self-contained; ` +
118
- `regenerate with \`jx schema\``,
115
+ `$ref "${ref}" ${reason} — committed entry documents must be self-contained ` +
116
+ `single-resource schemas; regenerate with \`jx schema\``,
119
117
  })),
120
118
  file: name,
121
119
  });
122
120
  }
123
121
  }
122
+ if (issues.length > 0) {
123
+ return { checked, issues, valid: false };
124
+ }
125
+
126
+ // 2. project.json against the generated entry schema.
127
+ const projectResult = await validateProjectFile(root);
128
+ checked += 1;
129
+ if (!projectResult.valid) {
130
+ issues.push({ errors: projectResult.errors ?? [], file: "project.json" });
131
+ }
124
132
 
125
133
  const { addFormats, Ajv } = await loadAjv();
126
134
 
@@ -186,30 +194,118 @@ export async function validateProjectTree(
186
194
  return { checked, issues, valid: issues.length === 0 };
187
195
  }
188
196
 
189
- /** Collect relative-path `$ref`s (anything without a URI scheme that isn't a `#` pointer). */
190
- function collectRelativeRefs(node: unknown, out: string[]): void {
191
- if (Array.isArray(node)) {
192
- for (const item of node) {
193
- collectRelativeRefs(item, out);
197
+ /**
198
+ * Every `$ref` a committed entry document may carry must be a root-relative JSON Pointer that
199
+ * resolves inside the same file. Three ways that fails, all of which break editor resolution: a
200
+ * relative path (needs Node module resolution), a URI (VS Code fetches it over the network instead
201
+ * of matching an embedded `$id`), and a pointer with no node at the other end.
202
+ *
203
+ * Walks only subschema keyword positions, since `examples`/`default`/`const`/`enum` hold instance
204
+ * data and Jx document examples legitimately contain `$ref` strings of their own.
205
+ *
206
+ * @param {Record<string, unknown>} entry - Parsed committed entry document
207
+ * @returns {{ ref: string; reason: string }[]} One entry per offending `$ref`
208
+ */
209
+ function collectUnresolvableRefs(
210
+ entry: Record<string, unknown>,
211
+ ): { ref: string; reason: string }[] {
212
+ const out: { ref: string; reason: string }[] = [];
213
+ const seen = new Set<Record<string, unknown>>();
214
+
215
+ const resolvePointer = (pointer: string): boolean => {
216
+ let current: unknown = entry;
217
+ for (const raw of pointer.slice(1).split("/")) {
218
+ if (raw === "") {
219
+ continue;
220
+ }
221
+ const segment = raw.replaceAll("~1", "/").replaceAll("~0", "~");
222
+ if (typeof current !== "object" || current === null) {
223
+ return false;
224
+ }
225
+ current = (current as Record<string, unknown>)[segment];
226
+ if (current === undefined) {
227
+ return false;
228
+ }
229
+ }
230
+ return true;
231
+ };
232
+
233
+ const walk = (node: unknown): void => {
234
+ if (!isRecord(node) || seen.has(node)) {
235
+ return;
194
236
  }
195
- return;
196
- }
197
- if (node === null || typeof node !== "object") {
198
- return;
199
- }
200
- for (const [key, value] of Object.entries(node as Record<string, unknown>)) {
201
- if (
202
- key === "$ref" &&
203
- typeof value === "string" &&
204
- !value.startsWith("#") &&
205
- !/^[a-zA-Z][a-zA-Z\d+.-]*:/.test(value)
206
- ) {
207
- out.push(value);
237
+ seen.add(node);
238
+ const ref = node.$ref;
239
+ if (typeof ref === "string") {
240
+ if (/^[a-zA-Z][a-zA-Z\d+.-]*:/.test(ref)) {
241
+ out.push({ reason: "is an absolute URI editors resolve over the network", ref });
242
+ } else if (!ref.startsWith("#")) {
243
+ out.push({ reason: "is a relative path only Node module resolution can follow", ref });
244
+ } else if (ref.length > 1 && !ref.startsWith("#/")) {
245
+ out.push({ reason: "is an $anchor reference, which has no root-pointer form", ref });
246
+ } else if (ref.length > 1 && !resolvePointer(ref.slice(1))) {
247
+ out.push({ reason: "points at no node in this document", ref });
248
+ }
208
249
  }
209
- collectRelativeRefs(value, out);
210
- }
250
+ for (const [keyword, value] of Object.entries(node)) {
251
+ if (!SUBSCHEMA_KEYWORDS.has(keyword)) {
252
+ continue;
253
+ }
254
+ if (Array.isArray(value)) {
255
+ for (const item of value) {
256
+ walk(item);
257
+ }
258
+ } else if (isRecord(value)) {
259
+ // Map-valued keywords hold subschemas one level down; single-valued ones are the schema.
260
+ walk(value);
261
+ if (MAP_SUBSCHEMA_KEYWORDS.has(keyword)) {
262
+ for (const item of Object.values(value)) {
263
+ walk(item);
264
+ }
265
+ }
266
+ }
267
+ }
268
+ };
269
+ walk(entry);
270
+ return out;
211
271
  }
212
272
 
273
+ /** True for a plain object (a candidate subschema node). */
274
+ function isRecord(value: unknown): value is Record<string, unknown> {
275
+ return typeof value === "object" && value !== null && !Array.isArray(value);
276
+ }
277
+
278
+ /** Keywords whose values map names to subschemas. */
279
+ const MAP_SUBSCHEMA_KEYWORDS = new Set([
280
+ "$defs",
281
+ "definitions",
282
+ "dependencies",
283
+ "dependentSchemas",
284
+ "patternProperties",
285
+ "properties",
286
+ ]);
287
+
288
+ /** Every keyword position a subschema can occupy (see {@link MAP_SUBSCHEMA_KEYWORDS} for maps). */
289
+ const SUBSCHEMA_KEYWORDS = new Set([
290
+ ...MAP_SUBSCHEMA_KEYWORDS,
291
+ "additionalItems",
292
+ "additionalProperties",
293
+ "allOf",
294
+ "anyOf",
295
+ "contains",
296
+ "contentSchema",
297
+ "else",
298
+ "if",
299
+ "items",
300
+ "not",
301
+ "oneOf",
302
+ "prefixItems",
303
+ "propertyNames",
304
+ "then",
305
+ "unevaluatedItems",
306
+ "unevaluatedProperties",
307
+ ]);
308
+
213
309
  /** Recursively list `.json` files under a directory (absent directories yield nothing). */
214
310
  function walkJsonFiles(dir: string): string[] {
215
311
  if (!existsSync(dir)) {
@@ -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,
@@ -47,7 +52,6 @@ import type {
47
52
  JxDocument,
48
53
  JxMappedArray,
49
54
  JxMutableNode,
50
- JxPrototypeDef,
51
55
  JxStyle,
52
56
  } from "@jxsuite/schema/types";
53
57
 
@@ -161,11 +165,11 @@ export function compileClient(
161
165
  if (!srcImportMap.has(def.$src)) {
162
166
  srcImportMap.set(def.$src, new Set());
163
167
  }
164
- (srcImportMap.get(def.$src) as Set<string>).add(key);
168
+ (srcImportMap.get(def.$src) as Set<string>).add(srcImportBinding(key, def));
165
169
 
166
170
  // $src functions always produce computed entries (they return values)
167
171
  computedEntries.push([key, `() => { return ${key}(state); }`]);
168
- } else if (typeof def.body === "string" && def.body.includes("return")) {
172
+ } else if (typeof def.body === "string" && bodyReturnsValue(def.body)) {
169
173
  // Body contains return → computed
170
174
  computedEntries.push([key, `() => { ${def.body} }`]);
171
175
  } else {
@@ -200,7 +204,7 @@ export function compileClient(
200
204
  // $prototype: "Request"
201
205
  if (def.$prototype === "Request") {
202
206
  stateEntries.push([key, null]);
203
- initBlocks.push(emitRequestInit(key, def));
207
+ initBlocks.push(emitRequestFetch(key, def));
204
208
  continue;
205
209
  }
206
210
 
@@ -335,6 +339,8 @@ function buildClientNode(
335
339
  );
336
340
 
337
341
  const tag = def.tagName ?? "div";
342
+ // `white-space` inherits, so once inside a <pre> the whole subtree stays whitespace-significant.
343
+ nextContext.preformatted = context.preformatted === true || PREFORMATTED_TAGS.has(tag);
338
344
  const bindAttrs = [];
339
345
  let needsBind = false;
340
346
 
@@ -530,9 +536,14 @@ function buildClientNode(
530
536
  const listKey = `_children${counter.l}`;
531
537
  counter.l += 1;
532
538
  const childrenTpl = source.children
533
- .map((c) => emitChildLit(c as JxMutableNode))
534
- .join("\n ");
535
- bindings.set(listKey, `() => html\`\n ${childrenTpl}\n \``);
539
+ .map((c) => emitChildLit(c as JxMutableNode, nextContext.preformatted))
540
+ .join(childSeparator(nextContext.preformatted, " "));
541
+ bindings.set(
542
+ listKey,
543
+ nextContext.preformatted
544
+ ? `() => html\`${childrenTpl}\``
545
+ : `() => html\`\n ${childrenTpl}\n \``,
546
+ );
536
547
  bindAttrs.push(`:render="${listKey}"`);
537
548
  needsBind = true;
538
549
  const bindAttrStr2 = ` ${bindAttrs.join(" ")}`;
@@ -551,7 +562,7 @@ function buildClientNode(
551
562
  counter,
552
563
  );
553
564
  })
554
- .join("\n ");
565
+ .join(childSeparator(nextContext.preformatted));
555
566
  }
556
567
 
557
568
  // Self-closing tags
@@ -570,13 +581,16 @@ function buildClientNode(
570
581
  * index.
571
582
  *
572
583
  * @param {JxMutableNode} def
584
+ * @param {boolean} [preformatted]
573
585
  * @returns {string}
574
586
  */
575
- function emitLitMapTemplate(def: JxMutableNode | undefined) {
587
+ function emitLitMapTemplate(def: JxMutableNode | undefined, preformatted = false) {
576
588
  if (!def) {
577
589
  return "";
578
590
  }
579
591
  const tag = def.tagName ?? "div";
592
+ // `white-space` inherits, so once inside a <pre> the whole subtree stays whitespace-significant.
593
+ const inPre = preformatted || PREFORMATTED_TAGS.has(tag);
580
594
  let attrs = "";
581
595
 
582
596
  if (def.id) {
@@ -586,6 +600,20 @@ function emitLitMapTemplate(def: JxMutableNode | undefined) {
586
600
  attrs += ` class="${mapRefsToLit(def.className)}"`;
587
601
  }
588
602
 
603
+ // $props → lit property bindings. The element target emits these; the client target emitted
604
+ // Nothing, so a component in a list silently received none of its per-item props.
605
+ if (def.$props && typeof def.$props === "object") {
606
+ for (const [k, v] of Object.entries(def.$props)) {
607
+ if (isRefObject(v)) {
608
+ attrs += ` .${k}="\${${mapRefToClientExpr((v as { $ref: string }).$ref)}}"`;
609
+ } else if (typeof v === "string" && isTemplateString(v)) {
610
+ attrs += ` .${k}="${mapRefsToLit(v)}"`;
611
+ } else {
612
+ attrs += ` .${k}="\${${JSON.stringify(v)}}"`;
613
+ }
614
+ }
615
+ }
616
+
589
617
  // Attributes object
590
618
  if (def.attributes && typeof def.attributes === "object") {
591
619
  for (const [k, v] of Object.entries(def.attributes)) {
@@ -674,7 +702,8 @@ function emitLitMapTemplate(def: JxMutableNode | undefined) {
674
702
  // Legacy whole-children repeater nested inside a map template.
675
703
  inner = `\n ${emitArrayHole(def.children)}\n `;
676
704
  } else if (Array.isArray(def.children)) {
677
- inner = `\n ${def.children.map((c) => emitChildLit(c)).join("\n ")}\n `;
705
+ const rendered = def.children.map((c) => emitChildLit(c, inPre));
706
+ inner = inPre ? rendered.join("") : `\n ${rendered.join("\n ")}\n `;
678
707
  }
679
708
 
680
709
  const voidTags = new Set(["input", "br", "hr", "img", "meta", "link"]);
@@ -689,9 +718,10 @@ function emitLitMapTemplate(def: JxMutableNode | undefined) {
689
718
  * via a `.map()` hole), or a nested element.
690
719
  *
691
720
  * @param {JxMutableNode | string} c
721
+ * @param {boolean} [preformatted]
692
722
  * @returns {string}
693
723
  */
694
- function emitChildLit(c: JxMutableNode | string): string {
724
+ function emitChildLit(c: JxMutableNode | string, preformatted = false): string {
695
725
  if (typeof c === "string") {
696
726
  return isTemplateString(c) ? mapRefsToLit(c) : escapeHtml(c);
697
727
  }
@@ -701,7 +731,7 @@ function emitChildLit(c: JxMutableNode | string): string {
701
731
  if (isMappedArray(c)) {
702
732
  return emitArrayHole(c);
703
733
  }
704
- return emitLitMapTemplate(c);
734
+ return emitLitMapTemplate(c, preformatted);
705
735
  }
706
736
 
707
737
  /**
@@ -888,61 +918,8 @@ function emitClientModule(
888
918
  }
889
919
 
890
920
  // ─── 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
- }
921
+ // Request auto-fetch lives in shared.ts (`emitRequestFetch`) so the element target emits the same
922
+ // Effect against `this.state`.
946
923
 
947
924
  /**
948
925
  * @param {string} key
@@ -995,6 +972,25 @@ function emitCookieInit(key: string, cookieName: string, defaultVal: unknown) {
995
972
  * @param {string} ref
996
973
  * @returns {string}
997
974
  */
975
+ /**
976
+ * Lower a `$ref` to the JS expression that reads it inside a map callback — the mirror of the
977
+ * element target's `refToExpr`/`mapRefToExpr`. Distinct from `refToBindingKey` below, which
978
+ * flattens `/` to `_` to name a binding and would produce nonsense (`state.$map_item`) as an
979
+ * expression.
980
+ *
981
+ * @param {string} ref
982
+ * @returns {string}
983
+ */
984
+ function mapRefToClientExpr(ref: string) {
985
+ if (ref.startsWith("$map/")) {
986
+ return ref.slice("$map/".length).replaceAll("/", ".");
987
+ }
988
+ if (ref.startsWith("#/state/")) {
989
+ return `state.${ref.slice("#/state/".length).replaceAll("/", ".")}`;
990
+ }
991
+ return `state.${ref.replaceAll("/", ".")}`;
992
+ }
993
+
998
994
  function refToBindingKey(ref: string) {
999
995
  if (ref.startsWith("#/state/")) {
1000
996
  return ref.slice("#/state/".length).replaceAll("/", "_");