@jxsuite/server 8.7.0 → 9.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jxsuite/server",
3
- "version": "8.7.0",
3
+ "version": "9.0.0",
4
4
  "description": "Jx development server with live reload, server-side proxy, and studio integration",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -22,6 +22,7 @@
22
22
  ".": "./src/server.ts",
23
23
  "./dev": "./src/dev.ts",
24
24
  "./convert": "./src/convert.ts",
25
+ "./format": "./src/format.ts",
25
26
  "./ai-api": "./src/ai-api.ts",
26
27
  "./data": "./src/data-api.ts",
27
28
  "./import-api": "./src/import-api.ts",
@@ -55,18 +56,18 @@
55
56
  "upgrade": "bun update --latest && bun install"
56
57
  },
57
58
  "dependencies": {
58
- "@jxsuite/ai": "^0.43.4",
59
- "@jxsuite/catalog": "^0.1.12",
60
- "@jxsuite/collab": "^0.11.10",
61
- "@jxsuite/compiler": "^8.6.0",
62
- "@jxsuite/create": "^1.8.0",
63
- "@jxsuite/erase": "^0.2.0",
64
- "@jxsuite/import": "^0.44.1",
65
- "@jxsuite/protocol": "^6.3.0",
66
- "@jxsuite/runtime": "^6.3.0",
67
- "@jxsuite/schema": "^5.6.0",
68
- "@jxsuite/site": "^2.6.0",
69
- "@jxsuite/starters": "^3.5.0",
59
+ "@jxsuite/ai": "^0.43.5",
60
+ "@jxsuite/catalog": "^0.1.13",
61
+ "@jxsuite/collab": "^0.12.0",
62
+ "@jxsuite/compiler": "^8.7.0",
63
+ "@jxsuite/create": "^1.8.1",
64
+ "@jxsuite/erase": "^0.3.0",
65
+ "@jxsuite/import": "^0.44.2",
66
+ "@jxsuite/protocol": "^6.4.0",
67
+ "@jxsuite/runtime": "^7.0.0",
68
+ "@jxsuite/schema": "^6.0.0",
69
+ "@jxsuite/site": "^2.7.0",
70
+ "@jxsuite/starters": "^3.6.0",
70
71
  "@npmcli/arborist": "^10.0.4",
71
72
  "chokidar": "^6.0.0",
72
73
  "kysely": "^0.29.8",
@@ -76,9 +77,9 @@
76
77
  "zod": "^4.6.7"
77
78
  },
