@jxsuite/compiler 0.34.1 → 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.
@@ -3,8 +3,7 @@
3
3
  *
4
4
  * Scans the pages/ directory and builds a route table mapping URL paths to their source files,
5
5
  * layouts, and metadata. `.json` pages are native; any other extension must be claimed by a format
6
- * class (with the `parse` capability and a "page" document kind) registered from the project's
7
- * imports map.
6
+ * class (with the `parse` capability and a "page" document kind) provided by an enabled extension.
8
7
  *
9
8
  * Conventions (per site-architecture spec §4): pages/index.json → / pages/about.json → /about
10
9
  * pages/about/index.json → /about pages/blog/[slug].json → /blog/:slug (dynamic)
@@ -15,9 +14,9 @@
15
14
  import { readFileSync, readdirSync } from "node:fs";
16
15
  import { parseJxDocument } from "@jxsuite/schema/parse";
17
16
  import { extname, join, relative, resolve } from "node:path";
17
+ import type { ExtensionRegistry } from "@jxsuite/schema/extension-registry";
18
18
  import type { FormatRegistry } from "@jxsuite/schema/format-registry";
19
- import type { JxDocument, JxPathsDef } from "@jxsuite/schema/types";
20
- import type { ContentLoaderEntry } from "@jxsuite/parser/types";
19
+ import type { JxDocument, JxPathsDef, ProjectConfig } from "@jxsuite/schema/types";
21
20
 
