@jxsuite/schema 1.4.0 → 1.8.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.
package/src/parse.ts CHANGED
@@ -4,10 +4,20 @@
4
4
  * Raw `JSON.parse()` returns `any`, which silently disables checking for everything downstream.
5
5
  * These helpers are the single sanctioned crossing point: they parse, structurally verify, and
6
6
  * return the domain type — failures carry the source path.
7
+ *
8
+ * Being the single crossing point is also what makes it the right place to enforce I-JSON (RFC
9
+ * 7493). `JSON.parse` accepts documents that mean something other than what they say — a repeated
10
+ * key whose first value it discards, an integer it rounds — and a Jx document does not stay JSON:
11
+ * it round-trips through markdown frontmatter and a CRDT, each rebuilding the object from the
12
+ * parsed value. Whatever was dropped here is dropped for good.
13
+ *
14
+ * It is also where identifiers are normalized (UAX #31 §R4), for the same reason: a name that means
15
+ * one thing on one keyboard and another thing on another is a defect nothing downstream can see.
7
16
  */
8
17
 
9
18
  import type { JxClassDef, JxDocument, ProjectConfig } from "../types";
10
19
  import { isJsonObject } from "./guards";
20
+ import { describeIJsonProblem, findIJsonProblems } from "./ijson";
11
21
 
12
22
  // ─── Error helpers ──────────────────────────────────────────────────────────────
13
23
 
@@ -21,6 +31,63 @@ export function toError(e: unknown): Error {
21
31
  return e instanceof Error ? e : new Error(String(e));
22
32
  }
23
33
 
34
+ // ─── Unicode normalization ──────────────────────────────────────────────────────
35
+
36
+ /**
37
+ * Text outside ASCII, which is the only text whose normalization form can differ. Testing for it
38
+ * first keeps this walk close to free on the pure-ASCII documents that are almost all of them.
39
+ */
40
+ const NON_ASCII = /[^\p{ASCII}]/u;
41
+
42
+ /**
43
+ * Recursively put every key and every string value into Normalization Form C.
44
+ *
45
+ * **The defect this fixes.** A state name is an identifier: `"état"` declared in `state`,
46
+ * referenced as `${state.état}` in a template and as `#/state/état` in a `$ref`. Typed on macOS it
47
+ * arrives decomposed (`e` + U+0301); typed on Windows or pasted from most of the web it arrives
48
+ * precomposed (U+00E9). Those are two different JavaScript property names, so a document whose
49
+ * declaration and reference were typed on different machines builds cleanly, emits a valid bundle,
50
+ * and renders nothing — `state.état` is simply `undefined`. There is no error to see and no symptom
51
+ * to search for. UAX #31 §R4 exists for exactly this, and NFC is the form the web platform assumes
52
+ * everywhere else.
53
+ *
54
+ * **Why values are normalized too, and why that is not a content change.** Two canonically
55
+ * equivalent strings are, by the definition in UAX #15, the same text: a conforming renderer must
56
+ * display them identically, and no process may distinguish them. So normalizing a `textContent` is
57
+ * a no-op for meaning. It is also unavoidable, because Jx has no syntactic boundary between content
58
+ * and code — `"Café ${state.été}"` is one string carrying both, and normalizing only "identifier
59
+ * positions" would mean parsing every template to find them.
60
+ *
61
+ * **Why here and not on the source text.** `"état"` is pure ASCII until `JSON.parse` turns the
62
+ * escape into a combining mark, so normalizing the raw text would miss exactly the documents a
63
+ * generator wrote.
64
+ *
65
+ * @param {unknown} value - Any parsed JSON value
66
+ * @returns {unknown} The same shape, with all text in NFC
67
+ */
68
+ function normalizeIdentifiers(value: unknown): unknown {
69
+ if (typeof value === "string") {
70
+ return NON_ASCII.test(value) ? value.normalize("NFC") : value;
71
+ }
72
+ if (Array.isArray(value)) {
73
+ return value.map((item) => normalizeIdentifiers(item));
74
+ }
75
+ if (isJsonObject(value)) {
76
+ const out: Record<string, unknown> = {};
77
+ for (const [key, entry] of Object.entries(value)) {
78
+ /*
79
+ * A later key wins on collision, matching what `JSON.parse` does with a literal duplicate.
80
+ * It cannot happen through this boundary — I-JSON already rejected duplicate keys, and two
81
+ * keys differing only by normalization form are not duplicates to `JSON.parse` — so this is
82
+ * a statement about which is authoritative rather than a branch anything reaches.
83
+ */
84
+ out[NON_ASCII.test(key) ? key.normalize("NFC") : key] = normalizeIdentifiers(entry);
85
+ }
86
+ return out;
87
+ }
88
+ return value;
89
+ }
90
+
24
91
  // ─── Parse helpers ──────────────────────────────────────────────────────────────
25
92
 
26
93
  function parseObject(text: string, sourcePath: string, what: string): Record<string, unknown> {
@@ -35,7 +102,17 @@ function parseObject(text: string, sourcePath: string, what: string): Record<str
35
102
  if (!isJsonObject(parsed)) {
36
103
  throw new Error(`Invalid ${what} at ${sourcePath}: expected a JSON object`);
37
104
  }
38
- return parsed;
105
+ /*
106
+ * An error rather than a warning, because both problems are silent data loss and neither has a
107
+ * reading under which the author got what they wrote. The whole repository — 375 documents —
108
+ * was already clean when this landed, so this rejects mistakes rather than existing work.
109
+ */
110
+ const problems = findIJsonProblems(text);
111
+ if (problems.length > 0) {
112
+ const described = problems.map((problem) => describeIJsonProblem(problem)).join("; ");
113
+ throw new Error(`Invalid ${what} at ${sourcePath}: ${described}`);
114
+ }
115
+ return normalizeIdentifiers(parsed) as Record<string, unknown>;
39
116
  }
40
117
 
41
118
  /** Parse a Jx document (.json page/layout/component source). */
@@ -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
@@ -26,6 +26,7 @@ import { listAll as listElements } from "@webref/elements";
26
26
  import css from "@webref/css";
27
27
  import idl from "@webref/idl";
28
28
 
29
+ import { elementTagNameSchema, tagExpressionSchema } from "../defs/tag-expression.schema";
29
30
  import { tagNameSchema } from "../defs/tag-name.schema";
30
31
  import {
31
32
  boolOrRefSchema,
@@ -102,6 +103,8 @@ import { extensionManifestSchema } from "../defs/extension-manifest.schema";
102
103
  import {
103
104
  DOCUMENT_PATHS_SCHEMA_ID,
104
105
  PROJECT_FIELDS_SCHEMA_ID,
106
+ documentPathsCoreMembers,
107
+ documentPathsUnknownSourceMember,
105
108
  jxFieldSchemaDef,
106
109
  relationshipRefSchema,
107
110
  } from "../defs/field-schema.schema";
@@ -285,9 +288,24 @@ export async function generateSchema() {
285
288
  ...tagNameSchema,
286
289
  examples: [...tagExamples, "my-counter", "todo-app", "user-card"],
287
290
  },
291
+ TagExpression: tagExpressionSchema,
292
+ ElementTagName: elementTagNameSchema,
288
293
  JsonSchemaType: jsonSchemaTypeSchema,
289
294
  HeadEntry: headEntrySchema,
290
295
  ImageConfig: imageConfigSchema,
296
+ /* The shipped DEFAULT paths union, embedded under its canonical $id so `$paths`'s ref
297
+ resolves with this file alone (it is registered standalone as the studio's offline
298
+ fallback). A per-project entry document embeds the same $id carrying core members PLUS
299
+ extension shapes; that embed is declared first, so it wins resolution and this one is
300
+ inert there (extensions.md §5.3). */
301
+ PathsValue: {
302
+ $id: DOCUMENT_PATHS_SCHEMA_ID,
303
+ anyOf: [...documentPathsCoreMembers, documentPathsUnknownSourceMember],
304
+ description:
305
+ "Default $paths source union: the core shapes, plus a catch-all for source shapes " +
306
+ "contributed by extensions this schema cannot see. The project's generated " +
307
+ "document.schema.json replaces this with the exact set its extensions provide.",
308
+ },
291
309
  },
292
310
  $id: "https://jxsuite.com/schema/v1",
293
311
  $schema: "https://json-schema.org/draft/2020-12/schema",
@@ -327,9 +345,16 @@ export async function generateSchema() {
327
345
  },
328
346
  $head: {
329
347
  description:
330
- "Page-level <head> entries. Array of element definitions for meta tags, " +
348
+ "Page-level <head> entries. Array of head entries for meta tags, " +
331
349
  "link tags, script tags, etc. Merged with layout and site-level $head entries.",
332
- items: { $ref: "#/$defs/ElementDef" },
350
+ /* `HeadEntry`, NOT `ElementDef`. These were typed as elements, which was harmless only
351
+ while an element's tagName was a plain name — the moment it could be chosen at creation,
352
+ a head tag could be too, and `head-merger.ts` splices one straight into the built page as
353
+ `<${tag} …>`. A head tag is static by nature (the runtime's `injectHead` builds a CSS
354
+ selector out of it before it ever creates anything), and `HeadEntry` already says so.
355
+ It had been an orphan def until now: its only inbound `$ref` was its own recursive
356
+ `children`. */
357
+ items: { $ref: "#/$defs/HeadEntry" },
333
358
  type: "array",
334
359
  },
335
360
  $id: {
@@ -337,6 +362,30 @@ export async function generateSchema() {
337
362
  examples: ["Counter", "TodoApp", "UserCard"],
338
363
  type: "string",
339
364
  },
365
+ $dir: {
366
+ description:
367
+ "Base direction for this page's <html> element, overriding `defaults.dir`. " +
368
+ "Right-to-left content renders left-to-right without it.",
369
+ enum: ["ltr", "rtl", "auto"],
370
+ type: "string",
371
+ },
372
+ $lang: {
373
+ description:
374
+ "Language tag for this page's <html> element, overriding `defaults.lang` " +
375
+ "(site-architecture.md §8.4).",
376
+ examples: ["en", "fr", "ar-EG"],
377
+ type: "string",
378
+ },
379
+ $translationKey: {
380
+ description:
381
+ "This page's identity across languages, overriding the one its path implies " +
382
+ "(site-architecture.md §13.5). Two routes are translations when their keys match; " +
383
+ "the default key is the path with the locale prefix removed, which is right whenever " +
384
+ "the paths are parallel and wrong for a localized slug — /fr-ca/a-propos/ shares " +
385
+ "nothing with /about/ until one of them says so.",
386
+ examples: ["about", "blog/hello-world"],
387
+ type: "string",
388
+ },
340
389
  $layout: {
341
390
  description:
342
391
  "Layout reference for pages. String path to a layout JSON file, " +
@@ -344,6 +393,24 @@ export async function generateSchema() {
344
393
  examples: ["./layouts/base.json"],
345
394
  oneOf: [{ type: "string" }, { const: false, type: "boolean" }],
346
395
  },
396
+ $shadow: {
397
+ description:
398
+ "Render this component into a shadow root instead of the light DOM (spec.md §16.6). " +
399
+ "Off unless set: a shadow root isolates the component's styles from the page and " +
400
+ "replaces the <slot> emulation with real slot distribution, so it changes how the " +
401
+ "component composes. Overrides `defaults.shadow` in both directions — `false` opts " +
402
+ "one component out of a project that opted in.",
403
+ oneOf: [
404
+ { enum: ["open", "closed"], type: "string" },
405
+ { const: false, type: "boolean" },
406
+ ],
407
+ },
408
+ $sitemap: {
409
+ description:
410
+ "Set false to exclude this page from sitemap.xml (site-architecture.md §8.4.1). " +
411
+ "Every other page with a URL is included.",
412
+ type: "boolean",
413
+ },
347
414
  $media: {
348
415
  additionalProperties: { type: "string" },
349
416
  description:
@@ -362,10 +429,17 @@ export async function generateSchema() {
362
429
  type: "object",
363
430
  },
364
431
  $paths: {
432
+ /* Was `type: "object"`, which accepted any object at all — a misspelled key or a
433
+ content-type source in a project without the parser enabled passed validation and then
434
+ silently expanded to zero pages. Referencing the union resource by its canonical $id is
435
+ what makes the per-project entry document's effective union apply (extensions.md §5.3);
436
+ `$defs.PathsValue` below carries the shipped default so this file still resolves alone. */
437
+ $ref: DOCUMENT_PATHS_SCHEMA_ID,
365
438
  description:
366
- "Dynamic route parameters. Maps parameter names to data sources " +
367
- "for generating one page per entry at build time.",
368
- type: "object",
439
+ "Dynamic route source for a page: expands one page per entry at build time. One of the " +
440
+ "core shapes (explicit `values`, a `$ref` data file, or a bare array of parameter " +
441
+ "objects) or a source shape contributed by an enabled extension, such as the parser's " +
442
+ "`contentType`.",
369
443
  },
370
444
  $schema: {
371
445
  description: "URI identifying the Jx dialect version. Enables schema-aware IDE tooling.",
@@ -499,9 +573,11 @@ export function generateDocumentPathsSchema() {
499
573
  return {
500
574
  $id: DOCUMENT_PATHS_SCHEMA_ID,
501
575
  $schema: "https://json-schema.org/draft/2020-12/schema",
576
+ anyOf: [...documentPathsCoreMembers, documentPathsUnknownSourceMember],
502
577
  description:
503
- "Default $paths-value union for documents. Per-project entry documents override this " +
504
- "resource with the union of extension-contributed paths shapes.",
578
+ "Default $paths-value union for documents: the core source shapes. Per-project entry " +
579
+ "documents override this resource with those members PLUS every extension-contributed " +
580
+ "paths shape.",
505
581
  title: "Jx Document Paths Union",
506
582
  };
507
583
  }
@@ -644,7 +720,8 @@ export async function validateWithSchema(
644
720
  doc: Record<string, unknown>,
645
721
  schema: Record<string, unknown>,
646
722
  ) {
647
- let Ajv: AjvCtor, addFormats: AddFormatsFn;
723
+ let Ajv: AjvCtor;
724
+ let addFormats: AddFormatsFn;
648
725
  try {
649
726
  // The generated schema is JSON Schema 2020-12, so use the matching Ajv build
650
727
  // (the default `ajv` export is draft-07 and can't compile a 2020-12 schema).