@jxsuite/server 4.0.0 → 4.1.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.1.0",
4
4
  "description": "Jx development server with live reload, server-side proxy, and studio integration",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -39,22 +39,22 @@
39
39
  "upgrade": "bun update --latest && bun install"
40
40
  },
41
41
  "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",
42
+ "@jxsuite/collab": "^0.8.5",
43
+ "@jxsuite/compiler": "^3.1.0",
44
+ "@jxsuite/create": "^1.3.9",
45
+ "@jxsuite/import": "^0.40.1",
46
+ "@jxsuite/protocol": "^2.2.0",
47
+ "@jxsuite/runtime": "^3.1.0",
48
+ "@jxsuite/schema": "^2.1.0",
49
+ "@jxsuite/site": "^1.1.0",
50
+ "@jxsuite/starters": "^1.7.0",
51
51
  "chokidar": "^5.0.0",
52
52
  "kysely": "^0.29.5",
53
- "zod": "^4.4.3"
53
+ "zod": "^4.5.4"
54
54
  },
55
55
  "devDependencies": {
56
- "@jxsuite/auth": "^0.5.6",
57
- "@jxsuite/connector": "^0.5.6",
58
- "@jxsuite/parser": "^1.6.0"
56
+ "@jxsuite/auth": "^0.5.7",
57
+ "@jxsuite/connector": "^0.5.7",
58
+ "@jxsuite/parser": "^1.7.0"
59
59
  }
60
60
  }
@@ -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 === "") {