@jxsuite/compiler 8.2.0 → 8.4.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.2.0",
3
+ "version": "8.4.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.4",
56
- "@jxsuite/runtime": "^6.1.0",
57
- "@jxsuite/schema": "^5.2.0",
58
- "@jxsuite/site": "^2.3.1",
55
+ "@jxsuite/create": "^1.6.0",
56
+ "@jxsuite/runtime": "^6.2.1",
57
+ "@jxsuite/schema": "^5.4.0",
58
+ "@jxsuite/site": "^2.5.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.4",
67
- "@jxsuite/parser": "^4.3.0",
66
+ "@jxsuite/connector": "^0.8.6",
67
+ "@jxsuite/parser": "^4.3.2",
68
68
  "@jxsuite/test-support": "^0.0.0",
69
69
  "@types/mdast": "^4.0.4"
70
70
  }
package/src/cli.ts CHANGED
@@ -29,6 +29,7 @@ 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 and the project file between .json and .jxn, repairing references
32
33
  db push [root] Sync the data section's tables to their connections (additive-only)
33
34
 
34
35
  Options:
@@ -38,10 +39,24 @@ Options:
38
39
  --port <n> dev/preview: port to listen on (default 3000 / 4173)
39
40
  --dry-run db push: print the statements without executing them
40
41
  --connection db push: restrict to one connection name