78
79
  "devDependencies": {
79
- "@jxsuite/auth": "^0.8.8",
80
- "@jxsuite/connector": "^0.8.8",
81
- "@jxsuite/parser": "^4.3.4",
80
+ "@jxsuite/auth": "^0.8.9",
81
+ "@jxsuite/connector": "^0.8.9",
82
+ "@jxsuite/parser": "^5.0.0",
82
83
  "@jxsuite/test-support": "^0.0.0",
83
84
  "@types/npm-package-arg": "^6.1.4",
84
85
  "@types/npmcli__arborist": "^6.3.3",
package/src/code-api.ts CHANGED
@@ -1,8 +1,8 @@
1
1
  /**
2
2
  * Code-api.js — OXC-powered code services for the studio function editor
3
3
  *
4
- * Endpoints under /__studio/code/* that format (oxfmt) and lint (oxlint CLI) a function body. A
5
- * body may be TypeScript (`spec.md` §23.1), so both read it as `.ts`.
4
+ * Endpoints under /__studio/code/* that format (oxfmt, through `code-format.ts`) and lint (oxlint
5
+ * CLI) a function body. A body may be TypeScript (`spec.md` §23.1), so both read it as `.ts`.
6
6
  */
7
7
 
8
8
  import { tmpdir } from "node:os";
@@ -11,7 +11,7 @@ import type { JsonValue } from "@jxsuite/schema/types";
11
11
  import { dirname, join } from "node:path";
12
12
  import { existsSync } from "node:fs";
13
13
  import { unlink } from "node:fs/promises";
14
- import { format } from "oxfmt";
14
+ import { formatBody } from "./code-format.ts";
15
15
  import { problem } from "./problem.ts";
16
16
 
17
17
  /** A label's source span; oxlint's other span members pass through to the client unchanged. */
@@ -67,6 +67,10 @@ export function resolveOxlintBin(startDir: string = import.meta.dir): string | n
67
67
  // ─── Wrapper utilities ───────────────────────────────────────────────────────
68
68
 
69
69
  /**
70
+ * A body inside a function with its parameters, so the linter knows each name it is called with.
71
+ * The function is not `async`, as the one a document's body becomes is not, so a body that awaits
72
+ * is told so here and not first when it runs.
73
+ *
70
74
  * @param {string} body
71
75
  * @param {string[]} [args]
72
76
  */
@@ -75,22 +79,6 @@ function wrapBody(body: string, args: string[] = ["state", "event"]) {
75
79
  return `function __jx_fn__(${params}) {\n${body}\n}`;
76
80
  }
77
81
 
78
- /** @param {string} formatted */
79
- function unwrapFormatted(formatted: string) {
80
- const lines = formatted.split("\n");
81
- // Remove first line (function header) and last non-empty line (closing brace)
82
- let end = lines.length - 1;
83
- while (end > 0 && lines[end]!.trim() === "") {
84
- end -= 1;
85
- }
86
- if (lines[end]!.trim() === "}") {
87
- end -= 1;
88
- }
89
- const bodyLines = lines.slice(1, end + 1);
90
- // Dedent by one tab (oxfmt uses the project's indentStyle)
91
- return bodyLines.map((l) => (l.startsWith("\t") ? l.slice(1) : l)).join("\n");
92
- }
93
-
94
82
  /**
95
83
  * @param {OxcDiagnostic[]} diagnostics
96
84
  * @param {number} headerLen
@@ -137,17 +125,16 @@ export async function handleCodeApi(req: Request, url: URL) {
137
125
  // ── Format ─────────────────────────────────────────────────────────────────
138
126
 
139
127
  if (action === "format") {
140
- const { code, args } = body;
128
+ const { code } = body;
141
129
  if (!code?.trim()) {
142
130
  return Response.json({ code: "", errors: [] });
143
131
  }
144
132
 
145
133
  try {
146
- const wrapped = wrapBody(code, args);
147
- const result = await format("fn.ts", wrapped, { useTabs: true });
134
+ const result = await formatBody(code);
148
135
  return Response.json({
149
- code: unwrapFormatted(result.code),
150
- errors: result.errors,
136
+ code: result.code,
137
+ errors: result.errors.map((message) => ({ message })),
151
138
  });
152
139
  } catch (error) {
153
140
  return Response.json({
@@ -0,0 +1,134 @@
1
+ /**
2
+ * How the code a document holds is formatted: a function body (`spec.md` §23.1) and a document's
3
+ * script (§23.2), by `oxfmt`, as TypeScript, at its defaults. Studio's code editor and `jx format`
4
+ * both ask here, so a body one of them has formatted is one the other leaves alone.
5
+ *
6
+ * A body is the statements of a function and no file of its own, so it is formatted inside one and
7
+ * taken back out. Taking it out is unindenting it, and a line the formatter did not write must not
8
+ * be unindented: the lines of a template literal are the string's own text. So a body is formatted
9
+ * at two depths, and a line is the formatter's where the deeper reading moved it.
10
+ *
11
+ * @docs framework/build/cli
12
+ * @docs studio/logic/code
13
+ */
14
+ import { format } from "oxfmt";
15
+
16
+ /** One formatting of one piece of code. */
17
+ export interface FormattedCode {
18
+ /** The code formatted, or as it was given where it could not be. */
19
+ code: string;
20
+ /** Why it could not be, which is nothing where it could. */
21
+ errors: string[];
22
+ }
23
+
24
+ /** The columns a line of code may take, counted from where the code starts and not the file. */
25
+ const COLUMNS = 100;
26
+
27
+ /** What one level of nesting adds to a line. */
28
+ const INDENT = " ";
29
+
30
+ /**
31
+ * What a body is wrapped in, so it may `return`. `async`, as a class method that awaits is: whether
32
+ * a body may await is its checker's to say, and a formatter that refused would leave it
33
+ * unformatted.
34
+ */
35
+ const OPEN = "async function __jx_fn__() {\n";
36
+
37
+ /** What the formatter says is wrong with a file. */
38
+ type FormatError = Awaited<ReturnType<typeof format>>["errors"][number];
39
+
40
+ /** What is wrong, and the line of the code it is on where the formatter gives it a place. */
41
+ function told(error: FormatError, text: string, placing: Placing): string {
42
+ const at = error.labels[0]?.start;
43
+ if (at === undefined) {
44
+ return error.message;
45
+ }
46
+ // The place is counted in bytes of UTF-8, and a line break is one of those in any text.
47
+ const line = Buffer.from(text).subarray(0, at).toString().split("\n").length - placing.above;
48
+ // Code that stops short is found wrong where the file goes on, which is its own last line.
49
+ return `${error.message} (line ${Math.min(line, placing.lines)})`;
50
+ }
51
+
52
+ /** Where code is in the file it is formatted as. */
53
+ interface Placing {
54
+ /** The lines of the file above the code, which a line number in the code does not count. */
55
+ above: number;
56
+ /** The lines the code has. */
57
+ lines: number;
58
+ /** The columns the file's nesting takes, which every line is allowed over {@link COLUMNS}. */
59
+ room: number;
60
+ }
61
+
62
+ /** Format a `.ts` file's text. */
63
+ async function formatted(name: string, text: string, placing: Placing): Promise<FormattedCode> {
64
+ const { code, errors } = await format(name, text, {
65
+ printWidth: COLUMNS + placing.room,
66
+ tabWidth: INDENT.length,
67
+ useTabs: false,
68
+ });
69
+ return { code, errors: errors.map((error) => told(error, text, placing)) };
70
+ }
71
+
72
+ /**
73
+ * A body inside its function, with `blocks` blocks around it. Each level is given back the columns
74
+ * it takes, so a line breaks where it would at no depth at all.
75
+ */
76
+ async function nested(body: string, blocks: number): Promise<FormattedCode> {
77
+ const text = `${OPEN}${"{\n".repeat(blocks)}${body}\n${"}\n".repeat(blocks)}}\n`;
78
+ const above = 1 + blocks;
79
+ const placing = { above, lines: body.split("\n").length, room: INDENT.length * above };
80
+ const { code, errors } = await formatted("body.ts", text, placing);
81
+ const lines = code.split("\n");
82
+ // The function's line and each block's come off the front, and their braces and the last newline
83
+ // Off the end.
84
+ return { code: lines.slice(above, -(above + 1)).join("\n"), errors };
85
+ }
86
+
87
+ /** One line of a body with its nesting taken off, or null where the two readings disagree. */
88
+ function unindented(line: string, deeper: string | undefined): string | null {
89
+ if (deeper === line) {
90
+ // Not moved by a level of nesting, so not the formatter's to indent: a template literal's text.
91
+ return line;
92
+ }
93
+ return deeper === `${INDENT}${line}` && line.startsWith(INDENT)
94
+ ? line.slice(INDENT.length)
95
+ : null;
96
+ }
97
+
98
+ /**
99
+ * Format a function body.
100
+ *
101
+ * @param body The body as written.
102
+ * @returns The body formatted. As written, with why, where it does not parse, and as written with
103
+ * nothing to say where its lines cannot be told from a string's.
104
+ */
105
+ export async function formatBody(body: string): Promise<FormattedCode> {
106
+ if (body.trim() === "") {
107
+ return { code: "", errors: [] };
108
+ }
109
+ const [shallow, deep] = await Promise.all([nested(body, 0), nested(body, 1)]);
110
+ if (shallow.errors.length > 0) {
111
+ return { code: body, errors: shallow.errors };
112
+ }
113
+ const deeper = deep.code.split("\n");
114
+ const lines = shallow.code.split("\n").map((line, at) => unindented(line, deeper[at]));
115
+ const kept = lines.flatMap((line) => (line === null ? [] : [line]));
116
+ const agreed = kept.length === lines.length && deeper.length === lines.length;
117
+ return { code: agreed ? kept.join("\n") : body, errors: [] };
118
+ }
119
+
120
+ /**
121
+ * Format a document's script, which is a module.
122
+ *
123
+ * @param script The module as written.
124
+ * @returns The module formatted, without the newline a file ends with, which a string has no use
125
+ * for. As written, with why, where it does not parse.
126
+ */
127
+ export async function formatScript(script: string): Promise<FormattedCode> {
128
+ if (script.trim() === "") {
129
+ return { code: "", errors: [] };
130
+ }
131
+ const placing = { above: 0, lines: script.trimEnd().split("\n").length, room: 0 };
132
+ const { code, errors } = await formatted("script.ts", script, placing);
133
+ return errors.length > 0 ? { code: script, errors } : { code: code.replace(/\n$/u, ""), errors };
134
+ }
package/src/dev.ts CHANGED
@@ -15,11 +15,14 @@
15
15
  import { existsSync, statSync } from "node:fs";
16
16
  import { createRequire } from "node:module";
17
17
  import { join, normalize, resolve, sep } from "node:path";
18
+ import { isScriptSpecifier } from "@jxsuite/schema/document-scripts";
18
19
  import { errorMessage } from "@jxsuite/schema/parse";
19
20
  import type { JsonObject } from "@jxsuite/schema/types";
20
21
  import { projectDevEnv } from "./dev-vars.ts";
21
22
  import { problem } from "./problem.ts";
22
- import { callServerExport } from "./resolve.ts";
23
+ import { callServerExport, importServerScript } from "./resolve.ts";
24
+ import type { ModuleNamespace } from "./resolve.ts";
25
+ import { serverScriptAddress } from "./server-script.ts";
23
26
  import { createDevServer } from "./server.ts";
24
27
  import { injectSSE } from "./watch.ts";
25
28
  import { hasProjectFile } from "@jxsuite/schema/project-file/node";
@@ -100,7 +103,10 @@ export function createDistMiddleware(
100
103
  /** One server entry the last build collected: the export, and where the site worker imports it from. */
101
104
  export interface ServerEntry {
102
105
  exportName: string;
103
- /** A `./` path from the project root, or a bare specifier, exactly as the worker bundle reads it. */
106
+ /**
107
+ * A `./` path from the project root, a document's server script by its qualified specifier, or a
108
+ * bare specifier, exactly as the worker bundle reads it.
109
+ */
104
110
  src: string;
105
111
  }
106
112
 
@@ -132,7 +138,8 @@ async function readArguments(req: Request): Promise<JsonObject> {
132
138
  * `process.env` under the project's `.dev.vars`), so it reads the same secrets in development.
133
139
  *
134
140
  * Each module is resolved as the worker bundle resolves it, from the project root: a `./` path
135
- * against the root, any other specifier through the project's own `package.json`. It applies no
141
+ * against the root, a document's own server script (`jx-script:pages/x.json#server`, spec.md §23.2)
142
+ * from that document, any other specifier through the project's own `package.json`. It applies no
136
143
  * containment check, because the module path comes from the build and never from the request;
137
144
  * containment stays in the `/__jx_server__` proxy, where the request names the module.
138
145
  *
@@ -156,18 +163,29 @@ export function createServerFunctionMiddleware(
156
163
  return problem("notFound", `The last build collected no server function "${name}"`);
157
164
  }
158
165
 
159
- let modulePath: string;
160
- try {
161
- modulePath =
162
- entry.src.startsWith("./") || entry.src.startsWith("../")
163
- ? resolve(root, entry.src)
164
- : requireFromProject.resolve(entry.src);
165
- } catch (error) {
166
- return Response.json({ error: errorMessage(error), ok: false }, { status: 500 });
166
+ let module: string | (() => Promise<ModuleNamespace>);
167
+ if (isScriptSpecifier(entry.src)) {
168
+ // A document's own server script (spec.md §23.2), which the build named by its project path.
169
+ const address = serverScriptAddress(entry.src);
170
+ if (address.kind !== "path") {
171
+ const error = `"${entry.src}" names no server script of a document in this project`;
172
+ return Response.json({ error, ok: false }, { status: 500 });
173
+ }
174
+ const documentPath = resolve(root, address.path);
175
+ module = () => importServerScript(documentPath, root);
176
+ } else {
177
+ try {
178
+ module =
179
+ entry.src.startsWith("./") || entry.src.startsWith("../")
180
+ ? resolve(root, entry.src)
181
+ : requireFromProject.resolve(entry.src);
182
+ } catch (error) {
183
+ return Response.json({ error: errorMessage(error), ok: false }, { status: 500 });
184
+ }
167
185
  }
168
186
 
169
187
  const args = await readArguments(req);
170
- const result = await callServerExport(modulePath, entry.exportName, args, projectDevEnv(root));
188
+ const result = await callServerExport(module, entry.exportName, args, projectDevEnv(root));
171
189
  if (result.ok) {
172
190
  return Response.json(result.value ?? null);
173
191
  }
package/src/format.ts ADDED
@@ -0,0 +1,271 @@
1
+ /**
2
+ * `jx format`: format the code a `.jxn` file holds, and the file around it (compiler.md §2.3).
3
+ *
4
+ * The code is what `oxfmt` cannot reach, because it is a string in a file `oxfmt` does not read:
5
+ * every `Function` entry's body and both of a document's scripts. The file around it is the
6
+ * writer's to lay out. A file with no comment is written whole, in the one form the writer has. A
7
+ * file with a comment is merged the way a save from Studio is (spec.md §22.4), so that only what
8
+ * the formatter changed is rewritten and no comment is lost.
9
+ *
10
+ * This runs under Bun for the same reason `jx convert` does, so `@jxsuite/compiler`'s CLI resolves
11
+ * it from the project and spawns it.
12
+ *
13
+ * @docs framework/build/cli
14
+ * @docs framework/concepts/jxn-files
15
+ */
16
+ import { existsSync, readdirSync, statSync } from "node:fs";
17
+ import { readFile, writeFile } from "node:fs/promises";
18
+ import { join, relative, resolve } from "node:path";
19
+ import { isFunctionEntry, isJsonObject, isString } from "@jxsuite/schema/guards";
20
+ import {
21
+ classifyJxn,
22
+ countJxnComments,
23
+ isJxnPath,
24
+ parseJxn,
25
+ parseProject,
26
+ serializeJxn,
27
+ serializeProject,
28
+ } from "@jxsuite/schema/jxn";
29
+ import { errorMessage } from "@jxsuite/schema/parse";
30
+ import type { JsonObject, JsonValue, JxDocument, ProjectConfig } from "@jxsuite/schema/types";
31
+ import { formatBody, formatScript } from "./code-format.ts";
32
+ import type { FormattedCode } from "./code-format.ts";
33
+
34
+ /** Code in a file that could not be formatted. */
35
+ export interface FormatProblem {
36
+ /** Why not, as the formatter says it. */
37
+ message: string;
38
+ /** The JSON Pointer of the string that holds the code. */
39
+ pointer: string;
40
+ }
41
+
42
+ /** One `.jxn` file's text, formatted. */
43
+ export interface FormattedJxn {
44
+ /** The code left as written, each with why. */
45
+ problems: FormatProblem[];
46
+ text: string;
47
+ }
48
+
49
+ /** What formats one piece of code. */
50
+ type CodeFormatter = (code: string) => Promise<FormattedCode>;
51
+
52
+ /** The folders under a named one that hold nothing an author wrote. */
53
+ const SKIPPED = new Set(["dist", "node_modules"]);
54
+
55
+ /** A JSON Pointer one step down. */
56
+ function pointerInto(pointer: string, key: string | number): string {
57
+ return `${pointer}/${String(key).replaceAll("~", "~0").replaceAll("/", "~1")}`;
58
+ }
59
+
60
+ /**
61
+ * What formats the string a member holds, or null where it is not code: a `Function` entry's body,
62
+ * wherever the entry sits, and each side of the document's `$script`. Any other `body` may be data
63
+ * (a `Request`'s payload), and a template is left to its author.
64
+ */
65
+ function formatterOf(owner: JsonObject, pointer: string, key: string): CodeFormatter | null {
66
+ if (key === "body") {
67
+ return isFunctionEntry(owner) ? formatBody : null;
68
+ }
69
+ return pointer === "/$script" && (key === "client" || key === "server") ? formatScript : null;
70
+ }
71
+
72
+ /** Copy a tree, formatting the code in it. */
73
+ async function walk(
74
+ value: JsonValue,
75
+ pointer: string,
76
+ problems: FormatProblem[],
77
+ ): Promise<JsonValue> {
78
+ if (Array.isArray(value)) {
79
+ const items: JsonValue[] = [];
80
+ for (const [index, item] of value.entries()) {
81
+ items.push(await walk(item, pointerInto(pointer, index), problems));
82
+ }
83
+ return items;
84
+ }
85
+ if (!isJsonObject(value)) {
86
+ return value;
87
+ }
88
+ const entries: [string, JsonValue][] = [];
89
+ for (const [key, child] of Object.entries(value)) {
90
+ const at = pointerInto(pointer, key);
91
+ const formatter = formatterOf(value, pointer, key);
92
+ // Nothing but white space is no code, and is left as the nothing its author wrote.
93
+ if (formatter === null || !isString(child) || child.trim() === "") {
94
+ entries.push([key, await walk(child, at, problems)]);
95
+ } else {
96
+ const { code, errors } = await formatter(child);
97
+ problems.push(...errors.map((message) => ({ message, pointer: at })));
98
+ entries.push([key, code]);
99
+ }
100
+ }
101
+ // Built from entries: assigning `__proto__` would set the copy's prototype.
102
+ return Object.fromEntries(entries);
103
+ }
104
+
105
+ /**
106
+ * Format one `.jxn` file's text: a document or a project file.
107
+ *
108
+ * @param text The file.
109
+ * @returns The file formatted, and the code in it that was left as written.
110
+ * @throws {JxnError} When the file cannot be read, or holds what Jx Notation cannot spell.
111
+ */
112
+ export async function formatJxn(text: string): Promise<FormattedJxn> {
113
+ const problems: FormatProblem[] = [];
114
+ const isProject = classifyJxn(text) === "project";
115
+ const read: unknown = isProject ? parseProject(text) : parseJxn(text);
116
+ const formatted = await walk(isJsonObject(read) ? read : {}, "", problems);
117
+ // Merged into the file it replaces only where there is a comment to lose (spec.md §22.4).
118
+ const previous = countJxnComments(text) > 0 ? text : undefined;
119
+ /* SAFETY: `walk` copies what was read member for member, and puts a string where a string was, so
120
+ the copy is whatever the file it came from was. */
121
+ const written = isProject
122
+ ? serializeProject(formatted as ProjectConfig, previous)
123
+ : serializeJxn(formatted as JxDocument, previous);
124
+ return { problems, text: written };
125
+ }
126
+
127
+ /** Every `.jxn` file a path names: itself, or what is under it. */
128
+ function expand(path: string): string[] {
129
+ // A path that is not there comes through as itself, so the report can say so.
130
+ if (!existsSync(path) || !statSync(path).isDirectory()) {
131
+ return [path];
132
+ }
133
+ return readdirSync(path, { withFileTypes: true }).flatMap((entry) => {
134
+ const child = join(path, entry.name);
135
+ if (entry.isDirectory()) {
136
+ return entry.name.startsWith(".") || SKIPPED.has(entry.name) ? [] : expand(child);
137
+ }
138
+ return isJxnPath(entry.name) ? [child] : [];
139
+ });
140
+ }
141
+
142
+ /** What happened to one file. */
143
+ export interface FormatRow {
144
+ /** Its text is not what formatting it gives. */
145
+ changed: boolean;
146
+ /** Its path from the root. */
147
+ path: string;
148
+ /** What is wrong with it, each as the line the report prints after the path. */
149
+ problems: string[];
150
+ }
151
+
152
+ /** Format one file where it is, or say what formatting it would do. */
153
+ async function formatFile(root: string, path: string, check: boolean): Promise<FormatRow> {
154
+ const row: FormatRow = {
155
+ changed: false,
156
+ path: relative(root, path).replaceAll("\\", "/"),
157
+ problems: [],
158
+ };
159
+ if (!existsSync(path)) {
160
+ row.problems.push(": there is no such file");
161
+ return row;
162
+ }
163
+ if (!isJxnPath(path)) {
164
+ row.problems.push(": only a .jxn file holds code this can reach");
165
+ return row;
166
+ }
167
+ try {
168
+ const text = await readFile(path, "utf8");
169
+ const formatted = await formatJxn(text);
170
+ row.problems = formatted.problems.map((problem) => `#${problem.pointer}: ${problem.message}`);
171
+ row.changed = formatted.text !== text;
172
+ if (row.changed && !check) {
173
+ await writeFile(path, formatted.text);
174
+ }
175
+ } catch (error) {
176
+ row.problems.push(`: ${errorMessage(error)}`);
177
+ }
178
+ return row;
179
+ }
180
+
181
+ /**
182
+ * Format the `.jxn` files under some paths.
183
+ *
184
+ * @param root What the paths are relative to.
185
+ * @param paths Files and directories.
186
+ * @param check Say what would change, and write nothing.
187
+ */
188
+ export async function formatFiles(
189
+ root: string,
190
+ paths: readonly string[],
191
+ check: boolean,
192
+ ): Promise<FormatRow[]> {
193
+ const files = new Set(paths.flatMap((item) => expand(resolve(root, item))));
194
+ const rows: FormatRow[] = [];
195
+ for (const path of [...files].toSorted()) {
196
+ rows.push(await formatFile(root, path, check));
197
+ }
198
+ return rows;
199
+ }
200
+
201
+ /** What `jx format` was given. */
202
+ export interface FormatArgs {
203
+ check: boolean;
204
+ paths: string[];
205
+ root: string;
206
+ }
207
+
208
+ /**
209
+ * Read `jx format`'s arguments.
210
+ *
211
+ * @param argv The arguments after the command.
212
+ * @returns What was asked, or null when it names an option there is not.
213
+ */
214
+ export function parseFormatArgs(argv: readonly string[]): FormatArgs | null {
215
+ const paths: string[] = [];
216
+ let root = process.cwd();
217
+ let check = false;
218
+ for (let index = 0; index < argv.length; index += 1) {
219
+ const arg = argv[index] ?? "";
220
+ if (arg === "--root") {
221
+ index += 1;
222
+ root = resolve(argv[index] ?? root);
223
+ } else if (arg === "--check") {
224
+ check = true;
225
+ } else if (arg.startsWith("-")) {
226
+ return null;
227
+ } else {
228
+ paths.push(arg);
229
+ }
230
+ }
231
+ // With no path named, the project is what is formatted.
232
+ return { check, paths: paths.length > 0 ? paths : ["."], root };
233
+ }
234
+
235
+ /**
236
+ * Run `jx format`.
237
+ *
238
+ * @param argv The arguments after the command.
239
+ * @param print Where the report goes.
240
+ * @returns The exit code: 1 when a file or code in one could not be formatted, or when `--check`
241
+ * found a file that formatting would change.
242
+ */
243
+ export async function runFormat(
244
+ argv: readonly string[],
245
+ print: (line: string) => void = console.log,
246
+ ): Promise<number> {
247
+ const args = parseFormatArgs(argv);
248
+ if (!args) {
249
+ print("Usage: jx format [paths…] [--check]");
250
+ return 2;
251
+ }
252
+ const rows = await formatFiles(args.root, args.paths, args.check);
253
+ for (const row of rows) {
254
+ for (const problem of row.problems) {
255
+ print(`${row.path}${problem}`);
256
+ }
257
+ if (row.changed) {
258
+ print(`${row.path} ${args.check ? "would be formatted" : "formatted"}`);
259
+ }
260
+ }
261
+ const changed = rows.filter((row) => row.changed).length;
262
+ print(
263
+ `${changed} file(s) ${args.check ? "would be formatted" : "formatted"}, ${rows.length - changed} left as written`,
264
+ );
265
+ const failed = rows.some((row) => row.problems.length > 0) || (args.check && changed > 0);
266
+ return failed ? 1 : 0;
267
+ }
268
+
269
+ if (import.meta.main) {
270
+ process.exitCode = await runFormat(process.argv.slice(2));
271
+ }