@jxsuite/compiler 1.3.0 → 1.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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";
@@ -37,44 +41,22 @@ export async function writeProjectSchemas(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
+ });
78
60
 
79
61
  const projectSchemaPath = resolve(projectRoot, "project.schema.json");
80
62
  const documentSchemaPath = resolve(projectRoot, "document.schema.json");
@@ -115,16 +97,16 @@ export async function readBundledProjectSchemas(projectRoot: string) {
115
97
  }
116
98
 
117
99
  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
- );
100
+ const projectFile = JSON.parse(readFileSync(projectSchemaPath, "utf8")) as Record<
101
+ string,
102
+ unknown
103
+ >;
104
+ const documentFile = JSON.parse(readFileSync(documentSchemaPath, "utf8")) as Record<
105
+ string,
106
+ unknown
107
+ >;
108
+ const project = flattenSchema(await bundleSchema(projectFile, loadJson, root));
109
+ const document = flattenSchema(await bundleSchema(documentFile, loadJson, root));
128
110
  return { document, project };
129
111
  }
130
112
 
@@ -231,25 +213,3 @@ function projectRelativeRef(projectRoot: string, absPath: string, conventional:
231
213
  }
232
214
  return rel.startsWith("./") ? rel : `./${rel}`;
233
215
  }
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
- }
@@ -32,6 +32,8 @@ import { loadProjectConfig } from "./site-loader.ts";
32
32
  import { discoverPages, expandDynamicRoutes, readPageDocument } from "./pages-discovery.ts";
33
33
  import { buildProjectExtensionRegistry } from "./format-host.ts";
34
34
  import { loadProjectSections } from "./project-sections.ts";
35
+ import { collectAssetRefs, copyMountedAssets, loadAssetMounts } from "./asset-mounts.ts";
36
+ import type { AssetMount } from "@jxsuite/schema/asset-paths";
35
37
  import type { FormatEntry, FormatRegistry } from "@jxsuite/schema/format-registry";
36
38
  import type { ExtensionRegistry } from "@jxsuite/schema/extension-registry";
37
39
  import { resolveLayout } from "./layout-resolver.ts";
@@ -208,6 +210,26 @@ export async function buildSite(
208
210
  log(` Loaded ${sectionKeys.length} section(s): ${sectionKeys.join(", ")}`);
209
211
  }
210
212
 
213
+ // ── 3c. Extension asset mounts (extensions.md §8.5) ─────────────────────
214
+ // Directories published at a site URL — typically an external content source's co-located
215
+ // Images. They resolve for image optimization below, and the files pages actually reference
216
+ // Are copied into dist in step 7c.
217
+ const { errors: mountErrors, mounts: assetMounts } = await loadAssetMounts(
218
+ registry,
219
+ projectConfig,
220
+ projectRoot,
221
+ );
222
+ errors.push(...mountErrors);
223
+ for (const message of mountErrors) {
224
+ console.error(message);
225
+ }
226
+ if (assetMounts.length > 0) {
227
+ log(
228
+ ` Mounted ${assetMounts.length} asset dir(s): ${assetMounts.map((m) => m.urlPrefix).join(", ")}`,
229
+ );
230
+ }
231
+ const assetRefs = new Set<string>();
232
+
211
233
  // ── 4. Expand dynamic routes ────────────────────────────────────────────
