@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jxsuite/server",
3
- "version": "4.0.0",
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/collab": "^0.8.4",
43
- "@jxsuite/compiler": "^3.0.0",
44
- "@jxsuite/create": "^1.3.8",
45
- "@jxsuite/import": "^0.40.0",
46
- "@jxsuite/protocol": "^2.1.0",
47
- "@jxsuite/runtime": "^3.0.0",
48
- "@jxsuite/schema": "^2.0.0",
49
- "@jxsuite/site": "^1.0.0",
50
- "@jxsuite/starters": "^1.6.5",
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.3"
55
+ "zod": "^4.6.4"
54
56
  },
55
57
  "devDependencies": {
56
- "@jxsuite/auth": "^0.5.6",
57
- "@jxsuite/connector": "^0.5.6",
58
- "@jxsuite/parser": "^1.6.0"
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?: { content?: string; tool_calls?: ToolCallDelta[] };
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
+ }
@@ -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
- if (!serialize) {
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, await serialize(doc));
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
@@ -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";
@@ -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 nine keys that carry references by name. The commonest media
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 += suffix;
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;
@@ -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 rather than by key name).
44
- * @param rootRelativeBare — whether a bare value resolves against the project root (`$layout`).
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
  *
@@ -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
- /** A parsed document plus the serializer that round-trips it back to disk (null when none). */
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 a matching serializer (null when the format can parse
62
- * but not serialize). Throws when no parser claims the extension.
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 { handleResolve, handleServerFunction, projectAssetMounts } from "./resolve.ts";
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
- const content = await runGit(["show", `${ref}:${fp}`]);
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