@jxsuite/compiler 8.1.0 → 8.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jxsuite/compiler",
3
- "version": "8.1.0",
3
+ "version": "8.3.0",
4
4
  "description": "Jx static HTML compiler, island detector, and site builder",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -52,10 +52,10 @@
52
52
  "upgrade": "bun update --latest && bun install"
53
53
  },
54
54
  "dependencies": {
55
- "@jxsuite/create": "^1.5.3",
56
- "@jxsuite/runtime": "^6.0.2",
57
- "@jxsuite/schema": "^5.1.0",
58
- "@jxsuite/site": "^2.3.0",
55
+ "@jxsuite/create": "^1.5.5",
56
+ "@jxsuite/runtime": "^6.2.0",
57
+ "@jxsuite/schema": "^5.3.0",
58
+ "@jxsuite/site": "^2.4.0",
59
59
  "@vue/reactivity": "3.5.44",
60
60
  "esbuild": "^0.28.2",
61
61
  "lit-html": "^3.3.3",
@@ -63,8 +63,8 @@
63
63
  },
64
64
  "devDependencies": {
65
65
  "@happy-dom/global-registrator": "^21.0.4",
66
- "@jxsuite/connector": "^0.8.3",
67
- "@jxsuite/parser": "^4.2.0",
66
+ "@jxsuite/connector": "^0.8.5",
67
+ "@jxsuite/parser": "^4.3.1",
68
68
  "@jxsuite/test-support": "^0.0.0",
69
69
  "@types/mdast": "^4.0.4"
70
70
  }
package/src/cli.ts CHANGED
@@ -2,8 +2,8 @@
2
2
  /**
3
3
  * Jx — Unified CLI for the Jx platform
4
4
  *
5
- * Commands: jx build [project-root] [--verbose] [--no-clean] jx schema [project-root] jx validate
6
- * [project-root]
5
+ * Commands: jx build [project-root] [--verbose] [--no-clean] [--include-held] jx schema
6
+ * [project-root] jx validate [project-root]
7
7
  *
8
8
  * @module jx-cli
9
9
  */
@@ -29,18 +29,34 @@ Commands:
29
29
  preview [root] Serve an already-built dist/ directory
30
30
  schema [root] Generate project.schema.json + document.schema.json from project.json#/extensions
31
31
  validate [root] Validate the whole project: project.json, documents, classes, and fragments
32
+ convert <paths…> Move documents between .json and .jxn, repairing every reference to them
32
33
  db push [root] Sync the data section's tables to their connections (additive-only)
33
34
 
34
35
  Options:
35
36
  --verbose Print detailed build progress
36
37
  --no-clean Don't clean outDir before building
38
+ --include-held Keep entries a content type's hold rules match, for a preview
37
39
  --port <n> dev/preview: port to listen on (default 3000 / 4173)
38
40
  --dry-run db push: print the statements without executing them
39
41
  --connection db push: restrict to one connection name
40
- --strict validate: fail on an overlay or accessibility lint error, not only a schema error`);
42
+ --strict validate: fail on an overlay or accessibility lint error, not only a schema error
43
+ --to <format> convert: jxn or json
44
+ --drop-comments convert: convert a .jxn file to .json even though it has comments`);
41
45
  process.exit(0);
42
46
  }
43
47
 
48
+ // Its positionals are paths and not a project root, so it does not join the split below.
49
+ if (command === "convert") {
50
+ try {
51
+ const { runConvert } = await import("./site/convert-command.ts");
52
+ runConvert(process.cwd(), args.slice(1));
53
+ } catch (error) {
54
+ console.error(errorMessage(error));
55
+ process.exit(1);
56
+ }
57
+ return;
58
+ }
59
+
44
60
  // `db push` is a two-word command; normalize it before the flag/positional split.
45
61
  const isDb = command === "db";
46
62
  const dbSubcommand = isDb ? args[1] : null;
