@jxsuite/schema 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.
@@ -56,3 +56,78 @@ export const relationshipRefSchema = {
56
56
  required: ["$ref"],
57
57
  type: "object",
58
58
  } as const;
59
+
60
+ /**
61
+ * The CORE `$paths` source shapes — the members the shipped default paths union carries, and the
62
+ * ones every per-project entry document unions extension shapes on top of.
63
+ *
64
+ * These mirror `resolvePathEntries` in the compiler's pages-discovery exactly (specs/
65
+ * site-architecture.md §4.3): an explicit value list, a JSON data-file reference, and the legacy
66
+ * bare array of parameter objects. Anything else is dispatched by discriminator key to an
67
+ * extension's `resolvePaths` capability, so extension shapes reach the union through the entry
68
+ * document rather than from here.
69
+ *
70
+ * Each shape is CLOSED (`additionalProperties: false`). `$paths` is a build-time instruction with a
71
+ * fully specified grammar and no forward-compatible extension points of its own — a stray or
72
+ * misspelled key means the route silently expands to nothing, which is exactly the failure worth
73
+ * catching in the editor.
74
+ */
75
+ export const documentPathsCoreMembers = [
76
+ {
77
+ additionalProperties: false,
78
+ description:
79
+ 'Explicit value list: generates one page per value, bound to `param` (default "value").',
80
+ properties: {
81
+ param: { description: 'Route parameter to fill (default "value").', type: "string" },
82
+ values: { description: "The values to expand.", type: "array" },
83
+ },
84
+ required: ["values"],
85
+ type: "object",
86
+ },
87
+ {
88
+ additionalProperties: false,
89
+ description:
90
+ "JSON data-file source: loads a project-relative file that must contain an array, and " +
91
+ "generates one page per item.",
92
+ properties: {
93
+ $ref: {
94
+ description: "Project-relative path to a .json file containing an array.",
95
+ type: "string",
96
+ },
97
+ field: {
98
+ description: 'Item field providing the parameter value (default "id").',
99
+ type: "string",
100
+ },
101
+ param: { description: 'Route parameter to fill (default "id").', type: "string" },
102
+ },
103
+ required: ["$ref"],
104
+ type: "object",
105
+ },
106
+ {
107
+ description:
108
+ "Legacy explicit form: an array of ready-made parameter objects, used as-is with no " +
109
+ "expansion. Prefer one of the source shapes above.",
110
+ items: { additionalProperties: { type: "string" }, type: "object" },
111
+ type: "array",
112
+ },
113
+ ] as const;
114
+
115
+ /**
116
+ * The extra member the SHIPPED DEFAULT union carries and a per-project entry document does not.
117
+ *
118
+ * A default cannot know which extensions a project enables, so without this any extension source
119
+ * shape — `{ contentType: "blog" }` with the parser on — would be a FALSE ERROR wherever the
120
+ * default is what is in play: the studio's offline fallback before per-project schemas arrive, and
121
+ * clients that fetch the canonical URL. Spec §5.4 promises that path degrades to
122
+ * _under-suggestion_, never false errors, so the default accepts any non-empty object and the entry
123
+ * document — which does know the enabled extensions, and is what `jx validate` and every editor
124
+ * actually resolve — is the strict one.
125
+ */
126
+ export const documentPathsUnknownSourceMember = {
127
+ description:
128
+ "A source shape contributed by an extension this schema cannot see. The project's generated " +
129
+ "document.schema.json validates these exactly; enable the owning extension and regenerate " +
130
+ "with `jx schema` to get precise checking here.",
131
+ minProperties: 1,
132
+ type: "object",
133
+ } as const;
package/package.json CHANGED
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "dependencies": {
3
- "@webref/css": "^8.7.0",
3
+ "@webref/css": "^8.7.1",
4
4
  "@webref/elements": "^2.7.1",
5
- "@webref/idl": "^3.82.0",
5
+ "@webref/idl": "^3.82.1",
6
6
  "ajv": "^8.20.0",
7
7
  "ajv-formats": "^3.0.1"
8
8
  },
@@ -50,7 +50,7 @@
50
50
  "upgrade": "bunx npm-check-updates -u && bun install"
51
51
  },
52
52
  "type": "module",
53
- "version": "1.4.0",
53
+ "version": "1.5.0",
54
54
  "devDependencies": {
55
55
  "json-schema-to-ts": "^3.1.1"
56
56
  }
package/schema.json CHANGED
@@ -7719,6 +7719,26 @@
7719
7719
  }
7720
7720
  ]
7721
7721
  },
