@jxsuite/server 2.2.7 → 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.
- package/README.md +5 -5
- package/package.json +15 -13
- package/src/import-api.ts +118 -3
- package/src/live-preview.ts +616 -0
- package/src/net-guard.ts +86 -10
- package/src/packages.ts +28 -10
- package/src/preview-client.ts +177 -0
- package/src/project-server.ts +16 -0
- package/src/refactor/apply.ts +10 -0
- package/src/refactor/find-refs.ts +0 -0
- package/src/refactor/mounts.ts +49 -0
- package/src/refactor/paths.ts +133 -12
- package/src/refactor/refs.ts +51 -3
- package/src/refactor/scan.ts +22 -2
- package/src/server.ts +1 -0
- package/src/sse.ts +126 -0
- package/src/studio-api.ts +130 -10
- package/src/watch.ts +9 -97
package/src/net-guard.ts
CHANGED
|
@@ -307,14 +307,83 @@ export async function serveContained(absPath: string, root: string): Promise<Res
|
|
|
307
307
|
return null;
|
|
308
308
|
}
|
|
309
309
|
|
|
310
|
+
/**
|
|
311
|
+
* Extensions a BUILD publishes as static assets, so a page may reference one by site URL.
|
|
312
|
+
*
|
|
313
|
+
* Deliberately excludes `.json`, `.md` and every other document extension: `serveProjectFile`
|
|
314
|
+
* serves two URL spaces at once — the SITE's, and the project tree's, which is how the Studio
|
|
315
|
+
* canvas fetches a component `$ref` — and only the site's is what {@link BUILD_LANES} defines. A
|
|
316
|
+
* `/components/x-card.json` answered from the project root is Studio's own protocol working
|
|
317
|
+
* correctly; a `/hero.jpg` answered from the project root is a preview telling a lie.
|
|
318
|
+
*/
|
|
319
|
+
const SITE_ASSET_EXTENSIONS = new Set([
|
|
320
|
+
".apng",
|
|
321
|
+
".avif",
|
|
322
|
+
".bmp",
|
|
323
|
+
".css",
|
|
324
|
+
".gif",
|
|
325
|
+
".ico",
|
|
326
|
+
".jpeg",
|
|
327
|
+
".jpg",
|
|
328
|
+
".mp3",
|
|
329
|
+
".mp4",
|
|
330
|
+
".oga",
|
|
331
|
+
".ogg",
|
|
332
|
+
".ogv",
|
|
333
|
+
".otf",
|
|
334
|
+
".pdf",
|
|
335
|
+
".png",
|
|
336
|
+
".svg",
|
|
337
|
+
".ttf",
|
|
338
|
+
".txt",
|
|
339
|
+
".wav",
|
|
340
|
+
".webm",
|
|
341
|
+
".webmanifest",
|
|
342
|
+
".webp",
|
|
343
|
+
".woff",
|
|
344
|
+
".woff2",
|
|
345
|
+
".xml",
|
|
346
|
+
]);
|
|
347
|
+
|
|
348
|
+
/** Paths already warned about, so a page that loads one image fifty times says it once. */
|
|
349
|
+
const rootLaneWarned = new Set<string>();
|
|
350
|
+
|
|
351
|
+
/**
|
|
352
|
+
* Say once, per path, that a file resolves here and will not resolve on the built site.
|
|
353
|
+
*
|
|
354
|
+
* @param {string} decodedPath - The site URL that matched the compatibility lane
|
|
355
|
+
*/
|
|
356
|
+
function warnRootLane(decodedPath: string): void {
|
|
357
|
+
const dot = decodedPath.lastIndexOf(".");
|
|
358
|
+
const ext = dot === -1 ? "" : decodedPath.slice(dot).toLowerCase();
|
|
359
|
+
if (!SITE_ASSET_EXTENSIONS.has(ext) || rootLaneWarned.has(decodedPath)) {
|
|
360
|
+
return;
|
|
361
|
+
}
|
|
362
|
+
rootLaneWarned.add(decodedPath);
|
|
363
|
+
console.warn(
|
|
364
|
+
`${decodedPath} resolves from the project root, which a BUILD does not do: it publishes ` +
|
|
365
|
+
`public/ at the site root and nothing else (site-architecture.md §9.3). This file loads in ` +
|
|
366
|
+
`the preview and 404s on the deployed site — move it into public/ to fix it. This ` +
|
|
367
|
+
`compatibility lane is going away.`,
|
|
368
|
+
);
|
|
369
|
+
}
|
|
370
|
+
|
|
310
371
|
/**
|
|
311
372
|
* Try to serve a project file at its natural URL: an extension asset mount, then
|
|
312
|
-
* absolute-under-root, then
|
|
313
|
-
* (realpath). `decodedPath` is the once-decoded URL pathname (leading slash, forward
|
|
373
|
+
* absolute-under-root, then `public/`, then the project root. Each candidate goes through
|
|
374
|
+
* containedPath (realpath). `decodedPath` is the once-decoded URL pathname (leading slash, forward
|
|
375
|
+
* slashes).
|
|
314
376
|
*
|
|
315
377
|
* Mounts come first and are contained against their own directory rather than the project root:
|
|
316
378
|
* they exist precisely to publish directories that may sit outside it (extensions.md §8.5).
|
|
317
379
|
*
|
|
380
|
+
* **`public/` precedes the project root, which is the order a BUILD resolves in.** It did not: the
|
|
381
|
+
* root came first, so a file at `<root>/hero.jpg` loaded at `/hero.jpg` here and 404'd in
|
|
382
|
+
* production — a preview that lies in the one direction that matters, and one the spec never
|
|
383
|
+
* described (`site-architecture.md` §9.3 has always matched the compiler). The root lane survives
|
|
384
|
+
* as a COMPATIBILITY lane that warns, and is going away; it also still answers the project tree's
|
|
385
|
+
* own URL space, which is how the Studio canvas fetches a component `$ref`.
|
|
386
|
+
*
|
|
318
387
|
* The project's BUILT OUTPUT is deliberately NOT here, nor anywhere else in an editing server's
|
|
319
388
|
* chain: these paths mean the project's SOURCES, and a built page addresses the same paths meaning
|
|
320
389
|
* its own output. The built site is served on its own origin instead — see
|
|
@@ -342,7 +411,8 @@ export async function serveProjectFile(
|
|
|
342
411
|
? decodedPath.slice(1)
|
|
343
412
|
: decodedPath.replace(/^\/([A-Za-z]:)/, "$1");
|
|
344
413
|
|
|
345
|
-
|
|
414
|
+
/* 1. Absolute path that falls under the project root. Not a site URL at all — it is how Studio
|
|
415
|
+
addresses a file it already holds an absolute path for, so it stays ahead of both lanes. */
|
|
346
416
|
if (normalizeForCompare(fsPath).startsWith(normalizeForCompare(root))) {
|
|
347
417
|
const res = await serveContained(fsPath, root);
|
|
348
418
|
if (res) {
|
|
@@ -350,21 +420,27 @@ export async function serveProjectFile(
|
|
|
350
420
|
}
|
|
351
421
|
}
|
|
352
422
|
|
|
353
|
-
// 2.
|
|
354
|
-
const relRes = await serveContained(resolve(root, `.${decodedPath}`), root);
|
|
355
|
-
if (relRes) {
|
|
356
|
-
return relRes;
|
|
357
|
-
}
|
|
358
|
-
|
|
359
|
-
// 3. public/ subdirectory.
|
|
423
|
+
// 2. public/ — the site's own URL space, and the only one a build publishes.
|
|
360
424
|
const pubRes = await serveContained(resolve(root, "public", `.${decodedPath}`), root);
|
|
361
425
|
if (pubRes) {
|
|
362
426
|
return pubRes;
|
|
363
427
|
}
|
|
364
428
|
|
|
429
|
+
// 3. The project root — the project tree's URL space, and a compatibility lane for site URLs.
|
|
430
|
+
const relRes = await serveContained(resolve(root, `.${decodedPath}`), root);
|
|
431
|
+
if (relRes) {
|
|
432
|
+
warnRootLane(decodedPath);
|
|
433
|
+
return relRes;
|
|
434
|
+
}
|
|
435
|
+
|
|
365
436
|
return null;
|
|
366
437
|
}
|
|
367
438
|
|
|
439
|
+
/** Forget which paths have been warned about — for tests, which assert the warning fires. */
|
|
440
|
+
export function resetRootLaneWarnings(): void {
|
|
441
|
+
rootLaneWarned.clear();
|
|
442
|
+
}
|
|
443
|
+
|
|
368
444
|
/** Result of decoding a URL pathname: the safe decoded/normalized path, or a rejection Response. */
|
|
369
445
|
export type DecodedPath = { path: string; normPath: string } | { reject: Response };
|
|
370
446
|
|
package/src/packages.ts
CHANGED
|
@@ -16,11 +16,19 @@ export interface PackageInfo {
|
|
|
16
16
|
dev?: boolean;
|
|
17
17
|
}
|
|
18
18
|
|
|
19
|
-
|
|
19
|
+
/**
|
|
20
|
+
* A dependency's pinned range beside the newest version published for it on the registry.
|
|
21
|
+
*
|
|
22
|
+
* Every registry-range dependency gets a row, behind or not. Deciding whether a row is an upgrade
|
|
23
|
+
* needs a semver comparison against the range's base, and the caller — which has to make that
|
|
24
|
+
* comparison anyway to tell an upgrade from a project deliberately pinned ahead — is where it
|
|
25
|
+
* belongs. A backend that filtered here could only describe an up-to-date package as an absence.
|
|
26
|
+
*/
|
|
27
|
+
export interface PackageVersionInfo {
|
|
20
28
|
name: string;
|
|
21
29
|
/** The version range pinned in package.json (e.g. "^0.19.0"). */
|
|
22
30
|
current: string;
|
|
23
|
-
/** The newest published
|
|
31
|
+
/** The newest version published for this package on the registry. */
|
|
24
32
|
latest: string;
|
|
25
33
|
dev?: boolean;
|
|
26
34
|
}
|
|
@@ -165,31 +173,41 @@ export async function fetchLatestVersion(
|
|
|
165
173
|
}
|
|
166
174
|
|
|
167
175
|
/**
|
|
168
|
-
*
|
|
169
|
-
*
|
|
176
|
+
* The newest published version of every dependency, asked of the registry package by package.
|
|
177
|
+
*
|
|
178
|
+
* Lookups run concurrently; non-registry specs (workspace:, file:, …) and lookup failures are
|
|
179
|
+
* skipped, because there is no honest answer for either and a guess is worse than a blank.
|
|
180
|
+
*
|
|
181
|
+
* This used to be `outdatedPackages`, which dropped any package whose latest matched its pinned
|
|
182
|
+
* base. Two assumptions were folded into that filter and both have since gone: that the only caller
|
|
183
|
+
* wanted a to-do list, and that `latest !== base` is the same question as "is there an upgrade" (it
|
|
184
|
+
* is not — a project pinned ahead of the registry, on a prerelease or a range bumped before the
|
|
185
|
+
* publish landed, differs from latest in the other direction). Reporting the registry's answer for
|
|
186
|
+
* every package leaves both judgements to the caller and lets the Packages table show a Latest
|
|
187
|
+
* column for rows that are already current.
|
|
170
188
|
*/
|
|
171
|
-
export async function
|
|
189
|
+
export async function packageVersions(
|
|
172
190
|
root: string,
|
|
173
191
|
fetchImpl: FetchLike = fetch,
|
|
174
|
-
): Promise<
|
|
192
|
+
): Promise<PackageVersionInfo[]> {
|
|
175
193
|
const pkgs = await listPackages(root);
|
|
176
194
|
const checks = await Promise.all(
|
|
177
|
-
pkgs.map(async (p): Promise<
|
|
195
|
+
pkgs.map(async (p): Promise<PackageVersionInfo | null> => {
|
|
178
196
|
if (!isRegistryRange(p.version)) {
|
|
179
197
|
return null;
|
|
180
198
|
}
|
|
181
199
|
const latest = await fetchLatestVersion(p.name, fetchImpl);
|
|
182
|
-
if (!latest
|
|
200
|
+
if (!latest) {
|
|
183
201
|
return null;
|
|
184
202
|
}
|
|
185
|
-
const entry:
|
|
203
|
+
const entry: PackageVersionInfo = { current: p.version, latest, name: p.name };
|
|
186
204
|
if (p.dev) {
|
|
187
205
|
entry.dev = true;
|
|
188
206
|
}
|
|
189
207
|
return entry;
|
|
190
208
|
}),
|
|
191
209
|
);
|
|
192
|
-
return checks.filter((entry): entry is
|
|
210
|
+
return checks.filter((entry): entry is PackageVersionInfo => entry !== null);
|
|
193
211
|
}
|
|
194
212
|
|
|
195
213
|
/**
|
|
@@ -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
|
+
`;
|
package/src/project-server.ts
CHANGED
|
@@ -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) {
|
package/src/refactor/apply.ts
CHANGED
|
@@ -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
|
+
}
|
package/src/refactor/paths.ts
CHANGED
|
@@ -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 =
|
|
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(
|
|
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 (
|
|
174
|
-
out =
|
|
265
|
+
if (rel.startsWith("..")) {
|
|
266
|
+
out = rel;
|
|
267
|
+
} else if (core.startsWith("./")) {
|
|
268
|
+
out = `./${rel}`;
|
|
175
269
|
} else {
|
|
176
|
-
|
|
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
|
+
}
|