@jxsuite/server 0.29.0 → 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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jxsuite/server",
3
- "version": "0.29.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.175",
31
- "@anthropic-ai/sdk": "^0.104.1",
32
- "@jxsuite/compiler": "^0.29.0",
33
- "@jxsuite/create": "^0.29.0",
34
- "@jxsuite/parser": "^0.29.0",
35
- "@jxsuite/runtime": "^0.29.0",
36
- "@jxsuite/schema": "^0.29.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"
@@ -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
- // Browsers produce "//abs/path" when an absolute path is used as a URL path — normalise.
278
- const fsPath = path.startsWith("//") ? path.slice(1) : path;
279
- if (activeProjectRoot && fsPath.startsWith(activeProjectRoot)) {
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,12 @@
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";
15
16
  import type { FormatRegistry } from "@jxsuite/schema/format-registry";
16
17
  import * as claude from "./claude-session.ts";
17
18
  import type { ClassJsonDef } from "./types.ts";
@@ -20,6 +21,14 @@ import type { ProjectConfig } from "@jxsuite/schema/types";
20
21
  /** Normalise a path to forward slashes (Windows `path` module returns backslashes). */
21
22
  const fwd = (p: string) => p.replaceAll("\\", "/");
22
23
 
24
+ /**
25
+ * Expand a leading `~` to the user's home directory using path.join so separators are normalised (a
26
+ * plain string replace left the input's forward slashes intact, yielding mixed separators on
27
+ * Windows) and falling back to USERPROFILE where HOME is unset.
28
+ */
29
+ const expandTilde = (p: string) =>
30
+ p.startsWith("~") ? join(process.env.HOME || process.env.USERPROFILE || "", p.slice(1)) : p;
31
+
23
32
  interface PackageJson {
24
33
  name?: string;
25
34
  workspaces?: unknown;
@@ -189,8 +198,8 @@ export function parseGitStatus(out: string) {
189
198
  } else if (line.startsWith("# branch.ab ")) {
190
199
  const m = line.match(/\+(\d+) -(\d+)/);
191
200
  if (m) {
192
- ahead = Number.parseInt(m[1]!, 10);
193
- behind = Number.parseInt(m[2]!, 10);
201
+ ahead = Math.trunc(Number(m[1]!));
202
+ behind = Math.trunc(Number(m[2]!));
194
203
  }
195
204
  } else if (line.startsWith("1 ") || line.startsWith("2 ")) {
196
205
  const parts = line.split(" ");
@@ -317,17 +326,13 @@ export async function handleStudioApi(
317
326
  }
318
327
  try {
319
328
  // Walk up from file's directory looking for project.json
320
- let dir = dirname(
321
- filePath.startsWith("~") ? filePath.replace("~", process.env.HOME || "") : filePath,
322
- );
329
+ const absFile = expandTilde(filePath);
330
+ let dir = dirname(absFile);
323
331
  while (dir) {
324
332
  const candidate = resolve(dir, "project.json");
325
333
  if (existsSync(candidate)) {
326
334
  const config = JSON.parse(readFileSync(candidate, "utf8")) as ProjectConfig;
327
335
  const relPath = fwd(dir);
328
- const absFile = filePath.startsWith("~")
329
- ? filePath.replace("~", process.env.HOME || "")
330
- : filePath;
331
336
  const fileRelPath = fwd(relative(dir, absFile));
332
337
  return Response.json({
333
338
  fileRelPath,
@@ -804,7 +809,7 @@ export async function handleStudioApi(
804
809
  if (!fp) {
805
810
  return new Response("Missing path", { status: 400 });
806
811
  }
807
- const abs = fp.startsWith("~") ? fp.replace("~", process.env.HOME || "") : fp;
812
+ const abs = expandTilde(fp);
808
813
  try {
809
814
  assertAccessible(abs, root, activeProjectRoot);
810
815
  } catch (error) {
@@ -913,13 +918,23 @@ export async function handleStudioApi(
913
918
  try {
914
919
  await mkdir(dirname(absTo), { recursive: true });
915
920
  await rename(absFrom, absTo);
921
+ } catch (error) {
922
+ return Response.json({ error: errorMessage(error) }, { status: 500 });
923
+ }
924
+ // Refactor pass: rewrite path references project-wide and, for a component, auto-rename its tag.
925
+ // The move already succeeded, so a refactor failure is reported but never fails the rename.
926
+ const scanRoot = activeProjectRoot ?? root;
927
+ try {
928
+ const registry = await getFormatRegistry(scanRoot);
929
+ const report = await applyRename({ absFrom, absTo, registry, root: scanRoot });
930
+ return Response.json(report);
931
+ } catch (error) {
916
932
  return Response.json({
933
+ error: errorMessage(error),
917
934
  from: fwd(relative(root, absFrom)),
918
935
  ok: true,
919
936
  to: fwd(relative(root, absTo)),
920
937
  });
921
- } catch (error) {
922
- return Response.json({ error: errorMessage(error) }, { status: 500 });
923
938
  }
924
939
  }
925
940
 
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", (_, changedPath) => {
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
  }