@jxsuite/server 0.28.5 → 0.30.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 +9 -8
- package/src/claude-session.ts +2 -2
- package/src/code-api.ts +11 -6
- package/src/refactor/apply.ts +215 -0
- package/src/refactor/fs-events.ts +71 -0
- package/src/refactor/index.ts +11 -0
- package/src/refactor/paths.ts +187 -0
- package/src/refactor/refs.ts +236 -0
- package/src/refactor/watcher.ts +70 -0
- package/src/resolve.ts +41 -12
- package/src/server.ts +26 -14
- package/src/studio-api.ts +199 -91
- package/src/watch.ts +33 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jxsuite/server",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.30.1",
|
|
4
4
|
"description": "Jx development server with live reload, server-side proxy, and studio integration",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -15,6 +15,7 @@
|
|
|
15
15
|
"exports": {
|
|
16
16
|
".": "./src/server.ts",
|
|
17
17
|
"./claude-session": "./src/claude-session.ts",
|
|
18
|
+
"./refactor": "./src/refactor/index.ts",
|
|
18
19
|
"./resolve": "./src/resolve.ts",
|
|
19
20
|
"./types": "./src/types.ts"
|
|
20
21
|
},
|
|
@@ -27,13 +28,13 @@
|
|
|
27
28
|
"test:coverage": "bun test --isolate --coverage"
|
|
28
29
|
},
|
|
29
30
|
"dependencies": {
|
|
30
|
-
"@anthropic-ai/claude-agent-sdk": "^0.3.
|
|
31
|
-
"@anthropic-ai/sdk": "^0.
|
|
32
|
-
"@jxsuite/compiler": "^0.
|
|
33
|
-
"@jxsuite/create": "^0.
|
|
34
|
-
"@jxsuite/parser": "^0.
|
|
35
|
-
"@jxsuite/runtime": "^0.
|
|
36
|
-
"@jxsuite/schema": "^0.
|
|
31
|
+
"@anthropic-ai/claude-agent-sdk": "^0.3.186",
|
|
32
|
+
"@anthropic-ai/sdk": "^0.105.0",
|
|
33
|
+
"@jxsuite/compiler": "^0.30.1",
|
|
34
|
+
"@jxsuite/create": "^0.30.1",
|
|
35
|
+
"@jxsuite/parser": "^0.30.1",
|
|
36
|
+
"@jxsuite/runtime": "^0.30.1",
|
|
37
|
+
"@jxsuite/schema": "^0.30.1",
|
|
37
38
|
"@modelcontextprotocol/sdk": "^1.29.0",
|
|
38
39
|
"chokidar": "^5.0.0",
|
|
39
40
|
"zod": "^4.4.3"
|
package/src/claude-session.ts
CHANGED
|
@@ -136,7 +136,7 @@ export function createSession(
|
|
|
136
136
|
|
|
137
137
|
const stream = query({ options: queryOpts, prompt: message });
|
|
138
138
|
session.query = stream;
|
|
139
|
-
processStream(session, stream);
|
|
139
|
+
void processStream(session, stream);
|
|
140
140
|
|
|
141
141
|
return { id };
|
|
142
142
|
}
|
|
@@ -177,7 +177,7 @@ export function sendMessage(id: string, message: string) {
|
|
|
177
177
|
|
|
178
178
|
const stream = query({ options: queryOpts, prompt: message });
|
|
179
179
|
session.query = stream;
|
|
180
|
-
processStream(session, stream);
|
|
180
|
+
void processStream(session, stream);
|
|
181
181
|
}
|
|
182
182
|
|
|
183
183
|
/**
|
package/src/code-api.ts
CHANGED
|
@@ -21,6 +21,11 @@ interface OxcDiagnostic {
|
|
|
21
21
|
[key: string]: unknown;
|
|
22
22
|
}
|
|
23
23
|
|
|
24
|
+
interface CodeApiBody {
|
|
25
|
+
code?: string;
|
|
26
|
+
args?: string[];
|
|
27
|
+
}
|
|
28
|
+
|
|
24
29
|
const OXLINT_BIN = resolve(
|
|
25
30
|
import.meta.dir,
|
|
26
31
|
"../../node_modules/.bin",
|
|
@@ -43,10 +48,10 @@ function unwrapFormatted(formatted: string) {
|
|
|
43
48
|
const lines = formatted.split("\n");
|
|
44
49
|
// Remove first line (function header) and last non-empty line (closing brace)
|
|
45
50
|
let end = lines.length - 1;
|
|
46
|
-
while (end > 0 && lines[end]
|
|
51
|
+
while (end > 0 && lines[end]!.trim() === "") {
|
|
47
52
|
end -= 1;
|
|
48
53
|
}
|
|
49
|
-
if (lines[end]
|
|
54
|
+
if (lines[end]!.trim() === "}") {
|
|
50
55
|
end -= 1;
|
|
51
56
|
}
|
|
52
57
|
const bodyLines = lines.slice(1, end + 1);
|
|
@@ -90,9 +95,9 @@ export async function handleCodeApi(req: Request, url: URL) {
|
|
|
90
95
|
return null;
|
|
91
96
|
}
|
|
92
97
|
|
|
93
|
-
let body;
|
|
98
|
+
let body: CodeApiBody;
|
|
94
99
|
try {
|
|
95
|
-
body = await req.json();
|
|
100
|
+
body = (await req.json()) as CodeApiBody;
|
|
96
101
|
} catch {
|
|
97
102
|
return new Response("Invalid JSON", { status: 400 });
|
|
98
103
|
}
|
|
@@ -162,8 +167,8 @@ export async function handleCodeApi(req: Request, url: URL) {
|
|
|
162
167
|
const output = await new Response(proc.stdout).text();
|
|
163
168
|
await proc.exited;
|
|
164
169
|
|
|
165
|
-
const parsed = JSON.parse(output);
|
|
166
|
-
const adjusted = adjustDiagnostics(parsed.diagnostics
|
|
170
|
+
const parsed = JSON.parse(output) as { diagnostics?: OxcDiagnostic[] };
|
|
171
|
+
const adjusted = adjustDiagnostics(parsed.diagnostics ?? [], headerLen);
|
|
167
172
|
return Response.json({ diagnostics: adjusted });
|
|
168
173
|
} catch (error) {
|
|
169
174
|
return Response.json({
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Apply.ts — IO orchestrator for rename-refactoring, shared by the dev-server endpoint
|
|
3
|
+
* (studio-api.ts) and the desktop project session.
|
|
4
|
+
*
|
|
5
|
+
* The caller performs the filesystem move first, then calls `applyRename`, which globs the
|
|
6
|
+
* project's document + content files, runs the pure engines from refs.ts against each, writes back
|
|
7
|
+
* only the files that changed, and returns a `RenameReport`. When the renamed file is a component,
|
|
8
|
+
* the tag is auto-derived from the new filename and renamed everywhere it is used (Pillar C).
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { basename, dirname, extname, relative, resolve } from "node:path";
|
|
12
|
+
import { readFile, stat, writeFile } from "node:fs/promises";
|
|
13
|
+
import { errorMessage } from "@jxsuite/schema/parse";
|
|
14
|
+
import { isUnder } from "./paths.ts";
|
|
15
|
+
import { rewriteDocRefs, rewriteTagName } from "./refs.ts";
|
|
16
|
+
import type { RefChange } from "./refs.ts";
|
|
17
|
+
import type { FormatRegistry } from "@jxsuite/schema/format-registry";
|
|
18
|
+
|
|
19
|
+
/** Normalise a path to forward slashes. */
|
|
20
|
+
const fwd = (p: string) => p.replaceAll("\\", "/");
|
|
21
|
+
|
|
22
|
+
export interface FileChange {
|
|
23
|
+
path: string;
|
|
24
|
+
count: number;
|
|
25
|
+
changes: RefChange[];
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface RenameReport {
|
|
29
|
+
ok: boolean;
|
|
30
|
+
from: string;
|
|
31
|
+
to: string;
|
|
32
|
+
isDir: boolean;
|
|
33
|
+
references: {
|
|
34
|
+
filesChanged: number;
|
|
35
|
+
refsUpdated: number;
|
|
36
|
+
files: FileChange[];
|
|
37
|
+
};
|
|
38
|
+
errors: { path: string; error: string }[];
|
|
39
|
+
tag?: { from: string; to: string; filesChanged: number; refsUpdated: number };
|
|
40
|
+
tagSkipped?: string;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface ApplyRenameOptions {
|
|
44
|
+
/** Absolute project root to scan. */
|
|
45
|
+
root: string;
|
|
46
|
+
/** Absolute path of the renamed file/dir, before the move. */
|
|
47
|
+
absFrom: string;
|
|
48
|
+
/** Absolute path of the renamed file/dir, after the move (already on disk). */
|
|
49
|
+
absTo: string;
|
|
50
|
+
/** Format registry for the project (parse/serialize of non-JSON documents). */
|
|
51
|
+
registry: FormatRegistry;
|
|
52
|
+
}
|
|
53
|
+
|
|
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
|
+
/** Derive a custom-element tag from a component filename (strips `.class.json` or the extension). */
|
|
69
|
+
export function deriveTag(absPath: string): string {
|
|
70
|
+
const base = basename(absPath);
|
|
71
|
+
if (base.endsWith(".class.json")) {
|
|
72
|
+
return base.slice(0, -".class.json".length);
|
|
73
|
+
}
|
|
74
|
+
const ext = extname(base);
|
|
75
|
+
return ext ? base.slice(0, -ext.length) : base;
|
|
76
|
+
}
|
|
77
|
+
|
|
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
|
+
/**
|
|
117
|
+
* Rewrite references (and, for components, the tag) across the project after a rename. The caller
|
|
118
|
+
* must have already moved the file on disk.
|
|
119
|
+
*/
|
|
120
|
+
export async function applyRename(opts: ApplyRenameOptions): Promise<RenameReport> {
|
|
121
|
+
const root = fwd(opts.root);
|
|
122
|
+
const absFrom = fwd(opts.absFrom);
|
|
123
|
+
const absTo = fwd(opts.absTo);
|
|
124
|
+
|
|
125
|
+
let isDir = false;
|
|
126
|
+
try {
|
|
127
|
+
const stats = await stat(absTo);
|
|
128
|
+
isDir = stats.isDirectory();
|
|
129
|
+
} catch {
|
|
130
|
+
isDir = false;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const report: RenameReport = {
|
|
134
|
+
errors: [],
|
|
135
|
+
from: fwd(relative(opts.root, opts.absFrom)),
|
|
136
|
+
isDir,
|
|
137
|
+
ok: true,
|
|
138
|
+
references: { files: [], filesChanged: 0, refsUpdated: 0 },
|
|
139
|
+
to: fwd(relative(opts.root, opts.absTo)),
|
|
140
|
+
};
|
|
141
|
+
|
|
142
|
+
// For a component file rename, derive the new tag from the new filename (Pillar C).
|
|
143
|
+
let tagRename: { from: string; to: string } | null = null;
|
|
144
|
+
if (!isDir) {
|
|
145
|
+
try {
|
|
146
|
+
const { doc } = await loadDoc(absTo, opts.registry);
|
|
147
|
+
const oldTag = (doc as { tagName?: unknown }).tagName;
|
|
148
|
+
const newTag = deriveTag(absTo);
|
|
149
|
+
if (typeof oldTag === "string" && oldTag.includes("-") && newTag !== oldTag) {
|
|
150
|
+
if (newTag.includes("-")) {
|
|
151
|
+
tagRename = { from: oldTag, to: newTag };
|
|
152
|
+
} else {
|
|
153
|
+
report.tagSkipped = `Tag "${oldTag}" left unchanged: "${newTag}" is not a valid custom-element name`;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
} catch {
|
|
157
|
+
// Not a parseable component — skip tag derivation.
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
let tagFilesChanged = 0;
|
|
162
|
+
let tagRefsUpdated = 0;
|
|
163
|
+
|
|
164
|
+
for await (const match of documentGlob(opts.registry).scan({ cwd: opts.root, dot: false })) {
|
|
165
|
+
if (skip(match)) {
|
|
166
|
+
continue;
|
|
167
|
+
}
|
|
168
|
+
const fp = fwd(resolve(opts.root, match));
|
|
169
|
+
try {
|
|
170
|
+
const { doc, serialize } = await loadDoc(fp, opts.registry);
|
|
171
|
+
const docNewDir = dirname(fp);
|
|
172
|
+
const docOldDir = isUnder(fp, absTo) ? dirname(absFrom + fp.slice(absTo.length)) : docNewDir;
|
|
173
|
+
|
|
174
|
+
const { changes } = rewriteDocRefs(doc, {
|
|
175
|
+
docNewDir,
|
|
176
|
+
docOldDir,
|
|
177
|
+
newAbs: absTo,
|
|
178
|
+
oldAbs: absFrom,
|
|
179
|
+
root,
|
|
180
|
+
});
|
|
181
|
+
const tagCount = tagRename ? rewriteTagName(doc, tagRename.from, tagRename.to).count : 0;
|
|
182
|
+
|
|
183
|
+
if (changes.length === 0 && tagCount === 0) {
|
|
184
|
+
continue;
|
|
185
|
+
}
|
|
186
|
+
if (!serialize) {
|
|
187
|
+
report.errors.push({ error: `No serializer for "${match}"`, path: fwd(match) });
|
|
188
|
+
continue;
|
|
189
|
+
}
|
|
190
|
+
await writeFile(fp, await serialize(doc));
|
|
191
|
+
|
|
192
|
+
if (changes.length > 0) {
|
|
193
|
+
report.references.files.push({ changes, count: changes.length, path: fwd(match) });
|
|
194
|
+
report.references.filesChanged += 1;
|
|
195
|
+
report.references.refsUpdated += changes.length;
|
|
196
|
+
}
|
|
197
|
+
if (tagCount > 0) {
|
|
198
|
+
tagFilesChanged += 1;
|
|
199
|
+
tagRefsUpdated += tagCount;
|
|
200
|
+
}
|
|
201
|
+
} catch (error) {
|
|
202
|
+
report.errors.push({ error: errorMessage(error), path: fwd(match) });
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
if (tagRename) {
|
|
207
|
+
report.tag = {
|
|
208
|
+
filesChanged: tagFilesChanged,
|
|
209
|
+
from: tagRename.from,
|
|
210
|
+
refsUpdated: tagRefsUpdated,
|
|
211
|
+
to: tagRename.to,
|
|
212
|
+
};
|
|
213
|
+
}
|
|
214
|
+
return report;
|
|
215
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Fs-events.ts — pure translation + coalescing of chokidar events into the wire payloads the studio
|
|
3
|
+
* sidebar consumes. No filesystem access (node:path.relative is a pure string op), so it is shared
|
|
4
|
+
* by the dev-server watcher (watch.ts) and the desktop project session, and unit-tests directly.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { relative } from "node:path";
|
|
8
|
+
|
|
9
|
+
/** A filesystem change as broadcast to the studio shell (root-relative, forward-slashed path). */
|
|
10
|
+
export interface FsEventPayload {
|
|
11
|
+
type: "add" | "change" | "unlink" | "addDir" | "unlinkDir";
|
|
12
|
+
path: string;
|
|
13
|
+
isDir: boolean;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const VALID = new Set(["add", "change", "unlink", "addDir", "unlinkDir"]);
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Map one chokidar event to a payload, or null for events we ignore (`ready`/`raw`/`error`) or
|
|
20
|
+
* paths that escape the root.
|
|
21
|
+
*/
|
|
22
|
+
export function toFsEvent(
|
|
23
|
+
eventType: string,
|
|
24
|
+
root: string,
|
|
25
|
+
changedPath: string,
|
|
26
|
+
): FsEventPayload | null {
|
|
27
|
+
if (!VALID.has(eventType)) {
|
|
28
|
+
return null;
|
|
29
|
+
}
|
|
30
|
+
const path = relative(root, changedPath).replaceAll("\\", "/");
|
|
31
|
+
if (!path || path.startsWith("..")) {
|
|
32
|
+
return null;
|
|
33
|
+
}
|
|
34
|
+
return {
|
|
35
|
+
isDir: eventType === "addDir" || eventType === "unlinkDir",
|
|
36
|
+
path,
|
|
37
|
+
type: eventType as FsEventPayload["type"],
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Fold one event into a buffer, collapsing redundant churn per path (last-write-wins, except
|
|
43
|
+
* add+unlink cancels, add+change stays add, and unlink+add becomes a re-add). Returns a new array.
|
|
44
|
+
*/
|
|
45
|
+
export function coalesceFsEvents(buffer: FsEventPayload[], next: FsEventPayload): FsEventPayload[] {
|
|
46
|
+
const prev = buffer.find((e) => e.path === next.path);
|
|
47
|
+
if (!prev) {
|
|
48
|
+
return [...buffer, next];
|
|
49
|
+
}
|
|
50
|
+
const index = buffer.indexOf(prev);
|
|
51
|
+
const out = [...buffer];
|
|
52
|
+
// A file (or dir) created then removed within the window is a no-op.
|
|
53
|
+
if (
|
|
54
|
+
(prev.type === "add" && next.type === "unlink") ||
|
|
55
|
+
(prev.type === "addDir" && next.type === "unlinkDir")
|
|
56
|
+
) {
|
|
57
|
+
out.splice(index, 1);
|
|
58
|
+
return out;
|
|
59
|
+
}
|
|
60
|
+
// A content change after a create is still just a create.
|
|
61
|
+
if ((prev.type === "add" || prev.type === "addDir") && next.type === "change") {
|
|
62
|
+
return out;
|
|
63
|
+
}
|
|
64
|
+
// Removed then recreated → treat as a fresh add.
|
|
65
|
+
if (prev.type === "unlink" && next.type === "add") {
|
|
66
|
+
out[index] = next;
|
|
67
|
+
return out;
|
|
68
|
+
}
|
|
69
|
+
out[index] = next;
|
|
70
|
+
return out;
|
|
71
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Public entrypoint for the rename-refactor engine (`@jxsuite/server/refactor`), shared by the
|
|
3
|
+
* dev-server endpoint and the desktop project session.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
export { applyRename, deriveTag } from "./apply.ts";
|
|
7
|
+
export type { ApplyRenameOptions, FileChange, RenameReport } from "./apply.ts";
|
|
8
|
+
export { coalesceFsEvents, toFsEvent } from "./fs-events.ts";
|
|
9
|
+
export type { FsEventPayload } from "./fs-events.ts";
|
|
10
|
+
export { createFsWatcher } from "./watcher.ts";
|
|
11
|
+
export type { FsWatcherHandle } from "./watcher.ts";
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Paths.ts — pure, OS-independent path math for the rename-refactor engine.
|
|
3
|
+
*
|
|
4
|
+
* Everything here operates on forward-slash POSIX strings and never touches the filesystem, so it
|
|
5
|
+
* unit-tests deterministically. Callers (apply.ts) normalise real paths with `fwd()` before handing
|
|
6
|
+
* them in. The relative-path style mirrors `computeRelativePath` in the studio
|
|
7
|
+
* (packages/studio/src/files/components.ts): "./" for same-dir/descendant targets, "../"-chains
|
|
8
|
+
* otherwise.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
/** Classification of a reference string value. */
|
|
12
|
+
export type RefClass =
|
|
13
|
+
| { kind: "none" }
|
|
14
|
+
| { kind: "state" }
|
|
15
|
+
| { kind: "external" }
|
|
16
|
+
| { kind: "path"; core: string; suffix: string; rooted: boolean };
|
|
17
|
+
|
|
18
|
+
/** Split a trailing `?query` or `#fragment` off a path-like value. */
|
|
19
|
+
export function splitQueryHash(value: string): { core: string; suffix: string } {
|
|
20
|
+
for (let i = 0; i < value.length; i += 1) {
|
|
21
|
+
const ch = value[i];
|
|
22
|
+
if (ch === "?" || ch === "#") {
|
|
23
|
+
return { core: value.slice(0, i), suffix: value.slice(i) };
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
return { core: value, suffix: "" };
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Classify a reference value. State-scope refs (`#/state/…`, `$map/…`, `parent#…`, `window#…`,
|
|
31
|
+
* `document#…`) and external URLs (`http:`, `data:`, protocol-relative `//…`) are never file paths.
|
|
32
|
+
* Everything else is treated as a `path`; the resolve-and-compare gate in `rewriteRef` provides the
|
|
33
|
+
* actual precision, so bare npm specifiers (which never resolve to the renamed file) are simply
|
|
34
|
+
* never matched.
|
|
35
|
+
*/
|
|
36
|
+
export function classifyRef(value: unknown): RefClass {
|
|
37
|
+
if (typeof value !== "string" || value.length === 0) {
|
|
38
|
+
return { kind: "none" };
|
|
39
|
+
}
|
|
40
|
+
if (
|
|
41
|
+
value === "$map" ||
|
|
42
|
+
value.startsWith("#") ||
|
|
43
|
+
value.startsWith("$map/") ||
|
|
44
|
+
value.startsWith("parent#") ||
|
|
45
|
+
value.startsWith("window#") ||
|
|
46
|
+
value.startsWith("document#")
|
|
47
|
+
) {
|
|
48
|
+
return { kind: "state" };
|
|
49
|
+
}
|
|
50
|
+
// Absolute URL with a scheme (http:, data:, mailto:) or protocol-relative (//host).
|
|
51
|
+
if (value.startsWith("//") || /^[a-zA-Z][a-zA-Z0-9+.-]*:/.test(value)) {
|
|
52
|
+
return { kind: "external" };
|
|
53
|
+
}
|
|
54
|
+
const { core, suffix } = splitQueryHash(value);
|
|
55
|
+
if (core.length === 0) {
|
|
56
|
+
return { kind: "none" };
|
|
57
|
+
}
|
|
58
|
+
return { core, kind: "path", rooted: core.startsWith("/"), suffix };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Collapse `.`/`..` segments in a POSIX path. Absolute paths cannot escape above root. */
|
|
62
|
+
export function normalizeSegments(path: string): string {
|
|
63
|
+
const isAbs = path.startsWith("/");
|
|
64
|
+
const out: string[] = [];
|
|
65
|
+
for (const part of path.split("/")) {
|
|
66
|
+
if (part === "" || part === ".") {
|
|
67
|
+
continue;
|
|
68
|
+
}
|
|
69
|
+
if (part === "..") {
|
|
70
|
+
if (out.length > 0 && out.at(-1) !== "..") {
|
|
71
|
+
out.pop();
|
|
72
|
+
} else if (!isAbs) {
|
|
73
|
+
out.push("..");
|
|
74
|
+
}
|
|
75
|
+
continue;
|
|
76
|
+
}
|
|
77
|
+
out.push(part);
|
|
78
|
+
}
|
|
79
|
+
return (isAbs ? "/" : "") + out.join("/");
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Join `rel` onto an absolute POSIX `base` and normalise (a leading "/" on rel still joins to
|
|
84
|
+
* base).
|
|
85
|
+
*/
|
|
86
|
+
export function joinPosix(base: string, rel: string): string {
|
|
87
|
+
return normalizeSegments(`${base}/${rel}`);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Relative path from directory `fromDir` to `to` (both absolute POSIX). Returns a bare path for
|
|
92
|
+
* descendants ("a/b.json") and "../"-chains otherwise — without a leading "./" (callers add it to
|
|
93
|
+
* preserve author style).
|
|
94
|
+
*/
|
|
95
|
+
export function relativeChain(fromDir: string, to: string): string {
|
|
96
|
+
const fromParts = fromDir.split("/").filter(Boolean);
|
|
97
|
+
const toParts = to.split("/").filter(Boolean);
|
|
98
|
+
let common = 0;
|
|
99
|
+
while (
|
|
100
|
+
common < fromParts.length &&
|
|
101
|
+
common < toParts.length &&
|
|
102
|
+
fromParts[common] === toParts[common]
|
|
103
|
+
) {
|
|
104
|
+
common += 1;
|
|
105
|
+
}
|
|
106
|
+
const ups = fromParts.length - common;
|
|
107
|
+
const remaining = toParts.slice(common);
|
|
108
|
+
return (ups > 0 ? "../".repeat(ups) : "") + remaining.join("/");
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** True when `p` is `dir` itself or nested under it. */
|
|
112
|
+
export function isUnder(p: string, dir: string): boolean {
|
|
113
|
+
return p === dir || p.startsWith(`${dir}/`);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Remap a resolved absolute target across a rename. If the target is the renamed file (or under the
|
|
118
|
+
* renamed directory), re-root it onto the new location; otherwise it is unaffected.
|
|
119
|
+
*/
|
|
120
|
+
export function remapTarget(targetOld: string, oldAbs: string, newAbs: string): string {
|
|
121
|
+
if (targetOld === oldAbs) {
|
|
122
|
+
return newAbs;
|
|
123
|
+
}
|
|
124
|
+
if (isUnder(targetOld, oldAbs)) {
|
|
125
|
+
return newAbs + targetOld.slice(oldAbs.length);
|
|
126
|
+
}
|
|
127
|
+
return targetOld;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** Context describing a single rename, shared by every reference rewrite within one document. */
|
|
131
|
+
export interface RemapCtx {
|
|
132
|
+
/** Absolute POSIX project root. */
|
|
133
|
+
root: string;
|
|
134
|
+
/** Absolute POSIX path of the renamed file/dir, before the move. */
|
|
135
|
+
oldAbs: string;
|
|
136
|
+
/** Absolute POSIX path of the renamed file/dir, after the move. */
|
|
137
|
+
newAbs: string;
|
|
138
|
+
/** Directory the referencing document lived in before the move. */
|
|
139
|
+
docOldDir: string;
|
|
140
|
+
/**
|
|
141
|
+
* Directory the referencing document lives in after the move (differs from docOldDir only when
|
|
142
|
+
* the document itself is inside the moved subtree).
|
|
143
|
+
*/
|
|
144
|
+
docNewDir: string;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Recompute a single path reference for a rename, preserving its original style. Returns the new
|
|
149
|
+
* string, or null when nothing changes.
|
|
150
|
+
*
|
|
151
|
+
* `rootRelativeBare` makes a bare value (no "./" or "../") resolve against the project root rather
|
|
152
|
+
* than the document directory — used for `$layout`, which the compiler resolves against projectRoot
|
|
153
|
+
* (layout-resolver.ts), and for site-relative conventions.
|
|
154
|
+
*/
|
|
155
|
+
export function rewriteRef(
|
|
156
|
+
cls: { core: string; suffix: string; rooted: boolean },
|
|
157
|
+
ctx: RemapCtx,
|
|
158
|
+
rootRelativeBare = false,
|
|
159
|
+
): string | null {
|
|
160
|
+
const { core, rooted, suffix } = cls;
|
|
161
|
+
const startsRel = core.startsWith("./") || core.startsWith("../");
|
|
162
|
+
const useRoot = rooted || (rootRelativeBare && !startsRel);
|
|
163
|
+
const baseOld = useRoot ? ctx.root : ctx.docOldDir;
|
|
164
|
+
const baseNew = useRoot ? ctx.root : ctx.docNewDir;
|
|
165
|
+
|
|
166
|
+
const targetOld = joinPosix(useRoot ? ctx.root : baseOld, core);
|
|
167
|
+
const targetNew = remapTarget(targetOld, ctx.oldAbs, ctx.newAbs);
|
|
168
|
+
if (targetNew === targetOld && baseOld === baseNew) {
|
|
169
|
+
return null;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
let out: string;
|
|
173
|
+
if (rooted) {
|
|
174
|
+
out = `/${relativeChain(ctx.root, targetNew)}`;
|
|
175
|
+
} 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
|
+
}
|
|
184
|
+
}
|
|
185
|
+
out += suffix;
|
|
186
|
+
return out === core + suffix ? null : out;
|
|
187
|
+
}
|