@jxsuite/schema 0.35.0 → 1.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.
@@ -0,0 +1,270 @@
1
+ /**
2
+ * Extension registry — manifest-driven discovery of extension packages (specs/extensions.md).
3
+ *
4
+ * A project declares extensions in project.json (`"extensions": ["@jxsuite/parser", ...]`); each
5
+ * extension package ships a `jx-extension.json` manifest enumerating its classes and schema
6
+ * fragments. Hosts resolve `<specifier>/jx-extension.json` (bare specifiers through the package's
7
+ * exports map, project-first), read each listed `.class.json`, and dispatch through the declared
8
+ * admission blocks (`format` | `project` | `server` | `connector`) and capability roles.
9
+ *
10
+ * All I/O is injected via FormatHostIO so the same module serves node and browser hosts. The
11
+ * format-dispatch view reuses FormatRegistry (including its extension/capability conflict checks).
12
+ */
13
+
14
+ import { FormatEntry, FormatRegistry } from "./format-registry";
15
+ import type { FormatHostIO } from "./format-registry";
16
+
17
+ export type {
18
+ CapabilityInfo,
19
+ ConnectorBlock,
20
+ ExtensionCapability,
21
+ FormatHostIO,
22
+ ProjectBlock,
23
+ ServerBlock,
24
+ } from "./format-registry";
25
+ export { EXTENSION_CAPABILITIES, FormatEntry, FormatRegistry } from "./format-registry";
26
+
27
+ /** The well-known manifest filename every extension package exports. */
28
+ export const EXTENSION_MANIFEST = "jx-extension.json";
29
+
30
+ export interface ExtensionManifest {
31
+ name: string;
32
+ title?: string;
33
+ description?: string;
34
+ classes?: Record<string, string>;
35
+ /**
36
+ * Schema fragments the package contributes: `project` (project.json sections), `document` ($paths
37
+ * shapes), and `fields` — a fragment whose `$defs` members are unioned into the per-project
38
+ * field-schema resource (extension field extras, specs/extensions.md §5.3).
39
+ */
40
+ schemas?: { project?: string; document?: string; fields?: string };
41
+ }
42
+
43
+ export interface ExtensionInfo {
44
+ /** The project.json `extensions` entry that produced this extension. */
45
+ specifier: string;
46
+ /** Resolved absolute path of the jx-extension.json manifest. */
47
+ manifestPath: string;
48
+ manifest: ExtensionManifest;
49
+ /** Class entries in manifest order. */
50
+ classes: FormatEntry[];
51
+ /** Resolved absolute fragment paths, when the manifest declares them. */
52
+ schemas: { project?: string; document?: string; fields?: string };
53
+ }
54
+
55
+ export type SchemaFragmentKind = "project" | "document" | "fields";
56
+
57
+ export class ExtensionRegistry {
58
+ #extensions: ExtensionInfo[];
59
+ #classes: FormatEntry[];
60
+ #formats: FormatRegistry;
61
+
62
+ constructor(extensions: ExtensionInfo[]) {
63
+ this.#extensions = extensions;
64
+ this.#classes = extensions.flatMap((e) => e.classes);
65
+
66
+ // Class names are the $prototype-visible namespace: duplicates across extensions are a
67
+ // Configuration error (a project-local `imports` entry may still shadow a manifest class —
68
+ // That overlay happens at the host, not here).
69
+ const byName = new Map<string, string>();
70
+ for (const ext of extensions) {
71
+ for (const cls of ext.classes) {
72
+ const prior = byName.get(cls.name);
73
+ if (prior && prior !== ext.specifier) {
74
+ throw new Error(
75
+ `Extension conflict: "${prior}" and "${ext.specifier}" both provide a class named "${cls.name}"`,
76
+ );
77
+ }
78
+ byName.set(cls.name, ext.specifier);
79
+ }
80
+ }
81
+
82
+ // Section keys are exclusive (specs/extensions.md §3.1).
83
+ const byKey = new Map<string, FormatEntry>();
84
+ for (const entry of this.#classes) {
85
+ if (!entry.project) {
86
+ continue;
87
+ }
88
+ const prior = byKey.get(entry.project.key);
89
+ if (prior) {
90
+ throw new Error(
91
+ `Extension conflict: "${prior.name}" and "${entry.name}" both claim the project section "${entry.project.key}"`,
92
+ );
93
+ }
94
+ byKey.set(entry.project.key, entry);
95
+ }
96
+
97
+ // Server mounts own disjoint /_jx/ subtrees (specs/extensions.md §11).
98
+ const byBasePath = new Map<string, FormatEntry>();
99
+ for (const entry of this.#classes) {
100
+ if (!entry.server) {
101
+ continue;
102
+ }
103
+ const { basePath } = entry.server;
104
+ if (!basePath.startsWith("/_jx/")) {
105
+ throw new Error(
106
+ `Extension "${entry.name}": server basePath "${basePath}" must be under /_jx/`,
107
+ );
108
+ }
109
+ const prior = byBasePath.get(basePath);
110
+ if (prior) {
111
+ throw new Error(
112
+ `Extension conflict: "${prior.name}" and "${entry.name}" both mount "${basePath}"`,
113
+ );
114
+ }
115
+ byBasePath.set(basePath, entry);
116
+ }
117
+
118
+ // The format-dispatch view; its constructor enforces (extension, capability) exclusivity.
119
+ this.#formats = new FormatRegistry(this.#classes.filter((e) => e.extensions.length > 0));
120
+ }
121
+
122
+ get extensions(): readonly ExtensionInfo[] {
123
+ return this.#extensions;
124
+ }
125
+
126
+ /** Every class entry across all extensions, in declaration order. */
127
+ get classes(): readonly FormatEntry[] {
128
+ return this.#classes;
129
+ }
130
+
131
+ /** File-extension dispatch view (parse/serialize/discover/load lookups). */
132
+ get formats(): FormatRegistry {
133
+ return this.#formats;
134
+ }
135
+
136
+ byName(name: string): FormatEntry | undefined {
137
+ return this.#classes.find((e) => e.name === name);
138
+ }
139
+
140
+ /** Classes owning a project.json section, in declaration order. */
141
+ projectContributions(): FormatEntry[] {
142
+ return this.#classes.filter((e) => e.project !== null);
143
+ }
144
+
145
+ byProjectKey(key: string): FormatEntry | undefined {
146
+ return this.#classes.find((e) => e.project?.key === key);
147
+ }
148
+
149
+ /** The class whose `resolvePaths` capability declares this `$paths` discriminator key. */
150
+ byPathsDiscriminator(key: string): FormatEntry | undefined {
151
+ return this.#classes.find((e) => e.capabilities.resolvePaths?.discriminator === key);
152
+ }
153
+
154
+ /** Server-mount classes, sorted by `server.order` (ascending; default 100), then name. */
155
+ serverMounts(): FormatEntry[] {
156
+ return this.#classes
157
+ .filter((e) => e.server !== null)
158
+ .toSorted(
159
+ (a, b) =>
160
+ (a.server?.order ?? 100) - (b.server?.order ?? 100) || a.name.localeCompare(b.name),
161
+ );
162
+ }
163
+
164
+ /** Connection-provider classes (connector block). */
165
+ connectors(): FormatEntry[] {
166
+ return this.#classes.filter((e) => e.connector !== null);
167
+ }
168
+
169
+ /** Resolved fragment paths of the given kind, in extension declaration order. */
170
+ schemaFragments(kind: SchemaFragmentKind): string[] {
171
+ return this.#extensions
172
+ .map((e) => e.schemas[kind])
173
+ .filter((p): p is string => typeof p === "string");
174
+ }
175
+ }
176
+
177
+ /**
178
+ * Build a registry from a project's `extensions` list.
179
+ *
180
+ * Specifiers are bare package names (resolved project-first through the package's exports map) or
181
+ * relative paths. Unlike the legacy imports scan, everything here is explicit — an unresolvable
182
+ * manifest or class file is an error, not a silent skip.
183
+ *
184
+ * @param {string[] | undefined} extensions - The project.json `extensions` array
185
+ * @param {FormatHostIO} io - Injected host I/O
186
+ * @param {string} base - Reference file for relative resolution (convention: `<root>/project.json`)
187
+ */
188
+ export async function buildExtensionRegistry(
189
+ extensions: string[] | undefined,
190
+ io: FormatHostIO,
191
+ base: string,
192
+ ): Promise<ExtensionRegistry> {
193
+ const infos: ExtensionInfo[] = [];
194
+ for (const specifier of extensions ?? []) {
195
+ infos.push(await loadExtension(specifier, io, base));
196
+ }
197
+ return new ExtensionRegistry(infos);
198
+ }
199
+
200
+ async function loadExtension(
201
+ specifier: string,
202
+ io: FormatHostIO,
203
+ base: string,
204
+ ): Promise<ExtensionInfo> {
205
+ let manifestPath: string;
206
+ if (specifier.startsWith("./") || specifier.startsWith("../")) {
207
+ const dir = io.resolvePath(base, specifier);
208
+ manifestPath = io.resolvePath(`${dir}/package.json`, `./${EXTENSION_MANIFEST}`);
209
+ } else {
210
+ try {
211
+ manifestPath = io.resolvePath(base, `${specifier}/${EXTENSION_MANIFEST}`);
212
+ } catch (error) {
213
+ throw new Error(
214
+ `Extension "${specifier}" is not resolvable: it must export ${EXTENSION_MANIFEST} ` +
215
+ `(is the package installed, and does its exports map include it?): ${errorMessage(error)}`,
216
+ { cause: error },
217
+ );
218
+ }
219
+ }
220
+
221
+ let manifest: ExtensionManifest;
222
+ try {
223
+ manifest = (await io.loadJson(manifestPath)) as unknown as ExtensionManifest;
224
+ } catch (error) {
225
+ throw new Error(
226
+ `Extension "${specifier}": cannot read manifest ${manifestPath}: ${errorMessage(error)}`,
227
+ { cause: error },
228
+ );
229
+ }
230
+ if (!manifest || typeof manifest !== "object" || typeof manifest.name !== "string") {
231
+ throw new Error(`Extension "${specifier}": ${EXTENSION_MANIFEST} must declare a "name"`);
232
+ }
233
+ if (!specifier.startsWith(".") && manifest.name !== specifier) {
234
+ throw new Error(
235
+ `Extension "${specifier}": manifest name "${manifest.name}" does not match the specifier`,
236
+ );
237
+ }
238
+
239
+ const classes: FormatEntry[] = [];
240
+ for (const [name, ref] of Object.entries(manifest.classes ?? {})) {
241
+ const classPath = io.resolvePath(manifestPath, ref);
242
+ let classDef: Record<string, unknown>;
243
+ try {
244
+ classDef = await io.loadJson(classPath);
245
+ } catch (error) {
246
+ throw new Error(
247
+ `Extension "${specifier}": cannot read class "${name}" at ${classPath}: ${errorMessage(error)}`,
248
+ { cause: error },
249
+ );
250
+ }
251
+ classes.push(new FormatEntry(name, classPath, classDef, io));
252
+ }
253
+
254
+ const schemas: ExtensionInfo["schemas"] = {};
255
+ if (manifest.schemas?.project) {
256
+ schemas.project = io.resolvePath(manifestPath, manifest.schemas.project);
257
+ }
258
+ if (manifest.schemas?.document) {
259
+ schemas.document = io.resolvePath(manifestPath, manifest.schemas.document);
260
+ }
261
+ if (manifest.schemas?.fields) {
262
+ schemas.fields = io.resolvePath(manifestPath, manifest.schemas.fields);
263
+ }
264
+
265
+ return { specifier, manifestPath, manifest, classes, schemas };
266
+ }
267
+
268
+ function errorMessage(error: unknown): string {
269
+ return error instanceof Error ? error.message : String(error);
270
+ }
@@ -19,6 +19,34 @@ export const FORMAT_CAPABILITIES: readonly FormatCapability[] = [
19
19
  "load",
20
20
  ];
