@jxsuite/server 8.3.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/server",
3
- "version": "8.3.0",
3
+ "version": "8.4.0",
4
4
  "description": "Jx development server with live reload, server-side proxy, and studio integration",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -21,6 +21,7 @@
21
21
  "exports": {
22
22
  ".": "./src/server.ts",
23
23
  "./dev": "./src/dev.ts",
24
+ "./convert": "./src/convert.ts",
24
25
  "./ai-api": "./src/ai-api.ts",
25
26
  "./data": "./src/data-api.ts",
26
27
  "./import-api": "./src/import-api.ts",
@@ -54,17 +55,17 @@
54
55
  "upgrade": "bun update --latest && bun install"
55
56
  },
56
57
  "dependencies": {
57
- "@jxsuite/ai": "^0.43.0",
58
- "@jxsuite/catalog": "^0.1.8",
59
- "@jxsuite/collab": "^0.11.6",
60
- "@jxsuite/compiler": "^8.2.0",
61
- "@jxsuite/create": "^1.5.4",
62
- "@jxsuite/import": "^0.42.4",
63
- "@jxsuite/protocol": "^6.2.2",
64
- "@jxsuite/runtime": "^6.1.0",
65
- "@jxsuite/schema": "^5.2.0",
66
- "@jxsuite/site": "^2.3.1",
67
- "@jxsuite/starters": "^3.3.0",
58
+ "@jxsuite/ai": "^0.43.1",
59
+ "@jxsuite/catalog": "^0.1.9",
60
+ "@jxsuite/collab": "^0.11.7",
61
+ "@jxsuite/compiler": "^8.3.0",
62
+ "@jxsuite/create": "^1.5.5",
63
+ "@jxsuite/import": "^0.42.5",
64
+ "@jxsuite/protocol": "^6.2.3",
65
+ "@jxsuite/runtime": "^6.2.0",
66
+ "@jxsuite/schema": "^5.3.0",
67
+ "@jxsuite/site": "^2.4.0",
68
+ "@jxsuite/starters": "^3.3.1",
68
69
  "@npmcli/arborist": "^10.0.4",
69
70
  "chokidar": "^6.0.0",
70
71
  "kysely": "^0.29.8",
@@ -74,9 +75,9 @@
74
75
  "zod": "^4.6.7"
75
76
  },
