@jxsuite/server 0.29.0 → 0.31.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/package.json +10 -8
- package/src/packages.ts +209 -0
- 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/server.ts +6 -3
- package/src/studio-api.ts +83 -12
- 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.31.0",
|
|
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,8 @@
|
|
|
15
15
|
"exports": {
|
|
16
16
|
".": "./src/server.ts",
|
|
17
17
|
"./claude-session": "./src/claude-session.ts",
|
|
18
|
+
"./packages": "./src/packages.ts",
|
|
19
|
+
"./refactor": "./src/refactor/index.ts",
|
|
18
20
|
"./resolve": "./src/resolve.ts",
|
|
19
21
|
"./types": "./src/types.ts"
|
|
20
22
|
},
|
|
@@ -27,13 +29,13 @@
|
|
|
27
29
|
"test:coverage": "bun test --isolate --coverage"
|
|
28
30
|
},
|
|
29
31
|
"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.
|
|
32
|
+
"@anthropic-ai/claude-agent-sdk": "^0.3.190",
|
|
33
|
+
"@anthropic-ai/sdk": "^0.105.0",
|
|
34
|
+
"@jxsuite/compiler": "^0.31.0",
|
|
35
|
+
"@jxsuite/create": "^0.31.0",
|
|
36
|
+
"@jxsuite/parser": "^0.31.0",
|
|
37
|
+
"@jxsuite/runtime": "^0.31.0",
|
|
38
|
+
"@jxsuite/schema": "^0.31.0",
|
|
37
39
|
"@modelcontextprotocol/sdk": "^1.29.0",
|
|
38
40
|
"chokidar": "^5.0.0",
|
|
39
41
|
"zod": "^4.4.3"
|
package/src/packages.ts
ADDED
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bun-driven package operations, parameterized by a project root. Shared by the desktop app
|
|
3
|
+
* (`createPackageOps`) and the dev server's `/__studio/packages*` endpoints so both backends behave
|
|
4
|
+
* identically. Every function is pure with respect to its `root` argument and spawns `bun` in that
|
|
5
|
+
* directory.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { existsSync } from "node:fs";
|
|
9
|
+
import { readFile, writeFile } from "node:fs/promises";
|
|
10
|
+
import { resolve } from "node:path";
|
|
11
|
+
|
|
12
|
+
export interface PackageInfo {
|
|
13
|
+
name: string;
|
|
14
|
+
version: string;
|
|
15
|
+
/** True when the dependency lives in `devDependencies` rather than `dependencies`. */
|
|
16
|
+
dev?: boolean;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface OutdatedInfo {
|
|
20
|
+
name: string;
|
|
21
|
+
/** The version range pinned in package.json (e.g. "^0.19.0"). */
|
|
22
|
+
current: string;
|
|
23
|
+
/** The newest published version. */
|
|
24
|
+
latest: string;
|
|
25
|
+
dev?: boolean;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Result of a package mutation that runs `bun install` (install / set-versions). */
|
|
29
|
+
export interface PackageOpResult {
|
|
30
|
+
ok: boolean;
|
|
31
|
+
/** Combined stdout/stderr from the bun invocation, surfaced to the user on failure. */
|
|
32
|
+
log?: string;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
interface PackageJson {
|
|
36
|
+
dependencies?: Record<string, string>;
|
|
37
|
+
devDependencies?: Record<string, string>;
|
|
38
|
+
[key: string]: unknown;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Minimal fetch signature (injectable for tests; the global `fetch` satisfies it). */
|
|
42
|
+
type FetchLike = (url: string, init?: RequestInit) => Promise<Response>;
|
|
43
|
+
|
|
44
|
+
/** Strip a semver range prefix to its base version: "^0.19.0" → "0.19.0". */
|
|
45
|
+
export function stripRange(range: string): string {
|
|
46
|
+
const base = range.replace(/^[\^~>=<v\s]+/, "").trim();
|
|
47
|
+
return base.split(/\s+/)[0] ?? "";
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Whether a version spec points at the npm registry (a plain semver range). Skips workspace:,
|
|
52
|
+
* file:, link:, npm:, git, and URL specs, plus wildcards like "*" or "latest".
|
|
53
|
+
*/
|
|
54
|
+
export function isRegistryRange(spec: string): boolean {
|
|
55
|
+
if (spec.includes(":")) {
|
|
56
|
+
return false;
|
|
57
|
+
}
|
|
58
|
+
return /^\d/.test(stripRange(spec));
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
async function readPackageJson(root: string): Promise<PackageJson | null> {
|
|
62
|
+
const pkgPath = resolve(root, "package.json");
|
|
63
|
+
if (!existsSync(pkgPath)) {
|
|
64
|
+
return null;
|
|
65
|
+
}
|
|
66
|
+
try {
|
|
67
|
+
return JSON.parse(await readFile(pkgPath, "utf8")) as PackageJson;
|
|
68
|
+
} catch {
|
|
69
|
+
return null;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** List the project's dependencies and devDependencies (devDeps tagged with `dev: true`). */
|
|
74
|
+
export async function listPackages(root: string): Promise<PackageInfo[]> {
|
|
75
|
+
const pkg = await readPackageJson(root);
|
|
76
|
+
if (!pkg) {
|
|
77
|
+
return [];
|
|
78
|
+
}
|
|
79
|
+
const out: PackageInfo[] = [];
|
|
80
|
+
for (const [name, version] of Object.entries(pkg.dependencies ?? {})) {
|
|
81
|
+
out.push({ name, version });
|
|
82
|
+
}
|
|
83
|
+
for (const [name, version] of Object.entries(pkg.devDependencies ?? {})) {
|
|
84
|
+
out.push({ dev: true, name, version });
|
|
85
|
+
}
|
|
86
|
+
return out;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Whether the project has a package.json but no installed node_modules. */
|
|
90
|
+
export function dependenciesNeedInstall(root: string): boolean {
|
|
91
|
+
if (!existsSync(resolve(root, "package.json"))) {
|
|
92
|
+
return false;
|
|
93
|
+
}
|
|
94
|
+
return !existsSync(resolve(root, "node_modules"));
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
async function runBun(args: string[], cwd: string): Promise<PackageOpResult> {
|
|
98
|
+
const proc = Bun.spawn(["bun", ...args], { cwd, stderr: "pipe", stdout: "pipe" });
|
|
99
|
+
const [stdout, stderr, exitCode] = await Promise.all([
|
|
100
|
+
new Response(proc.stdout).text(),
|
|
101
|
+
new Response(proc.stderr).text(),
|
|
102
|
+
proc.exited,
|
|
103
|
+
]);
|
|
104
|
+
const log = `${stdout}${stderr}`.trim();
|
|
105
|
+
return { ok: exitCode === 0, ...(log ? { log } : {}) };
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** Run `bun install` in the project root. */
|
|
109
|
+
export async function installDependencies(root: string): Promise<PackageOpResult> {
|
|
110
|
+
return runBun(["install"], root);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** Run `bun add` (with `-d` for a dev dependency). */
|
|
114
|
+
export async function addPackage(
|
|
115
|
+
root: string,
|
|
116
|
+
name: string,
|
|
117
|
+
dev = false,
|
|
118
|
+
): Promise<PackageOpResult> {
|
|
119
|
+
return runBun(dev ? ["add", "-d", name] : ["add", name], root);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** Run `bun remove`. */
|
|
123
|
+
export async function removePackage(root: string, name: string): Promise<PackageOpResult> {
|
|
124
|
+
return runBun(["remove", name], root);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** Fetch the newest published version for a package from the npm registry, or null on any failure. */
|
|
128
|
+
export async function fetchLatestVersion(
|
|
129
|
+
name: string,
|
|
130
|
+
fetchImpl: FetchLike = fetch,
|
|
131
|
+
): Promise<string | null> {
|
|
132
|
+
try {
|
|
133
|
+
const res = await fetchImpl(`https://registry.npmjs.org/${name}/latest`, {
|
|
134
|
+
headers: { accept: "application/vnd.npm.install-v1+json" },
|
|
135
|
+
});
|
|
136
|
+
if (!res.ok) {
|
|
137
|
+
return null;
|
|
138
|
+
}
|
|
139
|
+
const data = (await res.json()) as { version?: string };
|
|
140
|
+
return data.version ?? null;
|
|
141
|
+
} catch {
|
|
142
|
+
return null;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* List dependencies whose newest published version differs from the pinned base. Registry lookups
|
|
148
|
+
* run concurrently; non-registry specs (workspace:, file:, …) and lookup failures are skipped.
|
|
149
|
+
*/
|
|
150
|
+
export async function outdatedPackages(
|
|
151
|
+
root: string,
|
|
152
|
+
fetchImpl: FetchLike = fetch,
|
|
153
|
+
): Promise<OutdatedInfo[]> {
|
|
154
|
+
const pkgs = await listPackages(root);
|
|
155
|
+
const checks = await Promise.all(
|
|
156
|
+
pkgs.map(async (p): Promise<OutdatedInfo | null> => {
|
|
157
|
+
if (!isRegistryRange(p.version)) {
|
|
158
|
+
return null;
|
|
159
|
+
}
|
|
160
|
+
const latest = await fetchLatestVersion(p.name, fetchImpl);
|
|
161
|
+
if (!latest || latest === stripRange(p.version)) {
|
|
162
|
+
return null;
|
|
163
|
+
}
|
|
164
|
+
const entry: OutdatedInfo = { current: p.version, latest, name: p.name };
|
|
165
|
+
if (p.dev) {
|
|
166
|
+
entry.dev = true;
|
|
167
|
+
}
|
|
168
|
+
return entry;
|
|
169
|
+
}),
|
|
170
|
+
);
|
|
171
|
+
return checks.filter((entry): entry is OutdatedInfo => entry !== null);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Rewrite the version range of each named package in whichever section it already occupies
|
|
176
|
+
* (preserving dependencies/devDependencies placement; new names default to the caller's `dev`
|
|
177
|
+
* flag), then run a single `bun install`.
|
|
178
|
+
*/
|
|
179
|
+
export async function setPackageVersions(
|
|
180
|
+
root: string,
|
|
181
|
+
updates: { name: string; version: string; dev?: boolean }[],
|
|
182
|
+
): Promise<PackageOpResult> {
|
|
183
|
+
if (updates.length === 0) {
|
|
184
|
+
return { ok: true };
|
|
185
|
+
}
|
|
186
|
+
const pkgPath = resolve(root, "package.json");
|
|
187
|
+
if (!existsSync(pkgPath)) {
|
|
188
|
+
return { log: "No package.json in project root", ok: false };
|
|
189
|
+
}
|
|
190
|
+
let pkg: PackageJson;
|
|
191
|
+
try {
|
|
192
|
+
pkg = JSON.parse(await readFile(pkgPath, "utf8")) as PackageJson;
|
|
193
|
+
} catch (error) {
|
|
194
|
+
return { log: `Unparseable package.json: ${String(error)}`, ok: false };
|
|
195
|
+
}
|
|
196
|
+
for (const update of updates) {
|
|
197
|
+
if (pkg.devDependencies && update.name in pkg.devDependencies) {
|
|
198
|
+
pkg.devDependencies[update.name] = update.version;
|
|
199
|
+
} else if (pkg.dependencies && update.name in pkg.dependencies) {
|
|
200
|
+
pkg.dependencies[update.name] = update.version;
|
|
201
|
+
} else {
|
|
202
|
+
const bucket = update.dev ? "devDependencies" : "dependencies";
|
|
203
|
+
pkg[bucket] = { ...(pkg[bucket] as Record<string, string> | undefined) };
|
|
204
|
+
(pkg[bucket] as Record<string, string>)[update.name] = update.version;
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
await writeFile(pkgPath, `${JSON.stringify(pkg, null, 2)}\n`, "utf8");
|
|
208
|
+
return installDependencies(root);
|
|
209
|
+
}
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Refs.ts — pure document reference engine for rename-refactoring.
|
|
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).
|
|
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.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { classifyRef, rewriteRef } from "./paths.ts";
|
|
15
|
+
import type { RemapCtx } from "./paths.ts";
|
|
16
|
+
|
|
17
|
+
/** A single reference rewrite, for the rename report. */
|
|
18
|
+
export interface RefChange {
|
|
19
|
+
refType: string;
|
|
20
|
+
from: string;
|
|
21
|
+
to: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface RewriteResult {
|
|
25
|
+
doc: unknown;
|
|
26
|
+
changes: RefChange[];
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Matches `url(...)` targets inside a CSS string value, capturing the optional quote. */
|
|
30
|
+
const URL_RE = /url\(\s*(['"]?)([^'")]+)\1\s*\)/g;
|
|
31
|
+
|
|
32
|
+
/** Recompute a scalar string reference under `key`, recording any change. */
|
|
33
|
+
function rewriteScalar(
|
|
34
|
+
container: Record<string, unknown>,
|
|
35
|
+
key: string,
|
|
36
|
+
refType: string,
|
|
37
|
+
ctx: RemapCtx,
|
|
38
|
+
changes: RefChange[],
|
|
39
|
+
rootRelativeBare: boolean,
|
|
40
|
+
): void {
|
|
41
|
+
const value = container[key];
|
|
42
|
+
const cls = classifyRef(value);
|
|
43
|
+
if (cls.kind !== "path") {
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
const out = rewriteRef(cls, ctx, rootRelativeBare);
|
|
47
|
+
if (out !== null) {
|
|
48
|
+
changes.push({ from: value as string, refType, to: out });
|
|
49
|
+
container[key] = out;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
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 {
|
|
61
|
+
const value = arr[index];
|
|
62
|
+
const cls = classifyRef(value);
|
|
63
|
+
if (cls.kind !== "path") {
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
const out = rewriteRef(cls, ctx, false);
|
|
67
|
+
if (out !== null) {
|
|
68
|
+
changes.push({ from: value as string, refType, to: out });
|
|
69
|
+
arr[index] = out;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Rewrite every `url(...)` target inside a CSS string. */
|
|
74
|
+
function rewriteUrls(value: string, ctx: RemapCtx, changes: RefChange[]): string {
|
|
75
|
+
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
|
+
});
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Walk a `style` value tree, rewriting `url(...)` in every string it contains. */
|
|
90
|
+
function walkStyle(node: unknown, ctx: RemapCtx, changes: RefChange[]): void {
|
|
91
|
+
if (Array.isArray(node)) {
|
|
92
|
+
const arr = node as unknown[];
|
|
93
|
+
for (let i = 0; i < arr.length; i += 1) {
|
|
94
|
+
const item = arr[i];
|
|
95
|
+
if (typeof item === "string") {
|
|
96
|
+
const out = rewriteUrls(item, ctx, changes);
|
|
97
|
+
if (out !== item) {
|
|
98
|
+
arr[i] = out;
|
|
99
|
+
}
|
|
100
|
+
} else {
|
|
101
|
+
walkStyle(item, ctx, changes);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
if (!node || typeof node !== "object") {
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
const obj = node as Record<string, unknown>;
|
|
110
|
+
for (const key of Object.keys(obj)) {
|
|
111
|
+
const value = obj[key];
|
|
112
|
+
if (typeof value === "string") {
|
|
113
|
+
const out = rewriteUrls(value, ctx, changes);
|
|
114
|
+
if (out !== value) {
|
|
115
|
+
obj[key] = out;
|
|
116
|
+
}
|
|
117
|
+
} else {
|
|
118
|
+
walkStyle(value, ctx, changes);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** Recursive document walk dispatching each reference-bearing key. */
|
|
124
|
+
function walk(node: unknown, ctx: RemapCtx, changes: RefChange[]): void {
|
|
125
|
+
if (Array.isArray(node)) {
|
|
126
|
+
for (const item of node as unknown[]) {
|
|
127
|
+
walk(item, ctx, changes);
|
|
128
|
+
}
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
if (!node || typeof node !== "object") {
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
const obj = node as Record<string, unknown>;
|
|
135
|
+
for (const key of Object.keys(obj)) {
|
|
136
|
+
const value = obj[key];
|
|
137
|
+
switch (key) {
|
|
138
|
+
case "$ref": {
|
|
139
|
+
// File-path $ref only; state-scope refs (#/…, $map/…) are filtered by classifyRef.
|
|
140
|
+
rewriteScalar(obj, key, "$ref", ctx, changes, false);
|
|
141
|
+
break;
|
|
142
|
+
}
|
|
143
|
+
case "$layout": {
|
|
144
|
+
rewriteScalar(obj, key, "$layout", ctx, changes, true);
|
|
145
|
+
break;
|
|
146
|
+
}
|
|
147
|
+
case "$src":
|
|
148
|
+
case "$implementation": {
|
|
149
|
+
rewriteScalar(obj, key, key, ctx, changes, false);
|
|
150
|
+
break;
|
|
151
|
+
}
|
|
152
|
+
case "src":
|
|
153
|
+
case "href": {
|
|
154
|
+
rewriteScalar(obj, key, "attr", ctx, changes, false);
|
|
155
|
+
break;
|
|
156
|
+
}
|
|
157
|
+
case "imports": {
|
|
158
|
+
if (value && typeof value === "object" && !Array.isArray(value)) {
|
|
159
|
+
const imp = value as Record<string, unknown>;
|
|
160
|
+
for (const name of Object.keys(imp)) {
|
|
161
|
+
rewriteScalar(imp, name, "imports", ctx, changes, false);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
break;
|
|
165
|
+
}
|
|
166
|
+
case "$elements": {
|
|
167
|
+
if (Array.isArray(value)) {
|
|
168
|
+
const els = value as unknown[];
|
|
169
|
+
for (let i = 0; i < els.length; i += 1) {
|
|
170
|
+
if (typeof els[i] === "string") {
|
|
171
|
+
rewriteIndexed(els, i, "$elements", ctx, changes);
|
|
172
|
+
} else {
|
|
173
|
+
walk(els[i], ctx, changes);
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
} else {
|
|
177
|
+
walk(value, ctx, changes);
|
|
178
|
+
}
|
|
179
|
+
break;
|
|
180
|
+
}
|
|
181
|
+
case "style": {
|
|
182
|
+
walkStyle(value, ctx, changes);
|
|
183
|
+
break;
|
|
184
|
+
}
|
|
185
|
+
default: {
|
|
186
|
+
walk(value, ctx, changes);
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/** Rewrite every file-path reference in `doc` for a rename described by `ctx`. Mutates `doc`. */
|
|
193
|
+
export function rewriteDocRefs(doc: unknown, ctx: RemapCtx): RewriteResult {
|
|
194
|
+
const changes: RefChange[] = [];
|
|
195
|
+
walk(doc, ctx, changes);
|
|
196
|
+
return { changes, doc };
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/** Recursive walk setting `tagName: newTag` wherever it currently equals `oldTag`. */
|
|
200
|
+
function walkTags(node: unknown, oldTag: string, newTag: string, counter: { n: number }): void {
|
|
201
|
+
if (Array.isArray(node)) {
|
|
202
|
+
for (const item of node as unknown[]) {
|
|
203
|
+
walkTags(item, oldTag, newTag, counter);
|
|
204
|
+
}
|
|
205
|
+
return;
|
|
206
|
+
}
|
|
207
|
+
if (!node || typeof node !== "object") {
|
|
208
|
+
return;
|
|
209
|
+
}
|
|
210
|
+
const obj = node as Record<string, unknown>;
|
|
211
|
+
if (obj.tagName === oldTag) {
|
|
212
|
+
obj.tagName = newTag;
|
|
213
|
+
counter.n += 1;
|
|
214
|
+
}
|
|
215
|
+
for (const key of Object.keys(obj)) {
|
|
216
|
+
walkTags(obj[key], oldTag, newTag, counter);
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* Rename a custom-element tag throughout `doc` — instance nodes (`<old-tag>`), mapped-list
|
|
222
|
+
* `map.tagName`, and the component definition file's own root `tagName`. File references (`$ref`,
|
|
223
|
+
* `$elements`, `cases`) are intentionally left to `rewriteDocRefs`, so the two passes compose.
|
|
224
|
+
* Mutates `doc`.
|
|
225
|
+
*/
|
|
226
|
+
export function rewriteTagName(
|
|
227
|
+
doc: unknown,
|
|
228
|
+
oldTag: string,
|
|
229
|
+
newTag: string,
|
|
230
|
+
): { doc: unknown; count: number } {
|
|
231
|
+
const counter = { n: 0 };
|
|
232
|
+
if (oldTag !== newTag) {
|
|
233
|
+
walkTags(doc, oldTag, newTag, counter);
|
|
234
|
+
}
|
|
235
|
+
return { count: counter.n, doc };
|
|
236
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Watcher.ts — a small chokidar wrapper that emits coalesced, batched FS-event payloads to a sink.
|
|
3
|
+
*
|
|
4
|
+
* Used by the desktop project session (which has no SSE stream) to push filesystem changes to its
|
|
5
|
+
* webview over RPC. The dev server keeps its own watcher in watch.ts (intertwined with
|
|
6
|
+
* build/reload); both share the pure `toFsEvent`/`coalesceFsEvents` helpers.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { watch as chokidarWatch } from "chokidar";
|
|
10
|
+
import { coalesceFsEvents, toFsEvent } from "./fs-events.ts";
|
|
11
|
+
import type { FsEventPayload } from "./fs-events.ts";
|
|
12
|
+
|
|
13
|
+
const IGNORE_SEGMENTS = ["node_modules", ".git", "dist", ".jx", ".direnv", ".devenv"];
|
|
14
|
+
|
|
15
|
+
function isIgnored(path: string): boolean {
|
|
16
|
+
const f = path.replaceAll("\\", "/");
|
|
17
|
+
return IGNORE_SEGMENTS.some((s) => f.includes(`/${s}/`) || f.endsWith(`/${s}`));
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface FsWatcherHandle {
|
|
21
|
+
close: () => Promise<void>;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Watch `root`, coalescing rapid changes and delivering batches to `sink`. Returns a handle whose
|
|
26
|
+
* `close()` stops the watcher and clears any pending batch.
|
|
27
|
+
*/
|
|
28
|
+
export function createFsWatcher(
|
|
29
|
+
root: string,
|
|
30
|
+
sink: (events: FsEventPayload[]) => void,
|
|
31
|
+
opts: { debounce?: number } = {},
|
|
32
|
+
): FsWatcherHandle {
|
|
33
|
+
const debounceMs = opts.debounce ?? 40;
|
|
34
|
+
let buffer: FsEventPayload[] = [];
|
|
35
|
+
let timer: ReturnType<typeof setTimeout> | null = null;
|
|
36
|
+
|
|
37
|
+
const watcher = chokidarWatch(root, {
|
|
38
|
+
ignoreInitial: true,
|
|
39
|
+
ignorePermissionErrors: true,
|
|
40
|
+
ignored: (path) => isIgnored(path),
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
watcher.on("all", (eventType, changedPath) => {
|
|
44
|
+
const event = toFsEvent(eventType, root, changedPath);
|
|
45
|
+
if (!event) {
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
buffer = coalesceFsEvents(buffer, event);
|
|
49
|
+
if (timer) {
|
|
50
|
+
clearTimeout(timer);
|
|
51
|
+
}
|
|
52
|
+
timer = setTimeout(() => {
|
|
53
|
+
if (buffer.length > 0) {
|
|
54
|
+
sink(buffer);
|
|
55
|
+
buffer = [];
|
|
56
|
+
}
|
|
57
|
+
}, debounceMs);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
return {
|
|
61
|
+
close: async () => {
|
|
62
|
+
if (timer) {
|
|
63
|
+
clearTimeout(timer);
|
|
64
|
+
timer = null;
|
|
65
|
+
}
|
|
66
|
+
buffer = [];
|
|
67
|
+
await watcher.close();
|
|
68
|
+
},
|
|
69
|
+
};
|
|
70
|
+
}
|
package/src/server.ts
CHANGED
|
@@ -274,9 +274,12 @@ export async function createDevServer(options: {
|
|
|
274
274
|
// Static files
|
|
275
275
|
|
|
276
276
|
// If the URL path is an absolute filesystem path under the active project, serve directly.
|
|
277
|
-
//
|
|
278
|
-
|
|
279
|
-
|
|
277
|
+
// A POSIX absolute path arrives as "//abs/path"; a Windows one as "/C:/dir/file" (leading
|
|
278
|
+
// Slash + forward slashes), so drop the slash before the drive letter. Compare with
|
|
279
|
+
// Separators normalised, since activeProjectRoot is OS-native (backslashes on Windows).
|
|
280
|
+
const fsPath = path.startsWith("//") ? path.slice(1) : path.replace(/^\/([A-Za-z]:)/, "$1");
|
|
281
|
+
const normSep = (p: string) => p.replaceAll("\\", "/");
|
|
282
|
+
if (activeProjectRoot && normSep(fsPath).startsWith(normSep(activeProjectRoot))) {
|
|
280
283
|
const file = Bun.file(fsPath);
|
|
281
284
|
if (await file.exists()) {
|
|
282
285
|
return new Response(file);
|
package/src/studio-api.ts
CHANGED
|
@@ -7,11 +7,18 @@
|
|
|
7
7
|
* All paths are relative to the project root. Directory traversal above root is rejected.
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
|
-
import { basename, dirname, extname, isAbsolute, relative, resolve } from "node:path";
|
|
10
|
+
import { basename, dirname, extname, isAbsolute, join, relative, resolve } from "node:path";
|
|
11
11
|
import { errorMessage } from "@jxsuite/schema/parse";
|
|
12
12
|
import { mkdir, readFile, readdir, rename, stat, unlink, writeFile } from "node:fs/promises";
|
|
13
13
|
import { existsSync, readFileSync, statSync } from "node:fs";
|
|
14
14
|
import { buildProjectFormatRegistry } from "@jxsuite/compiler/format-host";
|
|
15
|
+
import { applyRename } from "./refactor/apply.ts";
|
|
16
|
+
import {
|
|
17
|
+
dependenciesNeedInstall,
|
|
18
|
+
installDependencies,
|
|
19
|
+
outdatedPackages,
|
|
20
|
+
setPackageVersions,
|
|
21
|
+
} from "./packages.ts";
|
|
15
22
|
import type { FormatRegistry } from "@jxsuite/schema/format-registry";
|
|
16
23
|
import * as claude from "./claude-session.ts";
|
|
17
24
|
import type { ClassJsonDef } from "./types.ts";
|
|
@@ -20,6 +27,14 @@ import type { ProjectConfig } from "@jxsuite/schema/types";
|
|
|
20
27
|
/** Normalise a path to forward slashes (Windows `path` module returns backslashes). */
|
|
21
28
|
const fwd = (p: string) => p.replaceAll("\\", "/");
|
|
22
29
|
|
|
30
|
+
/**
|
|
31
|
+
* Expand a leading `~` to the user's home directory using path.join so separators are normalised (a
|
|
32
|
+
* plain string replace left the input's forward slashes intact, yielding mixed separators on
|
|
33
|
+
* Windows) and falling back to USERPROFILE where HOME is unset.
|
|
34
|
+
*/
|
|
35
|
+
const expandTilde = (p: string) =>
|
|
36
|
+
p.startsWith("~") ? join(process.env.HOME || process.env.USERPROFILE || "", p.slice(1)) : p;
|
|
37
|
+
|
|
23
38
|
interface PackageJson {
|
|
24
39
|
name?: string;
|
|
25
40
|
workspaces?: unknown;
|
|
@@ -189,8 +204,8 @@ export function parseGitStatus(out: string) {
|
|
|
189
204
|
} else if (line.startsWith("# branch.ab ")) {
|
|
190
205
|
const m = line.match(/\+(\d+) -(\d+)/);
|
|
191
206
|
if (m) {
|
|
192
|
-
ahead =
|
|
193
|
-
behind =
|
|
207
|
+
ahead = Math.trunc(Number(m[1]!));
|
|
208
|
+
behind = Math.trunc(Number(m[2]!));
|
|
194
209
|
}
|
|
195
210
|
} else if (line.startsWith("1 ") || line.startsWith("2 ")) {
|
|
196
211
|
const parts = line.split(" ");
|
|
@@ -317,17 +332,13 @@ export async function handleStudioApi(
|
|
|
317
332
|
}
|
|
318
333
|
try {
|
|
319
334
|
// Walk up from file's directory looking for project.json
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
);
|
|
335
|
+
const absFile = expandTilde(filePath);
|
|
336
|
+
let dir = dirname(absFile);
|
|
323
337
|
while (dir) {
|
|
324
338
|
const candidate = resolve(dir, "project.json");
|
|
325
339
|
if (existsSync(candidate)) {
|
|
326
340
|
const config = JSON.parse(readFileSync(candidate, "utf8")) as ProjectConfig;
|
|
327
341
|
const relPath = fwd(dir);
|
|
328
|
-
const absFile = filePath.startsWith("~")
|
|
329
|
-
? filePath.replace("~", process.env.HOME || "")
|
|
330
|
-
: filePath;
|
|
331
342
|
const fileRelPath = fwd(relative(dir, absFile));
|
|
332
343
|
return Response.json({
|
|
333
344
|
fileRelPath,
|
|
@@ -672,6 +683,7 @@ export async function handleStudioApi(
|
|
|
672
683
|
const packages: {
|
|
673
684
|
name: string;
|
|
674
685
|
version: string;
|
|
686
|
+
dev: boolean;
|
|
675
687
|
hasCem: boolean;
|
|
676
688
|
customElementsPath: string | null;
|
|
677
689
|
}[] = [];
|
|
@@ -690,6 +702,7 @@ export async function handleStudioApi(
|
|
|
690
702
|
const depPkg = JSON.parse(await readFile(actualPath, "utf8")) as PackageJson;
|
|
691
703
|
packages.push({
|
|
692
704
|
customElementsPath: depPkg.customElements || null,
|
|
705
|
+
dev: pkg.dependencies?.[name] === undefined,
|
|
693
706
|
hasCem: Boolean(depPkg.customElements),
|
|
694
707
|
name,
|
|
695
708
|
version,
|
|
@@ -798,13 +811,61 @@ export async function handleStudioApi(
|
|
|
798
811
|
}
|
|
799
812
|
}
|
|
800
813
|
|
|
814
|
+
// Install all dependencies (bun install)
|
|
815
|
+
if (path === "/__studio/packages/install" && req.method === "POST") {
|
|
816
|
+
try {
|
|
817
|
+
const body = (await req.json().catch(() => ({}))) as { dir?: string };
|
|
818
|
+
const dir = body.dir || activeProjectRoot || root;
|
|
819
|
+
const scanRoot = isAbsolute(dir) ? dir : resolve(root, dir);
|
|
820
|
+
return Response.json(await installDependencies(scanRoot));
|
|
821
|
+
} catch (error) {
|
|
822
|
+
return Response.json({ error: errorMessage(error) }, { status: 500 });
|
|
823
|
+
}
|
|
824
|
+
}
|
|
825
|
+
|
|
826
|
+
// Whether dependencies need installing (node_modules missing)
|
|
827
|
+
if (path === "/__studio/packages/needs-install" && req.method === "GET") {
|
|
828
|
+
const dir = url.searchParams.get("dir") || activeProjectRoot || root;
|
|
829
|
+
const scanRoot = isAbsolute(dir) ? dir : resolve(root, dir);
|
|
830
|
+
return Response.json({ needsInstall: dependenciesNeedInstall(scanRoot) });
|
|
831
|
+
}
|
|
832
|
+
|
|
833
|
+
// Dependencies with a newer version available
|
|
834
|
+
if (path === "/__studio/packages/outdated" && req.method === "GET") {
|
|
835
|
+
try {
|
|
836
|
+
const dir = url.searchParams.get("dir") || activeProjectRoot || root;
|
|
837
|
+
const scanRoot = isAbsolute(dir) ? dir : resolve(root, dir);
|
|
838
|
+
return Response.json(await outdatedPackages(scanRoot));
|
|
839
|
+
} catch (error) {
|
|
840
|
+
return Response.json({ error: errorMessage(error) }, { status: 500 });
|
|
841
|
+
}
|
|
842
|
+
}
|
|
843
|
+
|
|
844
|
+
// Set version ranges for one or more packages, then reinstall
|
|
845
|
+
if (path === "/__studio/packages/set-versions" && req.method === "POST") {
|
|
846
|
+
try {
|
|
847
|
+
const body = (await req.json()) as {
|
|
848
|
+
dir?: string;
|
|
849
|
+
updates?: { name: string; version: string; dev?: boolean }[];
|
|
850
|
+
};
|
|
851
|
+
if (!Array.isArray(body.updates)) {
|
|
852
|
+
return Response.json({ error: "Missing updates" }, { status: 400 });
|
|
853
|
+
}
|
|
854
|
+
const dir = body.dir || activeProjectRoot || root;
|
|
855
|
+
const scanRoot = isAbsolute(dir) ? dir : resolve(root, dir);
|
|
856
|
+
return Response.json(await setPackageVersions(scanRoot, body.updates));
|
|
857
|
+
} catch (error) {
|
|
858
|
+
return Response.json({ error: errorMessage(error) }, { status: 500 });
|
|
859
|
+
}
|
|
860
|
+
}
|
|
861
|
+
|
|
801
862
|
// Read file
|
|
802
863
|
if (path === "/__studio/file" && req.method === "GET") {
|
|
803
864
|
const fp = url.searchParams.get("path");
|
|
804
865
|
if (!fp) {
|
|
805
866
|
return new Response("Missing path", { status: 400 });
|
|
806
867
|
}
|
|
807
|
-
const abs =
|
|
868
|
+
const abs = expandTilde(fp);
|
|
808
869
|
try {
|
|
809
870
|
assertAccessible(abs, root, activeProjectRoot);
|
|
810
871
|
} catch (error) {
|
|
@@ -913,13 +974,23 @@ export async function handleStudioApi(
|
|
|
913
974
|
try {
|
|
914
975
|
await mkdir(dirname(absTo), { recursive: true });
|
|
915
976
|
await rename(absFrom, absTo);
|
|
977
|
+
} catch (error) {
|
|
978
|
+
return Response.json({ error: errorMessage(error) }, { status: 500 });
|
|
979
|
+
}
|
|
980
|
+
// Refactor pass: rewrite path references project-wide and, for a component, auto-rename its tag.
|
|
981
|
+
// The move already succeeded, so a refactor failure is reported but never fails the rename.
|
|
982
|
+
const scanRoot = activeProjectRoot ?? root;
|
|
983
|
+
try {
|
|
984
|
+
const registry = await getFormatRegistry(scanRoot);
|
|
985
|
+
const report = await applyRename({ absFrom, absTo, registry, root: scanRoot });
|
|
986
|
+
return Response.json(report);
|
|
987
|
+
} catch (error) {
|
|
916
988
|
return Response.json({
|
|
989
|
+
error: errorMessage(error),
|
|
917
990
|
from: fwd(relative(root, absFrom)),
|
|
918
991
|
ok: true,
|
|
919
992
|
to: fwd(relative(root, absTo)),
|
|
920
993
|
});
|
|
921
|
-
} catch (error) {
|
|
922
|
-
return Response.json({ error: errorMessage(error) }, { status: 500 });
|
|
923
994
|
}
|
|
924
995
|
}
|
|
925
996
|
|
package/src/watch.ts
CHANGED
|
@@ -3,7 +3,9 @@
|
|
|
3
3
|
import { watch as chokidarWatch } from "chokidar";
|
|
4
4
|
import { relative } from "node:path";
|
|
5
5
|
import { rebuild } from "./build.ts";
|
|
6
|
+
import { coalesceFsEvents, toFsEvent } from "./refactor/fs-events.ts";
|
|
6
7
|
import type { BuildEntry } from "./types.ts";
|
|
8
|
+
import type { FsEventPayload } from "./refactor/fs-events.ts";
|
|
7
9
|
|
|
8
10
|
const DEFAULT_IGNORE = [
|
|
9
11
|
"**/node_modules/**",
|
|
@@ -86,6 +88,17 @@ export function createWatcher(
|
|
|
86
88
|
}
|
|
87
89
|
}
|
|
88
90
|
|
|
91
|
+
/**
|
|
92
|
+
* Send a _named_ SSE event. The preview iframe only listens to the default (unnamed) `onmessage`,
|
|
93
|
+
* so named events (e.g. "fs") reach the studio shell without triggering a preview reload.
|
|
94
|
+
*/
|
|
95
|
+
function broadcastEvent(event: string, payload: unknown) {
|
|
96
|
+
const frame = `event: ${event}\ndata: ${JSON.stringify(payload)}\n\n`;
|
|
97
|
+
for (const send of clients) {
|
|
98
|
+
send(frame);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
89
102
|
function handleSSE() {
|
|
90
103
|
/** @type {((msg: string) => void) | undefined} */
|
|
91
104
|
let send: ((msg: string) => void) | undefined;
|
|
@@ -121,6 +134,9 @@ export function createWatcher(
|
|
|
121
134
|
}
|
|
122
135
|
|
|
123
136
|
let timer: ReturnType<typeof setTimeout> | null = null;
|
|
137
|
+
let fsTimer: ReturnType<typeof setTimeout> | null = null;
|
|
138
|
+
let fsBuffer: FsEventPayload[] = [];
|
|
139
|
+
const fsDebounceMs = 40;
|
|
124
140
|
const watcher = chokidarWatch(root, {
|
|
125
141
|
awaitWriteFinish: {
|
|
126
142
|
pollInterval: 10,
|
|
@@ -131,11 +147,26 @@ export function createWatcher(
|
|
|
131
147
|
ignored: (watchedPath) => shouldIgnore(watchedPath, ignore),
|
|
132
148
|
});
|
|
133
149
|
|
|
134
|
-
watcher.on("all", (
|
|
150
|
+
watcher.on("all", (eventType, changedPath) => {
|
|
135
151
|
const filename = relative(root, changedPath);
|
|
136
152
|
if (!filename || filename.startsWith("..")) {
|
|
137
153
|
return;
|
|
138
154
|
}
|
|
155
|
+
|
|
156
|
+
// Structured FS events for the studio sidebar (coalesced + batched), emitted as a named "fs"
|
|
157
|
+
// SSE event so the preview iframe ignores them while the studio shell subscribes to them.
|
|
158
|
+
const fsEvent = toFsEvent(eventType, root, changedPath);
|
|
159
|
+
if (fsEvent) {
|
|
160
|
+
fsBuffer = coalesceFsEvents(fsBuffer, fsEvent);
|
|
161
|
+
clearTimeout(fsTimer ?? undefined);
|
|
162
|
+
fsTimer = setTimeout(() => {
|
|
163
|
+
if (fsBuffer.length > 0) {
|
|
164
|
+
broadcastEvent("fs", { events: fsBuffer });
|
|
165
|
+
fsBuffer = [];
|
|
166
|
+
}
|
|
167
|
+
}, fsDebounceMs);
|
|
168
|
+
}
|
|
169
|
+
|
|
139
170
|
clearTimeout(timer ?? undefined);
|
|
140
171
|
timer = setTimeout(async () => {
|
|
141
172
|
if (builds.length > 0) {
|
|
@@ -155,5 +186,5 @@ export function createWatcher(
|
|
|
155
186
|
}, debounceMs);
|
|
156
187
|
});
|
|
157
188
|
|
|
158
|
-
return { broadcast, handleSSE, watcher };
|
|
189
|
+
return { broadcast, broadcastEvent, handleSSE, watcher };
|
|
159
190
|
}
|