22
21
  interface Route {
23
22
  urlPattern: string; // URL pattern (e.g. "/blog/:slug")
@@ -50,9 +49,9 @@ export async function readPageDocument(
50
49
  const entry = registry?.byExtension(ext, "parse");
51
50
  if (!entry) {
52
51
  throw new Error(
53
- `No format class imported for "${ext}" (${filePath}). ` +
54
- `Add a format class to project.json imports, e.g. ` +
55
- `"Markdown": "@jxsuite/parser/Markdown.class.json".`,
52
+ `No format class registered for "${ext}" (${filePath}). ` +
53
+ `Enable an extension providing this format in project.json "extensions", ` +
54
+ `e.g. "@jxsuite/parser".`,
56
55
  );
57
56
  }
58
57
  return (await entry.call("parse", source)) as JxDocument;
@@ -208,23 +207,25 @@ async function fileToRoute(relativePath: string, absolutePath: string, registry?
208
207
  /**
209
208
  * Expand dynamic routes by resolving $paths from each dynamic page.
210
209
  *
211
- * Supports three $paths shapes (per spec §4.3): 1. Content type-based: { contentType: "blog",
212
- * param: "slug", field: "id" } 2. Explicit values: { values: ["en", "fr"], param: "lang" } 3. Data
213
- * file ref: { "$ref": "./data/products.json", param: "id", field: "sku" } 4. Legacy array: [{ slug:
214
- * "hello" }, { slug: "world" }]
210
+ * Supports these $paths shapes (per spec §4.3): 1. Explicit values: { values: ["en", "fr"], param:
211
+ * "lang" } 2. Data file ref: { "$ref": "./data/products.json", param: "id", field: "sku" } 3.
212
+ * Legacy array: [{ slug: "hello" }, { slug: "world" }] 4. Extension-discriminated: any object
213
+ * carrying a key an enabled extension registered as its `resolvePaths` discriminator (e.g. the
214
+ * parser's { contentType: "blog", param: "slug" }).
215
215
  *
216
216
  * @param {Route[]} routes - Discovered route table
217
217
  * @param {string} projectRoot - Project root for resolving $ref paths
218
- * @param {Map<string, ContentLoaderEntry[]>} [contentTypes] - Loaded content types (from
219
- * content-loader)
220
- * @param {FormatRegistry} [registry] - Format registry for reading non-JSON dynamic pages
218
+ * @param {Record<string, unknown>} [sections] - Loaded project sections (projectData results)
219
+ * @param {ExtensionRegistry} [registry] - Extension registry (formats view reads non-JSON pages)
220
+ * @param {ProjectConfig} [projectConfig] - Already-loaded project config
221
221
  * @returns {Promise<Route[]>} Expanded routes with concrete paths
222
222
  */
223
223
  export async function expandDynamicRoutes(
224
224
  routes: Route[],
225
225
  projectRoot: string,
226
- contentTypes = new Map<string, ContentLoaderEntry[]>(),
227
- registry?: FormatRegistry,
226
+ sections: Record<string, unknown> = {},
227
+ registry?: ExtensionRegistry,
228
+ projectConfig?: ProjectConfig,
228
229
  ) {
229
230
  const expanded: Route[] = [];
230
231
 
@@ -238,7 +239,7 @@ export async function expandDynamicRoutes(
238
239
  /** @type {Record<string, unknown>} */
239
240
  let raw;
240
241
  try {
241
- raw = await readPageDocument(route.sourcePath, registry);
242
+ raw = await readPageDocument(route.sourcePath, registry?.formats);
242
243
  } catch {
243
244
  expanded.push(route);
244
245
  continue;
@@ -249,7 +250,13 @@ export async function expandDynamicRoutes(
249
250
  continue;
250
251
  }
251
252
 
252
- const pathEntries = resolvePathEntries(raw.$paths, projectRoot, contentTypes);
253
+ const pathEntries = await resolvePathEntries(
254
+ raw.$paths,
255
+ projectRoot,
256
+ sections,
257
+ registry,
258
+ projectConfig,
259
+ );
253
260
 
254
261
  for (const pathEntry of pathEntries) {
255
262
  let concreteUrl = route.urlPattern;
@@ -277,67 +284,72 @@ export async function expandDynamicRoutes(
277
284
  /**
278
285
  * Resolve $paths into an array of param objects.
279
286
  *
287
+ * Core shapes (array / `values` / `$ref`) are handled inline; any other object dispatches through
288
+ * the extension registry — the first key with a registered `resolvePaths` discriminator routes the
289
+ * whole $paths value to the owning class, with its section's loaded data as context.
290
+ *
280
291
  * @param {import("@jxsuite/schema/types").JxPathsDef} $paths - The $paths declaration
281
292
  * @param {string} projectRoot
282
- * @param {Map<string, ContentLoaderEntry[]>} contentTypes
283
- * @returns {Record<string, unknown>[]} Array of { paramName: value } objects
293
+ * @param {Record<string, unknown>} sections - Loaded project sections keyed by section key
294
+ * @param {ExtensionRegistry} [registry]
295
+ * @param {ProjectConfig} [projectConfig]
296
+ * @returns {Promise<Record<string, unknown>[]>} Array of { paramName: value } objects
284
297
  */
285
- function resolvePathEntries(
298
+ async function resolvePathEntries(
286
299
  $paths: JxPathsDef,
287
300
  projectRoot: string,
288
- contentTypes: Map<string, ContentLoaderEntry[]>,
289
- ): Record<string, unknown>[] {
301
+ sections: Record<string, unknown>,
302
+ registry?: ExtensionRegistry,
303
+ projectConfig?: ProjectConfig,
304
+ ): Promise<Record<string, unknown>[]> {
290
305
  // Legacy: array of param objects
291
306
  if (Array.isArray($paths)) {
292
307
  return $paths;
293
308
  }
294
309
 
295
- // Content type-based: { contentType: "blog", param: "slug", field: "id" }
296
- if ("contentType" in $paths && $paths.contentType) {
297
- const entries = contentTypes.get($paths.contentType);
298
- if (!entries || entries.length === 0) {
299
- console.warn(
300
- `Warning: $paths references content type "${$paths.contentType}" but it has no entries`,
301
- );
302
- return [];
303
- }
304
- const param = $paths.param ?? "slug";
305
- const field = $paths.field ?? "id";
306
- return entries
307
- .map((entry: ContentLoaderEntry) => ({
308
- [param]: field === "id" ? entry.id : (entry.data[field] ?? entry.id),
309
- }))
310
- .filter((p: Record<string, unknown>) => p[param]);
311
- }
312
-
313
310
  // Explicit values: { values: ["en", "fr"], param: "lang" }
314
311
  if ("values" in $paths && Array.isArray($paths.values)) {
315
- const param = $paths.param ?? "value";
316
- return $paths.values.map((v) => ({ [param]: v }));
312
+ const param = typeof $paths.param === "string" ? $paths.param : "value";
313
+ return ($paths.values as unknown[]).map((v) => ({ [param]: v }));
317
314
  }
318
315
 
319
316
  // Data file ref: { "$ref": "./data/products.json", param: "id", field: "sku" }
320
- if ("$ref" in $paths && $paths.$ref) {
321
- const filePath = resolve(projectRoot, $paths.$ref);
317
+ if ("$ref" in $paths && typeof $paths.$ref === "string" && $paths.$ref) {
318
+ const refPath = $paths.$ref;
319
+ const filePath = resolve(projectRoot, refPath);
322
320
  let data: unknown;
323
321
  try {
324
322
  data = JSON.parse(readFileSync(filePath, "utf8")) as unknown;
325
323
  } catch (error) {
326
324
  const err = error as Error;
327
- console.warn(`Warning: $paths.$ref could not load "${$paths.$ref}": ${err.message}`);
325
+ console.warn(`Warning: $paths.$ref could not load "${refPath}": ${err.message}`);
328
326
  return [];
329
327
  }
330
328
  if (!Array.isArray(data)) {
331
- console.warn(`Warning: $paths.$ref "${$paths.$ref}" must be a JSON array`);
329
+ console.warn(`Warning: $paths.$ref "${refPath}" must be a JSON array`);
332
330
  return [];
333
331
  }
334
- const param = $paths.param ?? "id";
335
- const field = $paths.field ?? "id";
332
+ const param = typeof $paths.param === "string" ? $paths.param : "id";
333
+ const field = typeof $paths.field === "string" ? $paths.field : "id";
336
334
  return (data as Record<string, unknown>[]).map((item: Record<string, unknown>) => ({
337
335
  [param]: item[field] ?? item.id ?? String(item),
338
336
  }));
339
337
  }
340
338
 
339
+ // Extension-discriminated: the first key with a registered resolvePaths discriminator routes
340
+ // The whole value to the owning class (specs/extensions.md §8).
341
+ for (const key of Object.keys($paths)) {
342
+ const entry = registry?.byPathsDiscriminator(key);
343
+ if (!entry?.project) {
344
+ continue;
345
+ }
346
+ return (await entry.call("resolvePaths", $paths, {
347
+ data: sections[entry.project.key],
348
+ projectConfig,
349
+ root: projectRoot,
350
+ })) as Record<string, unknown>[];
351
+ }
352
+
341
353
  console.warn(`Warning: unrecognized $paths shape — skipping`);
342
354
  return [];
343
355
  }
@@ -0,0 +1,48 @@
1
+ /**
2
+ * Project-sections — generic section orchestration (specs/extensions.md §8, §9)
3
+ *
4
+ * Loads every extension-contributed project.json section through the owning class's `projectData`
5
+ * capability and returns the results keyed by section key. Hosts thread the record through the
6
+ * build (route expansion, prototype resolution) without knowing any section's shape.
7
+ */
8
+
9
+ import { createNodeFormatIO } from "./format-host.ts";
10
+ import type { ExtensionRegistry } from "@jxsuite/schema/extension-registry";
11
+ import type { ProjectConfig } from "@jxsuite/schema/types";
12
+
13
+ /**
14
+ * Load all extension-contributed project sections.
15
+ *
16
+ * A section is loaded only when its key is present in the project config: `projectData` on an
17
+ * absent section would only manufacture empty data (the parser's returns an empty Map), so skipping
18
+ * keeps `_project` free of keys the author never declared — and is the cheaper rule.
19
+ *
20
+ * @param {string} projectRoot - Absolute path to the project root
21
+ * @param {ProjectConfig | undefined} projectConfig - Already-loaded project config
22
+ * @param {ExtensionRegistry} registry - The project's extension registry
23
+ * @returns {Promise<Record<string, unknown>>} Section key → projectData result
24
+ */
25
+ export async function loadProjectSections(
26
+ projectRoot: string,
27
+ projectConfig: ProjectConfig | undefined,
28
+ registry: ExtensionRegistry,
29
+ ): Promise<Record<string, unknown>> {
30
+ const sections: Record<string, unknown> = {};
31
+ const io = createNodeFormatIO(projectRoot);
32
+ for (const entry of registry.projectContributions()) {
33
+ const key = entry.project?.key;
34
+ if (!key || !entry.capabilities.projectData) {
35
+ continue;
36
+ }
37
+ if (!projectConfig || !(key in projectConfig)) {
38
+ continue;
39
+ }
40
+ sections[key] = await entry.call("projectData", projectConfig[key], {
41
+ io,
42
+ projectConfig,
43
+ registry,
44
+ root: projectRoot,
45
+ });
46
+ }
47
+ return sections;
48
+ }
@@ -20,12 +20,14 @@ import { createRequire } from "node:module";
20
20
  import { importImplementation } from "./format-host.ts";
21
21
  import { isPrototypeDef } from "@jxsuite/schema/guards";
22
22
  import { errorMessage, parseClassDef } from "@jxsuite/schema/parse";
23
+ import type { ExtensionRegistry } from "@jxsuite/schema/extension-registry";
23
24
  import type {
24
25
  JsonObject,
25
26
  JsonValue,
26
27
  JxDocument,
27
28
  JxMutableNode,
28
29
  JxPrototypeDef,
30
+ JxStateDefinition,
29
31
  } from "@jxsuite/schema/types";
30
32
 
31
33
  /**
@@ -60,8 +62,14 @@ const RESERVED_KEYS = new Set([
60
62
  * @param {JxMutableNode | JxDocument} doc - The page document (mutated in place)
61
63
  * @param {{ sourcePath?: string; _pathParams?: Record<string, string> }} route - Route info
62
64
  * @param {string} projectRoot - Absolute path to the project root
63
- * @param {{ config?: Record<string, unknown>; contentTypes?: Map<string, unknown[]> }} [projectContext] -
64
- * Project-level context injected into all classes
65
+ * @param {{
66
+ * config?: Record<string, unknown>;
67
+ * sections?: Record<string, unknown>;
68
+ * registry?: ExtensionRegistry;
69
+ * }} [projectContext]
70
+ * - Project-level context injected into all classes; `sections` holds the extension-loaded project
71
+ * sections keyed by section key (spread into `_project`); `registry` enables manifest class
72
+ * resolution and the generic `lower` capability
65
73
  */
66
74
  export async function resolvePrototypes(
67
75
  doc: JxMutableNode | JxDocument,
@@ -69,7 +77,8 @@ export async function resolvePrototypes(
69
77
  projectRoot: string,
70
78
  projectContext: {
71
79
  config?: Record<string, unknown>;
72
- contentTypes?: Map<string, unknown[]>;
80
+ sections?: Record<string, unknown>;
81
+ registry?: ExtensionRegistry;
73
82
  } = {},
74
83
  ) {
75
84
  const imports = doc.imports ?? {};
@@ -85,15 +94,33 @@ export async function resolvePrototypes(
85
94
  if (SKIP_PROTOTYPES.has(def.$prototype)) {
86
95
  continue;
87
96
  }
88
- // Only resolve timing: "compiler" (or unset timing with a .class.json mapping).
89
- // Leave timing: "server" and timing: "client" for their respective pipelines.
97
+ // Only resolve timing: "compiler" (or unset timing with a class mapping). Timing: "server"
98
+ // And timing: "client" stay for their pipelines — but a registry class with a `lower`
99
+ // Capability compiles away here into a core-shape def (specs/extensions.md §8.3): dynamic
100
+ // Data becomes a plain reactive fetch with no extension code shipped to the browser.
90
101
  if (def.timing && def.timing !== "compiler") {
102
+ const entry = projectContext.registry?.byName(def.$prototype);
103
+ if (entry?.capabilities.lower) {
104
+ try {
105
+ const lowered = await entry.call("lower", def, {
106
+ projectConfig: projectContext.config,
107
+ root: projectRoot,
108
+ route,
109
+ });
110
+ state[key] = lowered as JxStateDefinition;
111
+ } catch (error) {
112
+ console.warn(
113
+ `prototype-resolver: failed to lower "${key}" ($prototype: "${def.$prototype}"):`,
114
+ errorMessage(error),
115
+ );
116
+ }
117
+ }
91
118
  continue;
92
119
  }
93
120
 
94
- // Look up in imports if no $src already set
121
+ // Look up in imports (project-local wins), then the extension registry's manifest classes.
95
122
  if (!def.$src) {
96
- const mapped = imports[def.$prototype];
123
+ const mapped = imports[def.$prototype] ?? registryClassPath(projectContext, def.$prototype);
97
124
  if (!mapped) {
98
125
  continue;
99
126
  }
@@ -116,6 +143,22 @@ export async function resolvePrototypes(
116
143
  }
117
144
  }
118
145
 
146
+ /**
147
+ * Descriptor path of a manifest class from the extension registry, when one is enabled. Lets
148
+ * `timing: "compiler"` defs name extension classes (TableQuery, ContentCollection, …) without a
149
+ * project-local imports entry.
150
+ *
151
+ * @param {{ registry?: ExtensionRegistry }} projectContext
152
+ * @param {string} prototypeName
153
+ * @returns {string | undefined}
154
+ */
155
+ function registryClassPath(
156
+ projectContext: { registry?: ExtensionRegistry },
157
+ prototypeName: string,
158
+ ): string | undefined {
159
+ return projectContext.registry?.byName(prototypeName)?.classPath;
160
+ }
161
+
119
162
  /**
120
163
  * Resolve a single $prototype entry via its .class.json.
121
164
  *
@@ -123,7 +166,7 @@ export async function resolvePrototypes(
123
166
  * @param {{ sourcePath?: string; _pathParams?: Record<string, string> }} route
124
167
  * @param {string} projectRoot
125
168
  * @param {Record<string, unknown>} state - The full page state object
126
- * @param {{ config?: Record<string, unknown>; contentTypes?: Map<string, unknown[]> }} projectContext
169
+ * @param {{ config?: Record<string, unknown>; sections?: Record<string, unknown> }} projectContext
127
170
  * @returns {Promise<JsonValue>} The resolved value
128
171
  */
129
172
  async function resolveClassPrototype(
@@ -133,7 +176,7 @@ async function resolveClassPrototype(
133
176
  state: Record<string, unknown>,
134
177
  projectContext: {
135
178
  config?: Record<string, unknown>;
136
- contentTypes?: Map<string, unknown[]>;
179
+ sections?: Record<string, unknown>;
137
180
  },
138
181
  ) {
139
182
  const src = def.$src;
@@ -191,11 +234,12 @@ async function resolveClassPrototype(
191
234
  config.basePath = dirname(route.sourcePath);
192
235
  }
193
236
 
194
- // Inject universal context — available to all classes
237
+ // Inject universal context — available to all classes. Extension-loaded sections land under
238
+ // Their section keys (e.g. _project.content for the parser's content collections).
195
239
  config._project = {
196
240
  config: projectContext.config ?? null,
197
- contentTypes: projectContext.contentTypes ?? null,
198
241
  root: projectRoot,
242
+ ...projectContext.sections,
199
243
  };
200
244
  config._document = { route, state };
201
245
 
@@ -0,0 +1,242 @@
1
+ /**
2
+ * Schema-command — `jx schema` implementation (specs/extensions.md §5.2)
3
+ *
4
+ * Composes the per-project entry documents (project.schema.json and document.schema.json) from the
5
+ * core schemas and each enabled extension's shipped fragments, then writes them into the project
6
+ * root as committed artifacts. All fragment refs are PROJECT-RELATIVE, POSIX-style `./` paths:
7
+ * paths that resolve inside the project are emitted as-is; bare-specifier packages resolving
8
+ * through workspace symlinks (outside the root) fall back to the conventional
9
+ * `./node_modules/<package>/<subpath>` form, which resolves once dependencies are installed.
10
+ */
11
+
12
+ import { existsSync, readFileSync, statSync, writeFileSync } from "node:fs";
13
+ import { createRequire } from "node:module";
14
+ import { dirname, isAbsolute, relative, resolve, sep } from "node:path";
15
+ import {
16
+ bundleSchema,
17
+ emitDocumentSchema,
18
+ emitProjectSchema,
19
+ } from "@jxsuite/schema/project-schemas";
20
+ import { buildProjectExtensionRegistry } from "./format-host.ts";
21
+ import { loadProjectConfig } from "./site-loader.ts";
22
+ import type { ExtensionInfo } from "@jxsuite/schema/extension-registry";
23
+ import type { SchemaJsonLoader } from "@jxsuite/schema/project-schemas";
24
+
25
+ const CORE_PROJECT_SPECIFIER = "@jxsuite/schema/schemas/project.core.schema.json";
26
+ const CORE_DOCUMENT_SPECIFIER = "@jxsuite/schema/schema.json";
27
+
28
+ /**
29
+ * Generate and write `<root>/project.schema.json` and `<root>/document.schema.json`.
30
+ *
31
+ * @param {string} projectRoot - Absolute path to the project directory
32
+ * @returns {Promise<{ projectSchemaPath: string; documentSchemaPath: string }>} Written paths
33
+ */
34
+ export async function writeProjectSchemas(projectRoot: string) {
35
+ const { config } = loadProjectConfig(projectRoot);
36
+ const registry = await buildProjectExtensionRegistry(projectRoot, config);
37
+
38
+ const corePath = coreSchemaRef(projectRoot, CORE_PROJECT_SPECIFIER);
39
+ const fragments: string[] = [];
40
+ const pathsValueRefs: string[] = [];
41
+ const fieldSchemaRefs: string[] = [];
42
+ for (const ext of registry.extensions) {
43
+ const projectFragment = ext.schemas.project;
44
+ if (projectFragment) {
45
+ fragments.push(fragmentRef(projectRoot, ext, projectFragment));
46
+ }
47
+ const documentFragment = ext.schemas.document;
48
+ if (documentFragment) {
49
+ pathsValueRefs.push(...fragmentDefsRefs(documentFragment));
50
+ }
51
+ // The `schemas.fields` manifest convention: a fragment whose $defs members are extension
52
+ // Field extras, unioned into the per-project field resource (specs/extensions.md §5.3).
53
+ const fieldsFragment = ext.schemas.fields;
54
+ if (fieldsFragment) {
55
+ fieldSchemaRefs.push(...fragmentDefsRefs(fieldsFragment));
56
+ fragments.push(fragmentRef(projectRoot, ext, fieldsFragment));
57
+ }
58
+ }
59
+
60
+ const projectSchema = emitProjectSchema({ corePath, fieldSchemaRefs, fragments });
61
+ const documentSchema = emitDocumentSchema({
62
+ corePath: coreSchemaRef(projectRoot, CORE_DOCUMENT_SPECIFIER),
63
+ pathsValueRefs,
64
+ });
65
+
66
+ const projectSchemaPath = resolve(projectRoot, "project.schema.json");
67
+ const documentSchemaPath = resolve(projectRoot, "document.schema.json");
68
+ writeFileSync(projectSchemaPath, `${JSON.stringify(projectSchema, null, 2)}\n`, "utf8");
69
+ writeFileSync(documentSchemaPath, `${JSON.stringify(documentSchema, null, 2)}\n`, "utf8");
70
+ return { documentSchemaPath, projectSchemaPath };
71
+ }
72
+
73
+ /**
74
+ * Read the project's entry documents as PRE-BUNDLED, self-contained schemas — the payload behind
75
+ * the studio's `fetchProjectSchemas` PAL member (dev server route and desktop RPC). Missing or
76
+ * stale entry documents (older than project.json) are regenerated first via
77
+ * {@link writeProjectSchemas}; each is then bundled with a loader restricted to the project root,
78
+ * falling back to host resolution for `./node_modules/...` refs the way `jx validate` does
79
+ * (workspace hoisting leaves in-repo projects without their own node_modules).
80
+ *
81
+ * @param {string} projectRoot - Absolute path to the project directory
82
+ * @returns {Promise<{ project: Record<string, unknown>; document: Record<string, unknown> }>}
83
+ */
84
+ export async function readBundledProjectSchemas(projectRoot: string) {
85
+ const root = resolve(projectRoot);
86
+ const projectJsonPath = resolve(root, "project.json");
87
+ const projectSchemaPath = resolve(root, "project.schema.json");
88
+ const documentSchemaPath = resolve(root, "document.schema.json");
89
+
90
+ const stale = (path: string): boolean => {
91
+ if (!existsSync(path)) {
92
+ return true;
93
+ }
94
+ try {
95
+ return statSync(projectJsonPath).mtimeMs > statSync(path).mtimeMs;
96
+ } catch {
97
+ return false;
98
+ }
99
+ };
100
+ if (stale(projectSchemaPath) || stale(documentSchemaPath)) {
101
+ await writeProjectSchemas(root);
102
+ }
103
+
104
+ const loadJson = restrictedSchemaLoader(root);
105
+ const project = await bundleSchema(
106
+ JSON.parse(readFileSync(projectSchemaPath, "utf8")) as Record<string, unknown>,
107
+ loadJson,
108
+ root,
109
+ );
110
+ const document = await bundleSchema(
111
+ JSON.parse(readFileSync(documentSchemaPath, "utf8")) as Record<string, unknown>,
112
+ loadJson,
113
+ root,
114
+ );
115
+ return { document, project };
116
+ }
117
+
118
+ /**
119
+ * Bundler loader restricted to the project root, with the validate-project host fallback: a missing
120
+ * `<root>/node_modules/<specifier>` file resolves the specifier project-first, then through the
121
+ * host (workspace symlinks resolve transparently).
122
+ *
123
+ * @param {string} root - Absolute project root
124
+ * @returns {SchemaJsonLoader}
125
+ */
126
+ function restrictedSchemaLoader(root: string): SchemaJsonLoader {
127
+ return async (path) => {
128
+ const abs = resolve(path);
129
+ const rel = relative(root, abs);
130
+ if (rel.startsWith("..") || isAbsolute(rel)) {
131
+ throw new Error(`Schema ref "${path}" escapes the project root ${root}`);
132
+ }
133
+ if (existsSync(abs)) {
134
+ return JSON.parse(readFileSync(abs, "utf8")) as Record<string, unknown>;
135
+ }
136
+ const posixRel = rel.split(sep).join("/");
137
+ const marker = "node_modules/";
138
+ const markerIndex = posixRel.lastIndexOf(marker);
139
+ if (markerIndex === -1) {
140
+ throw new Error(`Schema ref "${path}" does not exist`);
141
+ }
142
+ const specifier = posixRel.slice(markerIndex + marker.length);
143
+ let resolved: string;
144
+ try {
145
+ const projRequire = createRequire(resolve(root, "package.json"));
146
+ resolved = projRequire.resolve(specifier);
147
+ } catch {
148
+ try {
149
+ const selfRequire = createRequire(import.meta.url);
150
+ resolved = selfRequire.resolve(specifier);
151
+ } catch (error) {
152
+ throw new Error(
153
+ `Schema ref "${path}" does not exist and "${specifier}" is not resolvable from the ` +
154
+ `project or the host`,
155
+ { cause: error },
156
+ );
157
+ }
158
+ }
159
+ return JSON.parse(readFileSync(resolved, "utf8")) as Record<string, unknown>;
160
+ };
161
+ }
162
+
163
+ /**
164
+ * Project-relative ref for a core schema file: resolve the specifier project-first (then host) and
165
+ * relativize; escape from the root (workspace symlinks) falls back to the conventional
166
+ * `./node_modules/...` path.
167
+ *
168
+ * @param {string} projectRoot
169
+ * @param {string} specifier - Bare specifier of the shipped core schema file
170
+ * @returns {string}
171
+ */
172
+ function coreSchemaRef(projectRoot: string, specifier: string): string {
173
+ let resolved: string | null = null;
174
+ try {
175
+ const projRequire = createRequire(resolve(projectRoot, "package.json"));
176
+ resolved = projRequire.resolve(specifier);
177
+ } catch {
178
+ try {
179
+ const selfRequire = createRequire(import.meta.url);
180
+ resolved = selfRequire.resolve(specifier);
181
+ } catch {
182
+ resolved = null;
183
+ }
184
+ }
185
+ const conventional = `./node_modules/${specifier}`;
186
+ return resolved ? projectRelativeRef(projectRoot, resolved, conventional) : conventional;
187
+ }
188
+
189
+ /**
190
+ * Project-relative ref for an extension's fragment file. Local (relative-path) extensions live
191
+ * inside the project; bare-specifier packages use the conventional node_modules path built from the
192
+ * specifier and the fragment's manifest-relative location.
193
+ *
194
+ * @param {string} projectRoot
195
+ * @param {ExtensionInfo} ext
196
+ * @param {string} fragmentPath - Resolved absolute fragment path
197
+ * @returns {string}
198
+ */
199
+ function fragmentRef(projectRoot: string, ext: ExtensionInfo, fragmentPath: string): string {
200
+ const manifestRel = relative(dirname(ext.manifestPath), fragmentPath).split(sep).join("/");
201
+ const conventional = `./node_modules/${ext.specifier}/${manifestRel}`;
202
+ return projectRelativeRef(projectRoot, fragmentPath, conventional);
203
+ }
204
+
205
+ /**
206
+ * Relativize an absolute path against the project root as a POSIX `./` ref, or fall back to the
207
+ * given conventional path when it escapes the root.
208
+ *
209
+ * @param {string} projectRoot
210
+ * @param {string} absPath
211
+ * @param {string} conventional
212
+ * @returns {string}
213
+ */
214
+ function projectRelativeRef(projectRoot: string, absPath: string, conventional: string): string {
215
+ const rel = relative(projectRoot, absPath).split(sep).join("/");
216
+ if (rel.startsWith("..")) {
217
+ return conventional;
218
+ }
219
+ return rel.startsWith("./") ? rel : `./${rel}`;
220
+ }
221
+
222
+ /**
223
+ * Canonical "uri#/pointer" refs into a fragment's `$defs` shapes: one per `$defs` entry, addressed
224
+ * by the fragment's own `$id` (refs inside the entry document's embeds resolve against canonical
225
+ * URIs, never file locations). Serves both the document paths union and the fields-extras union.
226
+ *
227
+ * @param {string} fragmentPath - Resolved absolute fragment path
228
+ * @returns {string[]}
229
+ */
230
+ function fragmentDefsRefs(fragmentPath: string): string[] {
231
+ let fragment: { $id?: unknown; $defs?: Record<string, unknown> };
232
+ try {
233
+ fragment = JSON.parse(readFileSync(fragmentPath, "utf8")) as typeof fragment;
234
+ } catch {
235
+ return [];
236
+ }
237
+ const { $id } = fragment;
238
+ if (typeof $id !== "string" || !fragment.$defs) {
239
+ return [];
240
+ }
241
+ return Object.keys(fragment.$defs).map((name) => `${$id}#/$defs/${name}`);
242
+ }