41
- --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`);
42
45
  process.exit(0);
43
46
  }
44
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
+
45
60
  // `db push` is a two-word command; normalize it before the flag/positional split.
46
61
  const isDb = command === "db";
47
62
  const dbSubcommand = isDb ? args[1] : null;
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
  }
@@ -7,7 +7,7 @@
7
7
  */
8
8
 
9
9
  import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
10
- import { basename, resolve } from "node:path";
10
+ import { basename, extname, resolve } from "node:path";
11
11
  import { execSync } from "node:child_process";
12
12
  import { tmpdir } from "node:os";
13
13
  import { configHash, contentHash } from "./image-optimizer.ts";
@@ -112,14 +112,19 @@ export function getImageCacheDir(projectRoot: string): string {
112
112
  // ─── Cache key ───────────────────────────────────────────────────────────────
113
113
 
114
114
  /**
115
- * Build a cache key from source file content and config.
115
+ * Build a cache key from source file content, config, and the file's name.
116
+ *
117
+ * The name is part of the key because it is part of the output: variants are named after the source
118
+ * file (`variantFilename`). Keyed on content alone, a renamed photo found its old entry and kept
119
+ * the old variant names, so renaming a file to fix its URLs changed nothing until the cache was
120
+ * deleted by hand. The old entry is untouched by the build that renames it, so pruning removes it.
116
121
  *
117
122
  * @param {string} srcPath - Absolute path to source image
118
123
  * @param {ImageConfig} config
119
124
  * @returns {string}
120
125
  */
121
126
  export function cacheKey(srcPath: string, config: ImageConfig) {
122
- return `${contentHash(srcPath)}:${configHash(config)}`;
127
+ return `${contentHash(srcPath)}:${configHash(config)}:${basename(srcPath, extname(srcPath))}`;
123
128
  }
124
129
 
125
130
  // ─── Load / save ─────────────────────────────────────────────────────────────
@@ -263,6 +263,25 @@ export async function processImage(srcPath: string, cacheImgDir: string, config:
263
263
  */
264
264
  export function buildSrcset(variants: ImageVariant[], format: string) {
265
265
  return variants
266
- .flatMap((v) => (v.format === format ? [`${v.outputPath} ${v.width}w`] : []))
266
+ .flatMap((v) => (v.format === format ? [`${variantUrl(v.outputPath)} ${v.width}w`] : []))
267
267
  .join(", ");
268
268
  }
269
+
270
+ /**
271
+ * The URL of an emitted variant: its output path with each segment percent-encoded.
272
+ *
273
+ * A variant is named after its source file, and a source file may be named anything. Written raw, a
274
+ * name with a space breaks a `srcset`, which splits candidates on whitespace, so `Designer, White,
275
+ * Black.jpg` became a candidate for `…/Designer,` and a descriptor of `White,`. A comma is legal
276
+ * inside a candidate URL but not at its end, and is encoded too, so no file name can end a
277
+ * candidate early. The file on disk keeps its name: a server decodes the path back to it.
278
+ *
279
+ * @param {string} outputPath - A variant's `outputPath`, as written to disk
280
+ * @returns {string}
281
+ */
282
+ export function variantUrl(outputPath: string) {
283
+ return outputPath
284
+ .split("/")
285
+ .map((segment) => encodeURIComponent(segment))
286
+ .join("/");
287
+ }
@@ -15,12 +15,12 @@ import { existsSync } from "node:fs";
15
15
  import { basename, extname, resolve } from "node:path";
16
16
  import {
17
17
  buildSrcset,
18
- configHash,
19
18
  contentHash,
20
19
  getImageMetadata,
21
20
  processImage,
21
+ variantUrl,
22
22
  } from "./image-optimizer.ts";
23
- import { getCached, getImageCacheDir, setCached } from "./image-cache.ts";
23
+ import { cacheKey, getCached, getImageCacheDir, setCached } from "./image-cache.ts";
24
24
  import { imgLoadingAttrs } from "./img-loading.ts";
25
25
  import { resolveAssetUrl } from "@jxsuite/schema/asset-paths";
26
26
  import { isNodeObject, isNonNullObject, isString } from "@jxsuite/schema/guards";
@@ -96,17 +96,31 @@ function buildCloudflareSrcset(
96
96
  const quality = config.quality.webp ?? 80;
97
97
  const separator = src.startsWith("/") ? "" : "/";
98
98
  const suffix = hash ? `?v=${hash}` : "";
99
+ const path = srcsetSafe(src);
99
100
  return config.widths
100
101
  .filter((w) => w <= originalWidth)
101
102
  .toSorted((a, b) => a - b)
102
103
  .map(
103
104
  (w) =>
104
105
  `/cdn-cgi/image/width=${w},quality=${quality},fit=scale-down,format=auto` +
105
- `${separator}${src}${suffix} ${w}w`,
106
+ `${separator}${path}${suffix} ${w}w`,
106
107
  )
107
108
  .join(", ");
108
109
  }
109
110
 
111
+ /**
112
+ * An authored `src` made safe to stand as a `srcset` candidate: whitespace and commas encoded,
113
+ * everything else as written. A `src` attribute tolerates a space, but a `srcset` splits on it.
114
+ * Only those characters are touched, because an author's `src` may already be percent-encoded and
115
+ * encoding it whole would encode its `%` a second time.
116
+ *
117
+ * @param {string} src
118
+ * @returns {string}
119
+ */
120
+ function srcsetSafe(src: string) {
121
+ return src.replaceAll(/[\s,]/g, (char) => encodeURIComponent(char));
122
+ }
123
+
110
124
  /**
111
125
  * @param {string} absoluteSrc
112
126
  * @param {string} src
@@ -122,7 +136,7 @@ async function resolveManifest(
122
136
  projectRoot: string,
123
137
  cache: CacheManifest,
124
138
  ) {
125
- const key = `${contentHash(absoluteSrc)}:${configHash(config)}`;
139
+ const key = cacheKey(absoluteSrc, config);
126
140
  const cached = getCached(cache, key);
127
141
 
128
142
  if (cached) {
@@ -471,7 +485,7 @@ function fallbackVariant(variants: ImageVariant[], formats: readonly string[]):
471
485
  best = variant;
472
486
  }
473
487
  }
474
- return best?.outputPath;
488
+ return best && variantUrl(best.outputPath);
475
489
  }
476
490
 
477
491
  /**
@@ -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,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
  /**
@@ -463,9 +464,12 @@ export async function buildSite(
463
464
  if (existsSync(componentsDir)) {
464
465
  log("Compiling components...");
465
466
  const componentExtensions = [".json", ...formatRegistry.documentExtensions("component")];
466
- const componentFiles = readdirSync(componentsDir).filter((f: string) =>
467
- componentExtensions.some((ext) => f.endsWith(ext)),
468
- );
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();
469
473
  const componentOutDir = resolve(outDir, "components");
470
474
  mkdirSync(componentOutDir, { recursive: true });
471
475
 
@@ -505,7 +509,7 @@ export async function buildSite(
505
509
  resolveElementPath: (refPath: string, currentDir: string | null) => {
506
510
  const depPath = currentDir ? resolve(currentDir, refPath) : resolve(refPath);
507
511
  const depTag = componentTagByPath.get(depPath);
508
- return depTag ? `./${depTag}.js` : refPath.replace(/\.json$/, ".js");
512
+ return depTag ? `./${depTag}.js` : refPath.replace(/\.(?:json|jxn)$/i, ".js");
509
513
  },
510
514
  rewriteSrc: (specifier: string, docDir: string | null) =>
511
515
  withBase(componentBase, rewriteSidecarSrc(specifier, docDir)),
@@ -602,7 +606,7 @@ export async function buildSite(
602
606
  /* SAFETY: `ProjectConfig.state` is a plain record of state entries, and the collector reads
603
607
  only a document's `state`, so a document carrying that one member is all it needs. */
604
608
  { state: projectConfig.state } as JxElement,
605
- resolve(projectRoot, "project.json"),
609
+ projectFilePath(projectRoot),
606
610
  projectRoot,
607
611
  ),
608
612
  );
@@ -7,8 +7,10 @@
7
7
 
8
8
  import { existsSync, readFileSync } from "node:fs";
9
9
  import type { ImageConfig, JxDocumentValue, ProjectConfig } from "@jxsuite/schema/types";
10
- import { errorMessage, parseProjectConfig } from "@jxsuite/schema/parse";
11
- import { resolve } from "node:path";
10
+ import { errorMessage } from "@jxsuite/schema/parse";
11
+ import { parseProjectSource } from "@jxsuite/schema/project-file";
12
+ import { basename, resolve } from "node:path";
13
+ import { projectFilePath } from "@jxsuite/schema/project-file/node";
12
14
 
13
15
  /**
14
16
  * Default project configuration. All properties are optional in project.json; these defaults fill
@@ -64,24 +66,24 @@ const DEFAULTS = {
64
66
  } satisfies Partial<ResolvedProjectConfig>;
65
67
 
66
68
  /**
67
- * Load and validate project.json from a project root.
69
+ * Load and validate the project file, `project.jxn` or `project.json`, from a project root.
68
70
  *
69
71
  * @param {string} projectRoot - Absolute path to the project directory
70
72
  * @returns {{ config: ProjectConfig; configPath: string; projectRoot: string }}
71
- * @throws {Error} If project.json is missing or invalid JSON
73
+ * @throws {Error} If there is no project file, there are two, or it does not read
72
74
  */
73
75
  export function loadProjectConfig(projectRoot: string) {
74
- const configPath = resolve(projectRoot, "project.json");
76
+ const configPath = projectFilePath(projectRoot);
75
77
 
76
78
  if (!existsSync(configPath)) {
77
- throw new Error(`project.json not found in ${projectRoot}`);
79
+ throw new Error(`No project.jxn or project.json in ${projectRoot}`);
78
80
  }
79
81
 
80
82
  let raw: ProjectConfig;
81
83
  try {
82
- raw = parseProjectConfig(readFileSync(configPath, "utf8"), configPath);
84
+ raw = parseProjectSource(readFileSync(configPath, "utf8"), configPath);
83
85
  } catch (error) {
84
- throw new Error(`Invalid project.json: ${errorMessage(error)}`, { cause: error });
86
+ throw new Error(`Invalid ${basename(configPath)}: ${errorMessage(error)}`, { cause: error });
85
87
  }
86
88
 
87
89
  // Deep merge with defaults. The `...raw` spread carries every author-declared key through
@@ -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";
@@ -63,8 +65,8 @@ export interface ProjectTreeLintFinding {
63
65
  file: string;
64
66
  /** The node's path inside the document. */
65
67
  path: (string | number)[];
66
- /** Which lint: `popover`, `dialog` or `accessibility`. */
67
- source: "popover" | "dialog" | "accessibility";
68
+ /** Which lint: `popover`, `dialog`, `accessibility`, or `notation` for a `.jxn` file's text. */
69
+ source: "popover" | "dialog" | "accessibility" | "notation";
68
70
  rule: string;
69
71
  message: string;
70
72
  detail: string;
@@ -171,11 +173,11 @@ export async function validateProjectTree(
171
173
  return { checked, issues, lint, valid: false };
172
174
  }
173
175
 
174
- // 2. project.json against the generated entry schema.
176
+ // 2. The project file against the generated entry schema.
175
177
  const projectResult = await validateProjectFile(root);
176
178
  checked += 1;
177
179
  if (!projectResult.valid) {
178
- issues.push({ errors: projectResult.errors ?? [], file: "project.json" });
180
+ issues.push({ errors: projectResult.errors ?? [], file: projectResult.file });
179
181
  }
180
182
 
181
183
  const { addFormats, Ajv } = await loadAjv();
@@ -212,7 +214,13 @@ export async function validateProjectTree(
212
214
  continue; // Validated against the class schema below.
213
215
  }
214
216
  checked += 1;
215
- const doc = readJson(file);
217
+ const read = readDocumentFile(file, relative(root, file));
218
+ lint.push(...read.warnings);
219
+ if (read.failure !== null) {
220
+ issues.push(read.failure);
221
+ continue;
222
+ }
223
+ const doc = read.document;
216
224
  if (validateDoc(doc)) {
217
225
  /*
218
226
  * Only a well-formed document is judged; a schema error is the report for the rest.
@@ -270,6 +278,49 @@ export async function validateProjectTree(
270
278
  return { checked, issues, lint, valid: issues.length === 0 && !fatalLint };
271
279
  }
272
280
 
281
+ /** A document file as the validator takes it: its value, or why it has none. */
282
+ interface ReadDocumentFile {
283
+ document: JsonValue;
284
+ failure: ProjectTreeIssue | null;
285
+ warnings: ProjectTreeLintFinding[];
286
+ }
287
+
288
+ /**
289
+ * Read a document file for checking. A `.jxn` file that does not read is a finding with a line and
290
+ * column, and not a stack trace, and an escape that changes what its code means is reported whether
291
+ * or not the rest of the file reads.
292
+ */
293
+ function readDocumentFile(file: string, name: string): ReadDocumentFile {
294
+ if (!isJxnPath(file)) {
295
+ return { document: readJson(file), failure: null, warnings: [] };
296
+ }
297
+ const text = readFileSync(file, "utf8");
298
+ const warnings = lintJxn(text).map((warning): ProjectTreeLintFinding => ({
299
+ detail: warning.message,
300
+ file: name,
301
+ message: `line ${warning.line}, column ${warning.column}: ${warning.message}`,
302
+ path: [],
303
+ rule: "escape",
304
+ severity: "warn",
305
+ source: "notation",
306
+ }));
307
+ try {
308
+ return { document: readJxnValue(text), failure: null, warnings };
309
+ } catch (error) {
310
+ return {
311
+ document: null,
312
+ failure: { errors: [{ message: errorMessage(error) }], file: name },
313
+ warnings,
314
+ };
315
+ }
316
+ }
317
+
318
+ /** A `.jxn` file's document, as the JSON value it is. */
319
+ function readJxnValue(text: string): JsonValue {
320
+ const document = parseJxn(text);
321
+ return isJsonObject(document) ? document : null;
322
+ }
323
+
273
324
  /**
274
325
  * Read and parse a JSON file. `JSON.parse` without a reviver only ever produces JSON values, so the
275
326
  * declared type is exact; what shape of JSON it is stays for the caller to check.
@@ -431,7 +482,7 @@ const SUBSCHEMA_KEYWORDS = new Set([
431
482
  "unevaluatedProperties",
432
483
  ]);
433
484
 
434
- /** Recursively list `.json` files under a directory (absent directories yield nothing). */
485
+ /** Recursively list document files under a directory (absent directories yield nothing). */
435
486
  function walkJsonFiles(dir: string): string[] {
436
487
  if (!existsSync(dir)) {
437
488
  return [];
@@ -441,7 +492,7 @@ function walkJsonFiles(dir: string): string[] {
441
492
  const path = join(dir, name);
442
493
  if (statSync(path).isDirectory()) {
443
494
  out.push(...walkJsonFiles(path));
444
- } else if (name.endsWith(".json")) {
495
+ } else if (isNativeDocumentPath(name)) {
445
496
  out.push(path);
446
497
  }
447
498
  }
@@ -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