@jxsuite/server 3.0.0 → 4.0.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.
@@ -0,0 +1,177 @@
1
+ /**
2
+ * The script a live-preview page runs: reload when the tree changes, follow a retarget, and say so
3
+ * when the origin is gone.
4
+ *
5
+ * It ships as a string rather than a file because it is served, not imported — the page that runs
6
+ * it is on a different origin from anything in this package, and generating it is how the URLs it
7
+ * needs stay a fact of the server that serves it.
8
+ *
9
+ * Three behaviours are here rather than in the page because only this side can know them:
10
+ *
11
+ * **A reload, not an in-place re-render.** The runtime cannot be re-run over a live document:
12
+ * `Jx()` does not scope its effects, a custom element cannot be re-registered in a realm (so an
13
+ * edited component would keep its old class forever), the document cache has no invalidation,
14
+ * `$head` would be injected twice, and the shell renders the head server-side anyway. A document
15
+ * load is the only thing that is actually correct, and it is cheap here because composing is
16
+ * local.
17
+ *
18
+ * **Scroll is restored by hand.** The browser's own restoration runs against the document as it
19
+ * exists at that moment, and a client-rendered page has an empty body then — so it restores to a
20
+ * zero-height document and lands at the top. The shell turns it off; this puts it back after the
21
+ * render, and keeps re-applying briefly because content resolved after mount changes the height.
22
+ *
23
+ * **A dead origin is distinguished from a restarting one.** EventSource retries forever and cannot
24
+ * tell the two apart, so after enough consecutive failures this asks: one fetch that a live origin
25
+ * always answers. A network error means the project closed, and then the stream is stopped rather
26
+ * than left retrying every half-second for as long as the tab is open. The last render stays on
27
+ * screen, because it is still what the reader was reading.
28
+ */
29
+
30
+ /** Where the client's own surfaces live, so the page and the server cannot disagree about them. */
31
+ export const LIVE_NAMESPACE = "/__jx_live__";
32
+
33
+ /** Consecutive reconnect failures before the client asks whether the origin is gone at all. */
34
+ const PROBE_AFTER_FAILURES = 20;
35
+
36
+ /** How long the restore keeps re-applying, for content that resolves after the render. */
37
+ const SCROLL_SETTLE_MS = 1500;
38
+
39
+ /**
40
+ * The reload client, as source.
41
+ *
42
+ * Written as one string with no build step: it is ~80 lines of DOM code that must run in a page
43
+ * this package does not otherwise touch, and a bundler in the path would be a second thing to keep
44
+ * working for no gain.
45
+ */
46
+ export const PREVIEW_CLIENT_JS = `
47
+ const NS = ${JSON.stringify(LIVE_NAMESPACE)};
48
+ const KEY = "jx-preview-scroll";
49
+ let failures = 0;
50
+ let stopped = false;
51
+ let source = null;
52
+
53
+ /* The banner lives in a shadow root: the page around it is the project's own site, carrying the
54
+ project's own CSS, which is free to style every div on the page. */
55
+ function banner(text, tone) {
56
+ let host = document.getElementById("jx-preview-status");
57
+ if (!host) {
58
+ host = document.createElement("div");
59
+ host.id = "jx-preview-status";
60
+ document.documentElement.append(host);
61
+ host.attachShadow({ mode: "open" });
62
+ }
63
+ host.shadowRoot.innerHTML = text
64
+ ? '<div style="position:fixed;left:0;right:0;bottom:0;z-index:2147483647;' +
65
+ "font:13px/1.4 system-ui,sans-serif;padding:8px 12px;color:#fff;background:" +
66
+ (tone === "dead" ? "#8a1c1c" : "#444") + ';">' + text + "</div>"
67
+ : "";
68
+ }
69
+
70
+ function saveScroll() {
71
+ try {
72
+ sessionStorage.setItem(
73
+ KEY,
74
+ JSON.stringify({ path: location.pathname, x: scrollX, y: scrollY }),
75
+ );
76
+ } catch {}
77
+ }
78
+
79
+ /* Restored after the render resolves, then re-applied for a moment: a page whose height depends on
80
+ data fetched after mount is shorter at restore time than it will be a tick later. */
81
+ function restoreScroll() {
82
+ let saved = null;
83
+ try {
84
+ saved = JSON.parse(sessionStorage.getItem(KEY) || "null");
85
+ } catch {}
86
+ if (!saved || saved.path !== location.pathname || (saved.x === 0 && saved.y === 0)) {
87
+ return;
88
+ }
89
+ const until = Date.now() + ${SCROLL_SETTLE_MS};
90
+ const apply = () => {
91
+ scrollTo(saved.x, saved.y);
92
+ if (Date.now() < until) {
93
+ requestAnimationFrame(apply);
94
+ }
95
+ };
96
+ apply();
97
+ }
98
+
99
+ function reload() {
100
+ saveScroll();
101
+ location.reload();
102
+ }
103
+
104
+ function connect() {
105
+ if (stopped) {
106
+ return;
107
+ }
108
+ source = new EventSource(NS + "/reload?route=" + encodeURIComponent(location.pathname));
109
+ source.onopen = () => {
110
+ failures = 0;
111
+ banner("");
112
+ };
113
+ source.onmessage = reload;
114
+ source.addEventListener("navigate", (event) => {
115
+ const message = JSON.parse(event.data);
116
+ /* Acknowledge BEFORE navigating: the navigation tears this stream down, so an ack sent after
117
+ it would never leave the tab, and the editor would open a second one. */
118
+ fetch(NS + "/ack", {
119
+ body: JSON.stringify({ gen: message.gen }),
120
+ headers: { "Content-Type": "application/json" },
121
+ keepalive: true,
122
+ method: "POST",
123
+ }).catch(() => {});
124
+ /* Best effort, and never advertised: a background tab cannot raise itself in Chrome, and
125
+ under Wayland the compositor arbitrates. It costs one line and occasionally works. */
126
+ try {
127
+ window.focus();
128
+ } catch {}
129
+ if (message.route === location.pathname) {
130
+ reload();
131
+ } else {
132
+ saveScroll();
133
+ location.assign(message.route);
134
+ }
135
+ });
136
+ source.onerror = () => {
137
+ failures += 1;
138
+ if (failures === 1) {
139
+ /* A restart reconnects inside half a second, so saying anything now is noise. */
140
+ setTimeout(() => {
141
+ if (failures > 0 && !stopped) {
142
+ banner("Preview disconnected — reconnecting…", "warn");
143
+ }
144
+ }, 2000);
145
+ }
146
+ if (failures >= ${PROBE_AFTER_FAILURES}) {
147
+ probe();
148
+ }
149
+ };
150
+ }
151
+
152
+ /* EventSource cannot tell "restarting" from "gone", so ask something a live origin always answers. */
153
+ function probe() {
154
+ fetch(NS + "/ping", { cache: "no-store" }).catch(() => {
155
+ stopped = true;
156
+ if (source) {
157
+ source.close();
158
+ }
159
+ banner(
160
+ "This preview is no longer live — Jx Studio closed this project. " +
161
+ "The page below is the last render.",
162
+ "dead",
163
+ );
164
+ });
165
+ }
166
+
167
+ addEventListener("pagehide", saveScroll);
168
+ /* A tab restored from the back/forward cache has a suspended stream and a stale document, and no
169
+ reload will arrive to fix either. */
170
+ addEventListener("pageshow", (event) => {
171
+ if (event.persisted) {
172
+ location.reload();
173
+ }
174
+ });
175
+ restoreScroll();
176
+ connect();
177
+ `;
@@ -345,6 +345,22 @@ export function createProjectServer(options: CreateProjectServerOptions): Projec
345
345
  return;