@@ -110,12 +126,13 @@ Options:
110
126
  if (command === "build") {
111
127
  const verbose = flags.has("--verbose");
112
128
  const clean = !flags.has("--no-clean");
129
+ const includeHeld = flags.has("--include-held");
113
130
 
114
131
  console.log(`Building site from ${projectRoot}...`);
115
132
 
116
133
  try {
117
134
  const { buildSite } = await import("./site/site-build.ts");
118
- const result = await buildSite(projectRoot, { clean, verbose });
135
+ const result = await buildSite(projectRoot, { clean, includeHeld, verbose });
119
136
 
120
137
  if (result.errors.length > 0) {
121
138
  console.error(`\nBuild completed with ${result.errors.length} error(s):`);
package/src/compiler.ts CHANGED
@@ -27,7 +27,7 @@ import { emitElementModule } from "./targets/compile-element.ts";
27
27
  import { compileStaticPage } from "./targets/compile-static.ts";
28
28
  import { compileClient } from "./targets/compile-client.ts";
29
29
  import { isClassDef, isString } from "@jxsuite/schema/guards";
30
- import { parseJxDocument } from "@jxsuite/schema/parse";
30
+ import { isNativeDocumentPath, parseNativeDocument } from "@jxsuite/schema/jxn";
31
31
  import type { JxDocument, JxStyle } from "@jxsuite/schema/types";
32
32
  import type { FormatRegistry } from "@jxsuite/schema/format-registry";
33
33
 
@@ -94,8 +94,8 @@ export async function compile(sourcePath: string | JxDocument, opts: CompileOpti
94
94
  let raw: JxDocument;
95
95
  if (isString(sourcePath)) {
96
96
  const source = readFileSync(sourcePath, "utf8");
97
- if (sourcePath.endsWith(".json")) {
98
- raw = parseJxDocument(source, sourcePath);
97
+ if (isNativeDocumentPath(sourcePath)) {
98
+ raw = parseNativeDocument(source, sourcePath);
99
99
  } else {
100
100
  const { extname } = await import("node:path");
101
101
  const ext = extname(sourcePath).toLowerCase();
@@ -0,0 +1,59 @@
1
+ /**
2
+ * Convert command: `jx convert` resolves the project's `@jxsuite/server/convert` and runs it under
3
+ * Bun, as `jx dev` does and for the same reasons. The compiler cannot depend on the server, and the
4
+ * refactor pass that repairs references ships as TypeScript this Node entry cannot load.
5
+ *
6
+ * @module convert-command
7
+ */
8
+ import { spawn } from "node:child_process";
9
+ import type { ChildProcess } from "node:child_process";
10
+ import { createRequire } from "node:module";
11
+ import { join } from "node:path";
12
+ import type { ProcessLike } from "./dev-command.ts";
13
+
14
+ /**
15
+ * Resolve the project's converter.
16
+ *
17
+ * @param projectRoot Absolute project root.
18
+ * @throws {Error} When the server package is not installed.
19
+ */
20
+ export function resolveConvertEntry(projectRoot: string): string {
21
+ try {
22
+ return createRequire(join(projectRoot, "package.json")).resolve("@jxsuite/server/convert");
23
+ } catch {
24
+ throw new Error(
25
+ `@jxsuite/server is not installed in ${projectRoot}. ` +
26
+ `Add it to devDependencies (e.g. \`bun add -d @jxsuite/server\`) and retry.`,
27
+ );
28
+ }
29
+ }
30
+
31
+ /**
32
+ * Spawn the converter under Bun.
33
+ *
34
+ * @param projectRoot Absolute project root.
35
+ * @param argv The arguments after the command.
36
+ * @param spawnImpl Injectable for tests.
37
+ * @param proc Injectable for tests.
38
+ */
39
+ export function runConvert(
40
+ projectRoot: string,
41
+ argv: readonly string[],
42
+ spawnImpl: typeof spawn = spawn,
43
+ proc: ProcessLike = process,
44
+ ): ChildProcess {
45
+ const entry = resolveConvertEntry(projectRoot);
46
+ const child = spawnImpl("bun", [entry, "--root", projectRoot, ...argv], { stdio: "inherit" });
47
+ child.on("error", (error: NodeJS.ErrnoException) => {
48
+ console.error(
49
+ error.code === "ENOENT"
50
+ ? "jx convert runs on Bun, which was not found on your PATH. Install it from https://bun.sh and retry."
51
+ : `Failed to start jx convert: ${error.message}`,
52
+ );
53
+ proc.exitCode = 1;
54
+ });
55
+ child.on("exit", (code) => {
56
+ proc.exitCode = code ?? 0;
57
+ });
58
+ return child;
59
+ }
@@ -20,6 +20,7 @@ import type {
20
20
  } from "@jxsuite/schema/format-registry";
21
21
  import { isJsonObject } from "@jxsuite/schema/guards";
22
22
  import type { JsonObject, JsonValue, ProjectConfig } from "@jxsuite/schema/types";
23
+ import { projectFilePath } from "@jxsuite/schema/project-file/node";
23
24
 
24
25
  export type { ExtensionRegistry } from "@jxsuite/schema/extension-registry";
25
26
  export type { FormatRegistry, FormatEntry } from "@jxsuite/schema/format-registry";
@@ -133,7 +134,7 @@ export async function buildProjectExtensionRegistry(
133
134
  return buildExtensionRegistry(
134
135
  projectConfig?.extensions,
135
136
  createNodeFormatIO(projectRoot),
136
- resolve(projectRoot, "project.json"),
137
+ projectFilePath(projectRoot),
137
138
  );
138
139
  }
139
140
 
@@ -176,7 +177,7 @@ export async function buildInstalledExtensionRegistry(
176
177
  const registry = await buildExtensionRegistry(
177
178
  present,
178
179
  createNodeFormatIO(projectRoot),
179
- resolve(projectRoot, "project.json"),
180
+ projectFilePath(projectRoot),
180
181
  );
181
182
  return { missing, registry };
182
183
  }
@@ -9,7 +9,8 @@
9
9
  */
10
10
 
11
11
  import { existsSync, readFileSync } from "node:fs";
12
- import { errorMessage, parseJxDocument } from "@jxsuite/schema/parse";
12
+ import { parseNativeDocument } from "@jxsuite/schema/jxn";
13
+ import { errorMessage } from "@jxsuite/schema/parse";
13
14
  import { resolve } from "node:path";
14
15
  import { resolveLayout as resolveLayoutWith } from "@jxsuite/site/layout";
15
16
  import type { JxDocument, ProjectConfig } from "@jxsuite/schema/types";
@@ -26,9 +27,9 @@ export function nodeLayoutLoader(projectRoot: string): (layoutRef: string) => Jx
26
27
  throw new Error(`Layout not found: ${layoutRef} (resolved to ${layoutPath})`);
27
28
  }
28
29
  try {
29
- return parseJxDocument(readFileSync(layoutPath, "utf8"), layoutPath);
30
+ return parseNativeDocument(readFileSync(layoutPath, "utf8"), layoutPath);
30
31
  } catch (error) {
31
- throw new Error(`Invalid layout JSON at ${layoutPath}: ${errorMessage(error)}`, {
32
+ throw new Error(`Invalid layout at ${layoutPath}: ${errorMessage(error)}`, {
32
33
  cause: error,
33
34
  });
34
35
  }
@@ -18,7 +18,8 @@
18
18
 
19
19
  import { readFileSync, readdirSync, statSync } from "node:fs";
20
20
  import { isJsonObject, isString } from "@jxsuite/schema/guards";
21
- import { errorMessage, parseJxDocument } from "@jxsuite/schema/parse";
21
+ import { isNativeDocumentPath, parseNativeDocument } from "@jxsuite/schema/jxn";
22
+ import { errorMessage } from "@jxsuite/schema/parse";
22
23
  import { compareRoutes, fileToRoute as routeFromFileName } from "@jxsuite/site/routes";
23
24
  import { pageCount, pageSlice, pageUrl, pagedSource, pagination } from "@jxsuite/site/pagination";
24
25
  import type { PagePagination, PagedSource } from "@jxsuite/site/pagination";
@@ -80,8 +81,8 @@ export async function readPageDocument(
80
81
  registry?: FormatRegistry,
81
82
  ): Promise<JxDocument> {
82
83
  const source = readFileSync(filePath, "utf8");
83
- if (filePath.endsWith(".json")) {
84
- return parseJxDocument(source, filePath);
84
+ if (isNativeDocumentPath(filePath)) {
85
+ return parseNativeDocument(source, filePath);
85
86
  }
86
87
  const ext = extname(filePath).toLowerCase();
87
88
  const entry = registry?.byExtension(ext, "parse");
@@ -200,7 +201,11 @@ async function declaredTranslationKey(
200
201
  */
201
202
  export async function discoverPages(pagesDir: string, registry?: FormatRegistry) {
202
203
  const routes: Route[] = [];
203
- const pageExtensions = new Set([".json", ...(registry?.documentExtensions("page") ?? [])]);
204
+ const pageExtensions = new Set([
205
+ ".json",
206
+ ".jxn",
207
+ ...(registry?.documentExtensions("page") ?? []),
208
+ ]);
204
209
  await walkDir(pagesDir, pagesDir, routes, pageExtensions, registry);
205
210
 
206
211
  // Static routes first, then dynamic, then catch-all (spec §4.4) — the shared ordering.
@@ -29,15 +29,17 @@ export type ProjectSections = Record<string, ProjectSectionData>;
29
29
  * @param {string} projectRoot - Absolute path to the project root
30
30
  * @param {ProjectConfig | undefined} projectConfig - Already-loaded project config
31
31
  * @param {ExtensionRegistry} registry - The project's extension registry
32
- * @param {{ lazy?: boolean }} [options] - `lazy`: the host serves one page at a time, so an owner
33
- * may load its section on demand (specs/extensions.md §8). A build leaves it off.
32
+ * @param {{ lazy?: boolean; includeHeld?: boolean }} [options] - `lazy`: the host serves one page
33
+ * at a time, so an owner may load its section on demand (specs/extensions.md §8). A build leaves
34
+ * it off. `includeHeld`: the host shows authors their work, so entries a project's `hold` rules
35
+ * match are kept, labelled (site-architecture.md §7.6). Left off, they are left out.
34
36
  * @returns {Promise<ProjectSections>} Section key → projectData result
35
37
  */
36
38
  export async function loadProjectSections(
37
39
  projectRoot: string,
38
40
  projectConfig: ProjectConfig | undefined,
39
41
  registry: ExtensionRegistry,
40
- options: { lazy?: boolean } = {},
42
+ options: { lazy?: boolean; includeHeld?: boolean } = {},
41
43
  ): Promise<ProjectSections> {
42
44
  const sections: ProjectSections = {};
43
45
  const io = createNodeFormatIO(projectRoot);
@@ -55,6 +57,7 @@ export async function loadProjectSections(
55
57
  registry,
56
58
  root: projectRoot,
57
59
  ...(options.lazy === true && { lazy: true }),
60
+ ...(options.includeHeld === true && { includeHeld: true }),
58
61
  });
59
62
  }
60
63
  return sections;
@@ -29,6 +29,7 @@ import { loadProjectConfig } from "./site-loader.ts";
29
29
  import type { ExtensionInfo } from "@jxsuite/schema/extension-registry";
30
30
  import type { SchemaJsonLoader } from "@jxsuite/schema/project-schemas";
31
31
  import type { JsonObject, JsonValue } from "@jxsuite/schema/types";
32
+ import { projectFilePath } from "@jxsuite/schema/project-file/node";
32
33
 
33
34
  const CORE_PROJECT_SPECIFIER = "@jxsuite/schema/schemas/project.core.schema.json";
34
35
  const CORE_DOCUMENT_SPECIFIER = "@jxsuite/schema/schema.json";
@@ -95,7 +96,7 @@ export async function readBundledProjectSchemas(
95
96
  ) {
96
97
  const { write = true } = options;
97
98
  const root = resolve(projectRoot);
98
- const projectJsonPath = resolve(root, "project.json");
99
+ const projectJsonPath = projectFilePath(root);
99
100
  const projectSchemaPath = resolve(root, "project.schema.json");
100
101
  const documentSchemaPath = resolve(root, "document.schema.json");
101
102
 
@@ -160,6 +160,7 @@ import type {
160
160
  } from "@jxsuite/schema/types";
161
161
  import type { JxScope } from "@jxsuite/runtime/types";
162
162
  import type { CacheManifest } from "./image-cache.js";
163
+ import { projectFilePath } from "@jxsuite/schema/project-file/node";
163
164
 
164
165
  declare module "@jxsuite/schema/format-registry" {
165
166
  /**
@@ -188,6 +189,8 @@ const ADAPTER_REMEDY =
188
189
  * @param {object} [options]
189
190
  * @param {boolean} [options.clean] - Remove outDir before building
190
191
  * @param {boolean} [options.verbose] - Log progress
192
+ * @param {boolean} [options.includeHeld] - Keep entries a content type's `hold` rules match, for a
193
+ * preview. Left off, a build leaves them out (site-architecture.md §7.6).
191
194
  * @returns {Promise<{ routes: number; files: number; errors: string[] }>}
192
195
  */
193
196
  export async function buildSite(
@@ -195,9 +198,10 @@ export async function buildSite(
195
198
  options: {
196
199
  clean?: boolean;
197
200
  verbose?: boolean;
201
+ includeHeld?: boolean;
198
202
  } = {},
199
203
  ) {
200
- const { clean = true, verbose = false } = options;
204
+ const { clean = true, includeHeld = false, verbose = false } = options;
201
205
  const errors: string[] = [];
202
206
  const log = verbose ? console.log.bind(console) : () => {};
203
207
 
@@ -395,7 +399,7 @@ export async function buildSite(
395
399
 
396
400
  // ── 3b. Load extension-contributed project sections ────────────────────
397
401
  log("Loading project sections...");
398
- const sections = await loadProjectSections(projectRoot, projectConfig, registry);
402
+ const sections = await loadProjectSections(projectRoot, projectConfig, registry, { includeHeld });
399
403
  const sectionKeys = Object.keys(sections);
400
404
  if (sectionKeys.length > 0) {
401
405
  log(` Loaded ${sectionKeys.length} section(s): ${sectionKeys.join(", ")}`);
@@ -460,9 +464,12 @@ export async function buildSite(
460
464
  if (existsSync(componentsDir)) {
461
465
  log("Compiling components...");
462
466
  const componentExtensions = [".json", ...formatRegistry.documentExtensions("component")];
463
- const componentFiles = readdirSync(componentsDir).filter((f: string) =>
464
- componentExtensions.some((ext) => f.endsWith(ext)),
465
- );
467
+ /* Sorted, because this order is the order of the sheets in every page's `<head>`, and readdir
468
+ has none of its own: on ext4 it follows a hash of the name, so the same project built to
469
+ different bytes on different machines. */
470
+ const componentFiles = readdirSync(componentsDir)
471
+ .filter((f: string) => componentExtensions.some((ext) => f.endsWith(ext)))
472
+ .toSorted();
466
473
  const componentOutDir = resolve(outDir, "components");
467
474
  mkdirSync(componentOutDir, { recursive: true });
468
475
 
@@ -502,7 +509,7 @@ export async function buildSite(
502
509
  resolveElementPath: (refPath: string, currentDir: string | null) => {
503
510
  const depPath = currentDir ? resolve(currentDir, refPath) : resolve(refPath);
504
511
  const depTag = componentTagByPath.get(depPath);
505
- return depTag ? `./${depTag}.js` : refPath.replace(/\.json$/, ".js");
512
+ return depTag ? `./${depTag}.js` : refPath.replace(/\.(?:json|jxn)$/i, ".js");
506
513
  },
507
514
  rewriteSrc: (specifier: string, docDir: string | null) =>
508
515
  withBase(componentBase, rewriteSidecarSrc(specifier, docDir)),
@@ -599,7 +606,7 @@ export async function buildSite(
599
606
  /* SAFETY: `ProjectConfig.state` is a plain record of state entries, and the collector reads
600
607
  only a document's `state`, so a document carrying that one member is all it needs. */
601
608
  { state: projectConfig.state } as JxElement,
602
- resolve(projectRoot, "project.json"),
609
+ projectFilePath(projectRoot),
603
610
  projectRoot,
604
611
  ),
605
612
  );
@@ -1706,8 +1713,7 @@ async function compilePage(
1706
1713
 
1707
1714
  return {
1708
1715
  doc: layoutDoc,
1709
- // A page opts out of the sitemap by setting `$sitemap: false` (interim escape hatch
1710
- // Until draft filtering lands in the build pipeline).
1716
+ // A page opts out of the sitemap by setting `$sitemap: false`.
1711
1717
  excludeFromSitemap: pageDoc.$sitemap === false,
1712
1718
  files: result.files,
1713
1719
  html: result.html,
@@ -9,6 +9,7 @@ import { existsSync, readFileSync } from "node:fs";
9
9
  import type { ImageConfig, JxDocumentValue, ProjectConfig } from "@jxsuite/schema/types";
10
10
  import { errorMessage, parseProjectConfig } from "@jxsuite/schema/parse";
11
11
  import { resolve } from "node:path";
12
+ import { projectFilePath } from "@jxsuite/schema/project-file/node";
12
13
 
13
14
  /**
14
15
  * Default project configuration. All properties are optional in project.json; these defaults fill
@@ -71,7 +72,7 @@ const DEFAULTS = {
71
72
  * @throws {Error} If project.json is missing or invalid JSON
72
73
  */
73
74
  export function loadProjectConfig(projectRoot: string) {
74
- const configPath = resolve(projectRoot, "project.json");
75
+ const configPath = projectFilePath(projectRoot);
75
76
 
76
77
  if (!existsSync(configPath)) {
77
78
  throw new Error(`project.json not found in ${projectRoot}`);
@@ -18,6 +18,8 @@
18
18
  */
19
19
 
20
20
  import { existsSync, readFileSync, readdirSync, statSync } from "node:fs";
21
+ import { isJxnPath, isNativeDocumentPath, lintJxn, parseJxn } from "@jxsuite/schema/jxn";
22
+ import { errorMessage } from "@jxsuite/schema/parse";
21
23
  import { createRequire } from "node:module";
22
24
  import { join, relative, resolve } from "node:path";
23
25
  import { generateClassSchema } from "@jxsuite/schema";
@@ -31,6 +33,7 @@ import type { JsonObject, JsonValue, JxElement } from "@jxsuite/schema/types";
31
33
  import { buildProjectExtensionRegistry } from "./format-host.ts";
32
34
  import { loadProjectConfig } from "./site-loader.ts";
33
35
  import { readBundledProjectSchemas } from "./schema-command.ts";
36
+ import { PROJECT_FILE_NAME } from "@jxsuite/schema/project-file";
34
37
 
35
38
  /** Directories that hold Jx documents validated against the document schema. */
36
39
  const DOCUMENT_DIRS = ["components", "pages", "layouts"] as const;
@@ -63,8 +66,8 @@ export interface ProjectTreeLintFinding {
63
66
  file: string;
64
67
  /** The node's path inside the document. */
65
68
  path: (string | number)[];
66
- /** Which lint: `popover`, `dialog` or `accessibility`. */
67
- source: "popover" | "dialog" | "accessibility";
69
+ /** Which lint: `popover`, `dialog`, `accessibility`, or `notation` for a `.jxn` file's text. */
70
+ source: "popover" | "dialog" | "accessibility" | "notation";
68
71
  rule: string;
69
72
  message: string;
70
73
  detail: string;
@@ -175,7 +178,7 @@ export async function validateProjectTree(
175
178
  const projectResult = await validateProjectFile(root);
176
179
  checked += 1;
177
180
  if (!projectResult.valid) {
178
- issues.push({ errors: projectResult.errors ?? [], file: "project.json" });
181
+ issues.push({ errors: projectResult.errors ?? [], file: PROJECT_FILE_NAME });
179
182
  }
180
183
 
181
184
  const { addFormats, Ajv } = await loadAjv();
@@ -212,7 +215,13 @@ export async function validateProjectTree(
212
215
  continue; // Validated against the class schema below.
213
216
  }
214
217
  checked += 1;
215
- const doc = readJson(file);
218
+ const read = readDocumentFile(file, relative(root, file));
219
+ lint.push(...read.warnings);
220
+ if (read.failure !== null) {
221
+ issues.push(read.failure);
222
+ continue;
223
+ }
224
+ const doc = read.document;
216
225
  if (validateDoc(doc)) {
217
226
  /*
218
227
  * Only a well-formed document is judged; a schema error is the report for the rest.
@@ -270,6 +279,49 @@ export async function validateProjectTree(
270
279
  return { checked, issues, lint, valid: issues.length === 0 && !fatalLint };
271
280
  }
272
281
 
282
+ /** A document file as the validator takes it: its value, or why it has none. */
283
+ interface ReadDocumentFile {
284
+ document: JsonValue;
285
+ failure: ProjectTreeIssue | null;
286
+ warnings: ProjectTreeLintFinding[];
287
+ }
288
+
289
+ /**
290
+ * Read a document file for checking. A `.jxn` file that does not read is a finding with a line and
291
+ * column, and not a stack trace, and an escape that changes what its code means is reported whether
292
+ * or not the rest of the file reads.
293
+ */
294
+ function readDocumentFile(file: string, name: string): ReadDocumentFile {
295
+ if (!isJxnPath(file)) {
296
+ return { document: readJson(file), failure: null, warnings: [] };
297
+ }
298
+ const text = readFileSync(file, "utf8");
299
+ const warnings = lintJxn(text).map((warning): ProjectTreeLintFinding => ({
300
+ detail: warning.message,
301
+ file: name,
302
+ message: `line ${warning.line}, column ${warning.column}: ${warning.message}`,
303
+ path: [],
304
+ rule: "escape",
305
+ severity: "warn",
306
+ source: "notation",
307
+ }));
308
+ try {
309
+ return { document: readJxnValue(text), failure: null, warnings };
310
+ } catch (error) {
311
+ return {
312
+ document: null,
313
+ failure: { errors: [{ message: errorMessage(error) }], file: name },
314
+ warnings,
315
+ };
316
+ }
317
+ }
318
+
319
+ /** A `.jxn` file's document, as the JSON value it is. */
320
+ function readJxnValue(text: string): JsonValue {
321
+ const document = parseJxn(text);
322
+ return isJsonObject(document) ? document : null;
323
+ }
324
+
273
325
  /**
274
326
  * Read and parse a JSON file. `JSON.parse` without a reviver only ever produces JSON values, so the
275
327
  * declared type is exact; what shape of JSON it is stays for the caller to check.
@@ -431,7 +483,7 @@ const SUBSCHEMA_KEYWORDS = new Set([
431
483
  "unevaluatedProperties",
432
484
  ]);
433
485
 
434
- /** Recursively list `.json` files under a directory (absent directories yield nothing). */
486
+ /** Recursively list document files under a directory (absent directories yield nothing). */
435
487
  function walkJsonFiles(dir: string): string[] {
436
488
  if (!existsSync(dir)) {
437
489
  return [];
@@ -441,7 +493,7 @@ function walkJsonFiles(dir: string): string[] {
441
493
  const path = join(dir, name);
442
494
  if (statSync(path).isDirectory()) {
443
495
  out.push(...walkJsonFiles(path));
444
- } else if (name.endsWith(".json")) {
496
+ } else if (isNativeDocumentPath(name)) {
445
497
  out.push(path);
446
498
  }
447
499
  }
@@ -49,7 +49,7 @@ import {
49
49
  isTagExpression,
50
50
  paramNames,
51
51
  } from "@jxsuite/schema/guards";
52
- import { parseJxDocument } from "@jxsuite/schema/parse";
52
+ import { isNativeDocumentPath, parseNativeDocument } from "@jxsuite/schema/jxn";
53
53
  import { resolveShadowMode } from "../shadow.ts";
54
54
  import type { ShadowMode } from "../shadow.ts";
55
55
  import type { FormatRegistry } from "@jxsuite/schema/format-registry";
@@ -132,8 +132,8 @@ export async function compileElement(
132
132
  return;
133
133
  }
134
134
  visited.add(filePath);
135
- if (filePath.endsWith(".json")) {
136
- doc = parseJxDocument(readFileSync(filePath, "utf8"), filePath);
135
+ if (isNativeDocumentPath(filePath)) {
136
+ doc = parseNativeDocument(readFileSync(filePath, "utf8"), filePath);
137
137
  } else {
138
138
  const { extname } = await import("node:path");
139
139
  const ext = extname(filePath).toLowerCase();
@@ -179,7 +179,7 @@ export async function compileElement(
179
179
  /** @type {string} */
180
180
  const importPath = resolveElementPath
181
181
  ? resolveElementPath(refPath, currentDir)
182
- : refPath.replace(/\.json$/, ".js");
182
+ : refPath.replace(/\.(?:json|jxn)$/i, ".js");
183
183
  elementImports.push(importPath);
184
184
  }
185
185
  }
@@ -197,7 +197,7 @@ export async function compileElement(
197
197
  resolveShadowMode(doc, opts.defaults),
198
198
  opts.serverBaseUrl,
199
199
  );
200
- const outputPath = filePath ? filePath.replace(/\.json$/, ".js") : `${tagName}.js`;
200
+ const outputPath = filePath ? filePath.replace(/\.(?:json|jxn)$/i, ".js") : `${tagName}.js`;
201
201
  files.push({ content: jsContent, path: outputPath, tagName });
202
202
  }
203
203