21
21
 
22
+ /**
23
+ * All well-known static capability roles across admission blocks (specs/extensions.md §8): format
24
+ * roles plus project-section (`projectData`, `resolvePaths`), compile-away (`lower`), server-mount
25
+ * (`mount`), and connector (`dialect`, `deploySchema`, `bindings`, `testConnection`) roles.
26
+ */
27
+ export type ExtensionCapability =
28
+ | FormatCapability
29
+ | "projectData"
30
+ | "resolvePaths"
31
+ | "lower"
32
+ | "mount"
33
+ | "dialect"
34
+ | "deploySchema"
35
+ | "bindings"
36
+ | "testConnection";
37
+
38
+ export const EXTENSION_CAPABILITIES: readonly ExtensionCapability[] = [
39
+ ...FORMAT_CAPABILITIES,
40
+ "projectData",
41
+ "resolvePaths",
42
+ "lower",
43
+ "mount",
44
+ "dialect",
45
+ "deploySchema",
46
+ "bindings",
47
+ "testConnection",
48
+ ];
49
+
22
50
  export type FormatTiming = "compiler" | "server" | "client";
23
51
 
24
52
  export type FormatDocumentKind = "page" | "component" | "content";
@@ -62,6 +90,32 @@ export interface CapabilityInfo {
62
90
  identifier: string;
63
91
  /** Environments allowed to call directly; others round-trip via the dev server. */
64
92
  timing: FormatTiming[];
93
+ /** For `resolvePaths`: the `$paths` key that routes to this class. */
94
+ discriminator?: string;
95
+ }
96
+
97
+ /** A class owns a project.json section iff its descriptor carries a `project` block. */
98
+ export interface ProjectBlock {
99
+ key: string;
100
+ title?: string;
101
+ description?: string;
102
+ referenceable?: boolean;
103
+ }
104
+
105
+ /** A class mounts routes in the site worker/dev server iff it carries a `server` block. */
106
+ export interface ServerBlock {
107
+ basePath: string;
108
+ order?: number;
109
+ module?: string;
110
+ }
111
+
112
+ /** A class provides database connections iff it carries a `connector` block. */
113
+ export interface ConnectorBlock {
114
+ provider: string;
115
+ kind: "sqlite" | "postgres";
116
+ local?: string;
117
+ serve?: string;
118
+ [key: string]: unknown;
65
119
  }