7722
+ "animation-delay-end": {
7723
+ "oneOf": [
7724
+ {
7725
+ "type": "string"
7726
+ },
7727
+ {
7728
+ "type": "number"
7729
+ }
7730
+ ]
7731
+ },
7732
+ "animation-delay-start": {
7733
+ "oneOf": [
7734
+ {
7735
+ "type": "string"
7736
+ },
7737
+ {
7738
+ "type": "number"
7739
+ }
7740
+ ]
7741
+ },
7722
7742
  "animation-direction": {
7723
7743
  "oneOf": [
7724
7744
  {
@@ -7869,6 +7889,26 @@
7869
7889
  }
7870
7890
  ]
7871
7891
  },
7892
+ "animationDelayEnd": {
7893
+ "oneOf": [
7894
+ {
7895
+ "type": "string"
7896
+ },
7897
+ {
7898
+ "type": "number"
7899
+ }
7900
+ ]
7901
+ },
7902
+ "animationDelayStart": {
7903
+ "oneOf": [
7904
+ {
7905
+ "type": "string"
7906
+ },
7907
+ {
7908
+ "type": "number"
7909
+ }
7910
+ ]
7911
+ },
7872
7912
  "animationDirection": {
7873
7913
  "oneOf": [
7874
7914
  {
@@ -22996,6 +23036,67 @@
22996
23036
  }
22997
23037
  },
22998
23038
  "type": "object"
23039
+ },
23040
+ "PathsValue": {
23041
+ "$id": "https://jxsuite.com/schema/document/paths/v2",
23042
+ "anyOf": [
23043
+ {
23044
+ "additionalProperties": false,
23045
+ "description": "Explicit value list: generates one page per value, bound to `param` (default \"value\").",
23046
+ "properties": {
23047
+ "param": {
23048
+ "description": "Route parameter to fill (default \"value\").",
23049
+ "type": "string"
23050
+ },
23051
+ "values": {
23052
+ "description": "The values to expand.",
23053
+ "type": "array"
23054
+ }
23055
+ },
23056
+ "required": [
23057
+ "values"
23058
+ ],
23059
+ "type": "object"
23060
+ },
23061
+ {
23062
+ "additionalProperties": false,
23063
+ "description": "JSON data-file source: loads a project-relative file that must contain an array, and generates one page per item.",
23064
+ "properties": {
23065
+ "$ref": {
23066
+ "description": "Project-relative path to a .json file containing an array.",
23067
+ "type": "string"
23068
+ },
23069
+ "field": {
23070
+ "description": "Item field providing the parameter value (default \"id\").",
23071
+ "type": "string"
23072
+ },
23073
+ "param": {
23074
+ "description": "Route parameter to fill (default \"id\").",
23075
+ "type": "string"
23076
+ }
23077
+ },
23078
+ "required": [
23079
+ "$ref"
23080
+ ],
23081
+ "type": "object"
23082
+ },
23083
+ {
23084
+ "description": "Legacy explicit form: an array of ready-made parameter objects, used as-is with no expansion. Prefer one of the source shapes above.",
23085
+ "items": {
23086
+ "additionalProperties": {
23087
+ "type": "string"
23088
+ },
23089
+ "type": "object"
23090
+ },
23091
+ "type": "array"
23092
+ },
23093
+ {
23094
+ "description": "A source shape contributed by an extension this schema cannot see. The project's generated document.schema.json validates these exactly; enable the owning extension and regenerate with `jx schema` to get precise checking here.",
23095
+ "minProperties": 1,
23096
+ "type": "object"
23097
+ }
23098
+ ],
23099
+ "description": "Default $paths source union: the core shapes, plus a catch-all for source shapes contributed by extensions this schema cannot see. The project's generated document.schema.json replaces this with the exact set its extensions provide."
22999
23100
  }
23000
23101
  },
23001
23102
  "$id": "https://jxsuite.com/schema/v1",
@@ -23079,8 +23180,8 @@
23079
23180
  "type": "object"
23080
23181
  },
23081
23182
  "$paths": {
23082
- "description": "Dynamic route parameters. Maps parameter names to data sources for generating one page per entry at build time.",
23083
- "type": "object"
23183
+ "$ref": "https://jxsuite.com/schema/document/paths/v2",
23184
+ "description": "Dynamic route source for a page: expands one page per entry at build time. One of the core shapes (explicit `values`, a `$ref` data file, or a bare array of parameter objects) or a source shape contributed by an enabled extension, such as the parser's `contentType`."
23084
23185
  },
