@jxsuite/compiler 1.4.0 → 2.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.
@@ -17,6 +17,8 @@ import { extname, join, relative, resolve } from "node:path";
17
17
  import type { ExtensionRegistry } from "@jxsuite/schema/extension-registry";
18
18
  import type { FormatRegistry } from "@jxsuite/schema/format-registry";
19
19
  import type { JxDocument, JxPathsDef, ProjectConfig } from "@jxsuite/schema/types";
20
+ import { localeOfRoute } from "./i18n.ts";
21
+ import type { ResolvedI18n } from "./i18n.ts";
20
22
 
21
23
  interface Route {
22
24
  urlPattern: string; // URL pattern (e.g. "/blog/:slug")
@@ -27,6 +29,13 @@ interface Route {
27
29
  params: string[]; // Parameter names (e.g. ["slug"])
28
30
  $layout: string | null; // Layout override from page frontmatter, if any
29
31
  _pathParams?: Record<string, string>; // Resolved path parameters
32
+ /**
33
+ * The timestamp of the thing this concrete route was generated FROM, when that is not the
34
+ * template file. A collection route's source is an entry; `sourcePath` still points at the
35
+ * `[slug].json` that rendered it, so without this every post in an archive reports the template's
36
+ * mtime and the whole collection looks edited whenever the template is.
37
+ */
38
+ sourceMtime?: string;
30
39
  }
31
40
 
32
41
  /**
@@ -57,6 +66,104 @@ export async function readPageDocument(
57
66
  return (await entry.call("parse", source)) as JxDocument;
58
67
  }
59
68
 
69
+ /**
70
+ * The `$translationKey` each route's document declares, for the routes that declare one.
71
+ *
72
+ * A **pre-pass**, because a page's alternates depend on the whole route table (§13.5): the set has
73
+ * to be complete before the first page is compiled, so a document cannot tell the build what its
74
+ * key is while it is being compiled.
75
+ *
76
+ * The file is read and the **parse is skipped** unless the text mentions the key. Most pages of a
77
+ * multilingual site never declare one — their paths are parallel and the derivation is right — so
78
+ * paying a second full parse for every page to find a key that is usually absent would be a cost
79
+ * with nothing behind it. Documents sharing a source (a `$paths` template's expansions) are read
80
+ * once.
81
+ *
82
+ * A document that fails to parse is skipped rather than thrown from here. It will fail again a
83
+ * moment later while being compiled, where the error names the page and the rest of the site still
84
+ * builds; failing in a pre-pass would turn one bad page into no site at all.
85
+ *
86
+ * **A key may name its route's parameters**, as `${slug}`, and that is what makes a collection's
87
+ * localized URLs work: one `[slug]` template expands to one route per entry, so a key that could
88
+ * not vary per entry would claim a single identity for the whole collection and the build would
89
+ * report it as a duplicate. `pages/fr-ca/expositions/[slug].json` declaring `"exhibitions/${slug}"`
90
+ * pairs each French post with the English one it translates, because two translations of an entry
91
+ * share an id (§13.3) and the id is what the parameter carries.
92
+ *
93
+ * @param {readonly {
94
+ * sourcePath: string;
95
+ * urlPattern: string;
96
+ * _pathParams?: Record<string, string>;
97
+ * }[]} routes
98
+ * - Concrete routes
99
+ * @param {FormatRegistry} [registry]
100
+ * @returns {Promise<Map<string, string>>} Keyed by `urlPattern`
101
+ */
102
+ export async function readTranslationKeys(
103
+ routes: readonly {
104
+ sourcePath: string;
105
+ urlPattern: string;
106
+ _pathParams?: Record<string, string> | undefined;
107
+ }[],
108
+ registry?: FormatRegistry,
109
+ ): Promise<Map<string, string>> {
110
+ const out = new Map<string, string>();
111
+ const byFile = new Map<string, string | null>();
112
+ for (const route of routes) {
113
+ if (!byFile.has(route.sourcePath)) {
114
+ byFile.set(route.sourcePath, await declaredTranslationKey(route.sourcePath, registry));
115
+ }
116
+ const key = byFile.get(route.sourcePath);
117
+ if (key !== null && key !== undefined) {
118
+ out.set(route.urlPattern, withRouteParams(key, route._pathParams));
119
+ }
120
+ }
121
+ return out;
122
+ }
123
+
124
+ /**
125
+ * Substitute a route's own parameters into a declared key.
126
+ *
127
+ * A parameter with no value is left as it was written rather than blanked: a key that quietly
128
+ * became `exhibitions/` would pair every entry in the collection with every other, which is a
129
+ * duplicate the build reports — and the report naming `${slug}` says what is actually wrong.
130
+ *
131
+ * @param {string} key
132
+ * @param {Record<string, string> | undefined} params
133
+ * @returns {string}
134
+ */
135
+ function withRouteParams(key: string, params: Record<string, string> | undefined): string {
136
+ if (!key.includes("${")) {
137
+ return key;
138
+ }
139
+ return key.replaceAll(/\$\{(\w+)\}/g, (whole, name: string) => params?.[name] ?? whole);
140
+ }
141
+
142
+ /**
143
+ * One document's `$translationKey`, or null when it declares none or cannot be read.
144
+ *
145
+ * @param {string} sourcePath
146
+ * @param {FormatRegistry} [registry]
147
+ * @returns {Promise<string | null>}
148
+ */
149
+ async function declaredTranslationKey(
150
+ sourcePath: string,
151
+ registry?: FormatRegistry,
152
+ ): Promise<string | null> {
153
+ if (!readFileSync(sourcePath, "utf8").includes("$translationKey")) {
154
+ return null;
155
+ }
156
+ let doc: JxDocument;
157
+ try {
158
+ doc = await readPageDocument(sourcePath, registry);
159
+ } catch {
160
+ return null;
161
+ }
162
+ return typeof doc.$translationKey === "string" && doc.$translationKey !== ""
163
+ ? doc.$translationKey
164
+ : null;
165
+ }
166
+
60
167
  /**
61
168
  * Discover all routable pages in a pages/ directory.
62
169
  *
@@ -204,6 +311,35 @@ async function fileToRoute(relativePath: string, absolutePath: string, registry?
204
311
  };
205
312
  }
206
313
 
314
+ /**
315
+ * The reserved key a `resolvePaths` result may carry alongside its route parameters.
316
+ *
317
+ * A route parameter comes from a `[bracket]` segment in a filename, so this name cannot collide
318
+ * with one by accident, and it matches what the content loader already calls the same data on an
319
+ * entry (`parser.md` §9.3) — so a fact about an entry keeps one name from the file it was read out
320
+ * of all the way to the sitemap.
321
+ */
322
+ const PATH_ENTRY_META = "_meta";
323
+
324
+ /**
325
+ * The entry timestamp carried on a `$paths` result, or null when there is none.
326
+ *
327
+ * Null is ordinary: the core `$paths` shapes (`values`, `$ref`, a literal array) describe route
328
+ * parameters and nothing else, and a route with no entry of its own correctly falls back to its
329
+ * template's own modification time.
330
+ *
331
+ * @param {Record<string, unknown>} pathEntry
332
+ * @returns {string | null}
333
+ */
334
+ function entryMtime(pathEntry: Record<string, unknown>): string | null {
335
+ const meta = pathEntry[PATH_ENTRY_META];
336
+ if (meta === null || typeof meta !== "object") {
337
+ return null;
338
+ }
339
+ const { mtime } = meta as { mtime?: unknown };
340
+ return typeof mtime === "string" && mtime !== "" ? mtime : null;
341
+ }
342
+
207
343
  /**
208
344
  * Expand dynamic routes by resolving $paths from each dynamic page.
209
345
  *
@@ -226,6 +362,7 @@ export async function expandDynamicRoutes(
226
362
  sections: Record<string, unknown> = {},
227
363
  registry?: ExtensionRegistry,
228
364
  projectConfig?: ProjectConfig,
365
+ i18n?: ResolvedI18n | null,
229
366
  ) {
230
367
  const expanded: Route[] = [];
231
368
 
@@ -256,23 +393,33 @@ export async function expandDynamicRoutes(
256
393
  sections,
257
394
  registry,
258
395
  projectConfig,
396
+ // The template's OWN prefix, read before expansion: `/fr/blog/:slug` is a French route
397
+ // Whatever its entries turn out to be called, and that is what scopes a localized collection.
398
+ localeOfRoute(route.urlPattern, i18n ?? null),
259
399
  );
260
400
 
261
401
  for (const pathEntry of pathEntries) {
262
402
  let concreteUrl = route.urlPattern;
263
403
  const params: Record<string, string> = {};
264
404
  for (const [param, value] of Object.entries(pathEntry)) {
405
+ // `_meta` is the reserved carrier for facts about the source ENTRY (extensions.md §8).
406
+ // It is not a route parameter and must never reach substitution.
407
+ if (param === PATH_ENTRY_META) {
408
+ continue;
409
+ }
265
410
  params[param] = String(value);
266
411
  concreteUrl = concreteUrl.replace(`:${param}`, params[param]);
267
412
  concreteUrl = concreteUrl.replace("*", params[param]);
268
413
  }
269
414
 
415
+ const mtime = entryMtime(pathEntry);
270
416
  expanded.push({
271
417
  ...route,
272
418
  _pathParams: params,
273
419
  isCatchAll: false,
274
420
  isDynamic: false,
275
421
  params: [],
422
+ ...(mtime === null ? {} : { sourceMtime: mtime }),
276
423
  urlPattern: concreteUrl,
277
424
  });
278
425
  }
@@ -301,6 +448,7 @@ async function resolvePathEntries(
301
448
  sections: Record<string, unknown>,
302
449
  registry?: ExtensionRegistry,
303
450
  projectConfig?: ProjectConfig,
451
+ locale?: string | null,
304
452
  ): Promise<Record<string, unknown>[]> {
305
453
  // Legacy: array of param objects
306
454
  if (Array.isArray($paths)) {
@@ -345,6 +493,7 @@ async function resolvePathEntries(
345
493
  }
346
494
  return (await entry.call("resolvePaths", $paths, {
347
495
  data: sections[entry.project.key],
496
+ locale,
348
497
  projectConfig,
349
498
  root: projectRoot,
350
499
  })) as Record<string, unknown>[];
@@ -12,7 +12,15 @@ import { createServer } from "node:http";
12
12
  import type { Server } from "node:http";
13
13
  import { existsSync, readFileSync, statSync } from "node:fs";
14
14
  import { extname, join, normalize, resolve, sep } from "node:path";
15
+ import { MEDIA_TYPE_BY_EXTENSION } from "@jxsuite/schema/media-type";
15
16
 
17
+ /*
18
+ * The extensions this server can be asked for. `MEDIA_TYPE_BY_EXTENSION` is spread in **last** so
19
+ * the registered spellings win: `.md` carries the `variant` that says which markdown it is, and
20
+ * `.yaml`/`.yml` are `application/yaml` rather than an `application/octet-stream` download prompt.
21
+ * Everything else is this table's own, because a shared table for `image/png` would be a second
22
+ * source of truth for something no standard is ambiguous about.
23
+ */
16
24
  const MIME_TYPES: Record<string, string> = {
17
25
  ".avif": "image/avif",
18
26
  ".css": "text/css; charset=utf-8",
@@ -23,13 +31,13 @@ const MIME_TYPES: Record<string, string> = {
23
31
  ".jpg": "image/jpeg",
24
32
  ".js": "text/javascript; charset=utf-8",
25
33
  ".json": "application/json; charset=utf-8",
26
- ".md": "text/markdown; charset=utf-8",
27
34
  ".png": "image/png",
28
35
  ".svg": "image/svg+xml",
29
36
  ".txt": "text/plain; charset=utf-8",
30
37
  ".webp": "image/webp",
31
38
  ".woff2": "font/woff2",
32
39
  ".xml": "application/xml; charset=utf-8",
40
+ ...MEDIA_TYPE_BY_EXTENSION,
33
41
  };
34
42
 
35
43
  /**
@@ -3,12 +3,16 @@
3
3
  *
4
4
  * Composes the per-project entry documents (project.schema.json and document.schema.json) from the
5
5
  * core schemas and each enabled extension's shipped fragments, then writes them into the project
6
- * root as committed, SELF-CONTAINED bundles: every fragment ref is resolved through
7
- * {@link bundleSchema}, embedding the core + fragment resources under `$defs` keyed by their
8
- * canonical `$id`s. Relative `./node_modules/...` refs only resolve through Node module resolution,
9
- * which editors do not perform (hoisted workspaces leave projects without their own node_modules) —
10
- * the bundled form resolves offline for ajv `jx validate`, VS Code's JSON language service, and the
11
- * studio's Monaco alike, with one resolution behavior for all consumers.
6
+ * root as committed, SELF-CONTAINED, SINGLE-RESOURCE schemas: every fragment ref is resolved
7
+ * through {@link bundleSchema} (embedding the core + fragment resources under `$defs`) and then
8
+ * collapsed by {@link flattenSchema}, which drops the embedded `$id`s and rewrites every ref to a
9
+ * root-relative JSON Pointer. Both passes are needed for one resolution behavior across all
10
+ * consumers: relative `./node_modules/...` refs only resolve through Node module resolution, which
11
+ * editors do not perform (hoisted workspaces leave projects without their own node_modules), while
12
+ * `$id`-scoped refs inside a compound document resolve only in a fully compliant validator — VS
13
+ * Code's JSON language service and Monaco resolve `#/...` against the document root and fetch
14
+ * anything with a URI before the `#` over the network. Root pointers are what ajv, the language
15
+ * service, and Monaco all agree on.
12
16
  */
13
17
 
14
18
  import { existsSync, readFileSync, statSync, writeFileSync } from "node:fs";
@@ -16,8 +20,8 @@ import { createRequire } from "node:module";
16
20
  import { dirname, isAbsolute, relative, resolve, sep } from "node:path";
17
21
  import {
18
22
  bundleSchema,
19
- emitDocumentSchema,
20
- emitProjectSchema,
23
+ composeProjectSchemas,
24
+ flattenSchema,
21
25
  } from "@jxsuite/schema/project-schemas";
22
26
  import { buildProjectExtensionRegistry } from "./format-host.ts";
23
27
  import { loadProjectConfig } from "./site-loader.ts";
@@ -33,49 +37,38 @@ const CORE_DOCUMENT_SPECIFIER = "@jxsuite/schema/schema.json";
33
37
  * @param {string} projectRoot - Absolute path to the project directory
34
38
  * @returns {Promise<{ projectSchemaPath: string; documentSchemaPath: string }>} Written paths
35
39
  */
36
- export async function writeProjectSchemas(projectRoot: string) {
40
+ export async function composeEntryDocuments(projectRoot: string) {
37
41
  const { config } = loadProjectConfig(projectRoot);
38
42
  const registry = await buildProjectExtensionRegistry(projectRoot, config);
39
43
 
40
- const corePath = coreSchemaRef(projectRoot, CORE_PROJECT_SPECIFIER);
41
- const fragments: string[] = [];
42
- const pathsValueRefs: string[] = [];
43
- const fieldSchemaRefs: string[] = [];
44
- for (const ext of registry.extensions) {
45
- const projectFragment = ext.schemas.project;
46
- if (projectFragment) {
47
- fragments.push(fragmentRef(projectRoot, ext, projectFragment));
48
- }
49
- const documentFragment = ext.schemas.document;
50
- if (documentFragment) {
51
- pathsValueRefs.push(...fragmentDefsRefs(documentFragment));
52
- }
53
- // The `schemas.fields` manifest convention: a fragment whose $defs members are extension
54
- // Field extras, unioned into the per-project field resource (specs/extensions.md §5.3).
55
- const fieldsFragment = ext.schemas.fields;
56
- if (fieldsFragment) {
57
- fieldSchemaRefs.push(...fragmentDefsRefs(fieldsFragment));
58
- fragments.push(fragmentRef(projectRoot, ext, fieldsFragment));
59
- }
60
- }
61
-
62
- // Bundle before committing: the emitted relative refs are an intermediate form only.
44
+ /* Composition itself is host-agnostic (@jxsuite/schema `composeProjectSchemas`) — the filesystem
45
+ appears only in what this host contributes: project-relative refs for the fragments the
46
+ registry resolved, and a loader restricted to the project root. The cloud session composes the
47
+ same two documents from bundled artifacts through the same function. */
63
48
  const root = resolve(projectRoot);
64
- const loadJson = restrictedSchemaLoader(root);
65
- const projectSchema = await bundleSchema(
66
- emitProjectSchema({ corePath, fieldSchemaRefs, fragments }),
67
- loadJson,
68
- root,
69
- );
70
- const documentSchema = await bundleSchema(
71
- emitDocumentSchema({
72
- corePath: coreSchemaRef(projectRoot, CORE_DOCUMENT_SPECIFIER),
73
- pathsValueRefs,
74
- }),
75
- loadJson,
76
- root,
77
- );
49
+ const { document: documentSchema, project: projectSchema } = await composeProjectSchemas({
50
+ baseDir: root,
51
+ coreDocumentRef: coreSchemaRef(projectRoot, CORE_DOCUMENT_SPECIFIER),
52
+ coreProjectRef: coreSchemaRef(projectRoot, CORE_PROJECT_SPECIFIER),
53
+ extensions: registry.extensions.map((ext) => ({
54
+ document: ext.schemas.document && fragmentRef(projectRoot, ext, ext.schemas.document),
55
+ fields: ext.schemas.fields && fragmentRef(projectRoot, ext, ext.schemas.fields),
56
+ project: ext.schemas.project && fragmentRef(projectRoot, ext, ext.schemas.project),
57
+ })),
58
+ loadJson: restrictedSchemaLoader(root),
59
+ });
60
+
61
+ return { documentSchema, projectSchema };
62
+ }
78
63
 
64
+ /**
65
+ * Compose the entry documents and WRITE them to the project root.
66
+ *
67
+ * @param {string} projectRoot - Absolute path to the project directory
68
+ * @returns {Promise<{ projectSchemaPath: string; documentSchemaPath: string }>} Written paths
69
+ */
70
+ export async function writeProjectSchemas(projectRoot: string) {
71
+ const { documentSchema, projectSchema } = await composeEntryDocuments(projectRoot);
79
72
  const projectSchemaPath = resolve(projectRoot, "project.schema.json");
80
73
  const documentSchemaPath = resolve(projectRoot, "document.schema.json");
81
74
  writeFileSync(projectSchemaPath, `${JSON.stringify(projectSchema, null, 2)}\n`, "utf8");
@@ -94,7 +87,11 @@ export async function writeProjectSchemas(projectRoot: string) {
94
87
  * @param {string} projectRoot - Absolute path to the project directory
95
88
  * @returns {Promise<{ project: Record<string, unknown>; document: Record<string, unknown> }>}
96
89
  */
97
- export async function readBundledProjectSchemas(projectRoot: string) {
90
+ export async function readBundledProjectSchemas(
91
+ projectRoot: string,
92
+ options: { write?: boolean } = {},
93
+ ) {
94
+ const { write = true } = options;
98
95
  const root = resolve(projectRoot);
99
96
  const projectJsonPath = resolve(root, "project.json");
100
97
  const projectSchemaPath = resolve(root, "project.schema.json");
@@ -110,49 +107,125 @@ export async function readBundledProjectSchemas(projectRoot: string) {
110
107
  return false;
111
108
  }
112
109
  };
113
- if (stale(projectSchemaPath) || stale(documentSchemaPath)) {
114
- await writeProjectSchemas(root);
110
+ const isStale = stale(projectSchemaPath) || stale(documentSchemaPath);
111
+ // `write: false` is what a VALIDATOR passes. A stale entry document is regenerated in memory and
112
+ // The committed file is left exactly as it is: `jx validate` reports on the repository, and a
113
+ // Checker that edits what it is checking is not a checker. This mattered — running
114
+ // `schema:validate-all` on a machine with a shadowed core silently rewrote the committed
115
+ // Artifact with a narrower one, and it was green either way, because it validated against
116
+ // Whatever it had just written.
117
+ const composed = isStale ? await composeEntryDocuments(root) : null;
118
+ if (composed && write) {
119
+ writeFileSync(
120
+ projectSchemaPath,
121
+ `${JSON.stringify(composed.projectSchema, null, 2)}\n`,
122
+ "utf8",
123
+ );
124
+ writeFileSync(
125
+ documentSchemaPath,
126
+ `${JSON.stringify(composed.documentSchema, null, 2)}\n`,
127
+ "utf8",
128
+ );
115
129
  }
116
130
 
117
131
  const loadJson = restrictedSchemaLoader(root);
118
- const project = await bundleSchema(
119
- JSON.parse(readFileSync(projectSchemaPath, "utf8")) as Record<string, unknown>,
120
- loadJson,
121
- root,
122
- );
123
- const document = await bundleSchema(
124
- JSON.parse(readFileSync(documentSchemaPath, "utf8")) as Record<string, unknown>,
125
- loadJson,
126
- root,
127
- );
132
+ const projectFile =
133
+ composed?.projectSchema ??
134
+ (JSON.parse(readFileSync(projectSchemaPath, "utf8")) as Record<string, unknown>);
135
+ const documentFile =
136
+ composed?.documentSchema ??
137
+ (JSON.parse(readFileSync(documentSchemaPath, "utf8")) as Record<string, unknown>);
138
+ const project = flattenSchema(await bundleSchema(projectFile, loadJson, root));
139
+ const document = flattenSchema(await bundleSchema(documentFile, loadJson, root));
128
140
  return { document, project };
129
141
  }
130
142
 
143
+ /** Packages whose schemas define the Jx document contract itself, never a project's own content. */
144
+ const FIRST_PARTY_SCHEMA_SCOPE = "@jxsuite/";
145
+
146
+ /**
147
+ * Is this specifier one of the shipped core schemas, as opposed to a project-local fragment?
148
+ *
149
+ * @param {string} specifier - Bare specifier, e.g. `@jxsuite/schema/schema.json`
150
+ * @returns {boolean}
151
+ */
152
+ export function isFirstPartySchema(specifier: string): boolean {
153
+ return specifier.startsWith(FIRST_PARTY_SCHEMA_SCOPE) && specifier.endsWith(".json");
154
+ }
155
+
156
+ /**
157
+ * Resolve a first-party schema from the HOST — the workspace running the generator.
158
+ *
159
+ * Throws rather than falling back to the project, because a fallback is exactly the behaviour that
160
+ * let a stale published core answer for the workspace one. A generator that cannot find its own
161
+ * schema must stop, not improvise.
162
+ *
163
+ * @param {string} specifier
164
+ * @param {string} path - The original ref, for the error message
165
+ * @returns {string} Absolute path to the resolved schema file
166
+ */
167
+ function hostResolve(specifier: string, path: string): string {
168
+ try {
169
+ return createRequire(import.meta.url).resolve(specifier);
170
+ } catch (error) {
171
+ throw new Error(
172
+ `Schema ref "${path}" names the first-party schema "${specifier}", which must resolve from ` +
173
+ `the host workspace and does not. The project's own copy is deliberately not consulted, so ` +
174
+ `the host install is missing "${specifier.split("/").slice(0, 2).join("/")}" — check that ` +
175
+ `the package ships with this build`,
176
+ { cause: error },
177
+ );
178
+ }
179
+ }
180
+
131
181
  /**
132
182
  * Bundler loader restricted to the project root, with the validate-project host fallback: a missing
133
183
  * `<root>/node_modules/<specifier>` file resolves the specifier project-first, then through the
134
184
  * host (workspace symlinks resolve transparently).
135
185
  *
186
+ * Exported for test: both refusals below are reachable only through a project whose `node_modules`
187
+ * has been arranged to provoke them, and a fixture that arranges an extension registry as well says
188
+ * less about the rule than a call that states the path directly.
189
+ *
136
190
  * @param {string} root - Absolute project root
137
191
  * @returns {SchemaJsonLoader}
138
192
  */
139
- function restrictedSchemaLoader(root: string): SchemaJsonLoader {
193
+ export function restrictedSchemaLoader(root: string): SchemaJsonLoader {
140
194
  return async (path) => {
141
195
  const abs = resolve(path);
142
196
  const rel = relative(root, abs);
143
197
  if (rel.startsWith("..") || isAbsolute(rel)) {
144
198
  throw new Error(`Schema ref "${path}" escapes the project root ${root}`);
145
199
  }
146
- if (existsSync(abs)) {
147
- return JSON.parse(readFileSync(abs, "utf8")) as Record<string, unknown>;
148
- }
149
200
  const posixRel = rel.split(sep).join("/");
150
201
  const marker = "node_modules/";
151
202
  const markerIndex = posixRel.lastIndexOf(marker);
152
- if (markerIndex === -1) {
203
+ const specifier = markerIndex === -1 ? null : posixRel.slice(markerIndex + marker.length);
204
+ // A FIRST-PARTY core comes from the host, always — before the project-local file is even
205
+ // Looked at. A project may legitimately carry its own fragments, which is what the rest of this
206
+ // Loader is for; what it may not do is answer for `@jxsuite/schema` itself.
207
+ //
208
+ // This is not hypothetical. A stray `bun install` in packages/starters/sites/shop left a
209
+ // Gitignored node_modules holding @jxsuite/schema@0.35.0 beside a 1.5.0 workspace, and BOTH the
210
+ // `existsSync` shortcut below and the project-first `createRequire` resolved to it. The
211
+ // Generator then emitted a shop document schema missing Statement, StatementList, PathsValue
212
+ // And the three admission blocks, with ClassMethodDef.role down from 16 members to 6 and
213
+ // ChildrenValue.oneOf down from 3 to 2 — dropping the computed-children branch, which made
214
+ // Shop's own pages/products/[sku].json invalid against its own committed schema. Nothing
215
+ // Noticed for six weeks, because the artifact was only ever regenerated on the machine that
216
+ // Had the shadow.
217
+ if (specifier !== null && isFirstPartySchema(specifier)) {
218
+ return JSON.parse(readFileSync(hostResolve(specifier, path), "utf8")) as Record<
219
+ string,
220
+ unknown
221
+ >;
222
+ }
223
+ if (existsSync(abs)) {
224
+ return JSON.parse(readFileSync(abs, "utf8")) as Record<string, unknown>;
225
+ }
226
+ if (specifier === null) {
153
227
  throw new Error(`Schema ref "${path}" does not exist`);
154
228
  }
155
- const specifier = posixRel.slice(markerIndex + marker.length);
156
229
  let resolved: string;
157
230
  try {
158
231
  const projRequire = createRequire(resolve(root, "package.json"));
@@ -231,25 +304,3 @@ function projectRelativeRef(projectRoot: string, absPath: string, conventional:
231
304
  }
232
305
  return rel.startsWith("./") ? rel : `./${rel}`;
233
306
  }
234
-
235
- /**
236
- * Canonical "uri#/pointer" refs into a fragment's `$defs` shapes: one per `$defs` entry, addressed
237
- * by the fragment's own `$id` (refs inside the entry document's embeds resolve against canonical
238
- * URIs, never file locations). Serves both the document paths union and the fields-extras union.
239
- *
240
- * @param {string} fragmentPath - Resolved absolute fragment path
241
- * @returns {string[]}
242
- */
243
- function fragmentDefsRefs(fragmentPath: string): string[] {
244
- let fragment: { $id?: unknown; $defs?: Record<string, unknown> };
245
- try {
246
- fragment = JSON.parse(readFileSync(fragmentPath, "utf8")) as typeof fragment;
247
- } catch {
248
- return [];
249
- }
250
- const { $id } = fragment;
251
- if (typeof $id !== "string" || !fragment.$defs) {
252
- return [];
253
- }
254
- return Object.keys(fragment.$defs).map((name) => `${$id}#/$defs/${name}`);
255
- }