76
77
  "devDependencies": {
77
- "@jxsuite/auth": "^0.8.4",
78
- "@jxsuite/connector": "^0.8.4",
79
- "@jxsuite/parser": "^4.3.0",
78
+ "@jxsuite/auth": "^0.8.5",
79
+ "@jxsuite/connector": "^0.8.5",
80
+ "@jxsuite/parser": "^4.3.1",
80
81
  "@jxsuite/test-support": "^0.0.0",
81
82
  "@types/npm-package-arg": "^6.1.4",
82
83
  "@types/npmcli__arborist": "^6.3.3",
package/src/convert.ts ADDED
@@ -0,0 +1,254 @@
1
+ /**
2
+ * `jx convert`: move a document between `.json` and `.jxn`, with every reference repaired.
3
+ *
4
+ * It is a rename, because that is what makes it safe: writing a new file beside the old one would
5
+ * leave every `$ref` to it dangling, and the refactor pass is what repairs them. This runs under
6
+ * Bun for the same reason `jx dev` does, so `@jxsuite/compiler`'s CLI resolves it from the project
7
+ * and spawns it.
8
+ *
9
+ * @docs framework/build/cli
10
+ */
11
+ import { existsSync, readdirSync, statSync } from "node:fs";
12
+ import { readFile, rename, writeFile } from "node:fs/promises";
13
+ import { basename, dirname, extname, join, relative, resolve } from "node:path";
14
+ import { buildProjectExtensionRegistry } from "@jxsuite/compiler/format-host";
15
+ import { loadProjectConfig } from "@jxsuite/compiler/site-loader";
16
+ import { countJxnComments, parseJxn, serializeJxn } from "@jxsuite/schema/jxn";
17
+ import { isJsonObject, isString } from "@jxsuite/schema/guards";
18
+ import { errorMessage, parseJxDocument } from "@jxsuite/schema/parse";
19
+ import type { ProjectConfig } from "@jxsuite/schema/types";
20
+ import { applyRename } from "./refactor/index.ts";
21
+
22
+ /** The formats a document can move between. */
23
+ export type ConvertTarget = "json" | "jxn";
24
+
25
+ /** What was asked. */
26
+ export interface ConvertOptions {
27
+ /** Convert a `.jxn` file that has comments, which `.json` has nowhere to keep. */
28
+ dropComments: boolean;
29
+ /** Say what would happen, and change nothing. */
30
+ dryRun: boolean;
31
+ to: ConvertTarget;
32
+ }
33
+
34
+ /** What happened to one file. */
35
+ export interface ConvertRow {
36
+ from: string;
37
+ /** Other files whose references to this one were repaired. */
38
+ references: number;
39
+ /** Why it was left alone, or what is still wrong after moving it. */
40
+ problem: string | null;
41
+ to: string;
42
+ }
43
+
44
+ /** The directories that hold documents, which are conventions the build hard-codes. */
45
+ const DOCUMENT_ROOTS = ["components/", "layouts/", "pages/"];
46
+
47
+ /**
48
+ * Where each content collection's files are, project-relative: the part of its `source` before any
49
+ * `{locale}` or glob. Only a collection reaching into a document root can matter here.
50
+ */
51
+ function collectionRoots(config: ProjectConfig): string[] {
52
+ const content = isJsonObject(config.content) ? Object.values(config.content) : [];
53
+ return content.flatMap((definition) => {
54
+ const source = isJsonObject(definition) ? definition.source : null;
55
+ if (!isString(source)) {
56
+ return [];
57
+ }
58
+ const [fixed = ""] = /^[^{*]*/u.exec(source) ?? [];
59
+ return [fixed.replace(/^\.\//u, "").replace(/\/+$/u, "")];
60
+ });
61
+ }
62
+
63
+ /** Every convertible file a path names: itself, or what is under it. */
64
+ function expand(path: string): string[] {
65
+ // A path that is not there comes through as itself, so `refusal` can say so.
66
+ if (!existsSync(path) || !statSync(path).isDirectory()) {
67
+ return [path];
68
+ }
69
+ return readdirSync(path, { withFileTypes: true }).flatMap((entry) => {
70
+ const child = join(path, entry.name);
71
+ if (entry.isDirectory()) {
72
+ return expand(child);
73
+ }
74
+ return /\.(?:json|jxn)$/iu.test(entry.name) && !entry.name.endsWith(".class.json")
75
+ ? [child]
76
+ : [];
77
+ });
78
+ }
79
+
80
+ /** Why a file cannot be converted, or null when it can. */
81
+ async function refusal(
82
+ root: string,
83
+ path: string,
84
+ options: ConvertOptions,
85
+ sources: readonly string[],
86
+ ): Promise<string | null> {
87
+ if (!existsSync(path)) {
88
+ return "there is no such file";
89
+ }
90
+ const name = relative(root, path).replaceAll("\\", "/");
91
+ if (!DOCUMENT_ROOTS.some((prefix) => name.startsWith(prefix))) {
92
+ return `only ${DOCUMENT_ROOTS.join(", ")} hold documents`;
93
+ }
94
+ const ext = extname(path).toLowerCase();
95
+ if (ext !== ".json" && ext !== ".jxn") {
96
+ return `${ext || "this file"} is not a native document format`;
97
+ }
98
+ if (path.endsWith(".class.json")) {
99
+ return "a class descriptor stays JSON";
100
+ }
101
+ if (ext === `.${options.to}`) {
102
+ return `already .${options.to}`;
103
+ }
104
+ if (sources.some((source) => name === source || name.startsWith(`${source}/`))) {
105
+ // Both directions: an entry would drop out of its glob, and a neighbour would be enlisted.
106
+ return "a content collection's file keeps its collection's format";
107
+ }
108
+ if (existsSync(targetOf(path, options.to))) {
109
+ return `${basename(targetOf(path, options.to))} already exists`;
110
+ }
111
+ if (ext === ".jxn" && !options.dropComments) {
112
+ const comments = countJxnComments(await readFile(path, "utf8"));
113
+ if (comments > 0) {
114
+ return `it has ${comments} comment(s), and .json has nowhere to keep them. --drop-comments converts it anyway`;
115
+ }
116
+ }
117
+ return null;
118
+ }
119
+
120
+ /** The path a file has once converted. */
121
+ function targetOf(path: string, to: ConvertTarget): string {
122
+ return join(dirname(path), `${basename(path, extname(path))}.${to}`);
123
+ }
124
+
125
+ /** The file's text in the other format. */
126
+ async function converted(path: string, to: ConvertTarget): Promise<string> {
127
+ const text = await readFile(path, "utf8");
128
+ return to === "jxn"
129
+ ? serializeJxn(parseJxDocument(text, path))
130
+ : `${JSON.stringify(parseJxn(text, path), null, 2)}\n`;
131
+ }
132
+
133
+ /**
134
+ * Convert documents in a project.
135
+ *
136
+ * @param root The project root.
137
+ * @param paths Files and directories, relative to the root.
138
+ * @param options What was asked.
139
+ */
140
+ export async function convertDocuments(
141
+ root: string,
142
+ paths: readonly string[],
143
+ options: ConvertOptions,
144
+ ): Promise<ConvertRow[]> {
145
+ const { config } = loadProjectConfig(root);
146
+ const sources = collectionRoots(config);
147
+ const { formats } = await buildProjectExtensionRegistry(root, config);
148
+ const rows: ConvertRow[] = [];
149
+ for (const path of paths.flatMap((item) => expand(resolve(root, item)))) {
150
+ const from = relative(root, path);
151
+ const target = targetOf(path, options.to);
152
+ const row: ConvertRow = { from, references: 0, problem: null, to: relative(root, target) };
153
+ rows.push(row);
154
+ row.problem = await refusal(root, path, options, sources);
155
+ if (row.problem !== null) {
156
+ continue;
157
+ }
158
+ try {
159
+ const text = await converted(path, options.to);
160
+ if (options.dryRun) {
161
+ continue;
162
+ }
163
+ /* In place and then renamed, so a failure writing leaves the old name holding the old text or
164
+ the new, and nothing beside it to be mistaken for the document. */
165
+ await writeFile(path, text);
166
+ await rename(path, target);
167
+ const report = await applyRename({ absFrom: path, absTo: target, registry: formats, root });
168
+ row.references = report.references.filesChanged;
169
+ if (report.errors.length > 0) {
170
+ const unread = report.errors.map((item) => item.path).join(", ");
171
+ row.problem = `converted, and ${unread} could not be read, so a reference in it may still name the old path`;
172
+ }
173
+ } catch (error) {
174
+ row.problem = errorMessage(error);
175
+ }
176
+ }
177
+ return rows;
178
+ }
179
+
180
+ /** What `jx convert` was given. */
181
+ export interface ConvertArgs extends ConvertOptions {
182
+ paths: string[];
183
+ root: string;
184
+ }
185
+
186
+ /**
187
+ * Read `jx convert`'s arguments.
188
+ *
189
+ * @param argv The arguments after the command.
190
+ * @returns What was asked, or null when it does not say.
191
+ */
192
+ export function parseConvertArgs(argv: readonly string[]): ConvertArgs | null {
193
+ const paths: string[] = [];
194
+ let to: string | undefined;
195
+ let root = process.cwd();
196
+ const flags = new Set<string>();
197
+ for (let index = 0; index < argv.length; index += 1) {
198
+ const arg = argv[index] ?? "";
199
+ if (arg === "--to") {
200
+ index += 1;
201
+ to = argv[index];
202
+ } else if (arg === "--root") {
203
+ index += 1;
204
+ root = resolve(argv[index] ?? root);
205
+ } else if (arg.startsWith("--")) {
206
+ flags.add(arg);
207
+ } else {
208
+ paths.push(arg);
209
+ }
210
+ }
211
+ if ((to !== "json" && to !== "jxn") || paths.length === 0) {
212
+ return null;
213
+ }
214
+ return {
215
+ dropComments: flags.has("--drop-comments"),
216
+ dryRun: flags.has("--dry-run"),
217
+ paths,
218
+ root,
219
+ to,
220
+ };
221
+ }
222
+
223
+ /**
224
+ * Run `jx convert`.
225
+ *
226
+ * @param argv The arguments after the command.
227
+ * @param print Where the report goes.
228
+ * @returns The exit code: 1 when anything was left unconverted.
229
+ */
230
+ export async function runConvert(
231
+ argv: readonly string[],
232
+ print: (line: string) => void = console.log,
233
+ ): Promise<number> {
234
+ const args = parseConvertArgs(argv);
235
+ if (!args) {
236
+ print("Usage: jx convert <paths…> --to jxn|json [--dry-run] [--drop-comments]");
237
+ return 2;
238
+ }
239
+ const rows = await convertDocuments(args.root, args.paths, args);
240
+ for (const row of rows) {
241
+ if (row.problem !== null) {
242
+ print(`${row.from}: ${row.problem}`);
243
+ } else if (args.dryRun) {
244
+ print(`${row.from} would become ${row.to}`);
245
+ } else {
246
+ print(`${row.from} → ${row.to} (${row.references} file(s) repaired)`);
247
+ }
248
+ }
249
+ return rows.some((row) => row.problem !== null) ? 1 : 0;
250
+ }
251
+
252
+ if (import.meta.main) {
253
+ process.exitCode = await runConvert(process.argv.slice(2));
254
+ }
package/src/data-api.ts CHANGED
@@ -53,6 +53,7 @@ import type {
53
53
  SecretsSetResponse,
54
54
  } from "@jxsuite/protocol";
55
55
  import { problem, problemTypeForStatus } from "./problem.ts";
56
+ import { projectFilePath, readProjectFile } from "@jxsuite/schema/project-file/node";
56
57
 
57
58
  // ─── Local section shapes (structural — the connector owns the full types) ───
58
59
 
@@ -135,16 +136,17 @@ export function resetDataApi(): void {
135
136
 
136
137
  /** Read the project's config + connector-owned sections; 404 when there is no project.json. */
137
138
  function getProject(projectRoot: string): ProjectSections {
138
- const path = resolve(projectRoot, "project.json");
139
- let config: ProjectConfig;
140
- try {
141
- /* SAFETY: trust boundary. project.json is the project's own config, held to its per-project
142
- schema by the editor and `jx validate`; the connections and data sections are narrowed
143
- below, and unreadable or non-JSON text answers the 404 in the catch. */
144
- config = JSON.parse(readFileSync(path, "utf8")) as ProjectConfig;
145
- } catch (error) {
146
- throw new ApiError(404, `No project.json under "${projectRoot}"`, { cause: error });
139
+ const read = readProjectFile(projectRoot);
140
+ if (!read) {
141
+ throw new ApiError(404, `No project.json under "${projectRoot}"`);
142
+ }
143
+ if ("error" in read) {
144
+ throw new ApiError(
145
+ 404,
146
+ `The project.json under "${projectRoot}" does not parse: ${read.error}`,
147
+ );
147
148
  }
149
+ const { config } = read;
148
150
  /* Section keys are the connector's host wire contract (the same literals jx-mounts passes to
149
151
  the Data mount's options.sections) — JSON introspection, not a connector import.
150
152
  SAFETY: `connections` and `data` are the connector extension's sections, which its
@@ -158,7 +160,7 @@ function getProject(projectRoot: string): ProjectSections {
158
160
 
159
161
  /** Build (or reuse) the project's extension registry, keyed on project.json's mtime. */
160
162
  function getRegistry(projectRoot: string, config: ProjectConfig): Promise<ExtensionRegistry> {
161
- const projectJsonPath = resolve(projectRoot, "project.json");
163
+ const projectJsonPath = projectFilePath(projectRoot);
162
164
  const mtime = statSync(projectJsonPath).mtimeMs;
163
165
  const cached = registryCache.get(projectRoot);
164
166
  if (cached && cached.mtime === mtime) {
package/src/dev.ts CHANGED
@@ -22,6 +22,7 @@ import { problem } from "./problem.ts";
22
22
  import { callServerExport } from "./resolve.ts";
23
23
  import { createDevServer } from "./server.ts";
24
24
  import { injectSSE } from "./watch.ts";
25
+ import { hasProjectFile } from "@jxsuite/schema/project-file/node";
25
26
 
26
27
  export interface DevArgs {
27
28
  port: number;
@@ -187,7 +188,7 @@ export interface DevServerHandle {
187
188
  * @returns {Promise<DevServerHandle>} The listening server
188
189
  */
189
190
  export async function startDev({ port, root }: DevArgs): Promise<DevServerHandle> {
190
- const isSite = existsSync(join(root, "project.json"));
191
+ const isSite = hasProjectFile(root);
191
192
 
192
193
  let building = Promise.resolve();
193
194
  // What the last build collected, which the server-function route answers from.
@@ -31,6 +31,7 @@ import { isJsonObject, isString } from "@jxsuite/schema/guards";
31
31
  import { createNodeFormatIO, probeExtension } from "@jxsuite/compiler/format-host";
32
32
  import type { ExtensionCatalogEntry, ExtensionSectionInfo } from "@jxsuite/protocol";
33
33
  import type { JsonValue } from "@jxsuite/schema/types";
34
+ import { projectFilePath } from "@jxsuite/schema/project-file/node";
34
35
 
35
36
  interface PackageJson {
36
37
  dependencies?: Record<string, string>;
@@ -91,7 +92,7 @@ async function loadEntry(
91
92
  const registry = await buildExtensionRegistry(
92
93
  [name],
93
94
  createNodeFormatIO(projectRoot),
94
- resolve(projectRoot, "project.json"),
95
+ projectFilePath(projectRoot),
95
96
  );
96
97
  const [info] = registry.extensions;
97
98
  if (!info) {
package/src/import-api.ts CHANGED
@@ -30,7 +30,6 @@
30
30
  */
31
31
 
32
32
  import { readFile } from "node:fs/promises";
33
- import { resolve } from "node:path";
34
33
  import { problem } from "./problem.ts";
35
34
  import type { BreakpointPolicy, BreakpointRounding } from "@jxsuite/import/breakpoint-plan";
36
35
  import type { ImportProgressEvent } from "@jxsuite/import/pipeline";
@@ -38,6 +37,7 @@ import type { ImportSiteOptions, ImportSiteResult } from "@jxsuite/import/run";
38
37
  import { IMPORT_MAX_PAGES } from "@jxsuite/protocol";
39
38
  import type { ImportBreakpointPolicy } from "@jxsuite/protocol";
40
39
  import type { JsonObject } from "@jxsuite/schema/types";
40
+ import { projectFilePath } from "@jxsuite/schema/project-file/node";
41
41
 
42
42
  export interface ImportApiOptions {
43
43
  /**
@@ -301,7 +301,7 @@ async function handleImportSite(req: Request, opts: ImportApiOptions): Promise<R
301
301
  /* SAFETY: importSite's emit phase has just written project.json as a Jx project config,
302
302
  which is a JSON object; text that is not JSON throws into the error line below. */
303
303
  const config = JSON.parse(
304
- await readFile(resolve(result.outDir, "project.json"), "utf8"),
304
+ await readFile(projectFilePath(result.outDir), "utf8"),
305
305
  ) as JsonObject;
306
306
  /*
307
307
  * The result travels with the `done` line. It used to be discarded here: the pipeline had
package/src/jx-mounts.ts CHANGED
@@ -17,7 +17,6 @@
17
17
  */
18
18
 
19
19
  import { existsSync, readFileSync, statSync } from "node:fs";
20
- import { resolve } from "node:path";
21
20
  import { buildProjectExtensionRegistry } from "@jxsuite/compiler/format-host";
22
21
  import { isString } from "@jxsuite/schema/guards";
23
22
  import { projectDevEnv } from "./dev-vars.ts";
@@ -25,6 +24,7 @@ import type { ProjectDevEnv } from "./dev-vars.ts";
25
24
  import type { ExtensionRegistry } from "@jxsuite/schema/extension-registry";
26
25
  import type { ImplementationExport, MountHandler } from "@jxsuite/schema/format-registry";
27
26
  import type { JxDocumentValue, ProjectConfig } from "@jxsuite/schema/types";
27
+ import { projectFilePath } from "@jxsuite/schema/project-file/node";
28
28
 
29
29
  interface MountRuntime {
30
30
  handlers: { basePath: string; handler: MountHandler }[];
@@ -81,7 +81,7 @@ export async function handleJxMounts(
81
81
 
82
82
  /** Load (or reuse) the mount runtime for a project root. */
83
83
  function getRuntime(projectRoot: string): Promise<MountRuntime | null> {
84
- const projectJsonPath = resolve(projectRoot, "project.json");
84
+ const projectJsonPath = projectFilePath(projectRoot);
85
85
  if (!existsSync(projectJsonPath)) {
86
86
  return Promise.resolve(null);
87
87
  }
@@ -10,7 +10,10 @@
10
10
  import { extname } from "node:path";
11
11
  import { readFile } from "node:fs/promises";
12
12
  import type { FormatRegistry, ValueEdit } from "@jxsuite/schema/format-registry";
13
- import type { JsonValue } from "@jxsuite/schema/types";
13
+ import { isJsonObject } from "@jxsuite/schema/guards";
14
+ import { rewriteJson } from "@jxsuite/schema/json-layout";
15
+ import { isJxnPath, parseJxn, serializeJxn } from "@jxsuite/schema/jxn";
16
+ import type { JsonValue, JxDocument } from "@jxsuite/schema/types";
14
17
 
15
18
  /** Normalise a path to forward slashes (Windows `path` returns backslashes). */
16
19
  export const fwd = (p: string) => p.replaceAll("\\", "/");
@@ -77,13 +80,23 @@ export function documentGlob(registry: FormatRegistry): string {
77
80
  export async function loadDoc(fp: string, registry: FormatRegistry): Promise<LoadedDoc> {
78
81
  const raw = await readFile(fp, "utf8");
79
82
  if (isJsonPath(fp)) {
80
- const trailingNewline = raw.endsWith("\n");
81
83
  return {
82
84
  // SAFETY: JSON.parse without a reviver only ever yields a JSON value; its `any` says no more.
83
85
  doc: JSON.parse(raw) as JsonValue,
84
86
  rewrite: null,
85
- serialize: (doc) =>
86
- Promise.resolve(JSON.stringify(doc, null, 2) + (trailingNewline ? "\n" : "")),
87
+ // Over the text it replaces, so a rename changes the references and not the formatting.
88
+ serialize: (doc) => Promise.resolve(rewriteJson(doc, raw)),
89
+ };
90
+ }
91
+ if (isJxnPath(fp)) {
92
+ /* The one format whose writer can be told what it is replacing, so repairing a reference in a
93
+ `.jxn` file changes that reference and leaves its comments where they were. */
94
+ const document = parseJxn(raw);
95
+ return {
96
+ doc: isJsonObject(document) ? document : null,
97
+ rewrite: null,
98
+ // SAFETY: the document this just read, with reference values replaced by reference values.
99
+ serialize: (doc) => Promise.resolve(serializeJxn(doc as JxDocument, raw)),
87
100
  };
88
101
  }
89
102
  const ext = extname(fp);
package/src/resolve.ts CHANGED
@@ -25,6 +25,7 @@ import type {
25
25
  } from "@jxsuite/schema/types";
26
26
  import type { ProjectDevEnv } from "./dev-vars.ts";
27
27
  import { problem } from "./problem.ts";
28
+ import { projectFilePath } from "@jxsuite/schema/project-file/node";
28
29
 
29
30
  /**
30
31
  * Any value a module exports, as the proxies see it before they check it. Nothing about an export
@@ -255,7 +256,7 @@ async function completeSections(context: ProjectContext): Promise<void> {
255
256
 
256
257
  /** Load (or reuse) the cached project config, extension registry and asset mounts. */
257
258
  async function loadProjectEntry(projectRoot: string): Promise<ProjectEntry | null> {
258
- const projectJsonPath = resolve(projectRoot, "project.json");
259
+ const projectJsonPath = projectFilePath(projectRoot);
259
260
  const mtime = statSync(projectJsonPath, { throwIfNoEntry: false })?.mtimeMs;
260
261
  if (mtime === undefined) {
261
262
  return null;
package/src/server.ts CHANGED
@@ -169,7 +169,8 @@ export async function createDevServer(options: {
169
169
  const NO_CACHE = { "Cache-Control": "no-cache" };
170
170
  /*
171
171
  * `Bun.file` already infers a type from the extension, and for almost everything it is right. The
172
- * exceptions are the two RFCs Jx has an opinion about: `.md` comes back as bare `text/markdown`
172
+ * exceptions are the two RFCs Jx has an opinion about, and `.jxn`, which Bun has never heard of:
173
+ * `.md` comes back as bare `text/markdown`
173
174
  * with no `variant`, and `.yaml` comes back as `text/yaml`, the spelling RFC 9512 §5 asks
174
175
  * implementations to stop using. `mediaTypeForPath` answers null everywhere else, so the
175
176
  * inference stays in charge of the ordinary cases.
@@ -33,6 +33,7 @@ import { isJsonObject, isString } from "@jxsuite/schema/guards";
33
33
  import type { JsonValue } from "@jxsuite/schema/types";
34
34
 
35
35
  import { serveContained } from "./net-guard.ts";
36
+ import { projectFilePath } from "@jxsuite/schema/project-file/node";
36
37
 
37
38
  /** What `project.json` says the build writes to, cached per root against its mtime. */
38
39
  const outDirCache = new Map<string, { mtime: number; outDir: string | null }>();
@@ -51,7 +52,7 @@ export function resetSiteOutput(): void {
51
52
  * file read.
52
53
  */
53
54
  export function siteOutDir(projectRoot: string): string | null {
54
- const configPath = resolve(projectRoot, "project.json");
55
+ const configPath = projectFilePath(projectRoot);
55
56
  if (!existsSync(configPath)) {
56
57
  return null;
57
58
  }
package/src/studio-api.ts CHANGED
@@ -48,6 +48,12 @@ import type { JsonObject, JsonPrimitive, JsonValue, ProjectConfig } from "@jxsui
48
48
  import { isFunction, isJsonObject, isNonNullObject, isString } from "@jxsuite/schema/guards";
49
49
  import { problem } from "./problem.ts";
50
50
  import { scanFiles } from "./fs-glob.ts";
51
+ import { PROJECT_FILE_NAME, classifyProjectFile } from "@jxsuite/schema/project-file";
52
+ import {
53
+ findEnclosingProjectOnDisk,
54
+ hasProjectFile,
55
+ projectFilePath,
56
+ } from "@jxsuite/schema/project-file/node";
51
57
 
52
58
  /** Normalise a path to forward slashes (Windows `path` module returns backslashes). */
53
59
  const fwd = (p: string) => p.replaceAll("\\", "/");
@@ -115,7 +121,7 @@ const extensionRegistryCache = new Map<
115
121
  * @returns {Promise<ExtensionRegistry>}
116
122
  */
117
123
  async function getExtensionRegistry(projectRoot: string): Promise<ExtensionRegistry> {
118
- const projectJsonPath = resolve(projectRoot, "project.json");
124
+ const projectJsonPath = projectFilePath(projectRoot);
119
125
  let mtime = 0;
120
126
  let projectConfig: ProjectConfig | undefined;
121
127
  try {
@@ -211,7 +217,7 @@ export function isOwnedProjectDir(dir: string): boolean {
211
217
  if (containedPath(dir, resolve(home)) === null) {
212
218
  return false;
213
219
  }
214
- return existsSync(resolve(dir, "project.json"));
220
+ return hasProjectFile(dir);
215
221
  }
216
222
 
217
223
  /**
@@ -510,11 +516,10 @@ export async function handleStudioApi(
510
516
  let isSiteProject = false;
511
517
  let projectConfig: ProjectConfig | null = null;
512
518
  try {
519
+ const text = await readFile(projectFilePath(absDir), "utf8");
513
520
  // SAFETY: JSON.parse without a reviver only ever produces JSON values.
514
- const raw = JSON.parse(
515
- await readFile(resolve(absDir, "project.json"), "utf8"),
516
- ) as JsonValue;
517
- if (isJsonObject(raw)) {
521
+ const raw = JSON.parse(text) as JsonValue;
522
+ if (classifyProjectFile(text) === "project" && isJsonObject(raw)) {
518
523
  isSiteProject = true;
519
524
  /* SAFETY: `raw` is a JSON object and every ProjectConfig member is optional; the config
520
525
  is only relayed to the client here, never interpreted. */
@@ -551,30 +556,22 @@ export async function handleStudioApi(
551
556
  } catch {}
552
557
  if (isDir) {
553
558
  dir = absFile;
554
- absFile = resolve(absFile, "project.json");
559
+ absFile = projectFilePath(absFile);
555
560
  } else {
556
561
  dir = dirname(absFile);
557
562
  }
558
- while (dir) {
559
- const candidate = resolve(dir, "project.json");
560
- if (existsSync(candidate)) {
561
- /* SAFETY: the config is relayed to the client verbatim and not interpreted here, so its
562
- member types are the client's to check; malformed JSON throws into the catch below. */
563
- const config = JSON.parse(readFileSync(candidate, "utf8")) as ProjectConfig;
564
- const relPath = fwd(dir);
565
- const fileRelPath = fwd(relative(dir, absFile));
566
- return Response.json({
567
- fileRelPath,
568
- projectConfig: config,
569
- relPath,
570
- sitePath: dir,
571
- });
572
- }
573
- const parent = dirname(dir);
574
- if (parent === dir) {
575
- break;
576
- }
577
- dir = parent;
563
+ // A page named project.json is not a project, and neither is Nx's file of that name.
564
+ const found = findEnclosingProjectOnDisk(dir);
565
+ if (found) {
566
+ /* SAFETY: the config is relayed to the client verbatim and not interpreted here, so its
567
+ member types are the client's to check; malformed JSON throws into the catch below. */
568
+ const config = JSON.parse(readFileSync(found.projectFile, "utf8")) as ProjectConfig;
569
+ return Response.json({
570
+ fileRelPath: fwd(relative(found.root, absFile)),
571
+ projectConfig: config,
572
+ relPath: fwd(found.root),
573
+ sitePath: found.root,
574
+ });
578
575
  }
579
576
  return Response.json({ sitePath: null });
580
577
  } catch (error) {
@@ -586,7 +583,7 @@ export async function handleStudioApi(
586
583
  if (path === "/__studio/sites" && req.method === "GET") {
587
584
  try {
588
585
  const sites = [];
589
- for await (const match of scanFiles("**/project.json", root)) {
586
+ for await (const match of scanFiles(`**/${PROJECT_FILE_NAME}`, root)) {
590
587
  if (
591
588
  match.includes("node_modules") ||
592
589
  fwd(match).includes("dist/") ||
@@ -596,9 +593,10 @@ export async function handleStudioApi(
596
593
  }
597
594
  const fp = resolve(root, match);
598
595
  try {
596
+ const text = await readFile(fp, "utf8");
599
597
  // SAFETY: JSON.parse without a reviver only ever produces JSON values.
600
- const raw = JSON.parse(await readFile(fp, "utf8")) as JsonValue;
601
- if (isJsonObject(raw)) {
598
+ const raw = JSON.parse(text) as JsonValue;
599
+ if (classifyProjectFile(text) === "project" && isJsonObject(raw)) {
602
600
  const projectDir = fwd(dirname(fp));
603
601
  /* SAFETY: `raw` is a JSON object and every ProjectConfig member is optional; the site
604
602
  list only relays the config to the client. */
@@ -694,9 +692,7 @@ export async function handleStudioApi(
694
692
 
695
693
  /* SAFETY: generateProject just wrote this project.json from its own template, so it is a
696
694
  project config; it is relayed to the client, not interpreted here. */
697
- const config = JSON.parse(
698
- await readFile(resolve(destPath, "project.json"), "utf8"),
699
- ) as ProjectConfig;
695
+ const config = JSON.parse(await readFile(projectFilePath(destPath), "utf8")) as ProjectConfig;
700
696
  // Root-relative while the project happens to sit under the server root (the historical
701
697
  // Shape every caller already handles); absolute otherwise.
702
698
  const inRoot = containedPath(destPath, root) !== null;
@@ -939,7 +935,7 @@ export async function handleStudioApi(
939
935
  */
940
936
  if (path === "/__studio/build" && req.method === "POST") {
941
937
  const dir = activeProjectRoot ?? root;
942
- if (!existsSync(resolve(dir, "project.json"))) {
938
+ if (!hasProjectFile(dir)) {
943
939
  return problem("invalidRequest", "Not a site project");
944
940
  }
945
941
  try {
@@ -971,7 +967,7 @@ export async function handleStudioApi(
971
967
  */
972
968
  if (path === "/__studio/preview" && req.method === "POST") {
973
969
  const dir = activeProjectRoot ?? root;
974
- if (!existsSync(resolve(dir, "project.json"))) {
970
+ if (!hasProjectFile(dir)) {
975
971
  return problem("invalidRequest", "Not a site project");
976
972
  }
977
973
  let body: { route?: string };