@jxsuite/compiler 8.2.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.2.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.4",
56
- "@jxsuite/runtime": "^6.1.0",
57
- "@jxsuite/schema": "^5.2.0",
58
- "@jxsuite/site": "^2.3.1",
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.4",
67
- "@jxsuite/parser": "^4.3.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
@@ -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 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:
@@ -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
  }
@@ -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
  );
@@ -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