212
234
  const routes = await expandDynamicRoutes(
213
235
  staticRoutes,
@@ -234,20 +256,44 @@ export async function buildSite(
234
256
  const componentOutDir = resolve(outDir, "components");
235
257
  mkdirSync(componentOutDir, { recursive: true });
236
258
 
259
+ // Emitted modules are named after the component's `tagName` — the name the page's loader
260
+ // `<script>` and the CSS sidecar both use. So a component whose source basename differs from
261
+ // Its tag needs its `$elements` imports renamed to match, and those get resolved before the
262
+ // Dependency itself is compiled. Hence: read every component's tag up front.
263
+ const componentTagByPath = new Map<string, string>();
264
+ for (const file of componentFiles) {
265
+ const componentPath = resolve(componentsDir, file);
266
+ try {
267
+ const { tagName: depTag } = await readPageDocument(componentPath, formatRegistry);
268
+ if (depTag) {
269
+ componentTagByPath.set(componentPath, depTag);
270
+ }
271
+ } catch {
272
+ // Left to the compile pass below, which reports the parse failure with its file context.
273
+ }
274
+ }
275
+
237
276
  for (const file of componentFiles) {
238
277
  try {
239
278
  const componentPath = resolve(componentsDir, file);
240
279
  const result = await compileElement(componentPath, {
241
280
  ...(projectConfig.$media ? { $media: projectConfig.$media } : {}),
242
281
  formats: formatRegistry,
282
+ resolveElementPath: (refPath: string, currentDir: string | null) => {
283
+ const depPath = currentDir ? resolve(currentDir, refPath) : resolve(refPath);
284
+ const depTag = componentTagByPath.get(depPath);
285
+ return depTag ? `./${depTag}.js` : refPath.replace(/\.json$/, ".js");
286
+ },
243
287
  rewriteSrc: rewriteSidecarSrc,
244
288
  });
245
289
  for (const f of result.files) {
246
- // Reduce the emitted path (an absolute source path with .json swapped to .js) to a bare
247
- // Filename for the dist/components/ output. basename handles both / and \ — a
248
- // Forward-slash-only split left the full drive path on Windows, so resolve() then wrote
249
- // The sidecar back into the source tree instead of dist.
250
- const outName = basename(f.path);
290
+ // Name the output after the tag. `injectComponentScripts` emits
291
+ // `<script src="/components/<tag>.js">` and the CSS sidecar is written as `<tag>.css`, so
292
+ // A source basename here left the loader pointing at a file that was never written.
293
+ // Basename is the fallback, and handles both / and \ — a forward-slash-only split left
294
+ // The full drive path on Windows, so resolve() then wrote the sidecar back into the
295
+ // Source tree instead of dist.
296
+ const outName = f.tagName ? `${f.tagName}.js` : basename(f.path);
251
297
  writeFileSync(resolve(componentOutDir, outName), f.content, "utf8");
252
298
  if (f.tagName) {
253
299
  compiledComponentTags.push(f.tagName);
@@ -262,6 +308,7 @@ export async function buildSite(
262
308
  const css = buildComponentCSS(doc.tagName, doc.style, doc, projectConfig.$media ?? {});
263
309
  if (css) {
264
310
  componentCSS.set(doc.tagName, css);
311
+ collectAssetRefs(css, assetMounts, assetRefs);
265
312
  writeFileSync(resolve(componentOutDir, `${doc.tagName}.css`), css, "utf8");
266
313
  fileCount += 1;
267
314
  }
@@ -325,6 +372,7 @@ export async function buildSite(
325
372
  formatRegistry,
326
373
  registry,
327
374
  rewriteSidecarSrc,
375
+ assetMounts,
328
376
  );
329
377
 
330
378
  // Determine which component tags are fully static (for script omission)
@@ -342,9 +390,14 @@ export async function buildSite(
342
390
  compiledComponentTags,
343
391
  componentCSS,
344
392
  staticTags,
393
+ result.files.map((f: { content: string }) => f.content).join("\n"),
345
394
  );
346
395
  }
347
396
 
397
+ // Mounted assets this page actually references — scanned from the finished HTML so a
398
+ // Reference is caught wherever it came from (markdown image, hand-authored page, $head).
399
+ collectAssetRefs(result.html, assetMounts, assetRefs);
400
+
348
401
  // Determine output path
349
402
  const outPath = routeToOutputPath(route.urlPattern, outDir, trailingSlash);
350
403
  mkdirSync(dirname(outPath), { recursive: true });
@@ -568,7 +621,19 @@ export async function buildSite(
568
621
  console.warn("sitemap.xml skipped — set `url` in project.json to enable sitemap generation.");
569
622
  }
570
623
 
571
- // ── 7c. Copy public/ assets ─────────────────────────────────────────────
624
+ // ── 7c. Copy referenced mounted assets (extensions.md §8.5) ─────────────
625
+ // Only files the compiled output references are copied, so a content source's entry files
626
+ // Never land in dist. Runs before the public/ copy, which keeps shadowing everything.
627
+ if (assetRefs.size > 0) {
628
+ const { copied, missing } = copyMountedAssets(assetRefs, assetMounts, outDir);
629
+ fileCount += copied;
630
+ log(`Copying ${copied} mounted asset(s)...`);
631
+ for (const url of missing) {
632
+ console.warn(`Referenced asset not found: ${url}`);
633
+ }
634
+ }
635
+
636
+ // ── 7c(ii). Copy public/ assets ─────────────────────────────────────────
572
637
  if (existsSync(publicDir)) {
573
638
  log("Copying public/ assets...");
574
639
  cpSync(publicDir, outDir, { recursive: true });
@@ -630,6 +695,7 @@ async function compilePage(
630
695
  formatRegistry?: FormatRegistry,
631
696
  registry?: ExtensionRegistry,
632
697
  rewriteSidecarSrc?: (specifier: string, docDir: string | null) => string,
698
+ assetMounts: readonly AssetMount[] = [],
633
699
  ) {
634
700
  // Load the raw page document (.json natively, other formats via the registry)
635
701
  const pageDoc = await readPageDocument(route.sourcePath as string, formatRegistry);
@@ -744,6 +810,7 @@ async function compilePage(
744
810
  projectRoot,
745
811
  imageCache,
746
812
  imageMetaCache ?? undefined,
813
+ assetMounts,
747
814
  );
748
815
  }
749
816
 
@@ -1109,9 +1176,14 @@ function resolveDocTemplates(node: JxElement | string, scope: Record<string, unk
1109
1176
  }
1110
1177
  const rawChildren = node.children;
1111
1178
  // Legacy whole-children repeater: expand the items into the node's static children.
1179
+ // Only when the expansion actually produced nodes. `expandMappedArrayStatic` returns `null` when
1180
+ // `items` cannot be resolved at build time and `[]` when it resolves to an empty array — and `[]`
1181
+ // Is truthy, so an empty build-time list used to replace the repeater with nothing, discarding the
1182
+ // Definition the client needed. There is no prerendered output to preserve in that case, so the
1183
+ // Repeater is left in place for the client to bind (a page containing one is never zero-JS).
1112
1184
  if (isMappedArray(rawChildren)) {
1113
1185
  const expanded = expandMappedArrayStatic(rawChildren, scope);
1114
- if (expanded) {
1186
+ if (expanded && expanded.length > 0) {
1115
1187
  node.children = expanded;
1116
1188
  return;
1117
1189
  }
@@ -1141,10 +1213,11 @@ function resolveDocTemplates(node: JxElement | string, scope: Record<string, unk
1141
1213
  continue;
1142
1214
  }
1143
1215
  }
1144
- // Array pseudo-element among siblings: expand its items in place.
1216
+ // Array pseudo-element among siblings: expand its items in place, on the same
1217
+ // Produced-nothing rule as the whole-children branch above.
1145
1218
  if (isMappedArray(child)) {
1146
1219
  const expanded = expandMappedArrayStatic(child, scope);
1147
- if (expanded) {
1220
+ if (expanded && expanded.length > 0) {
1148
1221
  node.children.splice(i, 1, ...expanded);
1149
1222
  i += expanded.length;
1150
1223
  continue;
@@ -1309,6 +1382,7 @@ function expandComponents(
1309
1382
  * @param {string[]} allComponentTags - All compiled component tag names
1310
1383
  * @param {Map<string, string>} [cssMap] - TagName → CSS text (for link injection)
1311
1384
  * @param {Set<string>} [staticTags] - Tags where ALL instances are fully static (skip JS)
1385
+ * @param {string} [islandSource] - Concatenated island modules emitted for this page
1312
1386
  * @returns {string}
1313
1387
  */
1314
1388
  function injectComponentScripts(
@@ -1316,11 +1390,15 @@ function injectComponentScripts(
1316
1390
  allComponentTags: string[],
1317
1391
  cssMap = new Map<string, string>(),
1318
1392
  staticTags = new Set<string>(),
1393
+ islandSource = "",
1319
1394
  ) {
1320
- // Find which components are actually referenced in this page
1321
- const usedTags = allComponentTags.filter(
1322
- (tag: string) => html.includes(`<${tag}`), // Matches <tag> and <tag ...>
1323
- );
1395
+ // A component reaches a page two ways: as a literal tag in the static HTML, or referenced only
1396
+ // From inside an island's client template — in which case the tag exists solely in the island's
1397
+ // Compiled JS and never in the HTML the page ships. Searching the HTML alone missed the second
1398
+ // Kind entirely, so its module was never loaded and the element sat in the DOM un-upgraded.
1399
+ const inHtml = (tag: string) => html.includes(`<${tag}`); // Matches <tag> and <tag ...>
1400
+ const inIsland = (tag: string) => islandSource.includes(`<${tag}`);
1401
+ const usedTags = allComponentTags.filter((tag: string) => inHtml(tag) || inIsland(tag));
1324
1402
  if (usedTags.length === 0) {
1325
1403
  return html;
1326
1404
  }
@@ -1335,8 +1413,11 @@ function injectComponentScripts(
1335
1413
  result = result.replace("</head>", ` ${cssLinks}\n</head>`);
1336
1414
  }
1337
1415
 
1338
- // Only inject JS for components that have non-static instances
1339
- const jsTags = usedTags.filter((tag: string) => !staticTags.has(tag));
1416
+ // Only inject JS for components that have non-static instances. An island-rendered instance is
1417
+ // Never prerendered, so it needs its module even when the component itself is fully static —
1418
+ // That module is what fills the element in client-side. This holds when the tag ALSO appears in
1419
+ // The static HTML: those instances are prerendered, but the ones the island creates are not.
1420
+ const jsTags = usedTags.filter((tag: string) => inIsland(tag) || !staticTags.has(tag));
1340
1421
  if (jsTags.length === 0) {
1341
1422
  return result;
1342
1423
  }
@@ -3,9 +3,11 @@
3
3
  *
4
4
  * Extends the original project.json check into the full compliance walk:
5
5
  *
6
- * 1. `project.json` against the generated entry schema (validateProjectFile);
7
- * 2. The committed entry documents are SELF-CONTAINED (no residual relative `$ref`s — the
8
- * editor-resolution guarantee of the bundled form, specs/extensions.md §5.4);
6
+ * 1. The committed entry documents are SELF-CONTAINED and SINGLE-RESOURCE: every `$ref` is a
7
+ * root-relative JSON Pointer that resolves inside the file — the editor-resolution guarantee of
8
+ * the committed form (specs/extensions.md §5.4). Checked first and fatal, since walks 2–4
9
+ * compile those documents;
10
+ * 2. `project.json` against the generated entry schema (validateProjectFile);
9
11
  * 3. Every document under `components/`, `pages/`, and `layouts/` against the bundled document schema
10
12
  * (core + extension `$paths` union — the same schema the studio consumes);
11
13
  * 4. Every project-local `*.class.json` against the generated class schema;
@@ -94,33 +96,39 @@ export async function validateProjectTree(
94
96
  const issues: ProjectTreeIssue[] = [];
95
97
  let checked = 0;
96
98
 
97
- // 1. project.json against the generated entry schema.
98
- const projectResult = await validateProjectFile(root);
99
- checked += 1;
100
- if (!projectResult.valid) {
101
- issues.push({ errors: projectResult.errors ?? [], file: "project.json" });
102
- }
103
-
104
- // 2. Committed entry documents must be self-contained (the editor-resolution guarantee).
99
+ /* 1. Committed entry documents must be self-contained (the editor-resolution guarantee). This
100
+ runs FIRST because every later walk compiles one of them: an unresolvable ref surfaces from ajv
101
+ as a thrown MissingRefError, so diagnosing it here turns a stack trace into "regenerate with
102
+ `jx schema`". Nothing downstream is meaningful until it holds, hence the early return. */
105
103
  for (const name of ["project.schema.json", "document.schema.json"]) {
106
104
  const path = resolve(root, name);
107
105
  if (!existsSync(path)) {
108
106
  continue;
109
107
  }
110
108
  checked += 1;
111
- const residual: string[] = [];
112
- collectRelativeRefs(JSON.parse(readFileSync(path, "utf8")), residual);
113
- if (residual.length > 0) {
109
+ const entry = JSON.parse(readFileSync(path, "utf8")) as Record<string, unknown>;
110
+ const unresolvable = collectUnresolvableRefs(entry);
111
+ if (unresolvable.length > 0) {
114
112
  issues.push({
115
- errors: residual.map((ref) => ({
113
+ errors: unresolvable.map(({ reason, ref }) => ({
116
114
  message:
117
- `relative $ref "${ref}" — committed entry documents must be self-contained; ` +
118
- `regenerate with \`jx schema\``,
115
+ `$ref "${ref}" ${reason} — committed entry documents must be self-contained ` +
116
+ `single-resource schemas; regenerate with \`jx schema\``,
119
117
  })),
120
118
  file: name,
121
119
  });
122
120
  }
123
121
  }
122
+ if (issues.length > 0) {
123
+ return { checked, issues, valid: false };
124
+ }
125
+
126
+ // 2. project.json against the generated entry schema.
127
+ const projectResult = await validateProjectFile(root);
128
+ checked += 1;
129
+ if (!projectResult.valid) {
130
+ issues.push({ errors: projectResult.errors ?? [], file: "project.json" });
131
+ }
124
132
 
125
133
  const { addFormats, Ajv } = await loadAjv();
126
134
 
@@ -186,30 +194,118 @@ export async function validateProjectTree(
186
194
  return { checked, issues, valid: issues.length === 0 };
187
195
  }
188
196
 
189
- /** Collect relative-path `$ref`s (anything without a URI scheme that isn't a `#` pointer). */
190
- function collectRelativeRefs(node: unknown, out: string[]): void {
191
- if (Array.isArray(node)) {
192
- for (const item of node) {
193
- collectRelativeRefs(item, out);
197
+ /**
198
+ * Every `$ref` a committed entry document may carry must be a root-relative JSON Pointer that
199
+ * resolves inside the same file. Three ways that fails, all of which break editor resolution: a
200
+ * relative path (needs Node module resolution), a URI (VS Code fetches it over the network instead
201
+ * of matching an embedded `$id`), and a pointer with no node at the other end.
202
+ *
203
+ * Walks only subschema keyword positions, since `examples`/`default`/`const`/`enum` hold instance
204
+ * data and Jx document examples legitimately contain `$ref` strings of their own.
205
+ *
206
+ * @param {Record<string, unknown>} entry - Parsed committed entry document
207
+ * @returns {{ ref: string; reason: string }[]} One entry per offending `$ref`
208
+ */
209
+ function collectUnresolvableRefs(
210
+ entry: Record<string, unknown>,
211
+ ): { ref: string; reason: string }[] {
212
+ const out: { ref: string; reason: string }[] = [];
213
+ const seen = new Set<Record<string, unknown>>();
214
+
215
+ const resolvePointer = (pointer: string): boolean => {
216
+ let current: unknown = entry;
217
+ for (const raw of pointer.slice(1).split("/")) {
218
+ if (raw === "") {
219
+ continue;
220
+ }
221
+ const segment = raw.replaceAll("~1", "/").replaceAll("~0", "~");
222
+ if (typeof current !== "object" || current === null) {
223
+ return false;
224
+ }
225
+ current = (current as Record<string, unknown>)[segment];
226
+ if (current === undefined) {
227
+ return false;
228
+ }
229
+ }
230
+ return true;
231
+ };
232
+
233
+ const walk = (node: unknown): void => {
234
+ if (!isRecord(node) || seen.has(node)) {
235
+ return;
194
236
  }
195
- return;
196
- }
197
- if (node === null || typeof node !== "object") {
198
- return;
199
- }
200
- for (const [key, value] of Object.entries(node as Record<string, unknown>)) {
201
- if (
202
- key === "$ref" &&
203
- typeof value === "string" &&
204
- !value.startsWith("#") &&
205
- !/^[a-zA-Z][a-zA-Z\d+.-]*:/.test(value)
206
- ) {
207
- out.push(value);
237
+ seen.add(node);
238
+ const ref = node.$ref;
239
+ if (typeof ref === "string") {
240
+ if (/^[a-zA-Z][a-zA-Z\d+.-]*:/.test(ref)) {
241
+ out.push({ reason: "is an absolute URI editors resolve over the network", ref });
242
+ } else if (!ref.startsWith("#")) {
243
+ out.push({ reason: "is a relative path only Node module resolution can follow", ref });
244
+ } else if (ref.length > 1 && !ref.startsWith("#/")) {
245
+ out.push({ reason: "is an $anchor reference, which has no root-pointer form", ref });
246
+ } else if (ref.length > 1 && !resolvePointer(ref.slice(1))) {
247
+ out.push({ reason: "points at no node in this document", ref });
248
+ }
208
249
  }
209
- collectRelativeRefs(value, out);
210
- }
250
+ for (const [keyword, value] of Object.entries(node)) {
251
+ if (!SUBSCHEMA_KEYWORDS.has(keyword)) {
252
+ continue;
253
+ }
254
+ if (Array.isArray(value)) {
255
+ for (const item of value) {
256
+ walk(item);
257
+ }
258
+ } else if (isRecord(value)) {
259
+ // Map-valued keywords hold subschemas one level down; single-valued ones are the schema.
260
+ walk(value);
261
+ if (MAP_SUBSCHEMA_KEYWORDS.has(keyword)) {
262
+ for (const item of Object.values(value)) {
263
+ walk(item);
264
+ }
265
+ }
266
+ }
267
+ }
268
+ };
269
+ walk(entry);
270
+ return out;
211
271
  }
212
272
 
273
+ /** True for a plain object (a candidate subschema node). */
274
+ function isRecord(value: unknown): value is Record<string, unknown> {
275
+ return typeof value === "object" && value !== null && !Array.isArray(value);
276
+ }
277
+
278
+ /** Keywords whose values map names to subschemas. */
279
+ const MAP_SUBSCHEMA_KEYWORDS = new Set([
280
+ "$defs",
281
+ "definitions",
282
+ "dependencies",
283
+ "dependentSchemas",
284
+ "patternProperties",
285
+ "properties",
286
+ ]);
287
+
288
+ /** Every keyword position a subschema can occupy (see {@link MAP_SUBSCHEMA_KEYWORDS} for maps). */
289
+ const SUBSCHEMA_KEYWORDS = new Set([
290
+ ...MAP_SUBSCHEMA_KEYWORDS,
291
+ "additionalItems",
292
+ "additionalProperties",
293
+ "allOf",
294
+ "anyOf",
295
+ "contains",
296
+ "contentSchema",
297
+ "else",
298
+ "if",
299
+ "items",
300
+ "not",
301
+ "oneOf",
302
+ "prefixItems",
303
+ "propertyNames",
304
+ "then",
305
+ "unevaluatedItems",
306
+ "unevaluatedProperties",
307
+ ]);
308
+
213
309
  /** Recursively list `.json` files under a directory (absent directories yield nothing). */
214
310
  function walkJsonFiles(dir: string): string[] {
215
311
  if (!existsSync(dir)) {