346
346
  }
347
347
 
348
+ /*
349
+ * The keepalive, answered AHEAD of the session lookup.
350
+ *
351
+ * A ping's whole job is to prove the socket is alive, and that is a fact about the socket,
352
+ * not about which project a window is bound to. Behind the lookup it would be refused —
353
+ * and close the socket — in exactly the states where staying connected matters most: a
354
+ * window mid-re-root, or one whose session was torn down and rebuilt. The shell sends one
355
+ * every 30s because this server's idle timeout is 120s and a studio can be quiet for far
356
+ * longer than that (a site import runs for minutes over a separate HTTP stream and touches
357
+ * this socket not at all).
358
+ */
359
+ if (msg.method === "__ping") {
360
+ ws.send(JSON.stringify({ id: msg.id, result: null }));
361
+ return;
362
+ }
363
+
348
364
  // Re-resolve the session FRESH each message (fail-closed for Phase 7 multi-window).
349
365
  const session = resolveSession(ws.data.winId);
350
366
  if (!session) {
@@ -14,7 +14,9 @@ import { errorMessage } from "@jxsuite/schema/parse";
14
14
  import { isUnder } from "./paths.ts";
15
15
  import { rewriteDocRefs, rewriteTagName } from "./refs.ts";
16
16
  import { documentGlob, fwd, loadDoc, skipScanPath } from "./scan.ts";
17
+ import { refactorMounts } from "./mounts.ts";
17
18
  import type { RefChange } from "./refs.ts";
19
+ import type { AssetMount } from "@jxsuite/schema/asset-paths";
18
20
  import type { FormatRegistry } from "@jxsuite/schema/format-registry";
19
21
 
20
22
  export interface FileChange {
@@ -47,6 +49,12 @@ export interface ApplyRenameOptions {
47
49
  absTo: string;
48
50
  /** Format registry for the project (parse/serialize of non-JSON documents). */
49
51
  registry: FormatRegistry;
52
+ /**
53
+ * Project-relative asset mounts, for rewriting rooted references through the mount lane. Omitted,
54
+ * the engine loads them from `root` — see `mounts.ts` for why that is the default and not the
55
+ * exception.
56
+ */
57
+ mounts?: readonly AssetMount[];
50
58
  }
51
59
 
52
60
  /** Derive a custom-element tag from a component filename (strips `.class.json` or the extension). */
@@ -67,6 +75,7 @@ export async function applyRename(opts: ApplyRenameOptions): Promise<RenameRepor
67
75
  const root = fwd(opts.root);
68
76
  const absFrom = fwd(opts.absFrom);
69
77
  const absTo = fwd(opts.absTo);
78
+ const mounts = opts.mounts ?? (await refactorMounts(opts.root));
70
79
 
71
80
  let isDir = false;
72
81
  try {
@@ -120,6 +129,7 @@ export async function applyRename(opts: ApplyRenameOptions): Promise<RenameRepor
120
129
  const { changes } = rewriteDocRefs(doc, {
121
130
  docNewDir,
122
131
  docOldDir,
132
+ mounts,
123
133
  newAbs: absTo,
124
134
  oldAbs: absFrom,
125
135
  root,
Binary file
@@ -0,0 +1,49 @@
1
+ /**
2
+ * Mounts.ts — the asset mounts the refactor engine resolves rooted references through.
3
+ *
4
+ * A rooted reference (`/images/hero.jpg`) is a site URL, and an extension may claim a slice of that
5
+ * URL space and serve it out of a directory of its own (specs/extensions.md §8.5). So "which file
6
+ * does this URL name?" cannot be answered from the project root alone, and neither the read pass
7
+ * nor the write pass can be correct without the mount list.
8
+ *
9
+ * **The engine loads them rather than taking them.** The format registry is passed in by every
10
+ * caller because every caller already has one; nobody has a mount list, and there are four hosts
11
+ * (two dev-server routes, the desktop project session, the cloud gateway). An option four hosts
12
+ * each have to remember is an option three of them eventually do not — and a lane that silently
13
+ * fails to resolve is precisely the defect this module exists to close (issue 239). `mounts` stays
14
+ * available on both option types for a host that already has the list, and for tests.
15
+ *
16
+ * **Project-relative, filtered.** `loadAssetMounts` hands back whatever directory an extension
17
+ * declared, which is normally absolute; `asset-paths.ts` documents its mount parameter as
18
+ * project-relative. Rebasing here is that conversion, and a mount resolving OUTSIDE the project is
19
+ * dropped: its files are not addressable by a project-relative path, so no rename can reach them
20
+ * and no reference to them can be counted. `projectPathsForSiteUrl` declines the same case by
21
+ * name.
22
+ */
23
+
24
+ import { relative } from "node:path";
25
+ import { projectAssetMounts } from "../resolve.ts";
26
+ import { fwd } from "./scan.ts";
27
+ import type { AssetMount } from "@jxsuite/schema/asset-paths";
28
+
29
+ /**
30
+ * Project-relative asset mounts for a project root, or an empty list when there is no project here.
31
+ *
32
+ * Not memoised on its own account: `projectAssetMounts` is already cached against `project.json`'s
33
+ * mtime, and a second cache here would be a second thing to invalidate.
34
+ *
35
+ * @param {string} root - Absolute project root
36
+ * @returns {Promise<AssetMount[]>} Mounts whose `dir` is relative to `root`
37
+ */
38
+ export async function refactorMounts(root: string): Promise<AssetMount[]> {
39
+ const declared = await projectAssetMounts(root);
40
+ const out: AssetMount[] = [];
41
+ for (const mount of declared) {
42
+ const dir = fwd(relative(root, mount.dir));
43
+ if (dir === "" || dir.startsWith("../") || /^[A-Za-z]:/.test(dir)) {
44
+ continue;
45
+ }
46
+ out.push({ dir, urlPrefix: mount.urlPrefix });
47
+ }
48
+ return out;
49
+ }
@@ -6,7 +6,42 @@
6
6
  * them in. The relative-path style mirrors `computeRelativePath` in the studio
7
7
  * (packages/studio/src/files/components.ts): "./" for same-dir/descendant targets, "../"-chains
8
8
  * otherwise.
9
+ *
10
+ * **A rooted reference is a site URL, not a project path.** `/images/hero.jpg` names whichever file
11
+ * the host serves at that URL, and there are three places it can live: an extension's asset mount,
12
+ * the project root, and `public/`. This module does not re-derive that mapping — it calls
13
+ * `@jxsuite/schema/asset-paths`, the same lane math the canvas, the dev server and the build all
14
+ * use. Resolving a rooted ref against the project root alone was how a rename of any file under
15
+ * `public/` rewrote nothing while promising otherwise (issue 239).
16
+ *
17
+ * @docs studio/projects/pages-layouts-components
18
+ */
19
+
20
+ import { BUILD_LANES, projectPathsForSiteUrl, siteUrlForPath } from "@jxsuite/schema/asset-paths";
21
+ import type { AssetLane, AssetMount } from "@jxsuite/schema/asset-paths";
22
+
23
+ /**
24
+ * The lanes a rewritten site URL is re-emitted through, most-publishable first.
25
+ *
26
+ * {@link BUILD_LANES} first, so a file that a deployed build publishes keeps the URL that build
27
+ * serves — `public/images/hero.jpg` re-emits as `/images/hero.jpg`, not the `/public/...` form the
28
+ * dev server would also answer. The `root` lane is the fallback for a file only the dev server can
29
+ * reach: that reference was already build-broken before the rename (the server says so at request
30
+ * time), and preserving it is strictly better than leaving it pointing at a file that has moved.
31
+ *
32
+ * The consequence is that this is total for any non-empty in-project path — a rename can always
33
+ * express its destination — so there is no "unrewritable reference" case to report.
34
+ */
35
+ const AUTHORED_LANES: readonly AssetLane[] = [...BUILD_LANES, "root"];
36
+
37
+ /**
38
+ * The lanes a rooted reference is READ through: every place the dev server would look.
39
+ *
40
+ * Deliberately wider than {@link AUTHORED_LANES} and unordered in effect, because the reader checks
41
+ * all of them. Where two lanes both name an existing file, `/hero.jpg` genuinely is ambiguous, and
42
+ * counting both is the safe side of a question asked to warn someone before a delete.
9
43
  */
44
+ const READ_LANES: readonly AssetLane[] = ["mounts", "root", "public"];
10
45
 
11
46
  /** Classification of a reference string value. */
12
47
  export type RefClass =
@@ -127,6 +162,54 @@ export function remapTarget(targetOld: string, oldAbs: string, newAbs: string):
127
162
  return targetOld;
128
163
  }
129
164
 
165
+ /**
166
+ * Every project-relative path a rooted (site-URL) reference could name, most-preferred first.
167
+ *
168
+ * @param {string} core - The reference with any `?query`/`#fragment` already split off
169
+ * @param {readonly AssetMount[]} mounts - Project-relative asset mounts
170
+ * @returns {string[]} Candidate project-relative paths
171
+ */
172
+ export function projectPathsForRootedRef(core: string, mounts: readonly AssetMount[]): string[] {
173
+ return projectPathsForSiteUrl(core, mounts, READ_LANES);
174
+ }
175
+
176
+ /** A final path segment must carry an extension, and the extension must contain a letter. */
177
+ const FILE_SEGMENT_RE = /^[^.][^/]*\.[A-Za-z0-9]{0,7}[A-Za-z][A-Za-z0-9]{0,7}$/;
178
+
179
+ /**
180
+ * True when a string is shaped like a file a document could reference.
181
+ *
182
+ * The walker in refs.ts knows nine keys that carry references by name. The commonest media
183
+ * reference in a real project is under none of them — a schema-typed component prop (`$props.bg`),
184
+ * a content entry's frontmatter (`cover:`), `project.json`'s `defaults.layout` — so the walker
185
+ * falls back to SHAPE for every other key, and this is the shape test.
186
+ *
187
+ * It is deliberately not schema-aware. `walkDocRefs` is pure and schema-blind by design, and the
188
+ * write side shares it; a schema oracle would have to be threaded through `applyRename` too, would
189
+ * need a project-wide `tagName → media prop` index from a second pass, and would still miss a prop
190
+ * whose component definition failed to parse.
191
+ *
192
+ * Precision does not come from here. It comes from the resolve-and-compare gate the callers already
193
+ * apply: a candidate only counts when it resolves to the exact file queried. This just has to be
194
+ * cheap and to refuse prose. The extension requirement is what does that — it rejects routes
195
+ * (`/pricing`), bare words, MIME types (`image/png`) and identifiers, and the letter-in-extension
196
+ * rule additionally rejects version strings (`1.2.3`), which are otherwise file-shaped.
197
+ *
198
+ * @param {string} value
199
+ * @returns {boolean}
200
+ */
201
+ export function looksLikeFileRef(value: string): boolean {
202
+ if (value.length === 0 || /\s/.test(value) || value.includes("${")) {
203
+ return false;
204
+ }
205
+ const cls = classifyRef(value);
206
+ if (cls.kind !== "path") {
207
+ return false;
208
+ }
209
+ const last = cls.core.slice(cls.core.lastIndexOf("/") + 1);
210
+ return FILE_SEGMENT_RE.test(last);
211
+ }
212
+
130
213
  /** Context describing a single rename, shared by every reference rewrite within one document. */
131
214
  export interface RemapCtx {
132
215
  /** Absolute POSIX project root. */
@@ -142,6 +225,11 @@ export interface RemapCtx {
142
225
  * the document itself is inside the moved subtree).
143
226
  */
144
227
  docNewDir: string;
228
+ /**
229
+ * Project-relative asset mounts, for resolving rooted references through the mount lane. Absent
230
+ * is the same as none, which leaves the `root` and `public/` lanes.
231
+ */
232
+ mounts?: readonly AssetMount[];
145
233
  }
146
234
 
147
235
  /**
@@ -158,30 +246,63 @@ export function rewriteRef(
158
246
  rootRelativeBare = false,
159
247
  ): string | null {
160
248
  const { core, rooted, suffix } = cls;
249
+ if (rooted) {
250
+ return rewriteSiteUrl(core, suffix, ctx);
251
+ }
161
252
  const startsRel = core.startsWith("./") || core.startsWith("../");
162
- const useRoot = rooted || (rootRelativeBare && !startsRel);
253
+ const useRoot = rootRelativeBare && !startsRel;
163
254
  const baseOld = useRoot ? ctx.root : ctx.docOldDir;
164
255
  const baseNew = useRoot ? ctx.root : ctx.docNewDir;
165
256
 
166
- const targetOld = joinPosix(useRoot ? ctx.root : baseOld, core);
257
+ const targetOld = joinPosix(baseOld, core);
167
258
  const targetNew = remapTarget(targetOld, ctx.oldAbs, ctx.newAbs);
168
259
  if (targetNew === targetOld && baseOld === baseNew) {
169
260
  return null;
170
261
  }
171
262
 
263
+ const rel = relativeChain(baseNew, targetNew);
172
264
  let out: string;
173
- if (rooted) {
174
- out = `/${relativeChain(ctx.root, targetNew)}`;
265
+ if (rel.startsWith("..")) {
266
+ out = rel;
267
+ } else if (core.startsWith("./")) {
268
+ out = `./${rel}`;
175
269
  } else {
176
- const rel = relativeChain(baseNew, targetNew);
177
- if (rel.startsWith("..")) {
178
- out = rel;
179
- } else if (core.startsWith("./")) {
180
- out = `./${rel}`;
181
- } else {
182
- out = rel;
183
- }
270
+ out = rel;
184
271
  }
185
272
  out += suffix;
186
273
  return out === core + suffix ? null : out;
187
274
  }
275
+
276
+ /**
277
+ * Rewrite a rooted reference — a site URL — across a rename, through the lanes that give it
278
+ * meaning.
279
+ *
280
+ * Each lane is tried on its own so the MATCHED one is known: a lane is only a candidate when the
281
+ * file it names is the one that moved. The answer is then re-derived from the destination through
282
+ * {@link AUTHORED_LANES}, which is what turns a `public/` move into the right URL rather than a
283
+ * root-relative path that would 404 on the deployed site.
284
+ *
285
+ * Returns null when no lane names the moved file, which is the overwhelmingly common case — most
286
+ * references in a document have nothing to do with any given rename.
287
+ */
288
+ function rewriteSiteUrl(core: string, suffix: string, ctx: RemapCtx): string | null {
289
+ const mounts = ctx.mounts ?? [];
290
+ for (const lane of READ_LANES) {
291
+ const [candidate] = projectPathsForSiteUrl(core, mounts, [lane]);
292
+ if (candidate === undefined) {
293
+ continue;
294
+ }
295
+ const targetOld = joinPosix(ctx.root, candidate);
296
+ const targetNew = remapTarget(targetOld, ctx.oldAbs, ctx.newAbs);
297
+ if (targetNew === targetOld) {
298
+ continue;
299
+ }
300
+ const url = siteUrlForPath(relativeChain(ctx.root, targetNew), mounts, AUTHORED_LANES);
301
+ if (url === null) {
302
+ continue;
303
+ }
304
+ const out = url + suffix;
305
+ return out === core + suffix ? null : out;
306
+ }
307
+ return null;
308
+ }
@@ -16,9 +16,11 @@
16
16
  *
17
17
  * The visitor seam is what stops the read side from being a second, drifting copy of the key list.
18
18
  * A new reference-bearing key added below is a key the usage count sees on the same commit.
19
+ *
20
+ * @docs studio/projects/pages-layouts-components
19
21
  */
20
22
 
21
- import { classifyRef, rewriteRef } from "./paths.ts";
23
+ import { classifyRef, looksLikeFileRef, rewriteRef } from "./paths.ts";
22
24
  import type { RemapCtx } from "./paths.ts";
23
25
 
24
26
  /** A single reference rewrite, for the rename report. */
@@ -37,7 +39,8 @@ export interface RewriteResult {
37
39
  * One visit of a reference-bearing string.
38
40
  *
39
41
  * @param value — the raw authored reference, exactly as written.
40
- * @param refType — which key carried it (`$ref`, `$layout`, `attr`, `imports`, `$elements`, `url`).
42
+ * @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).
41
44
  * @param rootRelativeBare — whether a bare value resolves against the project root (`$layout`).
42
45
  * @returns A replacement string to write back, or `null` to leave the document untouched.
43
46
  */
@@ -50,6 +53,17 @@ export type RefVisitor = (
50
53
  /** Matches `url(...)` targets inside a CSS string value, capturing the optional quote. */
51
54
  const URL_RE = /url\(\s*(['"]?)([^'")]+)\1\s*\)/g;
52
55
 
56
+ /**
57
+ * Keys whose string values are PROSE, and are therefore never offered to the shape fallback.
58
+ *
59
+ * A page that writes "see layouts/base.json" in its body text names a file without referencing it,
60
+ * and rewriting that sentence during a rename would be vandalism. Almost every such string is
61
+ * already excluded by the whitespace test in `looksLikeFileRef`; these two keys are where a
62
+ * single-token one is plausible, and a whole-repo survey of file-shaped string values found
63
+ * non-reference hits under no other key.
64
+ */
65
+ const PROSE_KEYS = new Set(["textContent", "innerHTML"]);
66
+
53
67
  /** Offer a scalar string reference under `key` to the visitor, writing back any replacement. */
54
68
  function visitScalar(
55
69
  container: Record<string, unknown>,
@@ -189,8 +203,42 @@ export function walkDocRefs(node: unknown, visit: RefVisitor): void {
189
203
  walkStyle(value, visit);
190
204
  break;
191
205
  }
206
+ /*
207
+ * Everything else, by SHAPE.
208
+ *
209
+ * The cases above are the keys that carry a reference BY NAME, and for years that list was
210
+ * taken to be the whole of it. It is not: the commonest media reference in a real project is
211
+ * a schema-typed component prop (`$props.bg`, `attributes["props.image"]`), and beside it sit
212
+ * a content entry's frontmatter (`cover:`), a prop schema's `default`, `project.json`'s
213
+ * `defaults.layout`, `poster`, `srcset` and `$head` meta `content`. Measured across the
214
+ * committed starters, 73 of 101 files under `public/` were used and reported zero.
215
+ *
216
+ * Naming those keys one at a time would be the same mistake with a longer list — the next
217
+ * extension to define a media-typed prop would reopen it. So an unrecognised string is
218
+ * offered whenever it is SHAPED like a file, and precision stays where it already was: the
219
+ * caller's resolve-and-compare gate, which only counts a value that resolves to the exact
220
+ * file in question.
221
+ */
192
222
  default: {
193
- walkDocRefs(value, visit);
223
+ if (typeof value === "string") {
224
+ if (!PROSE_KEYS.has(key) && looksLikeFileRef(value)) {
225
+ visitScalar(obj, key, "path", visit, false);
226
+ }
227
+ } else if (Array.isArray(value)) {
228
+ const arr = value as unknown[];
229
+ for (let i = 0; i < arr.length; i += 1) {
230
+ const item = arr[i];
231
+ if (typeof item === "string") {
232
+ if (!PROSE_KEYS.has(key) && looksLikeFileRef(item)) {
233
+ visitIndexed(arr, i, "path", visit);
234
+ }
235
+ } else {
236
+ walkDocRefs(item, visit);
237
+ }
238
+ }
239
+ } else {
240
+ walkDocRefs(value, visit);
241
+ }
194
242
  }
195
243
  }
196
244
  }
@@ -22,10 +22,30 @@ export interface LoadedDoc {
22
22
 
23
23
  const isJsonPath = (p: string) => p.endsWith(".json");
24
24
 
25
- /** Skip vendored / build / agent directories (mirrors the component-discovery filter). */
25
+ /**
26
+ * Skip vendored / build / agent directories (mirrors the component-discovery filter), and generated
27
+ * schema artifacts.
28
+ *
29
+ * Every committed `*.schema.json` in a project is a build output — `bun run schema:generate-all`
30
+ * composes the `project.schema.json` / `document.schema.json` pair into each project root — and the
31
+ * generators embed live `examples` blocks. `project.core.schema.json`'s `$head` example carries
32
+ * `/favicon.svg`, and the per-project entry schema enumerates the project's own layouts, so a sweep
33
+ * that reads them makes every project report its own schema as a referrer of its favicon and its
34
+ * base layout. Those are not references anyone authored, and nobody should be told a rename will
35
+ * break one.
36
+ *
37
+ * The stale-schema problem a skipped file leaves behind already has an owner: `bun run
38
+ * schema:verify` catches it and `schema:sync` regenerates it. A rewrite here would be a second
39
+ * writer on a generated file.
40
+ */
26
41
  export function skipScanPath(match: string): boolean {
27
42
  const f = fwd(match);
28
- return match.includes("node_modules") || f.includes("dist/") || f.includes(".claude/");
43
+ return (
44
+ match.includes("node_modules") ||
45
+ f.includes("dist/") ||
46
+ f.includes(".claude/") ||
47
+ f.endsWith(".schema.json")
48
+ );
29
49
  }
30
50
 
31
51
  /** Build the recursive glob covering JSON plus every registered document/content extension. */
package/src/server.ts CHANGED
@@ -421,6 +421,7 @@ export async function createDevServer(options: {
421
421
 
422
422
  const res = await handleStudioApi(req, url, absRoot, activeProjectRoot, {
423
423
  allowedRoots,
424
+ onFileMoved: (absPath) => collabRegistry.handleExternalChange(absPath),
424
425
  onProjectCreated: (created) => createdRoots.push(created),
425
426
  });
426
427
  if (res) {