@jxsuite/server 8.2.1 → 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 +16 -15
- package/src/convert.ts +254 -0
- package/src/data-api.ts +12 -10
- package/src/dev.ts +4 -2
- package/src/extension-catalog.ts +2 -1
- package/src/import-api.ts +2 -2
- package/src/jx-mounts.ts +2 -2
- package/src/refactor/scan.ts +17 -4
- package/src/resolve.ts +9 -13
- package/src/server.ts +2 -1
- package/src/site-output.ts +2 -1
- package/src/studio-api.ts +57 -49
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jxsuite/server",
|
|
3
|
-
"version": "8.
|
|
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.
|
|
58
|
-
"@jxsuite/catalog": "^0.1.
|
|
59
|
-
"@jxsuite/collab": "^0.11.
|
|
60
|
-
"@jxsuite/compiler": "^8.
|
|
61
|
-
"@jxsuite/create": "^1.5.
|
|
62
|
-
"@jxsuite/import": "^0.42.
|
|
63
|
-
"@jxsuite/protocol": "^6.2.
|
|
64
|
-
"@jxsuite/runtime": "^6.0
|
|
65
|
-
"@jxsuite/schema": "^5.
|
|
66
|
-
"@jxsuite/site": "^2.
|
|
67
|
-
"@jxsuite/starters": "^3.
|
|
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.
|
|
78
|
-
"@jxsuite/connector": "^0.8.
|
|
79
|
-
"@jxsuite/parser": "^4.
|
|
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
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
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 =
|
|
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 =
|
|
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.
|
|
@@ -195,7 +196,8 @@ export async function startDev({ port, root }: DevArgs): Promise<DevServerHandle
|
|
|
195
196
|
const rebuild = () => {
|
|
196
197
|
building = building.then(async () => {
|
|
197
198
|
const { buildSite } = await import("@jxsuite/compiler/site");
|
|
198
|
-
|
|
199
|
+
// A dev server shows authors their work, so entries a project holds back stay in it.
|
|
200
|
+
const result = await buildSite(root, { includeHeld: true, verbose: false });
|
|
199
201
|
({ serverEntries } = result);
|
|
200
202
|
if (result.errors.length > 0) {
|
|
201
203
|
for (const err of result.errors) {
|
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,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(
|
|
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 =
|
|
84
|
+
const projectJsonPath = projectFilePath(projectRoot);
|
|
85
85
|
if (!existsSync(projectJsonPath)) {
|
|
86
86
|
return Promise.resolve(null);
|
|
87
87
|
}
|
package/src/refactor/scan.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
86
|
-
|
|
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
|
|
@@ -208,7 +209,9 @@ async function loadProjectContext(projectRoot: string): Promise<ProjectContext |
|
|
|
208
209
|
if (!entry) {
|
|
209
210
|
return null;
|
|
210
211
|
}
|
|
212
|
+
// This host draws the canvas and Preview, so entries a project holds back stay visible to authors.
|
|
211
213
|
entry.context ??= loadProjectSections(projectRoot, entry.config, entry.registry, {
|
|
214
|
+
includeHeld: true,
|
|
212
215
|
lazy: true,
|
|
213
216
|
}).then((sections) => ({ config: entry.config, root: projectRoot, ...sections }));
|
|
214
217
|
try {
|
|
@@ -253,7 +256,7 @@ async function completeSections(context: ProjectContext): Promise<void> {
|
|
|
253
256
|
|
|
254
257
|
/** Load (or reuse) the cached project config, extension registry and asset mounts. */
|
|
255
258
|
async function loadProjectEntry(projectRoot: string): Promise<ProjectEntry | null> {
|
|
256
|
-
const projectJsonPath =
|
|
259
|
+
const projectJsonPath = projectFilePath(projectRoot);
|
|
257
260
|
const mtime = statSync(projectJsonPath, { throwIfNoEntry: false })?.mtimeMs;
|
|
258
261
|
if (mtime === undefined) {
|
|
259
262
|
return null;
|
|
@@ -342,20 +345,16 @@ export async function handleResolve(
|
|
|
342
345
|
}
|
|
343
346
|
|
|
344
347
|
let moduleAbsPath;
|
|
348
|
+
let docDir: string | null = null;
|
|
345
349
|
// A bare specifier resolves through Node's module resolution (an installed package), so the
|
|
346
350
|
// Class file — and the `$implementation` sibling it names — are as trusted as the package's own
|
|
347
351
|
// Code. The root-escape guard only binds a caller-supplied relative `$src`/`$base`.
|
|
348
352
|
let trustedResolution = false;
|
|
349
353
|
try {
|
|
354
|
+
docDir = $base ? baseDirectory($base, root, activeProjectRoot || root) : null;
|
|
350
355
|
if ($src.startsWith("./") || $src.startsWith("../")) {
|
|
351
356
|
// Relative path
|
|
352
|
-
|
|
353
|
-
const docUrlPath = new URL($base).pathname;
|
|
354
|
-
const docDir = docUrlPath.slice(0, docUrlPath.lastIndexOf("/") + 1);
|
|
355
|
-
moduleAbsPath = resolve(resolve(root, `.${docDir}`), $src);
|
|
356
|
-
} else {
|
|
357
|
-
moduleAbsPath = resolve(activeProjectRoot || root, $src);
|
|
358
|
-
}
|
|
357
|
+
moduleAbsPath = resolve(docDir ?? (activeProjectRoot || root), $src);
|
|
359
358
|
if (!isImportable(moduleAbsPath, root, activeProjectRoot)) {
|
|
360
359
|
return new Response(`$src "${$src}" escapes the project root`, { status: 403 });
|
|
361
360
|
}
|
|
@@ -371,13 +370,10 @@ export async function handleResolve(
|
|
|
371
370
|
}
|
|
372
371
|
|
|
373
372
|
// Rebase relative config paths from doc-relative to CWD-relative
|
|
374
|
-
if (
|
|
375
|
-
const docUrlPath = new URL($base).pathname;
|
|
376
|
-
const docDir = docUrlPath.slice(0, docUrlPath.lastIndexOf("/") + 1);
|
|
377
|
-
const docAbsDir = resolve(root, `.${docDir}`);
|
|
373
|
+
if (docDir) {
|
|
378
374
|
for (const [k, v] of Object.entries(config)) {
|
|
379
375
|
if (isString(v) && (v.startsWith("./") || v.startsWith("../"))) {
|
|
380
|
-
config[k] = `./${relative(process.cwd(), resolve(
|
|
376
|
+
config[k] = `./${relative(process.cwd(), resolve(docDir, v)).split("\\").join("/")}`;
|
|
381
377
|
}
|
|
382
378
|
}
|
|
383
379
|
}
|
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
|
|
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.
|
package/src/site-output.ts
CHANGED
|
@@ -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 =
|
|
55
|
+
const configPath = projectFilePath(projectRoot);
|
|
55
56
|
if (!existsSync(configPath)) {
|
|
56
57
|
return null;
|
|
57
58
|
}
|
package/src/studio-api.ts
CHANGED
|
@@ -20,6 +20,7 @@ import {
|
|
|
20
20
|
missingExtensionsNotice,
|
|
21
21
|
} from "@jxsuite/compiler/format-host";
|
|
22
22
|
import { readBundledProjectSchemas } from "@jxsuite/compiler/schema-command";
|
|
23
|
+
import { projectCommitArgs, projectRelativePath, readGitPrefix } from "@jxsuite/protocol/git-scope";
|
|
23
24
|
import { handleDataApi } from "./data-api.ts";
|
|
24
25
|
import { containedPath } from "./net-guard.ts";
|
|
25
26
|
import { startSitePreview } from "./site-preview.ts";
|
|
@@ -47,6 +48,12 @@ import type { JsonObject, JsonPrimitive, JsonValue, ProjectConfig } from "@jxsui
|
|
|
47
48
|
import { isFunction, isJsonObject, isNonNullObject, isString } from "@jxsuite/schema/guards";
|
|
48
49
|
import { problem } from "./problem.ts";
|
|
49
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";
|
|
50
57
|
|
|
51
58
|
/** Normalise a path to forward slashes (Windows `path` module returns backslashes). */
|
|
52
59
|
const fwd = (p: string) => p.replaceAll("\\", "/");
|
|
@@ -114,7 +121,7 @@ const extensionRegistryCache = new Map<
|
|
|
114
121
|
* @returns {Promise<ExtensionRegistry>}
|
|
115
122
|
*/
|
|
116
123
|
async function getExtensionRegistry(projectRoot: string): Promise<ExtensionRegistry> {
|
|
117
|
-
const projectJsonPath =
|
|
124
|
+
const projectJsonPath = projectFilePath(projectRoot);
|
|
118
125
|
let mtime = 0;
|
|
119
126
|
let projectConfig: ProjectConfig | undefined;
|
|
120
127
|
try {
|
|
@@ -210,7 +217,7 @@ export function isOwnedProjectDir(dir: string): boolean {
|
|
|
210
217
|
if (containedPath(dir, resolve(home)) === null) {
|
|
211
218
|
return false;
|
|
212
219
|
}
|
|
213
|
-
return
|
|
220
|
+
return hasProjectFile(dir);
|
|
214
221
|
}
|
|
215
222
|
|
|
216
223
|
/**
|
|
@@ -509,11 +516,10 @@ export async function handleStudioApi(
|
|
|
509
516
|
let isSiteProject = false;
|
|
510
517
|
let projectConfig: ProjectConfig | null = null;
|
|
511
518
|
try {
|
|
519
|
+
const text = await readFile(projectFilePath(absDir), "utf8");
|
|
512
520
|
// SAFETY: JSON.parse without a reviver only ever produces JSON values.
|
|
513
|
-
const raw = JSON.parse(
|
|
514
|
-
|
|
515
|
-
) as JsonValue;
|
|
516
|
-
if (isJsonObject(raw)) {
|
|
521
|
+
const raw = JSON.parse(text) as JsonValue;
|
|
522
|
+
if (classifyProjectFile(text) === "project" && isJsonObject(raw)) {
|
|
517
523
|
isSiteProject = true;
|
|
518
524
|
/* SAFETY: `raw` is a JSON object and every ProjectConfig member is optional; the config
|
|
519
525
|
is only relayed to the client here, never interpreted. */
|
|
@@ -550,30 +556,22 @@ export async function handleStudioApi(
|
|
|
550
556
|
} catch {}
|
|
551
557
|
if (isDir) {
|
|
552
558
|
dir = absFile;
|
|
553
|
-
absFile =
|
|
559
|
+
absFile = projectFilePath(absFile);
|
|
554
560
|
} else {
|
|
555
561
|
dir = dirname(absFile);
|
|
556
562
|
}
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
sitePath: dir,
|
|
570
|
-
});
|
|
571
|
-
}
|
|
572
|
-
const parent = dirname(dir);
|
|
573
|
-
if (parent === dir) {
|
|
574
|
-
break;
|
|
575
|
-
}
|
|
576
|
-
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
|
+
});
|
|
577
575
|
}
|
|
578
576
|
return Response.json({ sitePath: null });
|
|
579
577
|
} catch (error) {
|
|
@@ -585,7 +583,7 @@ export async function handleStudioApi(
|
|
|
585
583
|
if (path === "/__studio/sites" && req.method === "GET") {
|
|
586
584
|
try {
|
|
587
585
|
const sites = [];
|
|
588
|
-
for await (const match of scanFiles(
|
|
586
|
+
for await (const match of scanFiles(`**/${PROJECT_FILE_NAME}`, root)) {
|
|
589
587
|
if (
|
|
590
588
|
match.includes("node_modules") ||
|
|
591
589
|
fwd(match).includes("dist/") ||
|
|
@@ -595,9 +593,10 @@ export async function handleStudioApi(
|
|
|
595
593
|
}
|
|
596
594
|
const fp = resolve(root, match);
|
|
597
595
|
try {
|
|
596
|
+
const text = await readFile(fp, "utf8");
|
|
598
597
|
// SAFETY: JSON.parse without a reviver only ever produces JSON values.
|
|
599
|
-
const raw = JSON.parse(
|
|
600
|
-
if (isJsonObject(raw)) {
|
|
598
|
+
const raw = JSON.parse(text) as JsonValue;
|
|
599
|
+
if (classifyProjectFile(text) === "project" && isJsonObject(raw)) {
|
|
601
600
|
const projectDir = fwd(dirname(fp));
|
|
602
601
|
/* SAFETY: `raw` is a JSON object and every ProjectConfig member is optional; the site
|
|
603
602
|
list only relays the config to the client. */
|
|
@@ -693,9 +692,7 @@ export async function handleStudioApi(
|
|
|
693
692
|
|
|
694
693
|
/* SAFETY: generateProject just wrote this project.json from its own template, so it is a
|
|
695
694
|
project config; it is relayed to the client, not interpreted here. */
|
|
696
|
-
const config = JSON.parse(
|
|
697
|
-
await readFile(resolve(destPath, "project.json"), "utf8"),
|
|
698
|
-
) as ProjectConfig;
|
|
695
|
+
const config = JSON.parse(await readFile(projectFilePath(destPath), "utf8")) as ProjectConfig;
|
|
699
696
|
// Root-relative while the project happens to sit under the server root (the historical
|
|
700
697
|
// Shape every caller already handles); absolute otherwise.
|
|
701
698
|
const inRoot = containedPath(destPath, root) !== null;
|
|
@@ -938,7 +935,7 @@ export async function handleStudioApi(
|
|
|
938
935
|
*/
|
|
939
936
|
if (path === "/__studio/build" && req.method === "POST") {
|
|
940
937
|
const dir = activeProjectRoot ?? root;
|
|
941
|
-
if (!
|
|
938
|
+
if (!hasProjectFile(dir)) {
|
|
942
939
|
return problem("invalidRequest", "Not a site project");
|
|
943
940
|
}
|
|
944
941
|
try {
|
|
@@ -970,7 +967,7 @@ export async function handleStudioApi(
|
|
|
970
967
|
*/
|
|
971
968
|
if (path === "/__studio/preview" && req.method === "POST") {
|
|
972
969
|
const dir = activeProjectRoot ?? root;
|
|
973
|
-
if (!
|
|
970
|
+
if (!hasProjectFile(dir)) {
|
|
974
971
|
return problem("invalidRequest", "Not a site project");
|
|
975
972
|
}
|
|
976
973
|
let body: { route?: string };
|
|
@@ -1792,12 +1789,26 @@ export async function handleStudioApi(
|
|
|
1792
1789
|
/* `--untracked-files=all`: every untracked FILE, not its collapsed folder. By default git
|
|
1793
1790
|
reports a wholly untracked `content/` as one entry, so a fresh repository listed a
|
|
1794
1791
|
handful of folders that no group, comparison or checkbox could treat as a file, and the
|
|
1795
|
-
count jumped to the real one the moment staging expanded them (studio.md §22.8).
|
|
1796
|
-
|
|
1797
|
-
|
|
1792
|
+
count jumped to the real one the moment staging expanded them (studio.md §22.8).
|
|
1793
|
+
`-- .` and the prefix: the project's changes only, in the coordinates every other verb
|
|
1794
|
+
reads. Porcelain prints from the repository root whatever the cwd is (git-scope.ts). */
|
|
1795
|
+
const [out, remotesOut, prefix] = await Promise.all([
|
|
1796
|
+
runGit([
|
|
1797
|
+
"status",
|
|
1798
|
+
"--porcelain=v2",
|
|
1799
|
+
"--branch",
|
|
1800
|
+
"-z",
|
|
1801
|
+
"--untracked-files=all",
|
|
1802
|
+
"--",
|
|
1803
|
+
".",
|
|
1804
|
+
]),
|
|
1798
1805
|
runGit(["remote"]),
|
|
1806
|
+
readGitPrefix(runGit),
|
|
1799
1807
|
]);
|
|
1800
1808
|
const status = parseGitStatus(out);
|
|
1809
|
+
for (const file of status.files) {
|
|
1810
|
+
file.path = projectRelativePath(prefix, file.path);
|
|
1811
|
+
}
|
|
1801
1812
|
const fullStatus = {
|
|
1802
1813
|
...status,
|
|
1803
1814
|
isRepo: true,
|
|
@@ -1908,12 +1919,7 @@ export async function handleStudioApi(
|
|
|
1908
1919
|
if (!message || !isString(message)) {
|
|
1909
1920
|
return problem("invalidRequest", "Missing message");
|
|
1910
1921
|
}
|
|
1911
|
-
const
|
|
1912
|
-
const hasStaged = statusOut
|
|
1913
|
-
.split("\n")
|
|
1914
|
-
.some((l) => l.length > 0 && l[0] !== " " && l[0] !== "?");
|
|
1915
|
-
const args = hasStaged ? ["commit", "-m", message] : ["commit", "-a", "-m", message];
|
|
1916
|
-
const out = await runGit(args);
|
|
1922
|
+
const out = await runGit(await projectCommitArgs(runGit, message));
|
|
1917
1923
|
const hashMatch = out.match(/\[[\w/]+ ([a-f0-9]+)\]/);
|
|
1918
1924
|
return Response.json({ hash: hashMatch?.[1] || "", ok: true });
|
|
1919
1925
|
}
|
|
@@ -1947,13 +1953,15 @@ export async function handleStudioApi(
|
|
|
1947
1953
|
try {
|
|
1948
1954
|
await runGit(["pull"]);
|
|
1949
1955
|
} catch (error) {
|
|
1950
|
-
const
|
|
1956
|
+
const conflicting =
|
|
1951
1957
|
error instanceof GitCommandError
|
|
1952
1958
|
? conflictingPaths(`${error.stdout}\n${error.stderr}`)
|
|
1953
1959
|
: [];
|
|
1954
|
-
if (
|
|
1960
|
+
if (conflicting.length === 0) {
|
|
1955
1961
|
throw error;
|
|
1956
1962
|
}
|
|
1963
|
+
const prefix = await readGitPrefix(runGit);
|
|
1964
|
+
const conflicts = conflicting.map((conflict) => projectRelativePath(prefix, conflict));
|
|
1957
1965
|
return problem(
|
|
1958
1966
|
"conflict",
|
|
1959
1967
|
`Pull stopped: ${conflicts.length} file(s) changed on both sides.`,
|
|
@@ -2010,9 +2018,9 @@ export async function handleStudioApi(
|
|
|
2010
2018
|
return problem("invalidRequest", "Invalid path");
|
|
2011
2019
|
}
|
|
2012
2020
|
/* `./` IS LOAD-BEARING. `git show <rev>:<path>` resolves <path> from the REPOSITORY ROOT,
|
|
2013
|
-
whatever the cwd is; only a `./` prefix makes it relative to the cwd.
|
|
2014
|
-
|
|
2015
|
-
|
|
2021
|
+
whatever the cwd is; only a `./` prefix makes it relative to the cwd. The paths this API
|
|
2022
|
+
answers and accepts are project-relative, and so is the `readFile` the studio pairs this
|
|
2023
|
+
with, so without it a project nested inside its repo compared two DIFFERENT
|
|
2016
2024
|
FILES: the working copy of `examples/package.json` against the committed root
|
|
2017
2025
|
`package.json`, with no error anywhere and a plausible-looking diff. */
|
|
2018
2026
|
const content = await runGit(["show", `${ref}:./${fp}`]);
|