66
120
 
67
121
  export interface FormatHostIO {
@@ -78,12 +132,16 @@ interface ClassMethodLike {
78
132
  identifier?: string;
79
133
  scope?: string;
80
134
  timing?: string[];
135
+ discriminator?: string;
81
136
  }
82
137
 
83
138
  interface ClassDefLike {
84
139
  title?: string;
85
140
  $implementation?: string;
86
141
  format?: FormatBlock;
142
+ project?: ProjectBlock;
143
+ server?: ServerBlock;
144
+ connector?: ConnectorBlock;
87
145
  $studio?: StudioHints;
88
146
  $defs?: { methods?: Record<string, ClassMethodLike> };
89
147
  [key: string]: unknown;
@@ -101,7 +159,10 @@ export class FormatEntry {
101
159
  readonly exportTarget: boolean;
102
160
  readonly remote: boolean;
103
161
  readonly studio: StudioHints | null;
104
- readonly capabilities: Partial<Record<FormatCapability, CapabilityInfo>>;
162
+ readonly project: ProjectBlock | null;
163
+ readonly server: ServerBlock | null;
164
+ readonly connector: ConnectorBlock | null;
165
+ readonly capabilities: Partial<Record<ExtensionCapability, CapabilityInfo>>;
105
166
 
106
167
  #io: FormatHostIO;
107
168
  #implementation: Promise<Record<string, unknown>> | null = null;
@@ -112,13 +173,18 @@ export class FormatEntry {
112
173
  this.classDef = classDef;
113
174
  this.#io = io;
114
175
 
115
- const format = classDef.format as FormatBlock;
116
- this.extensions = (format.extensions ?? []).map((e) => e.toLowerCase());
117
- this.mediaType = format.mediaType ?? null;
118
- this.documentKinds = format.documentKinds ?? [];
119
- this.exportTarget = format.exportTarget === true;
120
- this.remote = format.remote === true;
176
+ // The format block is optional: entries also represent project/server/connector classes
177
+ // (specs/extensions.md §6). Absent block → no extensions claimed.
178
+ const { format } = classDef;
179
+ this.extensions = (format?.extensions ?? []).map((e) => e.toLowerCase());
180
+ this.mediaType = format?.mediaType ?? null;
181
+ this.documentKinds = format?.documentKinds ?? [];
182
+ this.exportTarget = format?.exportTarget === true;
183
+ this.remote = format?.remote === true;
121
184
  this.studio = classDef.$studio ?? null;
185
+ this.project = classDef.project ?? null;
186
+ this.server = classDef.server ?? null;
187
+ this.connector = classDef.connector ?? null;
122
188
  this.capabilities = extractCapabilities(classDef);
123
189
  }
124
190
 
@@ -137,7 +203,7 @@ export class FormatEntry {
137
203
  }
138
204
 
139
205
  /** Invoke a capability's static method on the implementation class. */
140
- async call(capability: FormatCapability, ...args: unknown[]): Promise<unknown> {
206
+ async call(capability: ExtensionCapability, ...args: unknown[]): Promise<unknown> {
141
207
  const cap = this.capabilities[capability];
142
208
  if (!cap) {
143
209
  throw new Error(`Format class "${this.name}" does not declare a "${capability}" capability`);
@@ -264,18 +330,22 @@ export async function buildFormatRegistry(
264
330
 
265
331
  function extractCapabilities(
266
332
  classDef: ClassDefLike,
267
- ): Partial<Record<FormatCapability, CapabilityInfo>> {
268
- const out: Partial<Record<FormatCapability, CapabilityInfo>> = {};
333
+ ): Partial<Record<ExtensionCapability, CapabilityInfo>> {
334
+ const out: Partial<Record<ExtensionCapability, CapabilityInfo>> = {};
269
335
  const methods = classDef.$defs?.methods ?? {};
270
336
  for (const [key, method] of Object.entries(methods)) {
271
- const role = method.role as FormatCapability | undefined;
272
- if (!role || !FORMAT_CAPABILITIES.includes(role)) {
337
+ const role = method.role as ExtensionCapability | undefined;
338
+ if (!role || !EXTENSION_CAPABILITIES.includes(role)) {
273
339
  continue;
274
340
  }
275
- out[role] = {
341
+ const info: CapabilityInfo = {
276
342
  identifier: method.identifier ?? key,
277
343
  timing: (method.timing as FormatTiming[] | undefined) ?? DEFAULT_TIMING,
278
344
  };
345
+ if (typeof method.discriminator === "string") {
346
+ info.discriminator = method.discriminator;
347
+ }
348
+ out[role] = info;
279
349
  }
280
350
  return out;
281
351
  }
@@ -0,0 +1,262 @@
1
+ /**
2
+ * Per-project schema emitters (specs/extensions.md §5.2).
3
+ *
4
+ * Pure functions: they emit the generated entry documents (project.schema.json and
5
+ * document.schema.json) from already-resolved, project-relative fragment paths. All composition is
6
+ * standard JSON Schema 2020-12:
7
+ *
8
+ * - A plain `allOf` over fragment `$ref`s, closed with `unevaluatedProperties: false`;
9
+ * - The two well-known union resources (`PROJECT_FIELDS_SCHEMA_ID`, `DOCUMENT_PATHS_SCHEMA_ID`)
10
+ * re-embedded under `$defs` with their canonical `$id`s, carrying the _effective_ unions.
11
+ * Compound-document `$id` resolution makes every fragment's `$ref` to those URIs land on the
12
+ * entry document's embed instead of the shipped defaults — the override mechanism.
13
+ *
14
+ * Inside the embeds all refs are canonical URIs (never relative paths): a `$ref` inside a resource
15
+ * with an absolute `$id` resolves against that `$id`. Relative `./node_modules/...` paths appear
16
+ * only in the entry document's own `allOf`/`$ref`, resolved against the file location. Hosts
17
+ * validating with ajv should inject an `$id` set to the entry file's URL at load time so those
18
+ * relative refs resolve (the committed files stay machine-path-free).
19
+ */
20
+
21
+ import { DOCUMENT_PATHS_SCHEMA_ID, PROJECT_FIELDS_SCHEMA_ID } from "../defs/field-schema.schema";
22
+
23
+ export { DOCUMENT_PATHS_SCHEMA_ID, PROJECT_FIELDS_SCHEMA_ID } from "../defs/field-schema.schema";
24
+
25
+ /** The canonical $id of the core project fragment. */
26
+ export const PROJECT_CORE_SCHEMA_ID = "https://jxsuite.com/schema/project/core/v2";
27
+
28
+ export const GENERATED_SCHEMA_COMMENT =
29
+ "Generated by `jx schema` from project.json#/extensions — do not edit.";
30
+
31
+ export interface ProjectSchemaInputs {
32
+ /**
33
+ * Project-relative path of the core fragment, e.g.
34
+ * "./node_modules/@jxsuite/schema/schemas/project.core.schema.json".
35
+ */
36
+ corePath: string;
37
+ /** Project-relative extension fragment paths, in extension declaration order. */
38
+ fragments: string[];
39
+ /**
40
+ * Additional field-union members beyond the core field shape and RelationshipRef, as canonical
41
+ * "uri#/pointer" refs (e.g. a connector's column extras). Never relative paths — they resolve
42
+ * against the embed's $id.
43
+ */
44
+ fieldSchemaRefs?: string[];
45
+ }
46
+
47
+ /** Emit the `project.schema.json` entry document for a project. */
48
+ export function emitProjectSchema(inputs: ProjectSchemaInputs): Record<string, unknown> {
49
+ const { corePath, fragments, fieldSchemaRefs = [] } = inputs;
50
+ return {
51
+ $comment: GENERATED_SCHEMA_COMMENT,
52
+ $defs: {
53
+ Fields: {
54
+ $id: PROJECT_FIELDS_SCHEMA_ID,
55
+ anyOf: [
56
+ { $ref: `${PROJECT_CORE_SCHEMA_ID}#/$defs/JxFieldSchema` },
57
+ { $ref: `${PROJECT_CORE_SCHEMA_ID}#/$defs/RelationshipRef` },
58
+ ...fieldSchemaRefs.map((ref) => ({ $ref: ref })),
59
+ ],
60
+ },
61
+ },
62
+ $schema: "https://json-schema.org/draft/2020-12/schema",
63
+ allOf: [{ $ref: corePath }, ...fragments.map((ref) => ({ $ref: ref }))],
64
+ type: "object",
65
+ unevaluatedProperties: false,
66
+ };
67
+ }
68
+
69
+ // ─── Generic bundler (specs/extensions.md §5.4) ──────────────────────────────
70
+
71
+ /** Injected JSON loader for {@link bundleSchema} — receives a normalized, slash-separated path. */
72
+ export type SchemaJsonLoader = (path: string) => Promise<Record<string, unknown>>;
73
+
74
+ /**
75
+ * Bundle an entry document into a single self-contained compound schema for hosts that cannot fetch
76
+ * files (editor schema registration, cloud studio). Every relative-path `$ref` target is loaded
77
+ * through the injected loader and embedded under the entry's `$defs`, keyed by its `$id` (or a
78
+ * generated `urn:jx:bundled:*` one when the file declares none); the referencing `$ref`s are
79
+ * rewritten to those canonical URIs. Resource boundaries are preserved: embedded resources keep
80
+ * their `$id`s, refs inside an embedded absolute-`$id` resource are canonical and stay untouched,
81
+ * and a resource whose `$id` is already present in the compound document is not embedded twice.
82
+ *
83
+ * @param {Record<string, unknown>} entry - The entry document (e.g. a parsed project.schema.json)
84
+ * @param {SchemaJsonLoader} loadJson - Loader for `$ref` target files (host-restricted)
85
+ * @param {string} baseDir - Directory the entry document's relative refs resolve against
86
+ * @returns {Promise<Record<string, unknown>>} A new, self-contained schema (the input is not
87
+ * mutated)
88
+ */
89
+ export async function bundleSchema(
90
+ entry: Record<string, unknown>,
91
+ loadJson: SchemaJsonLoader,
92
+ baseDir: string,
93
+ ): Promise<Record<string, unknown>> {
94
+ const bundled = structuredClone(entry);
95
+ const embeddedByPath = new Map<string, string>();
96
+ const embeddedIds = new Set<string>();
97
+ collectResourceIds(bundled, embeddedIds);
98
+ let generatedCount = 0;
99
+
100
+ /** Ordered queue of embeds so $defs insertion happens after the entry walk (stable output). */
101
+ const embed = async (path: string): Promise<string> => {
102
+ const normalized = normalizeSchemaPath(path);
103
+ const prior = embeddedByPath.get(normalized);
104
+ if (prior !== undefined) {
105
+ return prior;
106
+ }
107
+ let resource: Record<string, unknown>;
108
+ try {
109
+ resource = structuredClone(await loadJson(normalized));
110
+ } catch (error) {
111
+ throw new Error(
112
+ `bundleSchema: cannot load $ref target "${normalized}": ${errorText(error)}`,
113
+ { cause: error },
114
+ );
115
+ }
116
+ const ownId =
117
+ typeof resource.$id === "string" && hasUriScheme(resource.$id) ? resource.$id : null;
118
+ generatedCount += ownId === null ? 1 : 0;
119
+ const id = ownId ?? `urn:jx:bundled:${generatedCount}`;
120
+ embeddedByPath.set(normalized, id);
121
+ if (embeddedIds.has(id)) {
122
+ // The canonical resource is already part of the compound document — refs land on it.
123
+ return id;
124
+ }
125
+ embeddedIds.add(id);
126
+ resource.$id = id;
127
+ await rewrite(resource, schemaDirOf(normalized), true);
128
+ const defs = (bundled.$defs ??= {}) as Record<string, unknown>;
129
+ defs[id] = resource;
130
+ return id;
131
+ };
132
+
133
+ /**
134
+ * Walk a (sub)schema rewriting relative-path `$ref`s. `fileDir` is the directory relative refs
135
+ * resolve against, or null inside an embedded absolute-`$id` resource (canonical scope).
136
+ */
137
+ const rewrite = async (node: unknown, fileDir: string | null, isRoot = false): Promise<void> => {
138
+ if (Array.isArray(node)) {
139
+ for (const item of node) {
140
+ await rewrite(item, fileDir);
141
+ }
142
+ return;
143
+ }
144
+ if (node === null || typeof node !== "object") {
145
+ return;
146
+ }
147
+ const obj = node as Record<string, unknown>;
148
+ let scope = fileDir;
149
+ if (!isRoot && typeof obj.$id === "string" && hasUriScheme(obj.$id)) {
150
+ // Entering an embedded resource with a canonical $id: refs inside resolve against that
151
+ // $id (never the file location), so there is nothing to inline.
152
+ scope = null;
153
+ }
154
+ const ref = obj.$ref;
155
+ if (typeof ref === "string" && scope !== null && !ref.startsWith("#") && !hasUriScheme(ref)) {
156
+ const hashIndex = ref.indexOf("#");
157
+ const target = hashIndex === -1 ? ref : ref.slice(0, hashIndex);
158
+ const fragment = hashIndex === -1 ? "" : ref.slice(hashIndex);
159
+ if (target) {
160
+ const id = await embed(`${scope}/${target}`);
161
+ obj.$ref = `${id}${fragment}`;
162
+ }
163
+ }
164
+ for (const value of Object.values(obj)) {
165
+ await rewrite(value, scope);
166
+ }
167
+ };
168
+
169
+ await rewrite(bundled, normalizeSchemaPath(baseDir), true);
170
+ return bundled;
171
+ }
172
+
173
+ /** Collect every absolute `$id` already present in a document (embeds like the field union). */
174
+ function collectResourceIds(node: unknown, out: Set<string>): void {
175
+ if (Array.isArray(node)) {
176
+ for (const item of node) {
177
+ collectResourceIds(item, out);
178
+ }
179
+ return;
180
+ }
181
+ if (node === null || typeof node !== "object") {
182
+ return;
183
+ }
184
+ const obj = node as Record<string, unknown>;
185
+ if (typeof obj.$id === "string" && hasUriScheme(obj.$id)) {
186
+ out.add(obj.$id);
187
+ }
188
+ for (const value of Object.values(obj)) {
189
+ collectResourceIds(value, out);
190
+ }
191
+ }
192
+
193
+ /** True when a ref/id carries a URI scheme (https:, urn:, file:) — i.e. is not a relative path. */
194
+ function hasUriScheme(ref: string): boolean {
195
+ return /^[a-zA-Z][a-zA-Z\d+.-]*:/.test(ref);
196
+ }
197
+
198
+ /** Normalize a path to forward slashes, resolving "." and ".." segments (environment-agnostic). */
199
+ function normalizeSchemaPath(path: string): string {
200
+ const slashed = path.replaceAll("\\", "/");
201
+ const absolute = slashed.startsWith("/");
202
+ const parts: string[] = [];
203
+ for (const segment of slashed.split("/")) {
204
+ if (segment === "" || segment === ".") {
205
+ continue;
206
+ }
207
+ if (segment === "..") {
208
+ if (parts.length > 0 && parts.at(-1) !== "..") {
209
+ parts.pop();
210
+ } else if (!absolute) {
211
+ parts.push("..");
212
+ }
213
+ continue;
214
+ }
215
+ parts.push(segment);
216
+ }
217
+ return (absolute ? "/" : "") + parts.join("/");
218
+ }
219
+
220
+ /** The directory portion of a normalized schema path ("." when there is none). */
221
+ function schemaDirOf(path: string): string {
222
+ const index = path.lastIndexOf("/");
223
+ if (index === -1) {
224
+ return ".";
225
+ }
226
+ return index === 0 ? "/" : path.slice(0, index);
227
+ }
228
+
229
+ function errorText(error: unknown): string {
230
+ return error instanceof Error ? error.message : String(error);
231
+ }
232
+
233
+ export interface DocumentSchemaInputs {
234
+ /**
235
+ * Project-relative path of the core document schema, e.g.
236
+ * "./node_modules/@jxsuite/schema/schema.json".
237
+ */
238
+ corePath: string;
239
+ /** Canonical "uri#/pointer" refs unioned into the paths resource, in declaration order. */
240
+ pathsValueRefs: string[];
241
+ }
242
+
243
+ /** Emit the `document.schema.json` entry document for a project. */
244
+ export function emitDocumentSchema(inputs: DocumentSchemaInputs): Record<string, unknown> {
245
+ const { corePath, pathsValueRefs } = inputs;
246
+ return {
247
+ $comment: GENERATED_SCHEMA_COMMENT,
248
+ $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 },
258
+ },
259
+ $ref: corePath,
260
+ $schema: "https://json-schema.org/draft/2020-12/schema",
261
+ };
262
+ }