@jxsuite/server 2.0.0 → 2.2.1
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 +19 -16
- package/src/ai-api.ts +30 -13
- package/src/code-api.ts +2 -1
- package/src/collab.ts +35 -4
- package/src/data-api.ts +8 -1
- package/src/import-api.ts +5 -4
- package/src/jx-mounts.ts +11 -0
- package/src/net-guard.ts +188 -28
- package/src/oauth-loopback.ts +373 -0
- package/src/packages.ts +13 -2
- package/src/problem.ts +89 -0
- package/src/project-server.ts +124 -11
- package/src/refactor/apply.ts +3 -57
- package/src/refactor/find-refs.ts +0 -0
- package/src/refactor/index.ts +7 -0
- package/src/refactor/refs.ts +103 -63
- package/src/refactor/scan.ts +70 -0
- package/src/refactor/watcher.ts +4 -0
- package/src/resolve.ts +9 -10
- package/src/server.ts +36 -6
- package/src/site-output.ts +111 -0
- package/src/site-preview.ts +106 -0
- package/src/studio-api.ts +198 -89
- package/src/watch.ts +54 -3
package/src/project-server.ts
CHANGED
|
@@ -26,6 +26,7 @@
|
|
|
26
26
|
*/
|
|
27
27
|
|
|
28
28
|
import { resolve, sep } from "node:path";
|
|
29
|
+
import type { ServerWebSocket } from "bun";
|
|
29
30
|
import { handleJxMounts } from "./jx-mounts.ts";
|
|
30
31
|
import { handleResolve, handleServerFunction, projectAssetMounts } from "./resolve.ts";
|
|
31
32
|
import { handleAiApi } from "./ai-api.ts";
|
|
@@ -35,9 +36,13 @@ import type { ImportApiOptions } from "./import-api.ts";
|
|
|
35
36
|
import {
|
|
36
37
|
decodeAndNormalizePath,
|
|
37
38
|
loopbackGate,
|
|
39
|
+
originHostGate,
|
|
38
40
|
serveContained,
|
|
39
41
|
serveProjectFile,
|
|
40
42
|
} from "./net-guard.ts";
|
|
43
|
+
import { problem } from "./problem.ts";
|
|
44
|
+
import { createLoopbackAuthorizer, OAUTH_CALLBACK_PATH } from "./oauth-loopback.ts";
|
|
45
|
+
import type { LoopbackAuthorizer } from "./oauth-loopback.ts";
|
|
41
46
|
|
|
42
47
|
/** A resolved per-window session: its project root plus its RPC handler map. */
|
|
43
48
|
export interface ProjectServerSession {
|
|
@@ -73,6 +78,23 @@ export interface ProjectServerHandle {
|
|
|
73
78
|
wsUrl: string;
|
|
74
79
|
rpcToken: string;
|
|
75
80
|
canvasUrl: string;
|
|
81
|
+
/**
|
|
82
|
+
* Push an UNSOLICITED message to the connected shells — the server-to-client half of the RPC.
|
|
83
|
+
*
|
|
84
|
+
* Every other frame on this socket answers an `id` the client chose; these carry a `method` and
|
|
85
|
+
* no `id`, which is how the client's dispatcher tells the two apart. It exists because some
|
|
86
|
+
* things the shell must know are not answers to anything it asked: a file changed on disk, or
|
|
87
|
+
* another window wants this one raised.
|
|
88
|
+
*
|
|
89
|
+
* @param winId Deliver only to sockets stashed with this window id; omit for every socket.
|
|
90
|
+
* @returns How many sockets the message reached — 0 means nothing is listening yet.
|
|
91
|
+
*/
|
|
92
|
+
push: (method: string, params?: unknown, winId?: string | null) => number;
|
|
93
|
+
/**
|
|
94
|
+
* The RFC 8252 loopback authorization host. The desktop launcher drives it: `begin()` for the URL
|
|
95
|
+
* to open in the user's browser, then `await pending.code` and {@link exchangeCode}.
|
|
96
|
+
*/
|
|
97
|
+
authorizer: LoopbackAuthorizer;
|
|
76
98
|
stop: () => void;
|
|
77
99
|
}
|
|
78
100
|
|
|
@@ -94,6 +116,14 @@ export function createProjectServer(options: CreateProjectServerOptions): Projec
|
|
|
94
116
|
// Bundle cache for npm bare specifiers (mirrors server.ts).
|
|
95
117
|
const bundleCache = new Map<string, string>();
|
|
96
118
|
|
|
119
|
+
/* Outstanding OAuth authorizations (RFC 8252). Per server, so a stopped server abandons them. */
|
|
120
|
+
const authorizer: LoopbackAuthorizer = createLoopbackAuthorizer();
|
|
121
|
+
|
|
122
|
+
/* Live shell sockets, so the server can speak first (see ProjectServerHandle.push). Membership is
|
|
123
|
+
the socket's whole lifetime — added on open, dropped on close — so a push after a window is
|
|
124
|
+
gone reaches nobody rather than throwing. */
|
|
125
|
+
const sockets = new Set<ServerWebSocket<{ winId: string | null }>>();
|
|
126
|
+
|
|
97
127
|
const server = Bun.serve<{ winId: string | null }>({
|
|
98
128
|
hostname,
|
|
99
129
|
port,
|
|
@@ -111,6 +141,30 @@ export function createProjectServer(options: CreateProjectServerOptions): Projec
|
|
|
111
141
|
|
|
112
142
|
const winId = url.searchParams.get("win");
|
|
113
143
|
|
|
144
|
+
/*
|
|
145
|
+
* 0. The OAuth loopback redirect (RFC 8252 §7.3).
|
|
146
|
+
*
|
|
147
|
+
* **Exempt from the token, and only from the token.** The provider redirects the user's
|
|
148
|
+
* own browser here; that navigation carries whatever the provider's `redirect_uri` said,
|
|
149
|
+
* and a page cannot append a secret to a URL it does not compose. Gating it on the token
|
|
150
|
+
* would make the flow impossible rather than safe.
|
|
151
|
+
*
|
|
152
|
+
* What replaces the token is the `state` parameter, which is unguessable, single-use and
|
|
153
|
+
* compared in constant time — plus the Host and Fetch Metadata checks, which still apply.
|
|
154
|
+
* An IdP redirect is exactly the one cross-site shape the strict policy admits: a
|
|
155
|
+
* top-level GET document navigation, a person following a link.
|
|
156
|
+
*/
|
|
157
|
+
if (normPath === OAUTH_CALLBACK_PATH) {
|
|
158
|
+
const gate = originHostGate(req, "strict");
|
|
159
|
+
if (gate) {
|
|
160
|
+
return gate;
|
|
161
|
+
}
|
|
162
|
+
const callback = authorizer.handleCallback(url);
|
|
163
|
+
if (callback) {
|
|
164
|
+
return callback;
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
114
168
|
// 1. WebSocket upgrade — token + loopback Origin/Host are the hard gate.
|
|
115
169
|
const upgrade = req.headers.get("upgrade");
|
|
116
170
|
if (upgrade && upgrade.toLowerCase() === "websocket") {
|
|
@@ -121,11 +175,20 @@ export function createProjectServer(options: CreateProjectServerOptions): Projec
|
|
|
121
175
|
if (srv.upgrade(req, { data: { winId } })) {
|
|
122
176
|
return;
|
|
123
177
|
}
|
|
124
|
-
return
|
|
178
|
+
return problem("invalidRequest", "Upgrade failed");
|
|
125
179
|
}
|
|
126
180
|
|
|
127
|
-
|
|
181
|
+
/*
|
|
182
|
+
* 2. AI proxy. Gated with the token, like every other surface that spends something: this
|
|
183
|
+
* route forwards to a provider on the user's key, so an ungated one is an open relay for
|
|
184
|
+
* any local process — and it was dispatched ahead of every gate. The token is what the
|
|
185
|
+
* desktop shell appends to this URL; a request without one is refused.
|
|
186
|
+
*/
|
|
128
187
|
if (normPath.startsWith("/__studio__/ai/")) {
|
|
188
|
+
const gate = loopbackGate(req, url, rpcToken);
|
|
189
|
+
if (gate) {
|
|
190
|
+
return gate;
|
|
191
|
+
}
|
|
129
192
|
const aiUrl = new URL(req.url);
|
|
130
193
|
aiUrl.pathname = normPath.replace("/__studio__/ai/", "/__studio/ai/");
|
|
131
194
|
const aiResponse = await handleAiApi(req, aiUrl);
|
|
@@ -139,7 +202,7 @@ export function createProjectServer(options: CreateProjectServerOptions): Projec
|
|
|
139
202
|
if (normPath === "/__studio__/import-site" && req.method === "POST") {
|
|
140
203
|
const { importApi } = options;
|
|
141
204
|
if (!importApi) {
|
|
142
|
-
return
|
|
205
|
+
return problem("notFound", "Not found");
|
|
143
206
|
}
|
|
144
207
|
const gate = loopbackGate(req, url, rpcToken);
|
|
145
208
|
if (gate) {
|
|
@@ -168,22 +231,34 @@ export function createProjectServer(options: CreateProjectServerOptions): Projec
|
|
|
168
231
|
headers.set("Referrer-Policy", "same-origin");
|
|
169
232
|
return new Response(res.body, { headers, status: res.status });
|
|
170
233
|
}
|
|
171
|
-
return
|
|
234
|
+
return problem("notFound", "Not found");
|
|
172
235
|
}
|
|
173
236
|
|
|
174
237
|
// 4. Resolve the session (fail-closed).
|
|
175
238
|
const session = resolveSession(winId);
|
|
176
239
|
if (!session) {
|
|
177
|
-
return
|
|
240
|
+
return problem("notFound", "Unknown window");
|
|
178
241
|
}
|
|
179
242
|
const root = session.projectRoot;
|
|
180
243
|
if (!root) {
|
|
181
|
-
return
|
|
244
|
+
return problem("notFound", "No project");
|
|
182
245
|
}
|
|
183
246
|
|
|
184
|
-
|
|
185
|
-
|
|
247
|
+
/*
|
|
248
|
+
* 4b. Extension server mounts (/_jx/data etc.) — registry-driven, same wire contract as the
|
|
249
|
+
* generated site worker and the dev server (specs/extensions.md §11).
|
|
250
|
+
*
|
|
251
|
+
* Gated on **Origin/Host and Fetch Metadata, not the token**, and the distinction is the
|
|
252
|
+
* point: these are fetched by the canvas iframe's own page, whose requests carry no
|
|
253
|
+
* `?token=` — a page cannot rewrite the URLs its own content asks for. So the token is
|
|
254
|
+
* the wrong instrument here and the origin check is the right one. The policy is
|
|
255
|
+
* `embeddable` because that iframe is cross-origin by construction.
|
|
256
|
+
*/
|
|
186
257
|
if (normPath.startsWith("/_jx/")) {
|
|
258
|
+
const gate = originHostGate(req, "embeddable");
|
|
259
|
+
if (gate) {
|
|
260
|
+
return gate;
|
|
261
|
+
}
|
|
187
262
|
const mountRes = await handleJxMounts(req, url, root);
|
|
188
263
|
if (mountRes) {
|
|
189
264
|
return mountRes;
|
|
@@ -206,8 +281,17 @@ export function createProjectServer(options: CreateProjectServerOptions): Projec
|
|
|
206
281
|
return handleServerFunction(req, root);
|
|
207
282
|
}
|
|
208
283
|
|
|
209
|
-
|
|
210
|
-
|
|
284
|
+
/*
|
|
285
|
+
* 6. Project files at natural URLs, including extension asset mounts (§8.5) — that is what
|
|
286
|
+
* lets a canvas preview show an image a content entry references relative to itself.
|
|
287
|
+
*
|
|
288
|
+
* `embeddable`, for the same reason as the mounts: these ARE the canvas iframe's
|
|
289
|
+
* subresources, and a cross-origin iframe's images arrive `cross-site`.
|
|
290
|
+
*/
|
|
291
|
+
const staticGate = originHostGate(req, "embeddable");
|
|
292
|
+
if (staticGate) {
|
|
293
|
+
return staticGate;
|
|
294
|
+
}
|
|
211
295
|
const fileRes = await serveProjectFile(normPath, root, await projectAssetMounts(root));
|
|
212
296
|
if (fileRes) {
|
|
213
297
|
return fileRes;
|
|
@@ -238,10 +322,20 @@ export function createProjectServer(options: CreateProjectServerOptions): Projec
|
|
|
238
322
|
}
|
|
239
323
|
}
|
|
240
324
|
|
|
241
|
-
|
|
325
|
+
/* 8. The project's BUILT SITE is not served here — see the same note in `server.ts`. This
|
|
326
|
+
server's paths mean the project's SOURCES, and a built page means its own output by the
|
|
327
|
+
very same paths; `site-preview.ts` gives that output its own origin, which is what
|
|
328
|
+
`View: Open in Browser` opens. */
|
|
329
|
+
return problem("notFound", "Not found");
|
|
242
330
|
},
|
|
243
331
|
|
|
244
332
|
websocket: {
|
|
333
|
+
open(ws) {
|
|
334
|
+
sockets.add(ws);
|
|
335
|
+
},
|
|
336
|
+
close(ws) {
|
|
337
|
+
sockets.delete(ws);
|
|
338
|
+
},
|
|
245
339
|
async message(ws, raw) {
|
|
246
340
|
let msg: { id: number; method: string; params?: unknown };
|
|
247
341
|
try {
|
|
@@ -291,7 +385,26 @@ export function createProjectServer(options: CreateProjectServerOptions): Projec
|
|
|
291
385
|
wsUrl,
|
|
292
386
|
rpcToken,
|
|
293
387
|
canvasUrl,
|
|
388
|
+
authorizer,
|
|
389
|
+
push: (method, params, winId) => {
|
|
390
|
+
const frame = JSON.stringify(params === undefined ? { method } : { method, params });
|
|
391
|
+
let delivered = 0;
|
|
392
|
+
for (const ws of sockets) {
|
|
393
|
+
if (winId !== undefined && winId !== null && ws.data.winId !== winId) {
|
|
394
|
+
continue;
|
|
395
|
+
}
|
|
396
|
+
try {
|
|
397
|
+
ws.send(frame);
|
|
398
|
+
delivered += 1;
|
|
399
|
+
} catch {
|
|
400
|
+
// A socket that closed between the iteration and the send is simply not a recipient.
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
return delivered;
|
|
404
|
+
},
|
|
294
405
|
stop: () => {
|
|
406
|
+
// Abandon outstanding sign-ins first: their callbacks can no longer arrive.
|
|
407
|
+
authorizer.stop();
|
|
295
408
|
void server.stop(true);
|
|
296
409
|
},
|
|
297
410
|
};
|
package/src/refactor/apply.ts
CHANGED
|
@@ -9,16 +9,14 @@
|
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
11
|
import { basename, dirname, extname, relative, resolve } from "node:path";
|
|
12
|
-
import {
|
|
12
|
+
import { stat, writeFile } from "node:fs/promises";
|
|
13
13
|
import { errorMessage } from "@jxsuite/schema/parse";
|
|
14
14
|
import { isUnder } from "./paths.ts";
|
|
15
15
|
import { rewriteDocRefs, rewriteTagName } from "./refs.ts";
|
|
16
|
+
import { documentGlob, fwd, loadDoc, skipScanPath } from "./scan.ts";
|
|
16
17
|
import type { RefChange } from "./refs.ts";
|
|
17
18
|
import type { FormatRegistry } from "@jxsuite/schema/format-registry";
|
|
18
19
|
|
|
19
|
-
/** Normalise a path to forward slashes. */
|
|
20
|
-
const fwd = (p: string) => p.replaceAll("\\", "/");
|
|
21
|
-
|
|
22
20
|
export interface FileChange {
|
|
23
21
|
path: string;
|
|
24
22
|
count: number;
|
|
@@ -51,20 +49,6 @@ export interface ApplyRenameOptions {
|
|
|
51
49
|
registry: FormatRegistry;
|
|
52
50
|
}
|
|
53
51
|
|
|
54
|
-
/** A parsed document plus the serializer that round-trips it back to disk. */
|
|
55
|
-
interface LoadedDoc {
|
|
56
|
-
doc: unknown;
|
|
57
|
-
serialize: ((doc: unknown) => Promise<string>) | null;
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
const isJsonPath = (p: string) => p.endsWith(".json");
|
|
61
|
-
|
|
62
|
-
/** Skip vendored / build / agent directories (mirrors the component-discovery filter). */
|
|
63
|
-
function skip(match: string): boolean {
|
|
64
|
-
const f = fwd(match);
|
|
65
|
-
return match.includes("node_modules") || f.includes("dist/") || f.includes(".claude/");
|
|
66
|
-
}
|
|
67
|
-
|
|
68
52
|
/** Derive a custom-element tag from a component filename (strips `.class.json` or the extension). */
|
|
69
53
|
export function deriveTag(absPath: string): string {
|
|
70
54
|
const base = basename(absPath);
|
|
@@ -75,44 +59,6 @@ export function deriveTag(absPath: string): string {
|
|
|
75
59
|
return ext ? base.slice(0, -ext.length) : base;
|
|
76
60
|
}
|
|
77
61
|
|
|
78
|
-
/** Read + parse a file, returning the doc and a matching serializer (null when none is available). */
|
|
79
|
-
async function loadDoc(fp: string, registry: FormatRegistry): Promise<LoadedDoc> {
|
|
80
|
-
const raw = await readFile(fp, "utf8");
|
|
81
|
-
if (isJsonPath(fp)) {
|
|
82
|
-
const trailingNewline = raw.endsWith("\n");
|
|
83
|
-
return {
|
|
84
|
-
doc: JSON.parse(raw) as unknown,
|
|
85
|
-
serialize: (doc) =>
|
|
86
|
-
Promise.resolve(JSON.stringify(doc, null, 2) + (trailingNewline ? "\n" : "")),
|
|
87
|
-
};
|
|
88
|
-
}
|
|
89
|
-
const ext = extname(fp);
|
|
90
|
-
const parseEntry = registry.byExtension(ext, "parse");
|
|
91
|
-
if (!parseEntry) {
|
|
92
|
-
throw new Error(`No parser for "${ext}"`);
|
|
93
|
-
}
|
|
94
|
-
const doc = await parseEntry.call("parse", raw);
|
|
95
|
-
const serializeEntry = registry.byExtension(ext, "serialize");
|
|
96
|
-
return {
|
|
97
|
-
doc,
|
|
98
|
-
serialize: serializeEntry
|
|
99
|
-
? async (d) => {
|
|
100
|
-
const out = await serializeEntry.call("serialize", d);
|
|
101
|
-
return typeof out === "string" ? out : String(out);
|
|
102
|
-
}
|
|
103
|
-
: null,
|
|
104
|
-
};
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
/** Build the recursive glob covering JSON plus every registered document/content extension. */
|
|
108
|
-
function documentGlob(registry: FormatRegistry) {
|
|
109
|
-
const exts = new Set(["json"]);
|
|
110
|
-
for (const ext of registry.documentExtensions()) {
|
|
111
|
-
exts.add(ext.replace(/^\./, ""));
|
|
112
|
-
}
|
|
113
|
-
return new Bun.Glob(`**/*.{${[...exts].join(",")}}`);
|
|
114
|
-
}
|
|
115
|
-
|
|
116
62
|
/**
|
|
117
63
|
* Rewrite references (and, for components, the tag) across the project after a rename. The caller
|
|
118
64
|
* must have already moved the file on disk.
|
|
@@ -162,7 +108,7 @@ export async function applyRename(opts: ApplyRenameOptions): Promise<RenameRepor
|
|
|
162
108
|
let tagRefsUpdated = 0;
|
|
163
109
|
|
|
164
110
|
for await (const match of documentGlob(opts.registry).scan({ cwd: opts.root, dot: false })) {
|
|
165
|
-
if (
|
|
111
|
+
if (skipScanPath(match)) {
|
|
166
112
|
continue;
|
|
167
113
|
}
|
|
168
114
|
const fp = fwd(resolve(opts.root, match));
|
|
Binary file
|
package/src/refactor/index.ts
CHANGED
|
@@ -5,6 +5,13 @@
|
|
|
5
5
|
|
|
6
6
|
export { applyRename, deriveTag } from "./apply.ts";
|
|
7
7
|
export type { ApplyRenameOptions, FileChange, RenameReport } from "./apply.ts";
|
|
8
|
+
export { findReferences, invalidateReferenceCache } from "./find-refs.ts";
|
|
9
|
+
export type {
|
|
10
|
+
FindReferencesOptions,
|
|
11
|
+
ReferenceFile,
|
|
12
|
+
ReferenceHit,
|
|
13
|
+
ReferencesResult,
|
|
14
|
+
} from "./find-refs.ts";
|
|
8
15
|
export { coalesceFsEvents, toFsEvent } from "./fs-events.ts";
|
|
9
16
|
export type { FsEventPayload } from "./fs-events.ts";
|
|
10
17
|
export { createFsWatcher } from "./watcher.ts";
|
package/src/refactor/refs.ts
CHANGED
|
@@ -1,14 +1,21 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Refs.ts — pure document reference engine
|
|
2
|
+
* Refs.ts — pure document reference engine, shared by the rename refactor and the usage query.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
* `
|
|
4
|
+
* One structure-aware walk, two directions. {@link walkDocRefs} dispatches by JSON key and hands
|
|
5
|
+
* every reference-bearing string to a visitor; whatever the visitor returns is written back, so a
|
|
6
|
+
* visitor that always returns `null` is a pure reader. Three passes are built on it:
|
|
7
7
|
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
8
|
+
* - `rewriteDocRefs` — recompute every file-path reference across a rename (Pillar B).
|
|
9
|
+
* - `rewriteTagName` — rename a custom-element tag wherever it is used (Pillar C).
|
|
10
|
+
* - `countTagUses` — the same tag walk, counting instead of writing (the usage query).
|
|
11
|
+
*
|
|
12
|
+
* The walk dispatches by JSON key rather than searching strings, and mutates the passed document in
|
|
13
|
+
* place — callers parse a fresh object per file, so this is safe. Precision comes from
|
|
14
|
+
* `rewriteRef`'s resolve-and-compare gate in paths.ts: a reference is only rewritten (or counted as
|
|
15
|
+
* a usage) when it actually resolves to the file in question.
|
|
16
|
+
*
|
|
17
|
+
* The visitor seam is what stops the read side from being a second, drifting copy of the key list.
|
|
18
|
+
* A new reference-bearing key added below is a key the usage count sees on the same commit.
|
|
12
19
|
*/
|
|
13
20
|
|
|
14
21
|
import { classifyRef, rewriteRef } from "./paths.ts";
|
|
@@ -26,79 +33,74 @@ export interface RewriteResult {
|
|
|
26
33
|
changes: RefChange[];
|
|
27
34
|
}
|
|
28
35
|
|
|
36
|
+
/**
|
|
37
|
+
* One visit of a reference-bearing string.
|
|
38
|
+
*
|
|
39
|
+
* @param value — the raw authored reference, exactly as written.
|
|
40
|
+
* @param refType — which key carried it (`$ref`, `$layout`, `attr`, `imports`, `$elements`, `url`).
|
|
41
|
+
* @param rootRelativeBare — whether a bare value resolves against the project root (`$layout`).
|
|
42
|
+
* @returns A replacement string to write back, or `null` to leave the document untouched.
|
|
43
|
+
*/
|
|
44
|
+
export type RefVisitor = (
|
|
45
|
+
value: string,
|
|
46
|
+
refType: string,
|
|
47
|
+
rootRelativeBare: boolean,
|
|
48
|
+
) => string | null;
|
|
49
|
+
|
|
29
50
|
/** Matches `url(...)` targets inside a CSS string value, capturing the optional quote. */
|
|
30
51
|
const URL_RE = /url\(\s*(['"]?)([^'")]+)\1\s*\)/g;
|
|
31
52
|
|
|
32
|
-
/**
|
|
33
|
-
function
|
|
53
|
+
/** Offer a scalar string reference under `key` to the visitor, writing back any replacement. */
|
|
54
|
+
function visitScalar(
|
|
34
55
|
container: Record<string, unknown>,
|
|
35
56
|
key: string,
|
|
36
57
|
refType: string,
|
|
37
|
-
|
|
38
|
-
changes: RefChange[],
|
|
58
|
+
visit: RefVisitor,
|
|
39
59
|
rootRelativeBare: boolean,
|
|
40
60
|
): void {
|
|
41
61
|
const value = container[key];
|
|
42
|
-
|
|
43
|
-
if (cls.kind !== "path") {
|
|
62
|
+
if (typeof value !== "string") {
|
|
44
63
|
return;
|
|
45
64
|
}
|
|
46
|
-
const out =
|
|
65
|
+
const out = visit(value, refType, rootRelativeBare);
|
|
47
66
|
if (out !== null) {
|
|
48
|
-
changes.push({ from: value as string, refType, to: out });
|
|
49
67
|
container[key] = out;
|
|
50
68
|
}
|
|
51
69
|
}
|
|
52
70
|
|
|
53
|
-
/**
|
|
54
|
-
function
|
|
55
|
-
arr: unknown[],
|
|
56
|
-
index: number,
|
|
57
|
-
refType: string,
|
|
58
|
-
ctx: RemapCtx,
|
|
59
|
-
changes: RefChange[],
|
|
60
|
-
): void {
|
|
71
|
+
/** Offer a string array member (e.g. an `$elements` entry) to the visitor. */
|
|
72
|
+
function visitIndexed(arr: unknown[], index: number, refType: string, visit: RefVisitor): void {
|
|
61
73
|
const value = arr[index];
|
|
62
|
-
|
|
63
|
-
if (cls.kind !== "path") {
|
|
74
|
+
if (typeof value !== "string") {
|
|
64
75
|
return;
|
|
65
76
|
}
|
|
66
|
-
const out =
|
|
77
|
+
const out = visit(value, refType, false);
|
|
67
78
|
if (out !== null) {
|
|
68
|
-
changes.push({ from: value as string, refType, to: out });
|
|
69
79
|
arr[index] = out;
|
|
70
80
|
}
|
|
71
81
|
}
|
|
72
82
|
|
|
73
|
-
/**
|
|
74
|
-
function
|
|
83
|
+
/** Offer every `url(...)` target inside a CSS string to the visitor. */
|
|
84
|
+
function visitUrls(value: string, visit: RefVisitor): string {
|
|
75
85
|
return value.replaceAll(URL_RE, (match, quote: string, inner: string) => {
|
|
76
|
-
const
|
|
77
|
-
|
|
78
|
-
return match;
|
|
79
|
-
}
|
|
80
|
-
const out = rewriteRef(cls, ctx, false);
|
|
81
|
-
if (out === null) {
|
|
82
|
-
return match;
|
|
83
|
-
}
|
|
84
|
-
changes.push({ from: inner.trim(), refType: "url", to: out });
|
|
85
|
-
return `url(${quote}${out}${quote})`;
|
|
86
|
+
const out = visit(inner.trim(), "url", false);
|
|
87
|
+
return out === null ? match : `url(${quote}${out}${quote})`;
|
|
86
88
|
});
|
|
87
89
|
}
|
|
88
90
|
|
|
89
|
-
/** Walk a `style` value tree,
|
|
90
|
-
function walkStyle(node: unknown,
|
|
91
|
+
/** Walk a `style` value tree, visiting `url(...)` in every string it contains. */
|
|
92
|
+
function walkStyle(node: unknown, visit: RefVisitor): void {
|
|
91
93
|
if (Array.isArray(node)) {
|
|
92
94
|
const arr = node as unknown[];
|
|
93
95
|
for (let i = 0; i < arr.length; i += 1) {
|
|
94
96
|
const item = arr[i];
|
|
95
97
|
if (typeof item === "string") {
|
|
96
|
-
const out =
|
|
98
|
+
const out = visitUrls(item, visit);
|
|
97
99
|
if (out !== item) {
|
|
98
100
|
arr[i] = out;
|
|
99
101
|
}
|
|
100
102
|
} else {
|
|
101
|
-
walkStyle(item,
|
|
103
|
+
walkStyle(item, visit);
|
|
102
104
|
}
|
|
103
105
|
}
|
|
104
106
|
return;
|
|
@@ -110,21 +112,26 @@ function walkStyle(node: unknown, ctx: RemapCtx, changes: RefChange[]): void {
|
|
|
110
112
|
for (const key of Object.keys(obj)) {
|
|
111
113
|
const value = obj[key];
|
|
112
114
|
if (typeof value === "string") {
|
|
113
|
-
const out =
|
|
115
|
+
const out = visitUrls(value, visit);
|
|
114
116
|
if (out !== value) {
|
|
115
117
|
obj[key] = out;
|
|
116
118
|
}
|
|
117
119
|
} else {
|
|
118
|
-
walkStyle(value,
|
|
120
|
+
walkStyle(value, visit);
|
|
119
121
|
}
|
|
120
122
|
}
|
|
121
123
|
}
|
|
122
124
|
|
|
123
|
-
/**
|
|
124
|
-
|
|
125
|
+
/**
|
|
126
|
+
* Recursive document walk dispatching each reference-bearing key to `visit`.
|
|
127
|
+
*
|
|
128
|
+
* This is the single definition of "what counts as a reference" in a Jx document. Mutates `doc`
|
|
129
|
+
* only where the visitor asks it to.
|
|
130
|
+
*/
|
|
131
|
+
export function walkDocRefs(node: unknown, visit: RefVisitor): void {
|
|
125
132
|
if (Array.isArray(node)) {
|
|
126
133
|
for (const item of node as unknown[]) {
|
|
127
|
-
|
|
134
|
+
walkDocRefs(item, visit);
|
|
128
135
|
}
|
|
129
136
|
return;
|
|
130
137
|
}
|
|
@@ -137,28 +144,28 @@ function walk(node: unknown, ctx: RemapCtx, changes: RefChange[]): void {
|
|
|
137
144
|
switch (key) {
|
|
138
145
|
case "$ref": {
|
|
139
146
|
// File-path $ref only; state-scope refs (#/…, $map/…) are filtered by classifyRef.
|
|
140
|
-
|
|
147
|
+
visitScalar(obj, key, "$ref", visit, false);
|
|
141
148
|
break;
|
|
142
149
|
}
|
|
143
150
|
case "$layout": {
|
|
144
|
-
|
|
151
|
+
visitScalar(obj, key, "$layout", visit, true);
|
|
145
152
|
break;
|
|
146
153
|
}
|
|
147
154
|
case "$src":
|
|
148
155
|
case "$implementation": {
|
|
149
|
-
|
|
156
|
+
visitScalar(obj, key, key, visit, false);
|
|
150
157
|
break;
|
|
151
158
|
}
|
|
152
159
|
case "src":
|
|
153
160
|
case "href": {
|
|
154
|
-
|
|
161
|
+
visitScalar(obj, key, "attr", visit, false);
|
|
155
162
|
break;
|
|
156
163
|
}
|
|
157
164
|
case "imports": {
|
|
158
165
|
if (value && typeof value === "object" && !Array.isArray(value)) {
|
|
159
166
|
const imp = value as Record<string, unknown>;
|
|
160
167
|
for (const name of Object.keys(imp)) {
|
|
161
|
-
|
|
168
|
+
visitScalar(imp, name, "imports", visit, false);
|
|
162
169
|
}
|
|
163
170
|
}
|
|
164
171
|
break;
|
|
@@ -168,22 +175,22 @@ function walk(node: unknown, ctx: RemapCtx, changes: RefChange[]): void {
|
|
|
168
175
|
const els = value as unknown[];
|
|
169
176
|
for (let i = 0; i < els.length; i += 1) {
|
|
170
177
|
if (typeof els[i] === "string") {
|
|
171
|
-
|
|
178
|
+
visitIndexed(els, i, "$elements", visit);
|
|
172
179
|
} else {
|
|
173
|
-
|
|
180
|
+
walkDocRefs(els[i], visit);
|
|
174
181
|
}
|
|
175
182
|
}
|
|
176
183
|
} else {
|
|
177
|
-
|
|
184
|
+
walkDocRefs(value, visit);
|
|
178
185
|
}
|
|
179
186
|
break;
|
|
180
187
|
}
|
|
181
188
|
case "style": {
|
|
182
|
-
walkStyle(value,
|
|
189
|
+
walkStyle(value, visit);
|
|
183
190
|
break;
|
|
184
191
|
}
|
|
185
192
|
default: {
|
|
186
|
-
|
|
193
|
+
walkDocRefs(value, visit);
|
|
187
194
|
}
|
|
188
195
|
}
|
|
189
196
|
}
|
|
@@ -192,12 +199,31 @@ function walk(node: unknown, ctx: RemapCtx, changes: RefChange[]): void {
|
|
|
192
199
|
/** Rewrite every file-path reference in `doc` for a rename described by `ctx`. Mutates `doc`. */
|
|
193
200
|
export function rewriteDocRefs(doc: unknown, ctx: RemapCtx): RewriteResult {
|
|
194
201
|
const changes: RefChange[] = [];
|
|
195
|
-
|
|
202
|
+
walkDocRefs(doc, (value, refType, rootRelativeBare) => {
|
|
203
|
+
const cls = classifyRef(value);
|
|
204
|
+
if (cls.kind !== "path") {
|
|
205
|
+
return null;
|
|
206
|
+
}
|
|
207
|
+
const out = rewriteRef(cls, ctx, rootRelativeBare);
|
|
208
|
+
if (out === null) {
|
|
209
|
+
return null;
|
|
210
|
+
}
|
|
211
|
+
changes.push({ from: value, refType, to: out });
|
|
212
|
+
return out;
|
|
213
|
+
});
|
|
196
214
|
return { changes, doc };
|
|
197
215
|
}
|
|
198
216
|
|
|
199
|
-
/**
|
|
200
|
-
|
|
217
|
+
/**
|
|
218
|
+
* Recursive tag walk. With `newTag` set, every `tagName === oldTag` is rewritten; with `newTag`
|
|
219
|
+
* null, they are only counted — the read side of the same traversal.
|
|
220
|
+
*/
|
|
221
|
+
function walkTags(
|
|
222
|
+
node: unknown,
|
|
223
|
+
oldTag: string,
|
|
224
|
+
newTag: string | null,
|
|
225
|
+
counter: { n: number },
|
|
226
|
+
): void {
|
|
201
227
|
if (Array.isArray(node)) {
|
|
202
228
|
for (const item of node as unknown[]) {
|
|
203
229
|
walkTags(item, oldTag, newTag, counter);
|
|
@@ -209,7 +235,9 @@ function walkTags(node: unknown, oldTag: string, newTag: string, counter: { n: n
|
|
|
209
235
|
}
|
|
210
236
|
const obj = node as Record<string, unknown>;
|
|
211
237
|
if (obj.tagName === oldTag) {
|
|
212
|
-
|
|
238
|
+
if (newTag !== null) {
|
|
239
|
+
obj.tagName = newTag;
|
|
240
|
+
}
|
|
213
241
|
counter.n += 1;
|
|
214
242
|
}
|
|
215
243
|
for (const key of Object.keys(obj)) {
|
|
@@ -234,3 +262,15 @@ export function rewriteTagName(
|
|
|
234
262
|
}
|
|
235
263
|
return { count: counter.n, doc };
|
|
236
264
|
}
|
|
265
|
+
|
|
266
|
+
/**
|
|
267
|
+
* How many nodes in `doc` carry `tagName === tag`. Read-only; `doc` is never touched.
|
|
268
|
+
*
|
|
269
|
+
* A component's OWN definition file answers 1 for its root node, which is why the usage query skips
|
|
270
|
+
* the definition file rather than subtracting one — a component may legitimately nest itself.
|
|
271
|
+
*/
|
|
272
|
+
export function countTagUses(doc: unknown, tag: string): number {
|
|
273
|
+
const counter = { n: 0 };
|
|
274
|
+
walkTags(doc, tag, null, counter);
|
|
275
|
+
return counter.n;
|
|
276
|
+
}
|