@aaqiljamal/visual-editor-server 0.2.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.
@@ -0,0 +1,285 @@
1
+ import * as http from 'node:http';
2
+
3
+ /**
4
+ * The refusal reasons are structured so the consumer (overlay UI, MCP layer)
5
+ * can branch on them: "show user a 'this className is composed by cn()' badge",
6
+ * "open the file at this location", etc. The `details` field is the human
7
+ * sentence; the `reason` is the machine code.
8
+ *
9
+ * v0.1 only accepts a static string literal at the located JSXOpeningElement.
10
+ * Anything dynamic — cn/clsx/twMerge/cva, template literals, conditionals,
11
+ * spreads, or anything we don't recognize — refuses with a clear reason.
12
+ * This is Principle 1's "determinism preconditions" enforced at the writer.
13
+ */
14
+ type MutateClassNameRefusalReason = "dynamic-call-expression" | "dynamic-template-literal" | "dynamic-conditional" | "dynamic-other" | "dynamic-uncertain-arg" | "dynamic-spread-arg" | "dynamic-conflict" | "unknown-merger" | "no-classname-attribute" | "no-jsx-at-location" | "token-not-found";
15
+ type MutateAttributeRefusalReason = "no-jsx-at-location" | "no-such-attribute" | "dynamic-value" | "token-not-found" | "parse-error";
16
+
17
+ type ApplyInput = {
18
+ /** Path relative to the configured workspace root (the same root the Babel data-oid plugin used). */
19
+ file: string;
20
+ /** 1-based line number, as Babel reports in `loc.start.line`. */
21
+ line: number;
22
+ /** 0-based column number, as Babel reports in `loc.start.column`. */
23
+ col: number;
24
+ /** Attribute to mutate. Default "className" — token-swap semantics. Anything else uses whole-value swap (src, href, alt, …). */
25
+ attribute?: string;
26
+ /** Exact static class token to swap, e.g. "p-4". For non-className attributes, this is the whole expected current value (or null to skip conflict check). */
27
+ before: string | null;
28
+ /** Replacement, e.g. "p-6". For non-className attributes, this is the whole new value. */
29
+ after: string;
30
+ };
31
+ type ApplyOutcome = {
32
+ ok: true;
33
+ /** Unified diff between original and rewritten source. */
34
+ diff: string;
35
+ /** Absolute path that was written, for logging. */
36
+ absolutePath: string;
37
+ /** For non-className mutations called with before=null, the actual pre-mutation value. Undo uses this. */
38
+ previousValue?: string;
39
+ } | {
40
+ ok: false;
41
+ /** HTTP-shaped status (the http layer maps this 1:1). */
42
+ status: 400 | 403 | 404 | 409 | 500;
43
+ reason: ApplyRefusalReason;
44
+ details: string;
45
+ };
46
+ type ApplyRefusalReason = "invalid-input" | "path-outside-workspace" | "file-not-found" | "read-failed" | "write-failed" | MutateClassNameRefusalReason | MutateAttributeRefusalReason;
47
+ type ApplyOptions = {
48
+ workspaceRoot: string;
49
+ /**
50
+ * If `true`, mutate in memory and return the diff but do NOT write to disk.
51
+ * /propose uses this; /apply doesn't.
52
+ */
53
+ dryRun: boolean;
54
+ };
55
+ /**
56
+ * The single entry point for both /apply and /propose. Reads, mutates,
57
+ * conflict-checks (the mutate step's `token-not-found` IS the conflict
58
+ * signal — if the file moved out from under us, the `before` token won't
59
+ * be there anymore), then either writes or skips the write.
60
+ *
61
+ * Returning `ApplyOutcome` instead of throwing keeps the HTTP layer
62
+ * mechanical: every refusal carries its own status code and reason.
63
+ */
64
+ declare function applyToFile(input: ApplyInput, options: ApplyOptions): Promise<ApplyOutcome>;
65
+
66
+ /**
67
+ * Small in-memory deque of recently applied mutations, sized to keep the
68
+ * footprint negligible. Used by the 4th MCP tool (`revert_change`) and
69
+ * the overlay's "undo last apply" affordance.
70
+ *
71
+ * v0.2: persists to `<workspace>/.visual-editor/history.json` so the undo
72
+ * stack survives server restarts. Persistence is fire-and-forget on every
73
+ * mutation — `persistNow()` returns a Promise that tests can await for
74
+ * deterministic checks.
75
+ */
76
+ type Apply = {
77
+ file: string;
78
+ line: number;
79
+ col: number;
80
+ before: string;
81
+ after: string;
82
+ appliedAt: number;
83
+ };
84
+ declare class RecentApplies {
85
+ private readonly maxSize;
86
+ private buffer;
87
+ private filePath;
88
+ constructor(maxSize?: number);
89
+ /**
90
+ * Wire up disk persistence. If the file exists, its contents become the
91
+ * initial buffer. If it doesn't, we remember the path for future writes.
92
+ */
93
+ load(filePath: string): Promise<void>;
94
+ push(apply: Apply): void;
95
+ /**
96
+ * Find the most-recent entry matching `key`. If no key is provided, returns
97
+ * the most-recent entry overall (single-step undo). Returns `null` when no
98
+ * matching entry is in the buffer.
99
+ */
100
+ find(key?: {
101
+ file: string;
102
+ line: number;
103
+ col: number;
104
+ }): Apply | null;
105
+ remove(apply: Apply): void;
106
+ list(): readonly Apply[];
107
+ clear(): void;
108
+ get size(): number;
109
+ /**
110
+ * Write the buffer to disk. No-op when no path has been configured.
111
+ * Safe to `void`-call for fire-and-forget; tests can `await` it for
112
+ * determinism.
113
+ */
114
+ persistNow(): Promise<void>;
115
+ }
116
+
117
+ type RevertInput = {
118
+ /** Optional — when omitted, the most-recent apply is reverted. */
119
+ file?: string;
120
+ line?: number;
121
+ col?: number;
122
+ };
123
+ type RevertOutcome = ApplyOutcome | {
124
+ ok: false;
125
+ status: 404;
126
+ reason: "no-recent-apply";
127
+ details: string;
128
+ } | {
129
+ ok: false;
130
+ status: 400;
131
+ reason: "invalid-input";
132
+ details: string;
133
+ };
134
+ /**
135
+ * Revert a recent apply by swapping `before` and `after` and re-running
136
+ * the deterministic mutation through `applyToFile`. The conflict path
137
+ * (file edited externally between Apply and Revert) is the same as
138
+ * /apply — returns 409 with `token-not-found` if the "after" token no
139
+ * longer sits at line:col.
140
+ *
141
+ * On success, removes the entry from the buffer so re-reverting is not
142
+ * an infinite loop. The user can re-apply by re-driving the gesture.
143
+ */
144
+ declare function revertToFile(input: RevertInput, options: ApplyOptions, recent: RecentApplies): Promise<RevertOutcome>;
145
+
146
+ type ApplyCssPropertyInput = {
147
+ /** JSX file containing the JSXOpeningElement we resolve from. Workspace-relative. */
148
+ file: string;
149
+ /** 1-based line of the JSXOpeningElement. */
150
+ line: number;
151
+ /** 0-based column of the JSXOpeningElement. */
152
+ col: number;
153
+ /** CSS property to set, e.g. "padding". */
154
+ property: string;
155
+ /** New value, e.g. "1.5rem". */
156
+ value: string;
157
+ };
158
+ type ApplyCssPropertyRefusalReason = "invalid-input" | "path-outside-workspace" | "jsx-file-not-found" | "css-file-not-found" | "read-failed" | "write-failed" | "no-jsx-at-location" | "no-classname-attribute" | "dynamic-classname" | "not-a-css-module" | "unresolved-import" | "parse-error" | "css-parse-error" | "selector-not-found" | "composes-chain" | "invalid-property";
159
+ type ApplyCssPropertyOutcome = {
160
+ ok: true;
161
+ diff: string;
162
+ cssAbsolutePath: string;
163
+ selector: string;
164
+ previousValue: string | null;
165
+ } | {
166
+ ok: false;
167
+ status: 400 | 403 | 404 | 409 | 500;
168
+ reason: ApplyCssPropertyRefusalReason;
169
+ details: string;
170
+ };
171
+ declare function applyCssProperty(input: ApplyCssPropertyInput, options: {
172
+ workspaceRoot: string;
173
+ dryRun: boolean;
174
+ }): Promise<ApplyCssPropertyOutcome>;
175
+
176
+ type ApplyStyledPropertyInput = {
177
+ /** Workspace-relative JSX file containing both the JSX element AND the styled definition. */
178
+ file: string;
179
+ line: number;
180
+ col: number;
181
+ property: string;
182
+ value: string;
183
+ };
184
+ type ApplyStyledPropertyRefusalReason = "invalid-input" | "path-outside-workspace" | "file-not-found" | "read-failed" | "write-failed" | "no-jsx-at-location" | "not-a-styled-component" | "styled-with-interpolation" | "styled-extension-not-supported" | "styled-attrs-not-supported" | "cross-file-styled-not-supported" | "component-not-found" | "parse-error" | "css-parse-error" | "invalid-property";
185
+ type ApplyStyledPropertyOutcome = {
186
+ ok: true;
187
+ diff: string;
188
+ componentName: string;
189
+ previousValue: string | null;
190
+ } | {
191
+ ok: false;
192
+ status: 400 | 403 | 404 | 500;
193
+ reason: ApplyStyledPropertyRefusalReason;
194
+ details: string;
195
+ };
196
+ declare function applyStyledProperty(input: ApplyStyledPropertyInput, options: {
197
+ workspaceRoot: string;
198
+ dryRun: boolean;
199
+ }): Promise<ApplyStyledPropertyOutcome>;
200
+
201
+ /**
202
+ * Single in-memory "what is the user currently looking at" record, set by
203
+ * the overlay on element acquire and read by the MCP `get_selected_element`
204
+ * tool. Intentionally null when no selection is active — the MCP tool
205
+ * returns "no selection" rather than stale state.
206
+ */
207
+ type Selection = {
208
+ /** Workspace-relative file path from the element's data-oid. */
209
+ file: string;
210
+ /** 1-based line number (Babel convention). */
211
+ line: number;
212
+ /** 0-based column (Babel convention). */
213
+ col: number;
214
+ /** The full data-oid string for traceability. */
215
+ oid: string;
216
+ /** Whole className string (overlay should split if it wants tokens). */
217
+ className: string;
218
+ /** DOM tag name like "div", "button". */
219
+ tagName: string;
220
+ /** Best-guess component name (file basename or Fiber name). */
221
+ componentName: string | null;
222
+ /** How many DOM elements share this data-oid right now (Principle 11). */
223
+ instanceCount: number;
224
+ };
225
+ declare class CurrentSelection {
226
+ private current;
227
+ set(s: Selection | null): void;
228
+ get(): Selection | null;
229
+ clear(): void;
230
+ }
231
+
232
+ /**
233
+ * Per-session token. Mounted on startup, kept in memory, and persisted to
234
+ * `<workspace>/.visual-editor/session.json` so the MCP stdio server (a
235
+ * sibling process spawned by Claude Code) can read it without us having to
236
+ * pipe it through a separate transport.
237
+ *
238
+ * Threat model: a random page running on the same dev machine (e.g. an ad
239
+ * iframe, a stale tab on another local port) could POST to the loopback
240
+ * server and clobber files. Requiring an Authorization header that only
241
+ * the overlay (and the MCP server, via the file) knows blocks that vector.
242
+ *
243
+ * Open trade-off in v0.1: GET /token returns the token without auth so
244
+ * the overlay can bootstrap. A drive-by page could fetch it the same way.
245
+ * Production hardening would either (a) inject the token into the page at
246
+ * dev-build time, or (b) pin the Origin/Referer header. Both require
247
+ * dev-server integration that's out of scope here.
248
+ */
249
+ declare class SessionToken {
250
+ private token;
251
+ private filePath;
252
+ load(workspaceRoot: string): Promise<string>;
253
+ /** Construct the token in-process without touching disk. Used by tests. */
254
+ setInMemory(token: string): void;
255
+ get(): string;
256
+ /**
257
+ * Constant-time compare against the bearer string the client sent. Returns
258
+ * `true` when the lengths match AND every byte is equal. Plain `===` would
259
+ * leak length information through timing.
260
+ */
261
+ matches(received: string | null): boolean;
262
+ }
263
+
264
+ type ServerOptions = {
265
+ /** Absolute path inside which all /apply and /propose writes are constrained. */
266
+ workspaceRoot: string;
267
+ /** Optional pre-built buffer (tests inject their own). */
268
+ recentApplies?: RecentApplies;
269
+ /** Optional pre-built selection state (tests inject their own). */
270
+ currentSelection?: CurrentSelection;
271
+ /** Optional pre-loaded session token (tests inject their own). */
272
+ sessionToken?: SessionToken;
273
+ /**
274
+ * Pinned allowed origins. Empty means any origin (compat with v0.1).
275
+ * Production: pass the dev URL, e.g. `["http://localhost:3000"]`.
276
+ */
277
+ allowedOrigins?: readonly string[];
278
+ };
279
+ /**
280
+ * Build (but do not start) the local HTTP server. Callers do `.listen(port)`.
281
+ * Tests pass `port: 0` to get a random port; the CLI binds 7790.
282
+ */
283
+ declare function createServer(options: ServerOptions): http.Server;
284
+
285
+ export { type ApplyCssPropertyInput, type ApplyInput, type ApplyStyledPropertyInput, CurrentSelection, RecentApplies, type RevertInput, type Selection, SessionToken, applyCssProperty, applyStyledProperty, applyToFile, createServer, revertToFile };
package/dist/index.js ADDED
@@ -0,0 +1,21 @@
1
+ import {
2
+ CurrentSelection,
3
+ RecentApplies,
4
+ SessionToken,
5
+ applyCssProperty,
6
+ applyStyledProperty,
7
+ applyToFile,
8
+ createServer,
9
+ revertToFile
10
+ } from "./chunk-QEI2RGE2.js";
11
+ export {
12
+ CurrentSelection,
13
+ RecentApplies,
14
+ SessionToken,
15
+ applyCssProperty,
16
+ applyStyledProperty,
17
+ applyToFile,
18
+ createServer,
19
+ revertToFile
20
+ };
21
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":[],"sourcesContent":[],"mappings":"","names":[]}
package/package.json ADDED
@@ -0,0 +1,57 @@
1
+ {
2
+ "name": "@aaqiljamal/visual-editor-server",
3
+ "version": "0.2.0",
4
+ "description": "Node-side AST mutator for visual-editor: Tailwind className, CSS Modules, styled-components. Used by @aaqiljamal/visual-editor-next as a library, or standalone via the `visual-editor-server` CLI.",
5
+ "type": "module",
6
+ "main": "./dist/index.js",
7
+ "module": "./dist/index.js",
8
+ "types": "./dist/index.d.ts",
9
+ "exports": {
10
+ ".": {
11
+ "types": "./dist/index.d.ts",
12
+ "import": "./dist/index.js"
13
+ }
14
+ },
15
+ "bin": {
16
+ "visual-editor-server": "bin/visual-editor-server.cjs"
17
+ },
18
+ "files": ["dist", "bin", "README.md"],
19
+ "scripts": {
20
+ "build": "tsup",
21
+ "dev": "tsup --watch",
22
+ "test": "node --import tsx --test 'test/**/*.test.ts'",
23
+ "typecheck": "tsc --noEmit",
24
+ "prepublishOnly": "npm run build"
25
+ },
26
+ "keywords": ["visual-editor", "tailwind", "css-modules", "styled-components", "ast"],
27
+ "license": "MIT",
28
+ "author": "Aaqil Jamal",
29
+ "repository": {
30
+ "type": "git",
31
+ "url": "https://github.com/The-Design-Alchemist/visual-editor.git",
32
+ "directory": "packages/server"
33
+ },
34
+ "homepage": "https://github.com/The-Design-Alchemist/visual-editor#readme",
35
+ "bugs": "https://github.com/The-Design-Alchemist/visual-editor/issues",
36
+ "publishConfig": {
37
+ "access": "public"
38
+ },
39
+ "engines": {
40
+ "node": ">=20"
41
+ },
42
+ "dependencies": {
43
+ "@babel/parser": "^7.27.0",
44
+ "@babel/types": "^7.27.0",
45
+ "@types/diff": "^7.0.2",
46
+ "diff": "^9.0.0",
47
+ "postcss": "^8.5.14",
48
+ "recast": "^0.23.11",
49
+ "tailwind-merge": "^3.6.0"
50
+ },
51
+ "devDependencies": {
52
+ "@types/node": "^22.10.0",
53
+ "tsup": "^8.3.5",
54
+ "tsx": "^4.19.2",
55
+ "typescript": "^5.6.3"
56
+ }
57
+ }