@jxsuite/server 4.0.0 → 4.2.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 -14
- package/src/ai-api.ts +17 -1
- package/src/extension-catalog.ts +218 -0
- package/src/refactor/apply.ts +21 -3
- package/src/refactor/find-refs.ts +0 -0
- package/src/refactor/mounts.ts +8 -0
- package/src/refactor/paths.ts +16 -3
- package/src/refactor/refs.ts +74 -2
- package/src/refactor/scan.ts +29 -3
- package/src/resolve.ts +17 -0
- package/src/server.ts +25 -1
- package/src/studio-api.ts +27 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jxsuite/server",
|
|
3
|
-
"version": "4.
|
|
3
|
+
"version": "4.2.0",
|
|
4
4
|
"description": "Jx development server with live reload, server-side proxy, and studio integration",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -18,6 +18,7 @@
|
|
|
18
18
|
"./ai-api": "./src/ai-api.ts",
|
|
19
19
|
"./data": "./src/data-api.ts",
|
|
20
20
|
"./import-api": "./src/import-api.ts",
|
|
21
|
+
"./catalog": "./src/extension-catalog.ts",
|
|
21
22
|
"./packages": "./src/packages.ts",
|
|
22
23
|
"./project-server": "./src/project-server.ts",
|
|
23
24
|
"./oauth-loopback": "./src/oauth-loopback.ts",
|
|
@@ -39,22 +40,23 @@
|
|
|
39
40
|
"upgrade": "bun update --latest && bun install"
|
|
40
41
|
},
|
|
41
42
|
"dependencies": {
|
|
42
|
-
"@jxsuite/
|
|
43
|
-
"@jxsuite/
|
|
44
|
-
"@jxsuite/
|
|
45
|
-
"@jxsuite/
|
|
46
|
-
"@jxsuite/
|
|
47
|
-
"@jxsuite/
|
|
48
|
-
"@jxsuite/
|
|
49
|
-
"@jxsuite/
|
|
50
|
-
"@jxsuite/
|
|
43
|
+
"@jxsuite/catalog": "^0.1.0",
|
|
44
|
+
"@jxsuite/collab": "^0.9.0",
|
|
45
|
+
"@jxsuite/compiler": "^4.0.0",
|
|
46
|
+
"@jxsuite/create": "^1.3.10",
|
|
47
|
+
"@jxsuite/import": "^0.40.2",
|
|
48
|
+
"@jxsuite/protocol": "^2.3.0",
|
|
49
|
+
"@jxsuite/runtime": "^4.0.0",
|
|
50
|
+
"@jxsuite/schema": "^2.2.0",
|
|
51
|
+
"@jxsuite/site": "^2.0.0",
|
|
52
|
+
"@jxsuite/starters": "^1.8.0",
|
|
51
53
|
"chokidar": "^5.0.0",
|
|
52
54
|
"kysely": "^0.29.5",
|
|
53
|
-
"zod": "^4.4
|
|
55
|
+
"zod": "^4.6.4"
|
|
54
56
|
},
|
|
55
57
|
"devDependencies": {
|
|
56
|
-
"@jxsuite/auth": "^0.
|
|
57
|
-
"@jxsuite/connector": "^0.
|
|
58
|
-
"@jxsuite/parser": "^1.
|
|
58
|
+
"@jxsuite/auth": "^0.6.0",
|
|
59
|
+
"@jxsuite/connector": "^0.6.0",
|
|
60
|
+
"@jxsuite/parser": "^1.8.0"
|
|
59
61
|
}
|
|
60
62
|
}
|
package/src/ai-api.ts
CHANGED
|
@@ -38,7 +38,14 @@ interface ToolCallDelta {
|
|
|
38
38
|
interface OpenAIStreamChunk {
|
|
39
39
|
choices?: {
|
|
40
40
|
index?: number;
|
|
41
|
-
delta?: {
|
|
41
|
+
delta?: {
|
|
42
|
+
content?: string;
|
|
43
|
+
/** Thinking models' chain-of-thought: DeepSeek/Volcengine spell it this way… */
|
|
44
|
+
reasoning_content?: string;
|
|
45
|
+
/** …and OpenRouter this way. Both carry the same thing. */
|
|
46
|
+
reasoning?: string;
|
|
47
|
+
tool_calls?: ToolCallDelta[];
|
|
48
|
+
};
|
|
42
49
|
finish_reason?: string | null;
|
|
43
50
|
}[];
|
|
44
51
|
}
|
|
@@ -350,6 +357,15 @@ export async function handleChat(req: Request): Promise<Response> {
|
|
|
350
357
|
writeSSE(controller, { type: "delta", content: delta.content });
|
|
351
358
|
}
|
|
352
359
|
|
|
360
|
+
/* Chain-of-thought, under either of the two names providers give it. Forwarded rather
|
|
361
|
+
than dropped: DeepSeek's thinking mode requires every prior turn's reasoning back on
|
|
362
|
+
any request carrying `tools`, so a proxy that swallows these frames makes the NEXT
|
|
363
|
+
round a 400 the client cannot repair. */
|
|
364
|
+
const reasoning = delta.reasoning_content ?? delta.reasoning;
|
|
365
|
+
if (typeof reasoning === "string" && reasoning) {
|
|
366
|
+
writeSSE(controller, { type: "reasoning", content: reasoning });
|
|
367
|
+
}
|
|
368
|
+
|
|
353
369
|
// Tool calls
|
|
354
370
|
if (delta.tool_calls) {
|
|
355
371
|
for (const tc of delta.tool_calls) {
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Catalog — what THIS backend can offer a project (specs/extensions.md §9.2).
|
|
3
|
+
*
|
|
4
|
+
* The available half of the pair whose enabled half is `buildExtensionsPayload`. Two sources, and
|
|
5
|
+
* the split is the point:
|
|
6
|
+
*
|
|
7
|
+
* 1. The shipped first-party catalogue (`@jxsuite/catalog`), which is data generated from the
|
|
8
|
+
* `extensions/` tree, so core keeps no dependency on an extension package (§2).
|
|
9
|
+
* 2. The project's own dependencies, scanned for anything exporting a `jx-extension.json`.
|
|
10
|
+
*
|
|
11
|
+
* Both are then annotated with two facts only a backend holds: whether the PROJECT resolves the
|
|
12
|
+
* package, and whether THIS HOST does. Those are probed rather than declared, because what a
|
|
13
|
+
* desktop build stages and what a Worker bundles are different facts and neither is knowable from a
|
|
14
|
+
* package list — which is also why the catalogue is a platform capability instead of a constant.
|
|
15
|
+
*
|
|
16
|
+
* **`installed` is answered here rather than by the client**, because `listPackages` does not mean
|
|
17
|
+
* one thing across backends: the dev server's own packages route drops a declared dependency it
|
|
18
|
+
* cannot resolve under `node_modules`, while desktop's reads the manifest and keeps it. A surface
|
|
19
|
+
* deciding "do I need to install this first?" from that would be right on one backend and wrong on
|
|
20
|
+
* the other.
|
|
21
|
+
*
|
|
22
|
+
* @docs extending/extensions/first-party
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
26
|
+
import { resolve } from "node:path";
|
|
27
|
+
import { listCatalog } from "@jxsuite/catalog";
|
|
28
|
+
import { buildExtensionRegistry } from "@jxsuite/schema/extension-registry";
|
|
29
|
+
import { createNodeFormatIO, probeExtension } from "@jxsuite/compiler/format-host";
|
|
30
|
+
import type { ExtensionCatalogEntry, ExtensionSectionInfo } from "@jxsuite/protocol";
|
|
31
|
+
|
|
32
|
+
/** The well-known manifest filename, mirroring `@jxsuite/schema`'s `EXTENSION_MANIFEST`. */
|
|
33
|
+
const MANIFEST = "jx-extension.json";
|
|
34
|
+
|
|
35
|
+
interface PackageJson {
|
|
36
|
+
dependencies?: Record<string, string>;
|
|
37
|
+
devDependencies?: Record<string, string>;
|
|
38
|
+
jx?: string;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Read a project's package.json, or null when it has none or it will not parse. */
|
|
42
|
+
function readPackageJson(projectRoot: string): PackageJson | null {
|
|
43
|
+
const path = resolve(projectRoot, "package.json");
|
|
44
|
+
if (!existsSync(path)) {
|
|
45
|
+
return null;
|
|
46
|
+
}
|
|
47
|
+
try {
|
|
48
|
+
return JSON.parse(readFileSync(path, "utf8")) as PackageJson;
|
|
49
|
+
} catch {
|
|
50
|
+
// A project mid-edit should cost the catalogue its discovered half, not the whole response.
|
|
51
|
+
return null;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Every dependency name a project declares, runtime and dev alike. */
|
|
56
|
+
function declaredDependencies(projectRoot: string): string[] {
|
|
57
|
+
const pkg = readPackageJson(projectRoot);
|
|
58
|
+
return pkg === null
|
|
59
|
+
? []
|
|
60
|
+
: [...Object.keys(pkg.dependencies ?? {}), ...Object.keys(pkg.devDependencies ?? {})];
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Load one extension through the REGISTRY rather than reading its manifest directly.
|
|
65
|
+
*
|
|
66
|
+
* Discovery and enablement then agree by construction: this is the same loader `project.json` will
|
|
67
|
+
* run, so a package that builds here is one that will build there, and a package that throws here
|
|
68
|
+
* throws with the message that names the fix. `FormatEntry`'s constructor is pure data and never
|
|
69
|
+
* imports an implementation module, so this stays JSON reads.
|
|
70
|
+
*
|
|
71
|
+
* @param {string} projectRoot
|
|
72
|
+
* @param {string} name - Bare package specifier
|
|
73
|
+
* @returns {Promise<{ sections; formats; title?; description? } | { problem: string }>}
|
|
74
|
+
*/
|
|
75
|
+
async function loadEntry(
|
|
76
|
+
projectRoot: string,
|
|
77
|
+
name: string,
|
|
78
|
+
): Promise<
|
|
79
|
+
| { description?: string; formats: string[]; sections: ExtensionSectionInfo[]; title?: string }
|
|
80
|
+
| { problem: string }
|
|
81
|
+
> {
|
|
82
|
+
try {
|
|
83
|
+
const registry = await buildExtensionRegistry(
|
|
84
|
+
[name],
|
|
85
|
+
createNodeFormatIO(projectRoot),
|
|
86
|
+
resolve(projectRoot, "project.json"),
|
|
87
|
+
);
|
|
88
|
+
const [info] = registry.extensions;
|
|
89
|
+
if (!info) {
|
|
90
|
+
return { problem: `"${name}" did not load as an extension` };
|
|
91
|
+
}
|
|
92
|
+
const sections: ExtensionSectionInfo[] = [];
|
|
93
|
+
const formats = new Set<string>();
|
|
94
|
+
for (const cls of info.classes) {
|
|
95
|
+
if (cls.project) {
|
|
96
|
+
const { key, title } = cls.project;
|
|
97
|
+
sections.push({ key, ...(typeof title === "string" ? { title } : {}) });
|
|
98
|
+
}
|
|
99
|
+
for (const ext of cls.extensions) {
|
|
100
|
+
formats.add(ext);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
return {
|
|
104
|
+
formats: [...formats].toSorted(),
|
|
105
|
+
sections,
|
|
106
|
+
...(info.manifest.title === undefined ? {} : { title: info.manifest.title }),
|
|
107
|
+
...(info.manifest.description === undefined
|
|
108
|
+
? {}
|
|
109
|
+
: { description: info.manifest.description }),
|
|
110
|
+
};
|
|
111
|
+
} catch (error) {
|
|
112
|
+
return { problem: error instanceof Error ? error.message : String(error) };
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Whether a package that failed to resolve nonetheless MEANT to be an extension.
|
|
118
|
+
*
|
|
119
|
+
* `"jx"` is not what a host resolves — the registry goes through the exports map and nothing else,
|
|
120
|
+
* which is why the probe is authoritative. But reading it separates "this package got its exports
|
|
121
|
+
* map wrong" from "this package is not an extension", and that is the difference between a silent
|
|
122
|
+
* omission and a row that says what is broken. It is the first job the field has ever had.
|
|
123
|
+
*
|
|
124
|
+
* @param {string} projectRoot
|
|
125
|
+
* @param {string} name
|
|
126
|
+
* @returns {boolean}
|
|
127
|
+
*/
|
|
128
|
+
function declaresJxField(projectRoot: string, name: string): boolean {
|
|
129
|
+
const path = resolve(projectRoot, "node_modules", name, "package.json");
|
|
130
|
+
if (!existsSync(path)) {
|
|
131
|
+
return false;
|
|
132
|
+
}
|
|
133
|
+
try {
|
|
134
|
+
return typeof (JSON.parse(readFileSync(path, "utf8")) as PackageJson).jx === "string";
|
|
135
|
+
} catch {
|
|
136
|
+
return false;
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* The catalogue for one project: the shipped first-party entries, then anything its own
|
|
142
|
+
* dependencies declare.
|
|
143
|
+
*
|
|
144
|
+
* Every first-party entry is returned whether or not the project has it, because the whole purpose
|
|
145
|
+
* is to advertise what could be turned on. A discovered entry is only ever one the project already
|
|
146
|
+
* depends on.
|
|
147
|
+
*
|
|
148
|
+
* @param {string} projectRoot - Absolute path to the project
|
|
149
|
+
* @returns {Promise<ExtensionCatalogEntry[]>}
|
|
150
|
+
*/
|
|
151
|
+
export async function buildExtensionCatalog(projectRoot: string): Promise<ExtensionCatalogEntry[]> {
|
|
152
|
+
const entries: ExtensionCatalogEntry[] = [];
|
|
153
|
+
const seen = new Set<string>();
|
|
154
|
+
|
|
155
|
+
for (const meta of listCatalog()) {
|
|
156
|
+
seen.add(meta.name);
|
|
157
|
+
const found = probeExtension(projectRoot, meta.name);
|
|
158
|
+
entries.push({
|
|
159
|
+
description: meta.description,
|
|
160
|
+
docs: meta.docs,
|
|
161
|
+
installed: found.project,
|
|
162
|
+
name: meta.name,
|
|
163
|
+
sections: meta.sections,
|
|
164
|
+
source: "first-party",
|
|
165
|
+
title: meta.title,
|
|
166
|
+
...(meta.formats ? { formats: meta.formats } : {}),
|
|
167
|
+
...(meta.requires ? { requires: meta.requires } : {}),
|
|
168
|
+
// Bundled means "resolves without a project install": the host answers for it, and the
|
|
169
|
+
// Project does not have to.
|
|
170
|
+
...(found.host && !found.project ? { bundled: true } : {}),
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
for (const name of declaredDependencies(projectRoot)) {
|
|
175
|
+
if (seen.has(name)) {
|
|
176
|
+
continue;
|
|
177
|
+
}
|
|
178
|
+
seen.add(name);
|
|
179
|
+
const found = probeExtension(projectRoot, name);
|
|
180
|
+
if (!found.project) {
|
|
181
|
+
if (declaresJxField(projectRoot, name)) {
|
|
182
|
+
entries.push({
|
|
183
|
+
installed: true,
|
|
184
|
+
name,
|
|
185
|
+
problem:
|
|
186
|
+
`declares "jx" but does not export "./${MANIFEST}", so no host can resolve its ` +
|
|
187
|
+
`manifest — add that entry to its exports map`,
|
|
188
|
+
sections: [],
|
|
189
|
+
source: "project",
|
|
190
|
+
});
|
|
191
|
+
}
|
|
192
|
+
continue;
|
|
193
|
+
}
|
|
194
|
+
const loaded = await loadEntry(projectRoot, name);
|
|
195
|
+
if ("problem" in loaded) {
|
|
196
|
+
entries.push({
|
|
197
|
+
installed: true,
|
|
198
|
+
name,
|
|
199
|
+
problem: loaded.problem,
|
|
200
|
+
sections: [],
|
|
201
|
+
source: "project",
|
|
202
|
+
});
|
|
203
|
+
continue;
|
|
204
|
+
}
|
|
205
|
+
entries.push({
|
|
206
|
+
installed: true,
|
|
207
|
+
name,
|
|
208
|
+
sections: loaded.sections,
|
|
209
|
+
source: "project",
|
|
210
|
+
...(loaded.title === undefined ? {} : { title: loaded.title }),
|
|
211
|
+
...(loaded.description === undefined ? {} : { description: loaded.description }),
|
|
212
|
+
...(loaded.formats.length > 0 ? { formats: loaded.formats } : {}),
|
|
213
|
+
...(found.host ? { bundled: true } : {}),
|
|
214
|
+
});
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
return entries;
|
|
218
|
+
}
|
package/src/refactor/apply.ts
CHANGED
|
@@ -122,7 +122,7 @@ export async function applyRename(opts: ApplyRenameOptions): Promise<RenameRepor
|
|
|
122
122
|
}
|
|
123
123
|
const fp = fwd(resolve(opts.root, match));
|
|
124
124
|
try {
|
|
125
|
-
const { doc, serialize } = await loadDoc(fp, opts.registry);
|
|
125
|
+
const { doc, rewrite, serialize } = await loadDoc(fp, opts.registry);
|
|
126
126
|
const docNewDir = dirname(fp);
|
|
127
127
|
const docOldDir = isUnder(fp, absTo) ? dirname(absFrom + fp.slice(absTo.length)) : docNewDir;
|
|
128
128
|
|
|
@@ -139,11 +139,29 @@ export async function applyRename(opts: ApplyRenameOptions): Promise<RenameRepor
|
|
|
139
139
|
if (changes.length === 0 && tagCount === 0) {
|
|
140
140
|
continue;
|
|
141
141
|
}
|
|
142
|
-
|
|
142
|
+
/*
|
|
143
|
+
* Two ways to write a document back, and the narrower one is not a fallback for a broken
|
|
144
|
+
* format — it is the only honest promise a load-only format can make. A CSV collection has a
|
|
145
|
+
* parser and deliberately no serializer, so a reference living in one used to be COUNTED and
|
|
146
|
+
* then reported as a remainder the author had to repair by hand (issue 246). `rewrite`
|
|
147
|
+
* replaces the authored values in the file's own bytes and changes nothing else, which is all
|
|
148
|
+
* a rename ever needed.
|
|
149
|
+
*
|
|
150
|
+
* `serialize` still wins where it exists: a full round trip can express a change a list of
|
|
151
|
+
* value edits cannot. A TAG rename is exactly such a change, so a document carrying one and
|
|
152
|
+
* no serializer is still named in the report rather than half-written.
|
|
153
|
+
*/
|
|
154
|
+
const written =
|
|
155
|
+
serialize === null
|
|
156
|
+
? tagCount === 0 && rewrite !== null
|
|
157
|
+
? await rewrite(changes)
|
|
158
|
+
: null
|
|
159
|
+
: await serialize(doc);
|
|
160
|
+
if (written === null) {
|
|
143
161
|
report.errors.push({ error: `No serializer for "${match}"`, path: fwd(match) });
|
|
144
162
|
continue;
|
|
145
163
|
}
|
|
146
|
-
await writeFile(fp,
|
|
164
|
+
await writeFile(fp, written);
|
|
147
165
|
|
|
148
166
|
if (changes.length > 0) {
|
|
149
167
|
report.references.files.push({ changes, count: changes.length, path: fwd(match) });
|
|
Binary file
|
package/src/refactor/mounts.ts
CHANGED
|
@@ -19,6 +19,14 @@
|
|
|
19
19
|
* dropped: its files are not addressable by a project-relative path, so no rename can reach them
|
|
20
20
|
* and no reference to them can be counted. `projectPathsForSiteUrl` declines the same case by
|
|
21
21
|
* name.
|
|
22
|
+
*
|
|
23
|
+
* That drop is deliberate and is not the near half of a bug. A collection's `source` may point
|
|
24
|
+
* outside the project (site-architecture.md §9.3), and its media renders — the compiler and the dev
|
|
25
|
+
* server both publish it at the mount URL — but the editing host's file API is project-scoped by
|
|
26
|
+
* containment check, so no surface can list, open or ask about the file either. Nothing therefore
|
|
27
|
+
* reports a confident zero about it, which is the failure the lane math exists to prevent. Reaching
|
|
28
|
+
* such a file means giving it a mount-relative identity in that file API, not widening this filter;
|
|
29
|
+
* see §9.3, which records the decision (issue 244).
|
|
22
30
|
*/
|
|
23
31
|
|
|
24
32
|
import { relative } from "node:path";
|
package/src/refactor/paths.ts
CHANGED
|
@@ -179,7 +179,7 @@ const FILE_SEGMENT_RE = /^[^.][^/]*\.[A-Za-z0-9]{0,7}[A-Za-z][A-Za-z0-9]{0,7}$/;
|
|
|
179
179
|
/**
|
|
180
180
|
* True when a string is shaped like a file a document could reference.
|
|
181
181
|
*
|
|
182
|
-
* The walker in refs.ts knows
|
|
182
|
+
* The walker in refs.ts knows eleven keys that carry references by name. The commonest media
|
|
183
183
|
* reference in a real project is under none of them — a schema-typed component prop (`$props.bg`),
|
|
184
184
|
* a content entry's frontmatter (`cover:`), `project.json`'s `defaults.layout` — so the walker
|
|
185
185
|
* falls back to SHAPE for every other key, and this is the shape test.
|
|
@@ -210,6 +210,19 @@ export function looksLikeFileRef(value: string): boolean {
|
|
|
210
210
|
return FILE_SEGMENT_RE.test(last);
|
|
211
211
|
}
|
|
212
212
|
|
|
213
|
+
/**
|
|
214
|
+
* Restore a trailing slash the path math dropped, when the author wrote one.
|
|
215
|
+
*
|
|
216
|
+
* `normalizeSegments` discards empty segments, so `./content/posts/` resolves and re-emits as
|
|
217
|
+
* `./content/posts`. For a file that is invisible; for a DIRECTORY reference — a content
|
|
218
|
+
* collection's `source` — it is a gratuitous edit to a line the rename had no business restyling,
|
|
219
|
+
* and `rewriteRef`'s contract is to preserve the authored style. A bare `/` is left alone: it is
|
|
220
|
+
* already only a slash.
|
|
221
|
+
*/
|
|
222
|
+
function keepTrailingSlash(out: string, core: string): string {
|
|
223
|
+
return core.endsWith("/") && core !== "/" && !out.endsWith("/") ? `${out}/` : out;
|
|
224
|
+
}
|
|
225
|
+
|
|
213
226
|
/** Context describing a single rename, shared by every reference rewrite within one document. */
|
|
214
227
|
export interface RemapCtx {
|
|
215
228
|
/** Absolute POSIX project root. */
|
|
@@ -269,7 +282,7 @@ export function rewriteRef(
|
|
|
269
282
|
} else {
|
|
270
283
|
out = rel;
|
|
271
284
|
}
|
|
272
|
-
out
|
|
285
|
+
out = keepTrailingSlash(out, core) + suffix;
|
|
273
286
|
return out === core + suffix ? null : out;
|
|
274
287
|
}
|
|
275
288
|
|
|
@@ -301,7 +314,7 @@ function rewriteSiteUrl(core: string, suffix: string, ctx: RemapCtx): string | n
|
|
|
301
314
|
if (url === null) {
|
|
302
315
|
continue;
|
|
303
316
|
}
|
|
304
|
-
const out = url + suffix;
|
|
317
|
+
const out = keepTrailingSlash(url, core) + suffix;
|
|
305
318
|
return out === core + suffix ? null : out;
|
|
306
319
|
}
|
|
307
320
|
return null;
|
package/src/refactor/refs.ts
CHANGED
|
@@ -14,6 +14,12 @@
|
|
|
14
14
|
* `rewriteRef`'s resolve-and-compare gate in paths.ts: a reference is only rewritten (or counted as
|
|
15
15
|
* a usage) when it actually resolves to the file in question.
|
|
16
16
|
*
|
|
17
|
+
* **A reference is not always a value.** Three of the four visit helpers offer a value under a
|
|
18
|
+
* container-plus-position; {@link visitKeys} offers a map's KEYS, because `project.json`'s `copy`
|
|
19
|
+
* map names the files it copies there. Everything downstream is unchanged — a key is renamed by the
|
|
20
|
+
* same visitor returning the same kind of string — but the fourth shape had to exist before the
|
|
21
|
+
* walk could see the reference at all (issue 242).
|
|
22
|
+
*
|
|
17
23
|
* The visitor seam is what stops the read side from being a second, drifting copy of the key list.
|
|
18
24
|
* A new reference-bearing key added below is a key the usage count sees on the same commit.
|
|
19
25
|
*
|
|
@@ -40,8 +46,10 @@ export interface RewriteResult {
|
|
|
40
46
|
*
|
|
41
47
|
* @param value — the raw authored reference, exactly as written.
|
|
42
48
|
* @param refType — which key carried it (`$ref`, `$layout`, `attr`, `imports`, `$elements`, `url`,
|
|
43
|
-
* or `path` for a value matched by shape
|
|
44
|
-
*
|
|
49
|
+
* `source`, `copy` for a map KEY naming a copied file, or `path` for a value matched by shape
|
|
50
|
+
* rather than by key name).
|
|
51
|
+
* @param rootRelativeBare — whether a bare value resolves against the project root (`$layout`,
|
|
52
|
+
* `copy`).
|
|
45
53
|
* @returns A replacement string to write back, or `null` to leave the document untouched.
|
|
46
54
|
*/
|
|
47
55
|
export type RefVisitor = (
|
|
@@ -82,6 +90,41 @@ function visitScalar(
|
|
|
82
90
|
}
|
|
83
91
|
}
|
|
84
92
|
|
|
93
|
+
/**
|
|
94
|
+
* Offer every KEY of a map to the visitor, renaming in place and preserving insertion order.
|
|
95
|
+
*
|
|
96
|
+
* The fourth visit helper, and the only one whose reference is not a value. `project.json`'s `copy`
|
|
97
|
+
* map names its sources in its keys (`{"assets/brochure.pdf": "brochure.pdf"}`), so a walk that
|
|
98
|
+
* only ever offered values could not see them at all — renaming a copied file reported zero
|
|
99
|
+
* references and rewrote nothing (issue 242).
|
|
100
|
+
*
|
|
101
|
+
* Order is preserved by rebuilding rather than by deleting one key and re-adding it: a plain
|
|
102
|
+
* `delete` + set moves the renamed entry to the end, which is a diff on a file nobody edited. When
|
|
103
|
+
* a rename collides with an existing key the later entry wins, exactly as an authored duplicate
|
|
104
|
+
* would.
|
|
105
|
+
*/
|
|
106
|
+
function visitKeys(
|
|
107
|
+
container: Record<string, unknown>,
|
|
108
|
+
refType: string,
|
|
109
|
+
visit: RefVisitor,
|
|
110
|
+
rootRelativeBare: boolean,
|
|
111
|
+
): void {
|
|
112
|
+
const entries = Object.entries(container);
|
|
113
|
+
const renamed = entries.map(([key, value]): [string, unknown] => {
|
|
114
|
+
const out = visit(key, refType, rootRelativeBare);
|
|
115
|
+
return [out === null ? key : out, value];
|
|
116
|
+
});
|
|
117
|
+
if (renamed.every(([key], i) => key === entries[i]![0])) {
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
for (const [key] of entries) {
|
|
121
|
+
delete container[key];
|
|
122
|
+
}
|
|
123
|
+
for (const [key, value] of renamed) {
|
|
124
|
+
container[key] = value;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
85
128
|
/** Offer a string array member (e.g. an `$elements` entry) to the visitor. */
|
|
86
129
|
function visitIndexed(arr: unknown[], index: number, refType: string, visit: RefVisitor): void {
|
|
87
130
|
const value = arr[index];
|
|
@@ -203,6 +246,35 @@ export function walkDocRefs(node: unknown, visit: RefVisitor): void {
|
|
|
203
246
|
walkStyle(value, visit);
|
|
204
247
|
break;
|
|
205
248
|
}
|
|
249
|
+
case "copy": {
|
|
250
|
+
/*
|
|
251
|
+
* `project.json`'s declarative copy map (site-architecture.md §5). Its KEYS are project
|
|
252
|
+
* files and its VALUES are destinations inside `outDir` — a directory the project does not
|
|
253
|
+
* contain and a rename can never move. So the key is visited and the value deliberately is
|
|
254
|
+
* not: offering the value to the shape fallback, which is what happened before, meant
|
|
255
|
+
* renaming a file at the project root could rewrite an unrelated copy DESTINATION that
|
|
256
|
+
* merely shared its name.
|
|
257
|
+
*/
|
|
258
|
+
if (value && typeof value === "object" && !Array.isArray(value)) {
|
|
259
|
+
visitKeys(value as Record<string, unknown>, "copy", visit, true);
|
|
260
|
+
}
|
|
261
|
+
break;
|
|
262
|
+
}
|
|
263
|
+
case "source": {
|
|
264
|
+
/*
|
|
265
|
+
* A content collection's source (site-architecture.md §6), which may name a single file
|
|
266
|
+
* (`./content/listings.csv`) or a DIRECTORY (`./content/posts/`).
|
|
267
|
+
*
|
|
268
|
+
* Named rather than left to the shape fallback because a directory has no extension, and
|
|
269
|
+
* the extension test is exactly what keeps that fallback from admitting every route and
|
|
270
|
+
* bare word. Here the key is known and the value's shape is not diagnostic, so the value is
|
|
271
|
+
* offered unconditionally and precision stays where it always was — the caller's
|
|
272
|
+
* resolve-and-compare gate, which only matches a value resolving to the file or directory
|
|
273
|
+
* in question. A remote `https://…` source is filtered by `classifyRef` before that.
|
|
274
|
+
*/
|
|
275
|
+
visitScalar(obj, key, "source", visit, false);
|
|
276
|
+
break;
|
|
277
|
+
}
|
|
206
278
|
/*
|
|
207
279
|
* Everything else, by SHAPE.
|
|
208
280
|
*
|
package/src/refactor/scan.ts
CHANGED
|
@@ -14,10 +14,28 @@ import type { FormatRegistry } from "@jxsuite/schema/format-registry";
|
|
|
14
14
|
/** Normalise a path to forward slashes (Windows `path` returns backslashes). */
|
|
15
15
|
export const fwd = (p: string) => p.replaceAll("\\", "/");
|
|
16
16
|
|
|
17
|
-
/**
|
|
17
|
+
/** One authored value to replace in a document's own source text. */
|
|
18
|
+
export interface ValueEdit {
|
|
19
|
+
from: string;
|
|
20
|
+
to: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** A parsed document and the two ways it might be written back. */
|
|
18
24
|
export interface LoadedDoc {
|
|
19
25
|
doc: unknown;
|
|
26
|
+
/** Round-trips the whole document. Null when the format declares no `serialize`. */
|
|
20
27
|
serialize: ((doc: unknown) => Promise<string>) | null;
|
|
28
|
+
/**
|
|
29
|
+
* Replaces authored values in the file's own bytes, changing nothing else. Null when the format
|
|
30
|
+
* declares no `rewrite`.
|
|
31
|
+
*
|
|
32
|
+
* The narrower of the two, and the only one a load-only format can offer: a CSV collection is a
|
|
33
|
+
* data source entries are read FROM, so there is no document to serialize — but one cell's text
|
|
34
|
+
* can be changed without inventing a quoting style. `applyRename` prefers `serialize` where it
|
|
35
|
+
* exists, because a full round trip can express a change (a tag rename) that a list of value
|
|
36
|
+
* edits cannot.
|
|
37
|
+
*/
|
|
38
|
+
rewrite: ((edits: readonly ValueEdit[]) => Promise<string>) | null;
|
|
21
39
|
}
|
|
22
40
|
|
|
23
41
|
const isJsonPath = (p: string) => p.endsWith(".json");
|
|
@@ -58,8 +76,8 @@ export function documentGlob(registry: FormatRegistry): Bun.Glob {
|
|
|
58
76
|
}
|
|
59
77
|
|
|
60
78
|
/**
|
|
61
|
-
* Read + parse a file, returning the doc and
|
|
62
|
-
*
|
|
79
|
+
* Read + parse a file, returning the doc and whichever write-back paths the format declares. Throws
|
|
80
|
+
* when no parser claims the extension.
|
|
63
81
|
*/
|
|
64
82
|
export async function loadDoc(fp: string, registry: FormatRegistry): Promise<LoadedDoc> {
|
|
65
83
|
const raw = await readFile(fp, "utf8");
|
|
@@ -67,6 +85,7 @@ export async function loadDoc(fp: string, registry: FormatRegistry): Promise<Loa
|
|
|
67
85
|
const trailingNewline = raw.endsWith("\n");
|
|
68
86
|
return {
|
|
69
87
|
doc: JSON.parse(raw) as unknown,
|
|
88
|
+
rewrite: null,
|
|
70
89
|
serialize: (doc) =>
|
|
71
90
|
Promise.resolve(JSON.stringify(doc, null, 2) + (trailingNewline ? "\n" : "")),
|
|
72
91
|
};
|
|
@@ -78,8 +97,15 @@ export async function loadDoc(fp: string, registry: FormatRegistry): Promise<Loa
|
|
|
78
97
|
}
|
|
79
98
|
const doc = await parseEntry.call("parse", raw);
|
|
80
99
|
const serializeEntry = registry.byExtension(ext, "serialize");
|
|
100
|
+
const rewriteEntry = registry.byExtension(ext, "rewrite");
|
|
81
101
|
return {
|
|
82
102
|
doc,
|
|
103
|
+
rewrite: rewriteEntry
|
|
104
|
+
? async (edits) => {
|
|
105
|
+
const out = await rewriteEntry.call("rewrite", raw, edits);
|
|
106
|
+
return typeof out === "string" ? out : String(out);
|
|
107
|
+
}
|
|
108
|
+
: null,
|
|
83
109
|
serialize: serializeEntry
|
|
84
110
|
? async (d) => {
|
|
85
111
|
const out = await serializeEntry.call("serialize", d);
|
package/src/resolve.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
/** Resolve.js — Generic $src module proxy + timing: "server" function proxy */
|
|
2
2
|
|
|
3
|
+
import { siteBasePath } from "@jxsuite/schema/asset-paths";
|
|
3
4
|
import { dirname, relative, resolve } from "node:path";
|
|
4
5
|
import { errorMessage, parseClassDef } from "@jxsuite/schema/parse";
|
|
5
6
|
import { existsSync, readFileSync, statSync } from "node:fs";
|
|
@@ -99,6 +100,22 @@ async function loadProjectEntry(projectRoot: string) {
|
|
|
99
100
|
}
|
|
100
101
|
}
|
|
101
102
|
|
|
103
|
+
/**
|
|
104
|
+
* The path a project says it is deployed under, or `""` for a site at an origin root.
|
|
105
|
+
*
|
|
106
|
+
* Read off `project.json`'s `url` (`@jxsuite/schema/asset-paths`), through the same mtime-keyed
|
|
107
|
+
* cache the asset mounts use — a second cache on the same file would be a second thing to
|
|
108
|
+
* invalidate, and this is asked on every request.
|
|
109
|
+
*
|
|
110
|
+
* @param {string} projectRoot
|
|
111
|
+
* @returns {Promise<string>} A path with a leading slash and no trailing one, or `""`
|
|
112
|
+
*/
|
|
113
|
+
export async function projectSiteBase(projectRoot: string): Promise<string> {
|
|
114
|
+
const entry = await loadProjectEntry(projectRoot);
|
|
115
|
+
const config = entry?.context.config as ProjectConfig | undefined;
|
|
116
|
+
return siteBasePath(config?.url);
|
|
117
|
+
}
|
|
118
|
+
|
|
102
119
|
/**
|
|
103
120
|
* Extension asset mounts for a project (specs/extensions.md §8.5) — the directories the dev server
|
|
104
121
|
* serves at their site URLs, so content-relative images resolve exactly as they will once built.
|
package/src/server.ts
CHANGED
|
@@ -18,7 +18,12 @@ import { buildAll } from "./build.ts";
|
|
|
18
18
|
import { createCollabRegistry } from "./collab.ts";
|
|
19
19
|
import { createWatcher, injectSSE } from "./watch.ts";
|
|
20
20
|
import { handleJxMounts } from "./jx-mounts.ts";
|
|
21
|
-
import {
|
|
21
|
+
import {
|
|
22
|
+
handleResolve,
|
|
23
|
+
handleServerFunction,
|
|
24
|
+
projectAssetMounts,
|
|
25
|
+
projectSiteBase,
|
|
26
|
+
} from "./resolve.ts";
|
|
22
27
|
import {
|
|
23
28
|
assertAccessible,
|
|
24
29
|
assertCreatableParent,
|
|
@@ -301,6 +306,25 @@ export async function createDevServer(options: {
|
|
|
301
306
|
}
|
|
302
307
|
// Keep the un-collapsed decoded path: the static branch detects a POSIX-absolute "//abs" prefix.
|
|
303
308
|
let { path } = decoded;
|
|
309
|
+
|
|
310
|
+
/*
|
|
311
|
+
* A subpath deployment's base, stripped once at the edge (site-architecture.md §5).
|
|
312
|
+
*
|
|
313
|
+
* A project whose `url` carries a path is SERVED from that path, and a build emits every URL
|
|
314
|
+
* under it — so a preview that only answered the bare path would exercise URLs the deployed
|
|
315
|
+
* site never uses, which is the class of bug that is only found in production. Stripping
|
|
316
|
+
* rather than moving the dev root means both spellings work: `localhost:3000/` is unchanged,
|
|
317
|
+
* and `localhost:3000/m/my-site/assets/x.js` resolves to the same file.
|
|
318
|
+
*
|
|
319
|
+
* `url` is mutated too, because everything downstream routes on it — the extension mounts
|
|
320
|
+
* match `url.pathname` against their own basePath, which is declared bare.
|
|
321
|
+
*/
|
|
322
|
+
const siteBase = await projectSiteBase(activeProjectRoot ?? absRoot);
|
|
323
|
+
if (siteBase !== "" && (path === siteBase || path.startsWith(`${siteBase}/`))) {
|
|
324
|
+
path = path.slice(siteBase.length) || "/";
|
|
325
|
+
url.pathname = url.pathname.slice(siteBase.length) || "/";
|
|
326
|
+
}
|
|
327
|
+
|
|
304
328
|
if (path.endsWith("/")) {
|
|
305
329
|
path += "index.html";
|
|
306
330
|
} else if (path === "") {
|
package/src/studio-api.ts
CHANGED
|
@@ -1497,6 +1497,26 @@ export async function handleStudioApi(
|
|
|
1497
1497
|
}
|
|
1498
1498
|
}
|
|
1499
1499
|
|
|
1500
|
+
// What this backend can OFFER, enabled or not (specs/extensions.md §9.2) — the shipped
|
|
1501
|
+
// First-party catalogue plus any dependency of THIS project exporting a jx-extension.json, each
|
|
1502
|
+
// Marked with whether the project resolves it and whether this host does.
|
|
1503
|
+
//
|
|
1504
|
+
// Deliberately NOT a field on /__studio/formats. That route builds the project's registry and
|
|
1505
|
+
// Fails when a declared extension does not resolve, which is exactly the state a reader is in
|
|
1506
|
+
// When they need the catalogue to repair it; riding on formats would make the answer unavailable
|
|
1507
|
+
// Precisely when it is the fix.
|
|
1508
|
+
if (path === "/__studio/catalog" && req.method === "GET") {
|
|
1509
|
+
const dir = url.searchParams.get("dir") || activeProjectRoot || root;
|
|
1510
|
+
const projectRoot = isAbsolute(dir) ? dir : resolve(root, dir);
|
|
1511
|
+
try {
|
|
1512
|
+
assertAccessible(projectRoot, root, activeProjectRoot);
|
|
1513
|
+
const { buildExtensionCatalog } = await import("./extension-catalog.ts");
|
|
1514
|
+
return Response.json(await buildExtensionCatalog(projectRoot));
|
|
1515
|
+
} catch (error) {
|
|
1516
|
+
return problem("invalidRequest", errorMessage(error));
|
|
1517
|
+
}
|
|
1518
|
+
}
|
|
1519
|
+
|
|
1500
1520
|
// Pre-bundled per-project entry schemas (project.schema.json / document.schema.json) for the
|
|
1501
1521
|
// Studio's editor — regenerated on demand when missing or older than project.json, then bundled
|
|
1502
1522
|
// Into self-contained compound documents so no relative $ref resolution is needed client-side.
|
|
@@ -1846,7 +1866,13 @@ export async function handleStudioApi(
|
|
|
1846
1866
|
if (fp.includes("..")) {
|
|
1847
1867
|
return problem("invalidRequest", "Invalid path");
|
|
1848
1868
|
}
|
|
1849
|
-
|
|
1869
|
+
/* `./` IS LOAD-BEARING. `git show <rev>:<path>` resolves <path> from the REPOSITORY ROOT,
|
|
1870
|
+
whatever the cwd is; only a `./` prefix makes it relative to the cwd. Every other verb
|
|
1871
|
+
here — status, diff, checkout — is cwd-relative, and so is the `readFile` the studio
|
|
1872
|
+
pairs this with, so without it a project nested inside its repo compared two DIFFERENT
|
|
1873
|
+
FILES: the working copy of `examples/package.json` against the committed root
|
|
1874
|
+
`package.json`, with no error anywhere and a plausible-looking diff. */
|
|
1875
|
+
const content = await runGit(["show", `${ref}:./${fp}`]);
|
|
1850
1876
|
return Response.json({ content });
|
|
1851
1877
|
}
|
|
1852
1878
|
|