23085
23186
  "$schema": {
23086
23187
  "description": "URI identifying the Jx dialect version. Enables schema-aware IDE tooling.",
@@ -1,6 +1,59 @@
1
1
  {
2
2
  "$id": "https://jxsuite.com/schema/document/paths/v2",
3
3
  "$schema": "https://json-schema.org/draft/2020-12/schema",
4
- "description": "Default $paths-value union for documents. Per-project entry documents override this resource with the union of extension-contributed paths shapes.",
4
+ "anyOf": [
5
+ {
6
+ "additionalProperties": false,
7
+ "description": "Explicit value list: generates one page per value, bound to `param` (default \"value\").",
8
+ "properties": {
9
+ "param": {
10
+ "description": "Route parameter to fill (default \"value\").",
11
+ "type": "string"
12
+ },
13
+ "values": {
14
+ "description": "The values to expand.",
15
+ "type": "array"
16
+ }
17
+ },
18
+ "required": ["values"],
19
+ "type": "object"
20
+ },
21
+ {
22
+ "additionalProperties": false,
23
+ "description": "JSON data-file source: loads a project-relative file that must contain an array, and generates one page per item.",
24
+ "properties": {
25
+ "$ref": {
26
+ "description": "Project-relative path to a .json file containing an array.",
27
+ "type": "string"
28
+ },
29
+ "field": {
30
+ "description": "Item field providing the parameter value (default \"id\").",
31
+ "type": "string"
32
+ },
33
+ "param": {
34
+ "description": "Route parameter to fill (default \"id\").",
35
+ "type": "string"
36
+ }
37
+ },
38
+ "required": ["$ref"],
39
+ "type": "object"
40
+ },
41
+ {
42
+ "description": "Legacy explicit form: an array of ready-made parameter objects, used as-is with no expansion. Prefer one of the source shapes above.",
43
+ "items": {
44
+ "additionalProperties": {
45
+ "type": "string"
46
+ },
47
+ "type": "object"
48
+ },
49
+ "type": "array"
50
+ },
51
+ {
52
+ "description": "A source shape contributed by an extension this schema cannot see. The project's generated document.schema.json validates these exactly; enable the owning extension and regenerate with `jx schema` to get precise checking here.",
53
+ "minProperties": 1,
54
+ "type": "object"
55
+ }
56
+ ],
57
+ "description": "Default $paths-value union for documents: the core source shapes. Per-project entry documents override this resource with those members PLUS every extension-contributed paths shape.",
5
58
  "title": "Jx Document Paths Union"
6
59
  }
package/src/guards.ts CHANGED
@@ -179,9 +179,14 @@ export function isTemplateString(value?: unknown): value is string {
179
179
  * "Function"` as a computed value vs an event handler. Uses a word boundary so identifiers like
180
180
  * `returned` do not match. Shared by the runtime interpreter and the compiler so both agree on the
181
181
  * classification.
182
+ *
183
+ * A bare `return;` does not count. It is an early-exit guard, which is handler code — reading it as
184
+ * a value return turned any handler with a guard clause into a `computed()`, so the binding that
185
+ * invoked it found a value where it expected a function. The value has to follow on the same line,
186
+ * because a newline after `return` is an ASI bare return.
182
187
  */
183
188
  export function bodyReturnsValue(body: string): boolean {
184
- return /\breturn\b/.test(body);
189
+ return /\breturn\b[^\S\n]*(?![;}\s]|$)/.test(body);
185
190
  }
186
191
 
187
192
  // ─── Style guards & accessors ───────────────────────────────────────────────────
@@ -18,7 +18,11 @@
18
18
  * relative refs resolve (the committed files stay machine-path-free).
19
19
  */
20
20
 
21
- import { DOCUMENT_PATHS_SCHEMA_ID, PROJECT_FIELDS_SCHEMA_ID } from "../defs/field-schema.schema";
21
+ import {
22
+ DOCUMENT_PATHS_SCHEMA_ID,
23
+ PROJECT_FIELDS_SCHEMA_ID,
24
+ documentPathsCoreMembers,
25
+ } from "../defs/field-schema.schema";
22
26
 
23
27
  export { DOCUMENT_PATHS_SCHEMA_ID, PROJECT_FIELDS_SCHEMA_ID } from "../defs/field-schema.schema";
24
28
 
@@ -83,6 +87,9 @@ export type SchemaJsonLoader = (path: string) => Promise<Record<string, unknown>
83
87
  * @param {Record<string, unknown>} entry - The entry document (e.g. a parsed project.schema.json)
84
88
  * @param {SchemaJsonLoader} loadJson - Loader for `$ref` target files (host-restricted)
85
89
  * @param {string} baseDir - Directory the entry document's relative refs resolve against
90
+ * @param {string[]} [alsoEmbed] - Relative paths of resources to embed even though no path-`$ref`
91
+ * reaches them, because they are referenced only by canonical `$id` (a document fragment whose
92
+ * `$defs` feed the paths union). Without this they would stay dangling.
86
93
  * @returns {Promise<Record<string, unknown>>} A new, self-contained schema (the input is not
87
94
  * mutated)
88
95
  */
@@ -90,6 +97,7 @@ export async function bundleSchema(
90
97
  entry: Record<string, unknown>,
91
98
  loadJson: SchemaJsonLoader,
92
99
  baseDir: string,
100
+ alsoEmbed: string[] = [],
93
101
  ): Promise<Record<string, unknown>> {
94
102
  const bundled = structuredClone(entry);
95
103
  const embeddedByPath = new Map<string, string>();
@@ -167,9 +175,220 @@ export async function bundleSchema(
167
175
  };
168
176
 
169
177
  await rewrite(bundled, normalizeSchemaPath(baseDir), true);
178
+ for (const path of alsoEmbed) {
179
+ await embed(`${normalizeSchemaPath(baseDir)}/${path}`);
180
+ }
170
181
  return bundled;
171
182
  }
172
183
 
184
+ // ─── Single-resource flattening (specs/extensions.md §5.4) ───────────────────
185
+
186
+ /**
187
+ * Keywords whose value is a single subschema (or, for draft-07 tuple `items`, an array of them).
188
+ * The flattener walks ONLY these keyword positions, never every object key: `examples`, `default`,
189
+ * `const` and `enum` hold arbitrary instance data, and Jx document examples legitimately contain
190
+ * `$ref` strings (`#/state/cart`) that must survive untouched.
191
+ */
192
+ const SINGLE_SUBSCHEMA_KEYWORDS = [
193
+ "additionalItems",
194
+ "additionalProperties",
195
+ "contains",
196
+ "contentSchema",
197
+ "else",
198
+ "if",
199
+ "items",
200
+ "not",
201
+ "propertyNames",
202
+ "then",
203
+ "unevaluatedItems",
204
+ "unevaluatedProperties",
205
+ ] as const;
206
+
207
+ /** Keywords whose value maps names to subschemas. */
208
+ const MAP_SUBSCHEMA_KEYWORDS = [
209
+ "$defs",
210
+ "definitions",
211
+ "dependencies",
212
+ "dependentSchemas",
213
+ "patternProperties",
214
+ "properties",
215
+ ] as const;
216
+
217
+ /** Keywords whose value is an array of subschemas. */
218
+ const LIST_SUBSCHEMA_KEYWORDS = ["allOf", "anyOf", "oneOf", "prefixItems"] as const;
219
+
220
+ /** True for a plain object node (a candidate subschema); booleans and arrays are not. */
221
+ function isSchemaObject(value: unknown): value is Record<string, unknown> {
222
+ return typeof value === "object" && value !== null && !Array.isArray(value);
223
+ }
224
+
225
+ /** Escape a `$defs` key for use as a JSON Pointer segment (RFC 6901). */
226
+ function escapePointerSegment(segment: string): string {
227
+ return segment.replaceAll("~", "~0").replaceAll("/", "~1");
228
+ }
229
+
230
+ /**
231
+ * Walk the known subschema keyword positions of `node`.
232
+ *
233
+ * @param {Record<string, unknown>} node - The schema object to walk
234
+ * @param {string} pointer - JSON Pointer of `node` within the document
235
+ * @yields {[Record<string, unknown>, string]} Each subschema paired with its own JSON Pointer
236
+ */
237
+ function* subschemaEntries(
238
+ node: Record<string, unknown>,
239
+ pointer: string,
240
+ ): Generator<[Record<string, unknown>, string]> {
241
+ for (const keyword of SINGLE_SUBSCHEMA_KEYWORDS) {
242
+ const value = node[keyword];
243
+ if (Array.isArray(value)) {
244
+ for (const [index, item] of value.entries()) {
245
+ if (isSchemaObject(item)) {
246
+ yield [item, `${pointer}/${keyword}/${index}`];
247
+ }
248
+ }
249
+ } else if (isSchemaObject(value)) {
250
+ yield [value, `${pointer}/${keyword}`];
251
+ }
252
+ }
253
+ for (const keyword of MAP_SUBSCHEMA_KEYWORDS) {
254
+ const value = node[keyword];
255
+ if (!isSchemaObject(value)) {
256
+ continue;
257
+ }
258
+ for (const [name, item] of Object.entries(value)) {
259
+ if (isSchemaObject(item)) {
260
+ yield [item, `${pointer}/${keyword}/${escapePointerSegment(name)}`];
261
+ }
262
+ }
263
+ }
264
+ for (const keyword of LIST_SUBSCHEMA_KEYWORDS) {
265
+ const value = node[keyword];
266
+ if (!Array.isArray(value)) {
267
+ continue;
268
+ }
269
+ for (const [index, item] of value.entries()) {
270
+ if (isSchemaObject(item)) {
271
+ yield [item, `${pointer}/${keyword}/${index}`];
272
+ }
273
+ }
274
+ }
275
+ }
276
+
277
+ /**
278
+ * A readable, collision-free `$defs` key for an embedded resource's `$id`: drop the scheme, the
279
+ * authority, and the conventional `schema/` namespace segment, then join the remainder with dashes
280
+ * (`https://jxsuite.com/schema/project/core/v2` → `project-core-v2`, `urn:jx:bundled:1` →
281
+ * `jx-bundled-1`). Collisions get a numeric suffix, so output stays deterministic given the
282
+ * emitters' stable `$defs` order.
283
+ *
284
+ * @param {string} id - The resource's canonical `$id`
285
+ * @param {Set<string>} used - Keys already taken in the target `$defs` (mutated)
286
+ * @returns {string}
287
+ */
288
+ function slugForSchemaId(id: string, used: Set<string>): string {
289
+ let rest = id.replace(/^[a-zA-Z][a-zA-Z\d+.-]*:/, "");
290
+ if (rest.startsWith("//")) {
291
+ const slash = rest.indexOf("/", 2);
292
+ rest = slash === -1 ? "" : rest.slice(slash + 1);
293
+ }
294
+ rest = rest.replace(/^schema\//, "");
295
+ const base =
296
+ rest
297
+ .split(/[^a-zA-Z0-9]+/)
298
+ .filter(Boolean)
299
+ .join("-") || "resource";
300
+ let key = base;
301
+ for (let n = 2; used.has(key); n += 1) {
302
+ key = `${base}-${n}`;
303
+ }
304
+ used.add(key);
305
+ return key;
306
+ }
307
+
308
+ /**
309
+ * Collapse a bundled compound document into a SINGLE schema resource: embedded resources are
310
+ * rekeyed from their canonical `$id`s to readable `$defs` slugs, their `$id`s are dropped, and
311
+ * every `$ref` becomes a root-relative JSON Pointer (`#/$defs/project-core-v2/$defs/StyleObject`).
312
+ *
313
+ * Why this is required rather than cosmetic: VS Code's JSON language service (and Monaco, which
314
+ * embeds it) implements neither half of compound-document resolution. It resolves a `#/pointer` ref
315
+ * against the DOCUMENT ROOT — never re-based on an enclosing `$id` — so a fragment's own
316
+ * `#/$defs/StyleObject` reports "can not be resolved"; and it treats any ref with a non-empty part
317
+ * before `#` as EXTERNAL, fetching `https://jxsuite.com/schema/...` over the network instead of
318
+ * matching the embedded resource, which fails offline and (when it succeeds) silently substitutes
319
+ * the shipped defaults for the project's effective unions. Root-pointer-only refs are the one form
320
+ * every consumer resolves identically — ajv, the language service, and Monaco alike.
321
+ *
322
+ * `$id` shadowing (§5.3) still decides the outcome; it is simply resolved AT FLATTEN TIME, since
323
+ * refs to a shadowed `$id` rewrite to the pointer of the entry document's embed.
324
+ *
325
+ * @param {Record<string, unknown>} entry - A bundled compound document (output of
326
+ * {@link bundleSchema})
327
+ * @returns {Record<string, unknown>} A new single-resource schema (the input is not mutated)
328
+ */
329
+ export function flattenSchema(entry: Record<string, unknown>): Record<string, unknown> {
330
+ const flat = structuredClone(entry);
331
+
332
+ // 1. Rekey URI-keyed `$defs` entries to readable slugs (pointers address them by key).
333
+ if (isSchemaObject(flat.$defs)) {
334
+ const used = new Set(Object.keys(flat.$defs).filter((key) => !hasUriScheme(key)));
335
+ const renamed: Record<string, unknown> = {};
336
+ for (const [key, value] of Object.entries(flat.$defs)) {
337
+ const id = isSchemaObject(value) && typeof value.$id === "string" ? value.$id : key;
338
+ renamed[hasUriScheme(key) ? slugForSchemaId(id, used) : key] = value;
339
+ }
340
+ flat.$defs = renamed;
341
+ }
342
+
343
+ // 2. Map each embedded resource's `$id` to its pointer here (first wins, as the bundler embeds).
344
+ const idToPointer = new Map<string, string>();
345
+ const scan = (node: Record<string, unknown>, pointer: string): void => {
346
+ const { $id } = node;
347
+ if (typeof $id === "string" && hasUriScheme($id) && !idToPointer.has($id)) {
348
+ idToPointer.set($id, pointer);
349
+ }
350
+ for (const [child, childPointer] of subschemaEntries(node, pointer)) {
351
+ scan(child, childPointer);
352
+ }
353
+ };
354
+ scan(flat, "#");
355
+
356
+ // 3. Rewrite refs against the enclosing resource, then dissolve the resource boundary.
357
+ const rewrite = (node: Record<string, unknown>, scope: string): void => {
358
+ let inner = scope;
359
+ const { $id } = node;
360
+ if (typeof $id === "string" && hasUriScheme($id)) {
361
+ inner = idToPointer.get($id) ?? scope;
362
+ if (inner !== "#") {
363
+ // The root keeps its identity; nested resources must not remain ref targets.
364
+ delete node.$id;
365
+ }
366
+ }
367
+ const ref = node.$ref;
368
+ if (typeof ref === "string") {
369
+ const hashIndex = ref.indexOf("#");
370
+ const target = hashIndex === -1 ? ref : ref.slice(0, hashIndex);
371
+ const fragment = hashIndex === -1 ? "" : ref.slice(hashIndex + 1);
372
+ if (target === "") {
373
+ // A local pointer resolves against the resource it appears in, not the file.
374
+ if (fragment.startsWith("/")) {
375
+ node.$ref = `${inner}${fragment}`;
376
+ }
377
+ } else {
378
+ const base = idToPointer.get(target);
379
+ if (base !== undefined) {
380
+ node.$ref = fragment ? `${base}${fragment}` : base;
381
+ }
382
+ }
383
+ }
384
+ for (const [child] of subschemaEntries(node, inner)) {
385
+ rewrite(child, inner);
386
+ }
387
+ };
388
+ rewrite(flat, "#");
389
+ return flat;
390
+ }
391
+
173
392
  /** Collect every absolute `$id` already present in a document (embeds like the field union). */
174
393
  function collectResourceIds(node: unknown, out: Set<string>): void {
175
394
  if (Array.isArray(node)) {
@@ -240,23 +459,150 @@ export interface DocumentSchemaInputs {
240
459
  pathsValueRefs: string[];
241
460
  }
242
461
 
243
- /** Emit the `document.schema.json` entry document for a project. */
462
+ /**
463
+ * Emit the `document.schema.json` entry document for a project.
464
+ *
465
+ * The core reference goes in a single-member `allOf`, never a root-level `$ref`. They are
466
+ * equivalent to a compliant validator, but VS Code's JSON language service resolves a `$ref` by
467
+ * SHALLOW-MERGING the target's keys into the referencing node — from the root, that overwrites the
468
+ * root's own `$defs` with the core resource's `$defs`, and every later `#/$defs/<embed>/...`
469
+ * pointer stops resolving. Wrapping in `allOf` keeps the merge target off the root.
470
+ *
471
+ * @param {DocumentSchemaInputs} inputs - Core path and the contributed paths-union refs
472
+ * @returns {Record<string, unknown>}
473
+ */
244
474
  export function emitDocumentSchema(inputs: DocumentSchemaInputs): Record<string, unknown> {
245
475
  const { corePath, pathsValueRefs } = inputs;
246
476
  return {
247
477
  $comment: GENERATED_SCHEMA_COMMENT,
248
478
  $defs: {
249
- // With no contributed paths shapes the embed stays permissive — core `$paths` forms
250
- // (arrays, values, $ref) are validated by the core schema itself.
251
- PathsValue:
252
- pathsValueRefs.length > 0
253
- ? {
254
- $id: DOCUMENT_PATHS_SCHEMA_ID,
255
- anyOf: pathsValueRefs.map((ref) => ({ $ref: ref })),
256
- }
257
- : { $id: DOCUMENT_PATHS_SCHEMA_ID },
479
+ /* The EFFECTIVE paths union: the core source shapes plus every extension-contributed one.
480
+ Carrying the core members here (rather than leaving them to the core schema) is what makes
481
+ the override additive — this embed shadows the shipped default by $id, so anything it omits
482
+ stops validating. Mirrors the field union in emitProjectSchema, which names its core
483
+ members the same way. */
484
+ PathsValue: {
485
+ $id: DOCUMENT_PATHS_SCHEMA_ID,
486
+ anyOf: [...documentPathsCoreMembers, ...pathsValueRefs.map((ref) => ({ $ref: ref }))],
487
+ },
258
488
  },
259
- $ref: corePath,
260
489
  $schema: "https://json-schema.org/draft/2020-12/schema",
490
+ allOf: [{ $ref: corePath }],
261
491
  };
262
492
  }
493
+
494
+ // ─── Composition (specs/extensions.md §5.2) ──────────────────────────────────
495
+
496
+ /** Loader-resolvable refs to one extension's shipped schema fragments, as its manifest names them. */
497
+ export interface ExtensionFragmentRefs {
498
+ /** Project fragment — plain `properties` for the extension's section keys. */
499
+ project?: string | undefined;
500
+ /** Document fragment — its `$defs` become `$paths` source-union members. */
501
+ document?: string | undefined;
502
+ /** Fields fragment — its `$defs` become Field-schema union members. */
503
+ fields?: string | undefined;
504
+ }
505
+
506
+ export interface ComposeProjectSchemasInputs {
507
+ /** Ref to the core project fragment (`@jxsuite/schema/schemas/project.core.schema.json`). */
508
+ coreProjectRef: string;
509
+ /** Ref to the core document schema (`@jxsuite/schema/schema.json`). */
510
+ coreDocumentRef: string;
511
+ /** Enabled extensions in registry order; each contributes the fragments it ships. */
512
+ extensions: ExtensionFragmentRefs[];
513
+ /** Loads a ref's JSON. Every ref above and every `$ref` inside resolves through this ALONE. */
514
+ loadJson: SchemaJsonLoader;
515
+ /** Directory the refs resolve against ("" for hosts addressing by bare specifier). */
516
+ baseDir: string;
517
+ }
518
+
519
+ /**
520
+ * Compose a project's two committed entry documents from the core schemas and its enabled
521
+ * extensions' fragments — bundled, then flattened to root JSON pointers (§5.2/§5.4).
522
+ *
523
+ * Host-agnostic by construction: every file it needs arrives through `loadJson`, so the same
524
+ * composition runs over a filesystem (`jx schema`) and over a virtual tree of bundled artifacts
525
+ * (the cloud session, which has no `node_modules` and no filesystem). That shared path is the point
526
+ * — a project's schemas must not depend on which host generated them.
527
+ *
528
+ * @param {ComposeProjectSchemasInputs} inputs - Core refs, extension fragments, loader, base
529
+ * @returns {Promise<{ project: Record<string, unknown>; document: Record<string, unknown> }>} The
530
+ * self-contained entry documents, ready to write or to hand an editor
531
+ */
532
+ export async function composeProjectSchemas(inputs: ComposeProjectSchemasInputs): Promise<{
533
+ project: Record<string, unknown>;
534
+ document: Record<string, unknown>;
535
+ }> {
536
+ const { baseDir, coreDocumentRef, coreProjectRef, extensions, loadJson } = inputs;
537
+
538
+ const fragments: string[] = [];
539
+ const pathsValueRefs: string[] = [];
540
+ const documentFragments: string[] = [];
541
+ const fieldSchemaRefs: string[] = [];
542
+ for (const ext of extensions) {
543
+ if (ext.project) {
544
+ fragments.push(ext.project);
545
+ }
546
+ if (ext.document) {
547
+ const documentRefs = await fragmentDefsRefs(ext.document, loadJson, baseDir);
548
+ pathsValueRefs.push(...documentRefs);
549
+ /* The paths union names this fragment's shapes by canonical $id only, so the bundler needs
550
+ the path handed to it explicitly or those refs stay dangling. A fragment that contributed
551
+ nothing (no $id, or no $defs) is left out rather than embedded unreferenced. */
552
+ if (documentRefs.length > 0) {
553
+ documentFragments.push(ext.document);
554
+ }
555
+ }
556
+ // The `schemas.fields` manifest convention: a fragment whose $defs members are extension
557
+ // Field extras, unioned into the per-project field resource (specs/extensions.md §5.3).
558
+ if (ext.fields) {
559
+ fieldSchemaRefs.push(...(await fragmentDefsRefs(ext.fields, loadJson, baseDir)));
560
+ fragments.push(ext.fields);
561
+ }
562
+ }
563
+
564
+ // Bundle then flatten: the emitted relative refs are an intermediate form only.
565
+ const projectEntry = emitProjectSchema({ corePath: coreProjectRef, fieldSchemaRefs, fragments });
566
+ const documentEntry = emitDocumentSchema({ corePath: coreDocumentRef, pathsValueRefs });
567
+ return {
568
+ document: flattenSchema(
569
+ await bundleSchema(documentEntry, loadJson, baseDir, documentFragments),
570
+ ),
571
+ project: flattenSchema(await bundleSchema(projectEntry, loadJson, baseDir)),
572
+ };
573
+ }
574
+
575
+ /**
576
+ * Canonical "uri#/pointer" refs into a fragment's `$defs` shapes: one per `$defs` entry, addressed
577
+ * by the fragment's own `$id` (refs inside the entry document's embeds resolve against canonical
578
+ * URIs, never file paths). An `$id`-less or `$defs`-less fragment contributes no union members —
579
+ * that is simply the shape of a fragment declaring only `properties`.
580
+ *
581
+ * An UNREADABLE fragment contributes nothing here but still fails the composition, because the
582
+ * bundler goes on to embed the same ref and throws. That is deliberate: silently dropping it would
583
+ * emit an entry document that under-validates the project with nothing to show for it. A host that
584
+ * cannot supply an extension's fragments must leave that extension out of `extensions` entirely —
585
+ * which is exactly what the cloud session does for packages it does not bundle.
586
+ *
587
+ * @param {string} ref - Loader-resolvable ref to the fragment, relative to `baseDir`
588
+ * @param {SchemaJsonLoader} loadJson - The host's loader
589
+ * @param {string} baseDir - Directory the ref resolves against
590
+ * @returns {Promise<string[]>} One ref per `$defs` member, in declaration order
591
+ */
592
+ async function fragmentDefsRefs(
593
+ ref: string,
594
+ loadJson: SchemaJsonLoader,
595
+ baseDir: string,
596
+ ): Promise<string[]> {
597
+ let fragment: { $id?: unknown; $defs?: Record<string, unknown> };
598
+ try {
599
+ fragment = await loadJson(normalizeSchemaPath(`${normalizeSchemaPath(baseDir)}/${ref}`));
600
+ } catch {
601
+ return [];
602
+ }
603
+ const { $id } = fragment;
604
+ if (typeof $id !== "string" || !fragment.$defs) {
605
+ return [];
606
+ }
607
+ return Object.keys(fragment.$defs).map((name) => `${$id}#/$defs/${name}`);
608
+ }
package/src/schema.ts CHANGED
@@ -102,6 +102,8 @@ import { extensionManifestSchema } from "../defs/extension-manifest.schema";
102
102
  import {
103
103
  DOCUMENT_PATHS_SCHEMA_ID,
104
104
  PROJECT_FIELDS_SCHEMA_ID,
105
+ documentPathsCoreMembers,
106
+ documentPathsUnknownSourceMember,
105
107
  jxFieldSchemaDef,
106
108
  relationshipRefSchema,
107
109
  } from "../defs/field-schema.schema";
@@ -288,6 +290,19 @@ export async function generateSchema() {
288
290
  JsonSchemaType: jsonSchemaTypeSchema,
289
291
  HeadEntry: headEntrySchema,
290
292
  ImageConfig: imageConfigSchema,
293
+ /* The shipped DEFAULT paths union, embedded under its canonical $id so `$paths`'s ref
294
+ resolves with this file alone (it is registered standalone as the studio's offline
295
+ fallback). A per-project entry document embeds the same $id carrying core members PLUS
296
+ extension shapes; that embed is declared first, so it wins resolution and this one is
297
+ inert there (extensions.md §5.3). */
298
+ PathsValue: {
299
+ $id: DOCUMENT_PATHS_SCHEMA_ID,
300
+ anyOf: [...documentPathsCoreMembers, documentPathsUnknownSourceMember],
301
+ description:
302
+ "Default $paths source union: the core shapes, plus a catch-all for source shapes " +
303
+ "contributed by extensions this schema cannot see. The project's generated " +
304
+ "document.schema.json replaces this with the exact set its extensions provide.",
305
+ },
291
306
  },
292
307
  $id: "https://jxsuite.com/schema/v1",
293
308
  $schema: "https://json-schema.org/draft/2020-12/schema",
@@ -362,10 +377,17 @@ export async function generateSchema() {
362
377
  type: "object",
363
378
  },
364
379
  $paths: {
380
+ /* Was `type: "object"`, which accepted any object at all — a misspelled key or a
381
+ content-type source in a project without the parser enabled passed validation and then
382
+ silently expanded to zero pages. Referencing the union resource by its canonical $id is
383
+ what makes the per-project entry document's effective union apply (extensions.md §5.3);
384
+ `$defs.PathsValue` below carries the shipped default so this file still resolves alone. */
385
+ $ref: DOCUMENT_PATHS_SCHEMA_ID,
365
386
  description:
366
- "Dynamic route parameters. Maps parameter names to data sources " +
367
- "for generating one page per entry at build time.",
368
- type: "object",
387
+ "Dynamic route source for a page: expands one page per entry at build time. One of the " +
388
+ "core shapes (explicit `values`, a `$ref` data file, or a bare array of parameter " +
389
+ "objects) or a source shape contributed by an enabled extension, such as the parser's " +
390
+ "`contentType`.",
369
391
  },
370
392
  $schema: {
371
393
  description: "URI identifying the Jx dialect version. Enables schema-aware IDE tooling.",
@@ -499,9 +521,11 @@ export function generateDocumentPathsSchema() {
499
521
  return {
500
522
  $id: DOCUMENT_PATHS_SCHEMA_ID,
501
523
  $schema: "https://json-schema.org/draft/2020-12/schema",
524
+ anyOf: [...documentPathsCoreMembers, documentPathsUnknownSourceMember],
502
525
  description:
503
- "Default $paths-value union for documents. Per-project entry documents override this " +
504
- "resource with the union of extension-contributed paths shapes.",
526
+ "Default $paths-value union for documents: the core source shapes. Per-project entry " +
527
+ "documents override this resource with those members PLUS every extension-contributed " +
528
+ "paths shape.",
505
529
  title: "Jx Document Paths Union",
506
530
  };
507
531
  }