@jxsuite/server 8.3.0 → 8.5.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 +16 -15
- package/src/convert.ts +300 -0
- package/src/data-api.ts +24 -18
- package/src/dev.ts +2 -1
- package/src/extension-catalog.ts +2 -1
- package/src/import-api.ts +8 -8
- package/src/jx-mounts.ts +18 -15
- package/src/refactor/scan.ts +37 -5
- package/src/resolve.ts +18 -14
- package/src/server.ts +13 -2
- package/src/site-output.ts +11 -15
- package/src/studio-api.ts +76 -66
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jxsuite/server",
|
|
3
|
-
"version": "8.
|
|
3
|
+
"version": "8.5.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.
|
|
58
|
-
"@jxsuite/catalog": "^0.1.
|
|
59
|
-
"@jxsuite/collab": "^0.11.
|
|
60
|
-
"@jxsuite/compiler": "^8.
|
|
61
|
-
"@jxsuite/create": "^1.
|
|
62
|
-
"@jxsuite/import": "^0.
|
|
63
|
-
"@jxsuite/protocol": "^6.2.
|
|
64
|
-
"@jxsuite/runtime": "^6.1
|
|
65
|
-
"@jxsuite/schema": "^5.
|
|
66
|
-
"@jxsuite/site": "^2.
|
|
67
|
-
"@jxsuite/starters": "^3.3.
|
|
58
|
+
"@jxsuite/ai": "^0.43.2",
|
|
59
|
+
"@jxsuite/catalog": "^0.1.10",
|
|
60
|
+
"@jxsuite/collab": "^0.11.8",
|
|
61
|
+
"@jxsuite/compiler": "^8.4.0",
|
|
62
|
+
"@jxsuite/create": "^1.6.0",
|
|
63
|
+
"@jxsuite/import": "^0.43.0",
|
|
64
|
+
"@jxsuite/protocol": "^6.2.4",
|
|
65
|
+
"@jxsuite/runtime": "^6.2.1",
|
|
66
|
+
"@jxsuite/schema": "^5.4.0",
|
|
67
|
+
"@jxsuite/site": "^2.5.0",
|
|
68
|
+
"@jxsuite/starters": "^3.3.2",
|
|
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.
|
|
78
|
-
"@jxsuite/connector": "^0.8.
|
|
79
|
-
"@jxsuite/parser": "^4.3.
|
|
78
|
+
"@jxsuite/auth": "^0.8.6",
|
|
79
|
+
"@jxsuite/connector": "^0.8.6",
|
|
80
|
+
"@jxsuite/parser": "^4.3.2",
|
|
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,300 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `jx convert`: move a document between `.json` and `.jxn`, with every reference repaired, and the
|
|
3
|
+
* project file between `project.json` and `project.jxn` (site-architecture.md §2.3).
|
|
4
|
+
*
|
|
5
|
+
* It is a rename, because that is what makes it safe: writing a new file beside the old one would
|
|
6
|
+
* leave every `$ref` to it dangling, and the refactor pass is what repairs them. A project file is
|
|
7
|
+
* named by nothing, so it needs no repair, and writing it beside the old one would leave a folder
|
|
8
|
+
* holding both, which every host refuses. This runs under Bun for the same reason `jx dev` does, so
|
|
9
|
+
* `@jxsuite/compiler`'s CLI resolves it from the project and spawns it.
|
|
10
|
+
*
|
|
11
|
+
* @docs framework/build/cli
|
|
12
|
+
*/
|
|
13
|
+
import { existsSync, readdirSync, statSync } from "node:fs";
|
|
14
|
+
import { readFile, rename, writeFile } from "node:fs/promises";
|
|
15
|
+
import { basename, dirname, extname, join, relative, resolve } from "node:path";
|
|
16
|
+
import { buildProjectExtensionRegistry } from "@jxsuite/compiler/format-host";
|
|
17
|
+
import { loadProjectConfig } from "@jxsuite/compiler/site-loader";
|
|
18
|
+
import { countJxnComments, parseJxn, serializeJxn } from "@jxsuite/schema/jxn";
|
|
19
|
+
import { isJsonObject, isString } from "@jxsuite/schema/guards";
|
|
20
|
+
import { errorMessage, parseJxDocument } from "@jxsuite/schema/parse";
|
|
21
|
+
import {
|
|
22
|
+
isProjectFileName,
|
|
23
|
+
parseProjectSource,
|
|
24
|
+
serializeProjectFile,
|
|
25
|
+
} from "@jxsuite/schema/project-file";
|
|
26
|
+
import type { ProjectConfig } from "@jxsuite/schema/types";
|
|
27
|
+
import { applyRename } from "./refactor/index.ts";
|
|
28
|
+
|
|
29
|
+
/** The formats a document can move between. */
|
|
30
|
+
export type ConvertTarget = "json" | "jxn";
|
|
31
|
+
|
|
32
|
+
/** What was asked. */
|
|
33
|
+
export interface ConvertOptions {
|
|
34
|
+
/** Convert a `.jxn` file that has comments, which `.json` has nowhere to keep. */
|
|
35
|
+
dropComments: boolean;
|
|
36
|
+
/** Say what would happen, and change nothing. */
|
|
37
|
+
dryRun: boolean;
|
|
38
|
+
to: ConvertTarget;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** What happened to one file. */
|
|
42
|
+
export interface ConvertRow {
|
|
43
|
+
from: string;
|
|
44
|
+
/** Other files whose references to this one were repaired. */
|
|
45
|
+
references: number;
|
|
46
|
+
/** Why it was left alone, or what is still wrong after moving it. */
|
|
47
|
+
problem: string | null;
|
|
48
|
+
to: string;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** The directories that hold documents, which are conventions the build hard-codes. */
|
|
52
|
+
const DOCUMENT_ROOTS = ["components/", "layouts/", "pages/"];
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Where each content collection's files are, project-relative: the part of its `source` before any
|
|
56
|
+
* `{locale}` or glob. Only a collection reaching into a document root can matter here.
|
|
57
|
+
*/
|
|
58
|
+
function collectionRoots(config: ProjectConfig): string[] {
|
|
59
|
+
const content = isJsonObject(config.content) ? Object.values(config.content) : [];
|
|
60
|
+
return content.flatMap((definition) => {
|
|
61
|
+
const source = isJsonObject(definition) ? definition.source : null;
|
|
62
|
+
if (!isString(source)) {
|
|
63
|
+
return [];
|
|
64
|
+
}
|
|
65
|
+
const [fixed = ""] = /^[^{*]*/u.exec(source) ?? [];
|
|
66
|
+
return [fixed.replace(/^\.\//u, "").replace(/\/+$/u, "")];
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Every convertible file a path names: itself, or what is under it. */
|
|
71
|
+
function expand(path: string): string[] {
|
|
72
|
+
// A path that is not there comes through as itself, so `refusal` can say so.
|
|
73
|
+
if (!existsSync(path) || !statSync(path).isDirectory()) {
|
|
74
|
+
return [path];
|
|
75
|
+
}
|
|
76
|
+
return readdirSync(path, { withFileTypes: true }).flatMap((entry) => {
|
|
77
|
+
const child = join(path, entry.name);
|
|
78
|
+
if (entry.isDirectory()) {
|
|
79
|
+
return expand(child);
|
|
80
|
+
}
|
|
81
|
+
return /\.(?:json|jxn)$/iu.test(entry.name) && !entry.name.endsWith(".class.json")
|
|
82
|
+
? [child]
|
|
83
|
+
: [];
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Whether a path is the project's own file, which is the one root file that converts. */
|
|
88
|
+
function isProjectFileAt(root: string, path: string): boolean {
|
|
89
|
+
return isProjectFileName(relative(root, path));
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Why the project file cannot be converted, or null when it can. The other name already being there
|
|
94
|
+
* needs no check here: the folder then holds both, and loading the project has refused it.
|
|
95
|
+
*/
|
|
96
|
+
async function projectFileRefusal(path: string, options: ConvertOptions): Promise<string | null> {
|
|
97
|
+
const ext = extname(path).toLowerCase();
|
|
98
|
+
if (ext === `.${options.to}`) {
|
|
99
|
+
return `already .${options.to}`;
|
|
100
|
+
}
|
|
101
|
+
return ext === ".jxn" ? commentRefusal(path, options) : null;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** Why a `.jxn` file's comments keep it from becoming `.json`, or null when nothing would be lost. */
|
|
105
|
+
async function commentRefusal(path: string, options: ConvertOptions): Promise<string | null> {
|
|
106
|
+
if (options.dropComments) {
|
|
107
|
+
return null;
|
|
108
|
+
}
|
|
109
|
+
const comments = countJxnComments(await readFile(path, "utf8"));
|
|
110
|
+
return comments > 0
|
|
111
|
+
? `it has ${comments} comment(s), and .json has nowhere to keep them. --drop-comments converts it anyway`
|
|
112
|
+
: null;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** Why a file cannot be converted, or null when it can. */
|
|
116
|
+
async function refusal(
|
|
117
|
+
root: string,
|
|
118
|
+
path: string,
|
|
119
|
+
options: ConvertOptions,
|
|
120
|
+
sources: readonly string[],
|
|
121
|
+
): Promise<string | null> {
|
|
122
|
+
if (!existsSync(path)) {
|
|
123
|
+
return "there is no such file";
|
|
124
|
+
}
|
|
125
|
+
if (isProjectFileAt(root, path)) {
|
|
126
|
+
return projectFileRefusal(path, options);
|
|
127
|
+
}
|
|
128
|
+
const name = relative(root, path).replaceAll("\\", "/");
|
|
129
|
+
if (!DOCUMENT_ROOTS.some((prefix) => name.startsWith(prefix))) {
|
|
130
|
+
return `only ${DOCUMENT_ROOTS.join(", ")} hold documents`;
|
|
131
|
+
}
|
|
132
|
+
const ext = extname(path).toLowerCase();
|
|
133
|
+
if (ext !== ".json" && ext !== ".jxn") {
|
|
134
|
+
return `${ext || "this file"} is not a native document format`;
|
|
135
|
+
}
|
|
136
|
+
if (path.endsWith(".class.json")) {
|
|
137
|
+
return "a class descriptor stays JSON";
|
|
138
|
+
}
|
|
139
|
+
if (ext === `.${options.to}`) {
|
|
140
|
+
return `already .${options.to}`;
|
|
141
|
+
}
|
|
142
|
+
if (sources.some((source) => name === source || name.startsWith(`${source}/`))) {
|
|
143
|
+
// Both directions: an entry would drop out of its glob, and a neighbour would be enlisted.
|
|
144
|
+
return "a content collection's file keeps its collection's format";
|
|
145
|
+
}
|
|
146
|
+
if (existsSync(targetOf(path, options.to))) {
|
|
147
|
+
return `${basename(targetOf(path, options.to))} already exists`;
|
|
148
|
+
}
|
|
149
|
+
return ext === ".jxn" ? commentRefusal(path, options) : null;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** The path a file has once converted. */
|
|
153
|
+
function targetOf(path: string, to: ConvertTarget): string {
|
|
154
|
+
return join(dirname(path), `${basename(path, extname(path))}.${to}`);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** The file's text in the other format. */
|
|
158
|
+
async function converted(root: string, path: string, to: ConvertTarget): Promise<string> {
|
|
159
|
+
const text = await readFile(path, "utf8");
|
|
160
|
+
if (isProjectFileAt(root, path)) {
|
|
161
|
+
// Canonical form: the comments a `.jxn` had are what `--drop-comments` agreed to lose.
|
|
162
|
+
return serializeProjectFile(parseProjectSource(text, path), null, targetOf(path, to));
|
|
163
|
+
}
|
|
164
|
+
return to === "jxn"
|
|
165
|
+
? serializeJxn(parseJxDocument(text, path))
|
|
166
|
+
: `${JSON.stringify(parseJxn(text, path), null, 2)}\n`;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Convert documents in a project.
|
|
171
|
+
*
|
|
172
|
+
* @param root The project root.
|
|
173
|
+
* @param paths Files and directories, relative to the root.
|
|
174
|
+
* @param options What was asked.
|
|
175
|
+
*/
|
|
176
|
+
export async function convertDocuments(
|
|
177
|
+
root: string,
|
|
178
|
+
paths: readonly string[],
|
|
179
|
+
options: ConvertOptions,
|
|
180
|
+
): Promise<ConvertRow[]> {
|
|
181
|
+
const { config } = loadProjectConfig(root);
|
|
182
|
+
const sources = collectionRoots(config);
|
|
183
|
+
const { formats } = await buildProjectExtensionRegistry(root, config);
|
|
184
|
+
const rows: ConvertRow[] = [];
|
|
185
|
+
for (const path of paths.flatMap((item) => expand(resolve(root, item)))) {
|
|
186
|
+
const from = relative(root, path);
|
|
187
|
+
const target = targetOf(path, options.to);
|
|
188
|
+
const row: ConvertRow = { from, references: 0, problem: null, to: relative(root, target) };
|
|
189
|
+
rows.push(row);
|
|
190
|
+
row.problem = await refusal(root, path, options, sources);
|
|
191
|
+
if (row.problem !== null) {
|
|
192
|
+
continue;
|
|
193
|
+
}
|
|
194
|
+
try {
|
|
195
|
+
const text = await converted(root, path, options.to);
|
|
196
|
+
if (options.dryRun) {
|
|
197
|
+
continue;
|
|
198
|
+
}
|
|
199
|
+
/* In place and then renamed, so a failure writing leaves the old name holding the old text or
|
|
200
|
+
the new, and nothing beside it to be mistaken for the document. */
|
|
201
|
+
await writeFile(path, text);
|
|
202
|
+
await rename(path, target);
|
|
203
|
+
if (isProjectFileAt(root, path)) {
|
|
204
|
+
continue;
|
|
205
|
+
}
|
|
206
|
+
const report = await applyRename({ absFrom: path, absTo: target, registry: formats, root });
|
|
207
|
+
row.references = report.references.filesChanged;
|
|
208
|
+
if (report.errors.length > 0) {
|
|
209
|
+
const unread = report.errors.map((item) => item.path).join(", ");
|
|
210
|
+
row.problem = `converted, and ${unread} could not be read, so a reference in it may still name the old path`;
|
|
211
|
+
}
|
|
212
|
+
} catch (error) {
|
|
213
|
+
row.problem = errorMessage(error);
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
return rows;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/** What `jx convert` was given. */
|
|
220
|
+
export interface ConvertArgs extends ConvertOptions {
|
|
221
|
+
paths: string[];
|
|
222
|
+
root: string;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* Read `jx convert`'s arguments.
|
|
227
|
+
*
|
|
228
|
+
* @param argv The arguments after the command.
|
|
229
|
+
* @returns What was asked, or null when it does not say.
|
|
230
|
+
*/
|
|
231
|
+
export function parseConvertArgs(argv: readonly string[]): ConvertArgs | null {
|
|
232
|
+
const paths: string[] = [];
|
|
233
|
+
let to: string | undefined;
|
|
234
|
+
let root = process.cwd();
|
|
235
|
+
const flags = new Set<string>();
|
|
236
|
+
for (let index = 0; index < argv.length; index += 1) {
|
|
237
|
+
const arg = argv[index] ?? "";
|
|
238
|
+
if (arg === "--to") {
|
|
239
|
+
index += 1;
|
|
240
|
+
to = argv[index];
|
|
241
|
+
} else if (arg === "--root") {
|
|
242
|
+
index += 1;
|
|
243
|
+
root = resolve(argv[index] ?? root);
|
|
244
|
+
} else if (arg.startsWith("--")) {
|
|
245
|
+
flags.add(arg);
|
|
246
|
+
} else {
|
|
247
|
+
paths.push(arg);
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
if ((to !== "json" && to !== "jxn") || paths.length === 0) {
|
|
251
|
+
return null;
|
|
252
|
+
}
|
|
253
|
+
return {
|
|
254
|
+
dropComments: flags.has("--drop-comments"),
|
|
255
|
+
dryRun: flags.has("--dry-run"),
|
|
256
|
+
paths,
|
|
257
|
+
root,
|
|
258
|
+
to,
|
|
259
|
+
};
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/**
|
|
263
|
+
* Run `jx convert`.
|
|
264
|
+
*
|
|
265
|
+
* @param argv The arguments after the command.
|
|
266
|
+
* @param print Where the report goes.
|
|
267
|
+
* @returns The exit code: 1 when anything was left unconverted.
|
|
268
|
+
*/
|
|
269
|
+
export async function runConvert(
|
|
270
|
+
argv: readonly string[],
|
|
271
|
+
print: (line: string) => void = console.log,
|
|
272
|
+
): Promise<number> {
|
|
273
|
+
const args = parseConvertArgs(argv);
|
|
274
|
+
if (!args) {
|
|
275
|
+
print("Usage: jx convert <paths…> --to jxn|json [--dry-run] [--drop-comments]");
|
|
276
|
+
return 2;
|
|
277
|
+
}
|
|
278
|
+
let rows: ConvertRow[];
|
|
279
|
+
try {
|
|
280
|
+
rows = await convertDocuments(args.root, args.paths, args);
|
|
281
|
+
} catch (error) {
|
|
282
|
+
// The project itself does not load, so there is nothing to convert in it.
|
|
283
|
+
print(errorMessage(error));
|
|
284
|
+
return 1;
|
|
285
|
+
}
|
|
286
|
+
for (const row of rows) {
|
|
287
|
+
if (row.problem !== null) {
|
|
288
|
+
print(`${row.from}: ${row.problem}`);
|
|
289
|
+
} else if (args.dryRun) {
|
|
290
|
+
print(`${row.from} would become ${row.to}`);
|
|
291
|
+
} else {
|
|
292
|
+
print(`${row.from} → ${row.to} (${row.references} file(s) repaired)`);
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
return rows.some((row) => row.problem !== null) ? 1 : 0;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
if (import.meta.main) {
|
|
299
|
+
process.exitCode = await runConvert(process.argv.slice(2));
|
|
300
|
+
}
|
package/src/data-api.ts
CHANGED
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
* (kind "auth") after the connector plan inside {@link pushDataSchema}.
|
|
17
17
|
*/
|
|
18
18
|
|
|
19
|
-
import { existsSync, readFileSync
|
|
19
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
20
20
|
import { isAbsolute, relative, resolve } from "node:path";
|
|
21
21
|
import { Kysely, sql } from "kysely";
|
|
22
22
|
import { buildProjectExtensionRegistry } from "@jxsuite/compiler/format-host";
|
|
@@ -53,6 +53,7 @@ import type {
|
|
|
53
53
|
SecretsSetResponse,
|
|
54
54
|
} from "@jxsuite/protocol";
|
|
55
55
|
import { problem, problemTypeForStatus } from "./problem.ts";
|
|
56
|
+
import { isSameStamp, projectFileStamp, readProjectFile } from "@jxsuite/schema/project-file/node";
|
|
56
57
|
|
|
57
58
|
// ─── Local section shapes (structural — the connector owns the full types) ───
|
|
58
59
|
|
|
@@ -125,26 +126,30 @@ class ApiError extends Error {
|
|
|
125
126
|
|
|
126
127
|
// ─── Project + registry resolution ───────────────────────────────────────────
|
|
127
128
|
|
|
128
|
-
/** Per-project registry cache, invalidated when project
|
|
129
|
-
const registryCache = new Map<
|
|
129
|
+
/** Per-project registry cache, invalidated when the project file moves or changes. */
|
|
130
|
+
const registryCache = new Map<
|
|
131
|
+
string,
|
|
132
|
+
{ mtime: number; path: string; registry: Promise<ExtensionRegistry> }
|
|
133
|
+
>();
|
|
130
134
|
|
|
131
135
|
/** Reset the registry cache (test hook). */
|
|
132
136
|
export function resetDataApi(): void {
|
|
133
137
|
registryCache.clear();
|
|
134
138
|
}
|
|
135
139
|
|
|
136
|
-
/** Read the project's config + connector-owned sections; 404 when there is no project.
|
|
140
|
+
/** Read the project's config + connector-owned sections; 404 when there is no project file. */
|
|
137
141
|
function getProject(projectRoot: string): ProjectSections {
|
|
138
|
-
const
|
|
139
|
-
|
|
140
|
-
|
|
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 });
|
|
142
|
+
const read = readProjectFile(projectRoot);
|
|
143
|
+
if (!read) {
|
|
144
|
+
throw new ApiError(404, `No project file under "${projectRoot}"`);
|
|
147
145
|
}
|
|
146
|
+
if ("error" in read) {
|
|
147
|
+
throw new ApiError(
|
|
148
|
+
404,
|
|
149
|
+
`The project file under "${projectRoot}" does not parse: ${read.error}`,
|
|
150
|
+
);
|
|
151
|
+
}
|
|
152
|
+
const { config } = read;
|
|
148
153
|
/* Section keys are the connector's host wire contract (the same literals jx-mounts passes to
|
|
149
154
|
the Data mount's options.sections) — JSON introspection, not a connector import.
|
|
150
155
|
SAFETY: `connections` and `data` are the connector extension's sections, which its
|
|
@@ -156,16 +161,17 @@ function getProject(projectRoot: string): ProjectSections {
|
|
|
156
161
|
return { config, connections, tables };
|
|
157
162
|
}
|
|
158
163
|
|
|
159
|
-
/** Build (or reuse) the project's extension registry, keyed on project
|
|
164
|
+
/** Build (or reuse) the project's extension registry, keyed on the project file's stamp. */
|
|
160
165
|
function getRegistry(projectRoot: string, config: ProjectConfig): Promise<ExtensionRegistry> {
|
|
161
|
-
const
|
|
162
|
-
const mtime = statSync(projectJsonPath).mtimeMs;
|
|
166
|
+
const stamp = projectFileStamp(projectRoot);
|
|
163
167
|
const cached = registryCache.get(projectRoot);
|
|
164
|
-
if (cached && cached
|
|
168
|
+
if (stamp && cached && isSameStamp(cached, stamp)) {
|
|
165
169
|
return cached.registry;
|
|
166
170
|
}
|
|
167
171
|
const registry = buildProjectExtensionRegistry(projectRoot, config);
|
|
168
|
-
|
|
172
|
+
if (stamp) {
|
|
173
|
+
registryCache.set(projectRoot, { ...stamp, registry });
|
|
174
|
+
}
|
|
169
175
|
return registry;
|
|
170
176
|
}
|
|
171
177
|
|
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 =
|
|
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.
|
package/src/extension-catalog.ts
CHANGED
|
@@ -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
|
-
|
|
95
|
+
projectFilePath(projectRoot),
|
|
95
96
|
);
|
|
96
97
|
const [info] = registry.extensions;
|
|
97
98
|
if (!info) {
|
package/src/import-api.ts
CHANGED
|
@@ -30,14 +30,15 @@
|
|
|
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";
|
|
37
36
|
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
|
-
import type {
|
|
39
|
+
import type { ProjectConfig } from "@jxsuite/schema/types";
|
|
40
|
+
import { parseProjectSource } from "@jxsuite/schema/project-file";
|
|
41
|
+
import { projectFilePath } from "@jxsuite/schema/project-file/node";
|
|
41
42
|
|
|
42
43
|
export interface ImportApiOptions {
|
|
43
44
|
/**
|
|
@@ -86,7 +87,7 @@ type ImportStreamLine =
|
|
|
86
87
|
| { type: "heartbeat" }
|
|
87
88
|
| { type: "ready"; root: string }
|
|
88
89
|
| ({ type: "progress" } & Omit<ImportProgressEvent, "root">)
|
|
89
|
-
| { type: "done"; root: string; config:
|
|
90
|
+
| { type: "done"; root: string; config: ProjectConfig; result: ImportDoneSummary }
|
|
90
91
|
| { type: "error"; error: string };
|
|
91
92
|
|
|
92
93
|
/** The AI pass's credentials, resolved from the request headers or the environment. */
|
|
@@ -298,11 +299,10 @@ async function handleImportSite(req: Request, opts: ImportApiOptions): Promise<R
|
|
|
298
299
|
writeLine({ type: "progress", ...progress });
|
|
299
300
|
});
|
|
300
301
|
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
const
|
|
304
|
-
|
|
305
|
-
) as JsonObject;
|
|
302
|
+
// The import's emit phase has just written it; text that does not read throws into the
|
|
303
|
+
// Error line below.
|
|
304
|
+
const configPath = projectFilePath(result.outDir);
|
|
305
|
+
const config = parseProjectSource(await readFile(configPath, "utf8"), configPath);
|
|
306
306
|
/*
|
|
307
307
|
* The result travels with the `done` line. It used to be discarded here: the pipeline had
|
|
308
308
|
* computed the page list, the file count, the warnings and (with verify) a per-page
|
package/src/jx-mounts.ts
CHANGED
|
@@ -16,15 +16,16 @@
|
|
|
16
16
|
* The runtime is cached per project root and invalidated when project.json changes on disk.
|
|
17
17
|
*/
|
|
18
18
|
|
|
19
|
-
import {
|
|
20
|
-
import { resolve } from "node:path";
|
|
19
|
+
import { readFileSync } from "node:fs";
|
|
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";
|
|
24
23
|
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
|
-
import type { JxDocumentValue
|
|
26
|
+
import type { JxDocumentValue } from "@jxsuite/schema/types";
|
|
27
|
+
import { parseProjectSource } from "@jxsuite/schema/project-file";
|
|
28
|
+
import { isSameStamp, projectFileStamp } from "@jxsuite/schema/project-file/node";
|
|
28
29
|
|
|
29
30
|
interface MountRuntime {
|
|
30
31
|
handlers: { basePath: string; handler: MountHandler }[];
|
|
@@ -32,7 +33,10 @@ interface MountRuntime {
|
|
|
32
33
|
}
|
|
33
34
|
|
|
34
35
|
/** Per-project runtime cache, invalidated when project.json's mtime moves. */
|
|
35
|
-
const runtimeCache = new Map<
|
|
36
|
+
const runtimeCache = new Map<
|
|
37
|
+
string,
|
|
38
|
+
{ mtime: number; path: string; runtime: Promise<MountRuntime | null> }
|
|
39
|
+
>();
|
|
36
40
|
|
|
37
41
|
/** Reset the runtime cache (test hook). */
|
|
38
42
|
export function resetJxMounts(): void {
|
|
@@ -81,35 +85,34 @@ export async function handleJxMounts(
|
|
|
81
85
|
|
|
82
86
|
/** Load (or reuse) the mount runtime for a project root. */
|
|
83
87
|
function getRuntime(projectRoot: string): Promise<MountRuntime | null> {
|
|
84
|
-
const
|
|
85
|
-
if (!
|
|
88
|
+
const stamp = projectFileStamp(projectRoot);
|
|
89
|
+
if (!stamp) {
|
|
86
90
|
return Promise.resolve(null);
|
|
87
91
|
}
|
|
88
|
-
const { mtimeMs } = statSync(projectJsonPath);
|
|
89
92
|
const cached = runtimeCache.get(projectRoot);
|
|
90
|
-
if (cached && cached
|
|
93
|
+
if (cached && isSameStamp(cached, stamp)) {
|
|
91
94
|
return cached.runtime;
|
|
92
95
|
}
|
|
93
|
-
const runtime = buildRuntime(projectRoot,
|
|
96
|
+
const runtime = buildRuntime(projectRoot, stamp.path).catch((error) => {
|
|
94
97
|
console.warn(
|
|
95
98
|
`jx-mounts: failed to build extension mounts for ${projectRoot}:`,
|
|
96
99
|
error instanceof Error ? error.message : String(error),
|
|
97
100
|
);
|
|
98
101
|
return null;
|
|
99
102
|
});
|
|
100
|
-
runtimeCache.set(projectRoot, {
|
|
103
|
+
runtimeCache.set(projectRoot, { ...stamp, runtime });
|
|
101
104
|
return runtime;
|
|
102
105
|
}
|
|
103
106
|
|
|
104
107
|
/** Build the runtime: registry, sections, connector stand-ins, and the ordered mount handlers. */
|
|
105
108
|
async function buildRuntime(
|
|
106
109
|
projectRoot: string,
|
|
107
|
-
|
|
110
|
+
projectFile: string,
|
|
108
111
|
): Promise<MountRuntime | null> {
|
|
109
|
-
/*
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
const config =
|
|
112
|
+
/* Trust boundary. The project file is the project's own config, held to its per-project schema
|
|
113
|
+
by the editor and `jx validate`; the sections are only relayed to the mounts that own them,
|
|
114
|
+
and any throw lands in getRuntime's catch, which disables the mounts with a warning. */
|
|
115
|
+
const config = parseProjectSource(readFileSync(projectFile, "utf8"), projectFile);
|
|
113
116
|
const registry = await buildProjectExtensionRegistry(projectRoot, config);
|
|
114
117
|
const mounts = registry.serverMounts();
|
|
115
118
|
if (mounts.length === 0) {
|
package/src/refactor/scan.ts
CHANGED
|
@@ -7,10 +7,20 @@
|
|
|
7
7
|
* rename that rewrites a file the usage count never looked at is a lie in both directions.
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
|
-
import { extname } from "node:path";
|
|
10
|
+
import { basename, 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
|
|
13
|
+
import { isJsonObject } from "@jxsuite/schema/guards";
|
|
14
|
+
import { rewriteJson } from "@jxsuite/schema/json-layout";
|
|
15
|
+
import {
|
|
16
|
+
classifyJxn,
|
|
17
|
+
isJxnPath,
|
|
18
|
+
parseJxn,
|
|
19
|
+
parseProject,
|
|
20
|
+
serializeJxn,
|
|
21
|
+
serializeProject,
|
|
22
|
+
} from "@jxsuite/schema/jxn";
|
|
23
|
+
import type { JsonValue, JxDocument, ProjectConfig } from "@jxsuite/schema/types";
|
|
14
24
|
|
|
15
25
|
/** Normalise a path to forward slashes (Windows `path` returns backslashes). */
|
|
16
26
|
export const fwd = (p: string) => p.replaceAll("\\", "/");
|
|
@@ -77,13 +87,35 @@ export function documentGlob(registry: FormatRegistry): string {
|
|
|
77
87
|
export async function loadDoc(fp: string, registry: FormatRegistry): Promise<LoadedDoc> {
|
|
78
88
|
const raw = await readFile(fp, "utf8");
|
|
79
89
|
if (isJsonPath(fp)) {
|
|
80
|
-
const trailingNewline = raw.endsWith("\n");
|
|
81
90
|
return {
|
|
82
91
|
// SAFETY: JSON.parse without a reviver only ever yields a JSON value; its `any` says no more.
|
|
83
92
|
doc: JSON.parse(raw) as JsonValue,
|
|
84
93
|
rewrite: null,
|
|
85
|
-
|
|
86
|
-
|
|
94
|
+
// Over the text it replaces, so a rename changes the references and not the formatting.
|
|
95
|
+
serialize: (doc) => Promise.resolve(rewriteJson(doc, raw)),
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
if (basename(fp) === "project.jxn" && classifyJxn(raw) === "project") {
|
|
99
|
+
/* The project file, in its own grammar (`site-architecture.md` §2.3): a rename repairs the
|
|
100
|
+
references it holds, `defaults.layout` among them, and the merge keeps its comments. A page
|
|
101
|
+
named `project.jxn` declares no project, and is read as the document it is below. */
|
|
102
|
+
const config: unknown = parseProject(raw, fp);
|
|
103
|
+
return {
|
|
104
|
+
doc: isJsonObject(config) ? config : null,
|
|
105
|
+
rewrite: null,
|
|
106
|
+
// SAFETY: the configuration this just read, with reference values replaced by reference values.
|
|
107
|
+
serialize: (doc) => Promise.resolve(serializeProject(doc as ProjectConfig, raw)),
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
if (isJxnPath(fp)) {
|
|
111
|
+
/* The one format whose writer can be told what it is replacing, so repairing a reference in a
|
|
112
|
+
`.jxn` file changes that reference and leaves its comments where they were. */
|
|
113
|
+
const document = parseJxn(raw);
|
|
114
|
+
return {
|
|
115
|
+
doc: isJsonObject(document) ? document : null,
|
|
116
|
+
rewrite: null,
|
|
117
|
+
// SAFETY: the document this just read, with reference values replaced by reference values.
|
|
118
|
+
serialize: (doc) => Promise.resolve(serializeJxn(doc as JxDocument, raw)),
|
|
87
119
|
};
|
|
88
120
|
}
|
|
89
121
|
const ext = extname(fp);
|
package/src/resolve.ts
CHANGED
|
@@ -4,7 +4,7 @@ import { siteBasePath } from "@jxsuite/schema/asset-paths";
|
|
|
4
4
|
import { dirname, isAbsolute, relative, resolve } from "node:path";
|
|
5
5
|
import { errorMessage, parseClassDef } from "@jxsuite/schema/parse";
|
|
6
6
|
import { isFunction, isNonNullObject, isString } from "@jxsuite/schema/guards";
|
|
7
|
-
import { readFileSync
|
|
7
|
+
import { readFileSync } from "node:fs";
|
|
8
8
|
import { createRequire } from "node:module";
|
|
9
9
|
import { projectDevEnv } from "./dev-vars.ts";
|
|
10
10
|
import { containedPath } from "./net-guard.ts";
|
|
@@ -25,6 +25,8 @@ 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 { parseProjectSource } from "@jxsuite/schema/project-file";
|
|
29
|
+
import { isSameStamp, projectFileStamp } from "@jxsuite/schema/project-file/node";
|
|
28
30
|
|
|
29
31
|
/**
|
|
30
32
|
* Any value a module exports, as the proxies see it before they check it. Nothing about an export
|
|
@@ -193,7 +195,10 @@ function baseDirectory($base: string, root: string, projectRoot: string): string
|
|
|
193
195
|
* seconds for data it never reads. Both together put a component fetch past the canvas's register
|
|
194
196
|
* timeout on every cold open.
|
|
195
197
|
*/
|
|
196
|
-
const projectEntryCache = new Map<
|
|
198
|
+
const projectEntryCache = new Map<
|
|
199
|
+
string,
|
|
200
|
+
{ mtime: number; path: string; entry: Promise<ProjectEntry | null> }
|
|
201
|
+
>();
|
|
197
202
|
|
|
198
203
|
/**
|
|
199
204
|
* Lazy-load project context (project.json + extension-loaded sections) for class instantiation.
|
|
@@ -255,31 +260,30 @@ async function completeSections(context: ProjectContext): Promise<void> {
|
|
|
255
260
|
|
|
256
261
|
/** Load (or reuse) the cached project config, extension registry and asset mounts. */
|
|
257
262
|
async function loadProjectEntry(projectRoot: string): Promise<ProjectEntry | null> {
|
|
258
|
-
const
|
|
259
|
-
|
|
260
|
-
if (mtime === undefined) {
|
|
263
|
+
const stamp = projectFileStamp(projectRoot);
|
|
264
|
+
if (!stamp) {
|
|
261
265
|
return null;
|
|
262
266
|
}
|
|
263
267
|
const cached = projectEntryCache.get(projectRoot);
|
|
264
|
-
if (cached && cached
|
|
268
|
+
if (cached && isSameStamp(cached, stamp)) {
|
|
265
269
|
return cached.entry;
|
|
266
270
|
}
|
|
267
|
-
const entry = readProjectEntry(projectRoot,
|
|
268
|
-
projectEntryCache.set(projectRoot, {
|
|
271
|
+
const entry = readProjectEntry(projectRoot, stamp.path, stamp.mtime);
|
|
272
|
+
projectEntryCache.set(projectRoot, { ...stamp, entry });
|
|
269
273
|
return entry;
|
|
270
274
|
}
|
|
271
275
|
|
|
272
|
-
/** Read one
|
|
276
|
+
/** Read one project file generation. Never rejects: any failure answers "no project context". */
|
|
273
277
|
async function readProjectEntry(
|
|
274
278
|
projectRoot: string,
|
|
275
|
-
|
|
279
|
+
projectFile: string,
|
|
276
280
|
mtime: number,
|
|
277
281
|
): Promise<ProjectEntry | null> {
|
|
278
282
|
try {
|
|
279
|
-
/*
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
const config =
|
|
283
|
+
/* Trust boundary. The project file is the project's own config, which the editor and
|
|
284
|
+
`jx validate` hold to its per-project schema. Any failure reading it below is caught here
|
|
285
|
+
and answers "no project context". */
|
|
286
|
+
const config = parseProjectSource(readFileSync(projectFile, "utf8"), projectFile);
|
|
283
287
|
const registry = await buildProjectExtensionRegistry(projectRoot, config);
|
|
284
288
|
const { mounts } = await loadAssetMounts(registry, config, projectRoot);
|
|
285
289
|
return { config, mounts, mtime, registry };
|
package/src/server.ts
CHANGED
|
@@ -42,6 +42,8 @@ import { handleCodeApi } from "./code-api.ts";
|
|
|
42
42
|
import { handleAiApi } from "./ai-api.ts";
|
|
43
43
|
import { handleImportApi } from "./import-api.ts";
|
|
44
44
|
import { mediaTypeForPath } from "@jxsuite/schema/media-type";
|
|
45
|
+
import { isJxnPath } from "@jxsuite/schema/jxn";
|
|
46
|
+
import { jxnAsJson } from "@jxsuite/site/serve";
|
|
45
47
|
import { problem } from "./problem.ts";
|
|
46
48
|
|
|
47
49
|
import { resolveNpmPath } from "./npm-path.ts";
|
|
@@ -169,12 +171,21 @@ export async function createDevServer(options: {
|
|
|
169
171
|
const NO_CACHE = { "Cache-Control": "no-cache" };
|
|
170
172
|
/*
|
|
171
173
|
* `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
|
|
174
|
+
* exceptions are the two RFCs Jx has an opinion about, and `.jxn`, which Bun has never heard of:
|
|
175
|
+
* `.md` comes back as bare `text/markdown`
|
|
173
176
|
* with no `variant`, and `.yaml` comes back as `text/yaml`, the spelling RFC 9512 §5 asks
|
|
174
177
|
* implementations to stop using. `mediaTypeForPath` answers null everywhere else, so the
|
|
175
178
|
* inference stays in charge of the ordinary cases.
|
|
176
179
|
*/
|
|
177
|
-
const fileResponse = (file: ReturnType<typeof Bun.file>) => {
|
|
180
|
+
const fileResponse = async (file: ReturnType<typeof Bun.file>) => {
|
|
181
|
+
if (file.name !== undefined && isJxnPath(file.name)) {
|
|
182
|
+
/* The canvas runtime fetches a `$ref` and reads it as JSON, as a site origin answers it
|
|
183
|
+
(`@jxsuite/site` `serve.ts`); Studio reads a file's text through `/__studio/file`. */
|
|
184
|
+
return jxnAsJson(await file.bytes(), file.name, (type) => ({
|
|
185
|
+
...NO_CACHE,
|
|
186
|
+
"Content-Type": type,
|
|
187
|
+
}));
|
|
188
|
+
}
|
|
178
189
|
const corrected = file.name === undefined ? null : mediaTypeForPath(file.name);
|
|
179
190
|
return new Response(file, {
|
|
180
191
|
headers: corrected === null ? NO_CACHE : { ...NO_CACHE, "Content-Type": corrected },
|
package/src/site-output.ts
CHANGED
|
@@ -30,12 +30,13 @@
|
|
|
30
30
|
import { existsSync, readFileSync, statSync } from "node:fs";
|
|
31
31
|
import { join, resolve } from "node:path";
|
|
32
32
|
import { isJsonObject, isString } from "@jxsuite/schema/guards";
|
|
33
|
-
import type { JsonValue } from "@jxsuite/schema/types";
|
|
34
33
|
|
|
35
34
|
import { serveContained } from "./net-guard.ts";
|
|
35
|
+
import { parseProjectSource } from "@jxsuite/schema/project-file";
|
|
36
|
+
import { isSameStamp, projectFileStamp } 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
|
-
const outDirCache = new Map<string, { mtime: number; outDir: string | null }>();
|
|
39
|
+
const outDirCache = new Map<string, { mtime: number; outDir: string | null; path: string }>();
|
|
39
40
|
|
|
40
41
|
/** Reset the output-directory cache (test hook). */
|
|
41
42
|
export function resetSiteOutput(): void {
|
|
@@ -51,32 +52,27 @@ export function resetSiteOutput(): void {
|
|
|
51
52
|
* file read.
|
|
52
53
|
*/
|
|
53
54
|
export function siteOutDir(projectRoot: string): string | null {
|
|
54
|
-
|
|
55
|
-
|
|
55
|
+
// Both project files is no project until the editor's report is acted on, so this declines.
|
|
56
|
+
const stamp = projectFileStamp(projectRoot);
|
|
57
|
+
if (!stamp) {
|
|
56
58
|
return null;
|
|
57
59
|
}
|
|
58
|
-
const { mtimeMs } = statSync(configPath);
|
|
59
60
|
const cached = outDirCache.get(projectRoot);
|
|
60
|
-
if (cached && cached
|
|
61
|
+
if (cached && isSameStamp(cached, stamp)) {
|
|
61
62
|
return cached.outDir;
|
|
62
63
|
}
|
|
63
64
|
let outDir: string | null = null;
|
|
64
65
|
try {
|
|
65
|
-
|
|
66
|
-
const config = JSON.parse(readFileSync(configPath, "utf8")) as JsonValue;
|
|
66
|
+
const config: unknown = parseProjectSource(readFileSync(stamp.path, "utf8"), stamp.path);
|
|
67
67
|
const build = isJsonObject(config) ? config.build : undefined;
|
|
68
68
|
const declared = isJsonObject(build) ? build.outDir : undefined;
|
|
69
|
-
|
|
70
|
-
outDir =
|
|
71
|
-
config === null
|
|
72
|
-
? null
|
|
73
|
-
: resolve(projectRoot, isString(declared) && declared ? declared : "dist");
|
|
69
|
+
outDir = resolve(projectRoot, isString(declared) && declared ? declared : "dist");
|
|
74
70
|
} catch {
|
|
75
|
-
// An unparseable project
|
|
71
|
+
// An unparseable project file is the editor's problem to report, not this server's to guess
|
|
76
72
|
// At. No output directory means this fallback simply declines, and the request 404s as before.
|
|
77
73
|
outDir = null;
|
|
78
74
|
}
|
|
79
|
-
outDirCache.set(projectRoot, {
|
|
75
|
+
outDirCache.set(projectRoot, { ...stamp, outDir });
|
|
80
76
|
return outDir;
|
|
81
77
|
}
|
|
82
78
|
|
package/src/studio-api.ts
CHANGED
|
@@ -48,6 +48,21 @@ 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 {
|
|
52
|
+
PROJECT_FILE_NAMES,
|
|
53
|
+
classifyProjectFile,
|
|
54
|
+
parseProjectFile,
|
|
55
|
+
parseProjectSource,
|
|
56
|
+
projectFileConflict,
|
|
57
|
+
} from "@jxsuite/schema/project-file";
|
|
58
|
+
import {
|
|
59
|
+
findEnclosingProjectOnDisk,
|
|
60
|
+
hasProjectFile,
|
|
61
|
+
isSameStamp,
|
|
62
|
+
projectFilePath,
|
|
63
|
+
projectFileStamp,
|
|
64
|
+
readProjectFile,
|
|
65
|
+
} from "@jxsuite/schema/project-file/node";
|
|
51
66
|
|
|
52
67
|
/** Normalise a path to forward slashes (Windows `path` module returns backslashes). */
|
|
53
68
|
const fwd = (p: string) => p.replaceAll("\\", "/");
|
|
@@ -96,11 +111,11 @@ interface Cem {
|
|
|
96
111
|
modules?: CemModule[];
|
|
97
112
|
}
|
|
98
113
|
|
|
99
|
-
// ─── Extension registry (per project root, invalidated on project
|
|
114
|
+
// ─── Extension registry (per project root, invalidated on a project file change) ──
|
|
100
115
|
|
|
101
116
|
const extensionRegistryCache = new Map<
|
|
102
117
|
string,
|
|
103
|
-
{ mtime: number; missing: string[]; registry: ExtensionRegistry }
|
|
118
|
+
{ mtime: number; missing: string[]; path: string; registry: ExtensionRegistry }
|
|
104
119
|
>();
|
|
105
120
|
|
|
106
121
|
/**
|
|
@@ -115,26 +130,23 @@ const extensionRegistryCache = new Map<
|
|
|
115
130
|
* @returns {Promise<ExtensionRegistry>}
|
|
116
131
|
*/
|
|
117
132
|
async function getExtensionRegistry(projectRoot: string): Promise<ExtensionRegistry> {
|
|
118
|
-
const
|
|
119
|
-
let mtime = 0;
|
|
133
|
+
const stamp = projectFileStamp(projectRoot) ?? { mtime: 0, path: "" };
|
|
120
134
|
let projectConfig: ProjectConfig | undefined;
|
|
121
135
|
try {
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
that list itself; every other ProjectConfig member is optional and unread on this path. */
|
|
125
|
-
projectConfig = JSON.parse(readFileSync(projectJsonPath, "utf8")) as ProjectConfig;
|
|
136
|
+
// The registry reads only `projectConfig?.extensions`, and checks that list itself.
|
|
137
|
+
projectConfig = parseProjectSource(readFileSync(stamp.path, "utf8"), stamp.path);
|
|
126
138
|
} catch {
|
|
127
139
|
projectConfig = undefined;
|
|
128
140
|
}
|
|
129
141
|
const cached = extensionRegistryCache.get(projectRoot);
|
|
130
|
-
if (cached && cached
|
|
142
|
+
if (cached && isSameStamp(cached, stamp) && !anyNowInstalled(projectRoot, cached.missing)) {
|
|
131
143
|
return cached.registry;
|
|
132
144
|
}
|
|
133
145
|
const { missing, registry } = await buildInstalledExtensionRegistry(projectRoot, projectConfig);
|
|
134
146
|
if (missing.length > 0 && missing.join("\n") !== cached?.missing.join("\n")) {
|
|
135
147
|
console.warn(`[studio] ${missingExtensionsNotice(missing)}`);
|
|
136
148
|
}
|
|
137
|
-
extensionRegistryCache.set(projectRoot, {
|
|
149
|
+
extensionRegistryCache.set(projectRoot, { ...stamp, missing, registry });
|
|
138
150
|
return registry;
|
|
139
151
|
}
|
|
140
152
|
|
|
@@ -211,7 +223,7 @@ export function isOwnedProjectDir(dir: string): boolean {
|
|
|
211
223
|
if (containedPath(dir, resolve(home)) === null) {
|
|
212
224
|
return false;
|
|
213
225
|
}
|
|
214
|
-
return
|
|
226
|
+
return hasProjectFile(dir);
|
|
215
227
|
}
|
|
216
228
|
|
|
217
229
|
/**
|
|
@@ -509,23 +521,26 @@ export async function handleStudioApi(
|
|
|
509
521
|
|
|
510
522
|
let isSiteProject = false;
|
|
511
523
|
let projectConfig: ProjectConfig | null = null;
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
+
let projectProblem: string | null = null;
|
|
525
|
+
const read = readProjectFile(absDir);
|
|
526
|
+
const verdict = read ? classifyProjectFile(read.text, read.path) : null;
|
|
527
|
+
if (read && "config" in read && verdict === "project") {
|
|
528
|
+
isSiteProject = true;
|
|
529
|
+
// Relayed to the client here, never interpreted.
|
|
530
|
+
projectConfig = read.config;
|
|
531
|
+
} else if (read && "error" in read && verdict !== "other" && read.text !== "") {
|
|
532
|
+
/* A file that does not read is believed, as the walk believes it (desktop.md §4.6), and
|
|
533
|
+
opened with no configuration so the client can show it as text. A folder holding both
|
|
534
|
+
files has no text to show, and stays what it was. */
|
|
535
|
+
isSiteProject = true;
|
|
536
|
+
projectProblem = read.error;
|
|
537
|
+
}
|
|
524
538
|
|
|
525
539
|
return Response.json({
|
|
526
540
|
directories,
|
|
527
541
|
isSiteProject,
|
|
528
542
|
projectConfig,
|
|
543
|
+
...(projectProblem !== null && { projectProblem }),
|
|
529
544
|
projectRoot,
|
|
530
545
|
});
|
|
531
546
|
} catch (error) {
|
|
@@ -543,38 +558,30 @@ export async function handleStudioApi(
|
|
|
543
558
|
// Walk up looking for project.json. Accept a directory (e.g. the project root itself, as
|
|
544
559
|
// Deep links like ?project=/abs/my-site pass) by starting the walk AT the directory and
|
|
545
560
|
// Treating its project.json as the target file, so callers land on the home page.
|
|
546
|
-
|
|
547
|
-
let dir: string;
|
|
561
|
+
const absFile = expandTilde(filePath);
|
|
548
562
|
let isDir = false;
|
|
549
563
|
try {
|
|
550
564
|
isDir = statSync(absFile).isDirectory();
|
|
551
565
|
} catch {}
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
});
|
|
572
|
-
}
|
|
573
|
-
const parent = dirname(dir);
|
|
574
|
-
if (parent === dir) {
|
|
575
|
-
break;
|
|
576
|
-
}
|
|
577
|
-
dir = parent;
|
|
566
|
+
// A page named project.json is not a project, and neither is Nx's file of that name.
|
|
567
|
+
const found = findEnclosingProjectOnDisk(isDir ? absFile : dirname(absFile));
|
|
568
|
+
if (found?.verdict === "conflict") {
|
|
569
|
+
return problem("conflict", projectFileConflict(found.root));
|
|
570
|
+
}
|
|
571
|
+
if (found) {
|
|
572
|
+
/* Relayed to the client and not interpreted here. A file that does not read is still the
|
|
573
|
+
project's (site-architecture.md §2.3): the client opens it with no configuration, says
|
|
574
|
+
where the file fails and shows it as text, rather than finding no project at all. */
|
|
575
|
+
const read = parseProjectFile(found.projectFile, readFileSync(found.projectFile, "utf8"));
|
|
576
|
+
// A folder is asked for as its project file, which the client reads as "go home".
|
|
577
|
+
const asked = isDir ? join(absFile, basename(found.projectFile)) : absFile;
|
|
578
|
+
return Response.json({
|
|
579
|
+
fileRelPath: fwd(relative(found.root, asked)),
|
|
580
|
+
projectConfig: "config" in read ? read.config : null,
|
|
581
|
+
...("error" in read && { projectProblem: read.error }),
|
|
582
|
+
relPath: fwd(found.root),
|
|
583
|
+
sitePath: found.root,
|
|
584
|
+
});
|
|
578
585
|
}
|
|
579
586
|
return Response.json({ sitePath: null });
|
|
580
587
|
} catch (error) {
|
|
@@ -585,8 +592,8 @@ export async function handleStudioApi(
|
|
|
585
592
|
// Discover site projects — find all project.json files under root
|
|
586
593
|
if (path === "/__studio/sites" && req.method === "GET") {
|
|
587
594
|
try {
|
|
588
|
-
const sites = [];
|
|
589
|
-
for await (const match of scanFiles(
|
|
595
|
+
const sites: { config: ProjectConfig; path: string; problem?: string }[] = [];
|
|
596
|
+
for await (const match of scanFiles(`**/{${PROJECT_FILE_NAMES.join(",")}}`, root)) {
|
|
590
597
|
if (
|
|
591
598
|
match.includes("node_modules") ||
|
|
592
599
|
fwd(match).includes("dist/") ||
|
|
@@ -596,13 +603,18 @@ export async function handleStudioApi(
|
|
|
596
603
|
}
|
|
597
604
|
const fp = resolve(root, match);
|
|
598
605
|
try {
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
if (isJsonObject(raw)) {
|
|
606
|
+
const text = await readFile(fp, "utf8");
|
|
607
|
+
if (classifyProjectFile(text, fp) === "project") {
|
|
602
608
|
const projectDir = fwd(dirname(fp));
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
sites.
|
|
609
|
+
// The site list only relays the config to the client.
|
|
610
|
+
const config = parseProjectSource(text, fp);
|
|
611
|
+
const twin = sites.find((site) => site.path === projectDir);
|
|
612
|
+
if (twin) {
|
|
613
|
+
// Neither file is the project's, so the list says so rather than choosing.
|
|
614
|
+
twin.problem = projectFileConflict(projectDir);
|
|
615
|
+
} else {
|
|
616
|
+
sites.push({ config, path: projectDir });
|
|
617
|
+
}
|
|
606
618
|
}
|
|
607
619
|
} catch {}
|
|
608
620
|
}
|
|
@@ -692,11 +704,9 @@ export async function handleStudioApi(
|
|
|
692
704
|
}
|
|
693
705
|
await generateProject(destPath, projectOptions);
|
|
694
706
|
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
const config =
|
|
698
|
-
await readFile(resolve(destPath, "project.json"), "utf8"),
|
|
699
|
-
) as ProjectConfig;
|
|
707
|
+
// It was just written by generateProject, and is relayed to the client, not interpreted.
|
|
708
|
+
const configPath = projectFilePath(destPath);
|
|
709
|
+
const config = parseProjectSource(await readFile(configPath, "utf8"), configPath);
|
|
700
710
|
// Root-relative while the project happens to sit under the server root (the historical
|
|
701
711
|
// Shape every caller already handles); absolute otherwise.
|
|
702
712
|
const inRoot = containedPath(destPath, root) !== null;
|
|
@@ -939,7 +949,7 @@ export async function handleStudioApi(
|
|
|
939
949
|
*/
|
|
940
950
|
if (path === "/__studio/build" && req.method === "POST") {
|
|
941
951
|
const dir = activeProjectRoot ?? root;
|
|
942
|
-
if (!
|
|
952
|
+
if (!hasProjectFile(dir)) {
|
|
943
953
|
return problem("invalidRequest", "Not a site project");
|
|
944
954
|
}
|
|
945
955
|
try {
|
|
@@ -971,7 +981,7 @@ export async function handleStudioApi(
|
|
|
971
981
|
*/
|
|
972
982
|
if (path === "/__studio/preview" && req.method === "POST") {
|
|
973
983
|
const dir = activeProjectRoot ?? root;
|
|
974
|
-
if (!
|
|
984
|
+
if (!hasProjectFile(dir)) {
|
|
975
985
|
return problem("invalidRequest", "Not a site project");
|
|
976
986
|
}
|
|
977
987
|
let body: { route?: string };
|