@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.
@@ -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 new Response("Upgrade failed", { status: 400 });
178
+ return problem("invalidRequest", "Upgrade failed");
125
179
  }
126
180
 
127
- // 2. AI SSE — keep the existing handleAiApi behavior (rewrite the studio-ai prefix).
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 new Response("Not found", { status: 404 });
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 new Response("Not found", { status: 404 });
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 new Response("Unknown window", { status: 404 });
240
+ return problem("notFound", "Unknown window");
178
241
  }
179
242
  const root = session.projectRoot;
180
243
  if (!root) {
181
- return new Response("No project", { status: 404 });
244
+ return problem("notFound", "No project");
182
245
  }
183
246
 
184
- // 4b. Extension server mounts (/_jx/data etc.) — registry-driven, same wire contract as
185
- // The generated site worker and the dev server (specs/extensions.md §11).
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
- // 6. Project files at natural URLs, including extension asset mounts (§8.5) — that is what
210
- // Lets a canvas preview show an image a content entry references relative to itself.
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
- return new Response("Not found", { status: 404 });
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
  };
@@ -9,16 +9,14 @@
9
9
  */
10
10
 
11
11
  import { basename, dirname, extname, relative, resolve } from "node:path";
12
- import { readFile, stat, writeFile } from "node:fs/promises";
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 (skip(match)) {
111
+ if (skipScanPath(match)) {
166
112
  continue;
167
113
  }
168
114
  const fp = fwd(resolve(opts.root, match));
Binary file
@@ -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";
@@ -1,14 +1,21 @@
1
1
  /**
2
- * Refs.ts — pure document reference engine for rename-refactoring.
2
+ * Refs.ts — pure document reference engine, shared by the rename refactor and the usage query.
3
3
  *
4
- * Two passes, both operating on a parsed (JSON) document tree with no filesystem access: -
5
- * `rewriteDocRefs` — recompute every file-path reference across a rename (Pillar B). -
6
- * `rewriteTagName` — rename a custom-element tag wherever it is used (Pillar C).
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
- * The walk is structure-aware (it dispatches by JSON key), not a blind string search, and mutates
9
- * the passed document in place — callers parse a fresh object per file, so this is safe. Precision
10
- * comes from `rewriteRef`'s resolve-and-compare gate in paths.ts: a reference is only rewritten
11
- * when it actually resolves to the renamed file.
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
- /** Recompute a scalar string reference under `key`, recording any change. */
33
- function rewriteScalar(
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
- ctx: RemapCtx,
38
- changes: RefChange[],
58
+ visit: RefVisitor,
39
59
  rootRelativeBare: boolean,
40
60
  ): void {
41
61
  const value = container[key];
42
- const cls = classifyRef(value);
43
- if (cls.kind !== "path") {
62
+ if (typeof value !== "string") {
44
63
  return;
45
64
  }
46
- const out = rewriteRef(cls, ctx, rootRelativeBare);
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
- /** Recompute a string array member (e.g. an `$elements` entry), recording any change. */
54
- function rewriteIndexed(
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
- const cls = classifyRef(value);
63
- if (cls.kind !== "path") {
74
+ if (typeof value !== "string") {
64
75
  return;
65
76
  }
66
- const out = rewriteRef(cls, ctx, false);
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
- /** Rewrite every `url(...)` target inside a CSS string. */
74
- function rewriteUrls(value: string, ctx: RemapCtx, changes: RefChange[]): string {
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 cls = classifyRef(inner.trim());
77
- if (cls.kind !== "path") {
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, rewriting `url(...)` in every string it contains. */
90
- function walkStyle(node: unknown, ctx: RemapCtx, changes: RefChange[]): void {
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 = rewriteUrls(item, ctx, changes);
98
+ const out = visitUrls(item, visit);
97
99
  if (out !== item) {
98
100
  arr[i] = out;
99
101
  }
100
102
  } else {
101
- walkStyle(item, ctx, changes);
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 = rewriteUrls(value, ctx, changes);
115
+ const out = visitUrls(value, visit);
114
116
  if (out !== value) {
115
117
  obj[key] = out;
116
118
  }
117
119
  } else {
118
- walkStyle(value, ctx, changes);
120
+ walkStyle(value, visit);
119
121
  }
120
122
  }
121
123
  }
122
124
 
123
- /** Recursive document walk dispatching each reference-bearing key. */
124
- function walk(node: unknown, ctx: RemapCtx, changes: RefChange[]): void {
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
- walk(item, ctx, changes);
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
- rewriteScalar(obj, key, "$ref", ctx, changes, false);
147
+ visitScalar(obj, key, "$ref", visit, false);
141
148
  break;
142
149
  }
143
150
  case "$layout": {
144
- rewriteScalar(obj, key, "$layout", ctx, changes, true);
151
+ visitScalar(obj, key, "$layout", visit, true);
145
152
  break;
146
153
  }
147
154
  case "$src":
148
155
  case "$implementation": {
149
- rewriteScalar(obj, key, key, ctx, changes, false);
156
+ visitScalar(obj, key, key, visit, false);
150
157
  break;
151
158
  }
152
159
  case "src":
153
160
  case "href": {
154
- rewriteScalar(obj, key, "attr", ctx, changes, false);
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
- rewriteScalar(imp, name, "imports", ctx, changes, false);
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
- rewriteIndexed(els, i, "$elements", ctx, changes);
178
+ visitIndexed(els, i, "$elements", visit);
172
179
  } else {
173
- walk(els[i], ctx, changes);
180
+ walkDocRefs(els[i], visit);
174
181
  }
175
182
  }
176
183
  } else {
177
- walk(value, ctx, changes);
184
+ walkDocRefs(value, visit);
178
185
  }
179
186
  break;
180
187
  }
181
188
  case "style": {
182
- walkStyle(value, ctx, changes);
189
+ walkStyle(value, visit);
183
190
  break;
184
191
  }
185
192
  default: {
186
- walk(value, ctx, changes);
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
- walk(doc, ctx, changes);
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
- /** Recursive walk setting `tagName: newTag` wherever it currently equals `oldTag`. */
200
- function walkTags(node: unknown, oldTag: string, newTag: string, counter: { n: number }): void {
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
- obj.tagName = newTag;
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
+ }