cmdzero 0.5.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/README.md +63 -0
- package/bin/cmdzero.js +13 -0
- package/overlay/overlay.js +1284 -0
- package/package.json +35 -0
- package/src/resolver.js +694 -0
- package/src/router.js +276 -0
- package/src/server.js +316 -0
- package/src/telemetry.js +97 -0
package/src/resolver.js
ADDED
|
@@ -0,0 +1,694 @@
|
|
|
1
|
+
import { parse } from '@babel/parser';
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
|
|
5
|
+
function parseSource(content, file) {
|
|
6
|
+
const plugins = /\.tsx?$/.test(file) ? ['typescript', 'jsx'] : ['jsx'];
|
|
7
|
+
return parse(content, { sourceType: 'module', plugins, errorRecovery: true });
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
function walk(node, cb) {
|
|
11
|
+
if (!node || typeof node.type !== 'string') return;
|
|
12
|
+
cb(node);
|
|
13
|
+
for (const key of Object.keys(node)) {
|
|
14
|
+
if (key === 'loc') continue;
|
|
15
|
+
const v = node[key];
|
|
16
|
+
if (Array.isArray(v)) v.forEach((c) => c && typeof c.type === 'string' && walk(c, cb));
|
|
17
|
+
else if (v && typeof v.type === 'string') walk(v, cb);
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** loc format: "<relative file>:<line 1-based>:<col 0-based>" (matches the babel stamp) */
|
|
22
|
+
export function parseLoc(loc) {
|
|
23
|
+
const m = /^(.*):(\d+):(\d+)$/.exec(loc);
|
|
24
|
+
if (!m) throw new Error(`bad loc: ${loc}`);
|
|
25
|
+
return { file: m[1], line: Number(m[2]), col: Number(m[3]) };
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function loadTarget(root, loc) {
|
|
29
|
+
const parsed = parseLoc(loc);
|
|
30
|
+
const { line, col } = parsed;
|
|
31
|
+
// Stamps come from two sources: the babel plugin (root-relative path,
|
|
32
|
+
// 0-based column) and the @cmdzero/react dev runtime (absolute path,
|
|
33
|
+
// 1-based column). Normalize the path and match either column convention.
|
|
34
|
+
const file = path.isAbsolute(parsed.file)
|
|
35
|
+
? path.relative(root, parsed.file)
|
|
36
|
+
: parsed.file;
|
|
37
|
+
if (file.startsWith('..')) throw new Error('file outside root');
|
|
38
|
+
const abs = path.resolve(root, file);
|
|
39
|
+
const content = fs.readFileSync(abs, 'utf8');
|
|
40
|
+
const ast = parseSource(content, file);
|
|
41
|
+
let element = null;
|
|
42
|
+
let nearMiss = null;
|
|
43
|
+
walk(ast, (n) => {
|
|
44
|
+
if (n.type !== 'JSXElement' || !n.openingElement.loc) return;
|
|
45
|
+
const s = n.openingElement.loc.start;
|
|
46
|
+
if (s.line !== line) return;
|
|
47
|
+
if (s.column === col) element = n;
|
|
48
|
+
else if (s.column === col - 1 && !nearMiss) nearMiss = n;
|
|
49
|
+
});
|
|
50
|
+
element = element || nearMiss;
|
|
51
|
+
if (!element) throw new Error(`no JSX element at ${loc}`);
|
|
52
|
+
return { file, abs, content, element, ast };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function describeTarget(root, loc) {
|
|
56
|
+
const { file, content, element } = loadTarget(root, loc);
|
|
57
|
+
const opening = element.openingElement;
|
|
58
|
+
const texts = element.children
|
|
59
|
+
.filter((c) => c.type === 'JSXText' && c.value.trim())
|
|
60
|
+
.map((c) => ({ value: c.value.trim(), start: c.start, end: c.end }));
|
|
61
|
+
const classAttr = opening.attributes.find(
|
|
62
|
+
(a) =>
|
|
63
|
+
a.type === 'JSXAttribute' &&
|
|
64
|
+
a.name.name === 'className' &&
|
|
65
|
+
a.value &&
|
|
66
|
+
a.value.type === 'StringLiteral'
|
|
67
|
+
);
|
|
68
|
+
return {
|
|
69
|
+
file,
|
|
70
|
+
tagName: opening.name.name,
|
|
71
|
+
span: { start: element.start, end: element.end },
|
|
72
|
+
lines: { start: element.loc.start.line, end: element.loc.end.line },
|
|
73
|
+
snippet: content.slice(element.start, element.end),
|
|
74
|
+
texts,
|
|
75
|
+
className: classAttr ? classAttr.value.value : null,
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function applyTextEdit(root, loc, oldText, newText) {
|
|
80
|
+
const { abs, content, element } = loadTarget(root, loc);
|
|
81
|
+
const node = element.children.find(
|
|
82
|
+
(c) => c.type === 'JSXText' && c.value.trim() === oldText.trim()
|
|
83
|
+
);
|
|
84
|
+
if (!node) throw new Error(`text "${oldText}" not found in element at ${loc}`);
|
|
85
|
+
const raw = content.slice(node.start, node.end);
|
|
86
|
+
const leading = raw.match(/^\s*/)[0];
|
|
87
|
+
const trailing = raw.match(/\s*$/)[0];
|
|
88
|
+
const next =
|
|
89
|
+
content.slice(0, node.start) + leading + newText + trailing + content.slice(node.end);
|
|
90
|
+
return writeChecked(abs, content, next);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// Rebuild a template-literal className, editing only the STATIC class tokens
|
|
94
|
+
// and keeping every `${...}` expression verbatim. Returns the `...` source.
|
|
95
|
+
function rebuildTemplateClasses(content, expr, removes, adds) {
|
|
96
|
+
const SENT = '\u0000';
|
|
97
|
+
const parts = [];
|
|
98
|
+
expr.quasis.forEach((q, i) => {
|
|
99
|
+
parts.push(q.value.raw);
|
|
100
|
+
if (i < expr.expressions.length) parts.push(SENT);
|
|
101
|
+
});
|
|
102
|
+
let tokens = parts
|
|
103
|
+
.join('')
|
|
104
|
+
.split(/\s+/)
|
|
105
|
+
.filter(Boolean)
|
|
106
|
+
.flatMap((t) => {
|
|
107
|
+
if (!t.includes(SENT)) return [t];
|
|
108
|
+
const out = [];
|
|
109
|
+
t.split(SENT).forEach((seg, i, a) => { if (seg) out.push(seg); if (i < a.length - 1) out.push(SENT); });
|
|
110
|
+
return out;
|
|
111
|
+
});
|
|
112
|
+
tokens = tokens.filter((t) => t === SENT || !removes.includes(t));
|
|
113
|
+
for (const a of adds) if (!tokens.includes(a)) tokens.push(a);
|
|
114
|
+
let ei = 0;
|
|
115
|
+
const rebuilt = tokens
|
|
116
|
+
.map((t) => (t === SENT ? '${' + content.slice(expr.expressions[ei].start, expr.expressions[ei++].end) + '}' : t))
|
|
117
|
+
.join(' ');
|
|
118
|
+
return '`' + rebuilt + '`';
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// Resolve a local `const name = "..."` (or template) referenced by
|
|
122
|
+
// className={name}. Returns the string/template literal node, or null.
|
|
123
|
+
function resolveClassConst(ast, name) {
|
|
124
|
+
let found = null;
|
|
125
|
+
walk(ast, (n) => {
|
|
126
|
+
if (found) return;
|
|
127
|
+
if (n.type === 'VariableDeclarator' && n.id?.type === 'Identifier' && n.id.name === name) {
|
|
128
|
+
const init = unwrapExpr(n.init);
|
|
129
|
+
if (init && (init.type === 'StringLiteral' || init.type === 'TemplateLiteral')) found = init;
|
|
130
|
+
}
|
|
131
|
+
});
|
|
132
|
+
return found;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// The literal node we can actually edit for a className attribute — the
|
|
136
|
+
// attribute's own string/template, or the `const` it references.
|
|
137
|
+
function classValueNode(ast, attr) {
|
|
138
|
+
const v = attr.value;
|
|
139
|
+
if (v && v.type === 'StringLiteral') return v;
|
|
140
|
+
const expr = v && v.type === 'JSXExpressionContainer' ? v.expression : null;
|
|
141
|
+
if (!expr) return null;
|
|
142
|
+
if (expr.type === 'StringLiteral' || expr.type === 'TemplateLiteral') return expr;
|
|
143
|
+
if (expr.type === 'Identifier') return resolveClassConst(ast, expr.name);
|
|
144
|
+
return null;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
export function applyClassEdit(root, loc, removes = [], adds = []) {
|
|
148
|
+
const { abs, content, element, ast } = loadTarget(root, loc);
|
|
149
|
+
const opening = element.openingElement;
|
|
150
|
+
const classAttrs = opening.attributes.filter(
|
|
151
|
+
(a) => a.type === 'JSXAttribute' && a.name.name === 'className'
|
|
152
|
+
);
|
|
153
|
+
|
|
154
|
+
const editList = (list) => {
|
|
155
|
+
let classes = list.split(/\s+/).filter(Boolean).filter((c) => !removes.includes(c));
|
|
156
|
+
for (const a of adds) if (!classes.includes(a)) classes.push(a);
|
|
157
|
+
return classes.join(' ');
|
|
158
|
+
};
|
|
159
|
+
|
|
160
|
+
// No className yet — add a fresh string attribute.
|
|
161
|
+
if (!classAttrs.length) {
|
|
162
|
+
const pos = opening.name.end;
|
|
163
|
+
return writeChecked(abs, content, content.slice(0, pos) + ` className="${adds.join(' ')}"` + content.slice(pos));
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// When an element has DUPLICATE className attributes (a corruption the old
|
|
167
|
+
// class editor could introduce), React renders only the LAST one. Edit that
|
|
168
|
+
// effective className, and strip the earlier dead duplicates so the element
|
|
169
|
+
// ends up valid and single-className.
|
|
170
|
+
const attr = classAttrs[classAttrs.length - 1];
|
|
171
|
+
const node = classValueNode(ast, attr);
|
|
172
|
+
if (!node) {
|
|
173
|
+
throw new Error(
|
|
174
|
+
"className here is computed, so I can't edit its classes deterministically. Use the size/color controls (inline style) or describe the change to route it through the model."
|
|
175
|
+
);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
const replacement =
|
|
179
|
+
node.type === 'TemplateLiteral'
|
|
180
|
+
? rebuildTemplateClasses(content, node, removes, adds)
|
|
181
|
+
: JSON.stringify(editList(node.value));
|
|
182
|
+
|
|
183
|
+
// Apply the value replacement plus removal of each dead duplicate attribute
|
|
184
|
+
// (with its leading space), right-to-left so offsets stay valid.
|
|
185
|
+
const edits = [{ start: node.start, end: node.end, text: replacement }];
|
|
186
|
+
for (const d of classAttrs.slice(0, -1)) {
|
|
187
|
+
const start = content[d.start - 1] === ' ' ? d.start - 1 : d.start;
|
|
188
|
+
edits.push({ start, end: d.end, text: '' });
|
|
189
|
+
}
|
|
190
|
+
edits.sort((a, b) => b.start - a.start);
|
|
191
|
+
let next = content;
|
|
192
|
+
for (const e of edits) next = next.slice(0, e.start) + e.text + next.slice(e.end);
|
|
193
|
+
return writeChecked(abs, content, next);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* Merge CSS properties into the element's inline style prop — the
|
|
198
|
+
* styling-system-agnostic edit lane (works with CSS modules, styled-components,
|
|
199
|
+
* vanilla CSS, anything). styles: { camelCaseProp: value | null }, null removes.
|
|
200
|
+
* Appended keys win in React's style object, so replacements are drop+append.
|
|
201
|
+
*/
|
|
202
|
+
export function applyStyleEdit(root, loc, styles) {
|
|
203
|
+
const { abs, content, element } = loadTarget(root, loc);
|
|
204
|
+
const opening = element.openingElement;
|
|
205
|
+
const toSrc = (v) => (typeof v === 'number' ? String(v) : `'${String(v).replace(/'/g, "\\'")}'`);
|
|
206
|
+
const attr = opening.attributes.find(
|
|
207
|
+
(a) => a.type === 'JSXAttribute' && a.name.name === 'style'
|
|
208
|
+
);
|
|
209
|
+
|
|
210
|
+
if (!attr) {
|
|
211
|
+
const entries = Object.entries(styles).filter(([, v]) => v != null);
|
|
212
|
+
if (!entries.length) return writeChecked(abs, content, content);
|
|
213
|
+
const obj = entries.map(([k, v]) => `${k}: ${toSrc(v)}`).join(', ');
|
|
214
|
+
const pos = opening.name.end;
|
|
215
|
+
return writeChecked(
|
|
216
|
+
abs,
|
|
217
|
+
content,
|
|
218
|
+
content.slice(0, pos) + ` style={{ ${obj} }}` + content.slice(pos)
|
|
219
|
+
);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
const expr =
|
|
223
|
+
attr.value && attr.value.type === 'JSXExpressionContainer' ? attr.value.expression : null;
|
|
224
|
+
if (!expr || expr.type !== 'ObjectExpression')
|
|
225
|
+
throw new Error('style prop is not an object literal — describe the change instead');
|
|
226
|
+
|
|
227
|
+
// Rebuild the object: keep untouched properties (and spreads) verbatim,
|
|
228
|
+
// drop replaced/removed keys, append new values last.
|
|
229
|
+
const parts = [];
|
|
230
|
+
for (const p of expr.properties) {
|
|
231
|
+
if (p.type === 'ObjectProperty' && !p.computed) {
|
|
232
|
+
const name =
|
|
233
|
+
p.key.type === 'Identifier' ? p.key.name : p.key.type === 'StringLiteral' ? p.key.value : null;
|
|
234
|
+
if (name && name in styles) continue;
|
|
235
|
+
}
|
|
236
|
+
parts.push(content.slice(p.start, p.end));
|
|
237
|
+
}
|
|
238
|
+
for (const [k, v] of Object.entries(styles)) if (v != null) parts.push(`${k}: ${toSrc(v)}`);
|
|
239
|
+
|
|
240
|
+
// Preserve multi-line formatting when the original object was multi-line.
|
|
241
|
+
const original = content.slice(expr.start, expr.end);
|
|
242
|
+
let objSrc;
|
|
243
|
+
if (original.includes('\n') && expr.properties.length) {
|
|
244
|
+
const firstProp = expr.properties[0];
|
|
245
|
+
const lineStart = content.lastIndexOf('\n', firstProp.start) + 1;
|
|
246
|
+
const indent = content.slice(lineStart, firstProp.start).match(/^\s*/)[0];
|
|
247
|
+
const braceLineStart = content.lastIndexOf('\n', expr.end - 1) + 1;
|
|
248
|
+
const braceIndent = content.slice(braceLineStart).match(/^\s*/)[0];
|
|
249
|
+
objSrc = `{\n${indent}${parts.join(`,\n${indent}`)},\n${braceIndent}}`;
|
|
250
|
+
} else {
|
|
251
|
+
objSrc = `{ ${parts.join(', ')} }`;
|
|
252
|
+
}
|
|
253
|
+
return writeChecked(
|
|
254
|
+
abs,
|
|
255
|
+
content,
|
|
256
|
+
content.slice(0, expr.start) + objSrc + content.slice(expr.end)
|
|
257
|
+
);
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// Remove an element from its source string, consuming its whole line(s) when
|
|
261
|
+
// it sits alone on them. Returns the new content, or null if unparseable.
|
|
262
|
+
function removeElementFromContent(content, element, file) {
|
|
263
|
+
let start = element.start;
|
|
264
|
+
let end = element.end;
|
|
265
|
+
const lineStart = content.lastIndexOf('\n', start - 1) + 1;
|
|
266
|
+
const afterMatch = content.slice(end).match(/^[ \t]*\n/);
|
|
267
|
+
if (/^[ \t]*$/.test(content.slice(lineStart, start)) && afterMatch) {
|
|
268
|
+
start = lineStart;
|
|
269
|
+
end += afterMatch[0].length;
|
|
270
|
+
}
|
|
271
|
+
const next = content.slice(0, start) + content.slice(end);
|
|
272
|
+
try {
|
|
273
|
+
parseSource(next, file);
|
|
274
|
+
return next;
|
|
275
|
+
} catch {
|
|
276
|
+
return null;
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
// Ancestor chain from `element` up to the Program node (element first).
|
|
281
|
+
function ancestorChain(ast, element) {
|
|
282
|
+
const chain = [];
|
|
283
|
+
(function descend(node, trail) {
|
|
284
|
+
if (!node || typeof node.type !== 'string') return false;
|
|
285
|
+
if (node === element) {
|
|
286
|
+
chain.push(element, ...trail);
|
|
287
|
+
return true;
|
|
288
|
+
}
|
|
289
|
+
if (node.start > element.start || node.end < element.end) return false;
|
|
290
|
+
for (const key of Object.keys(node)) {
|
|
291
|
+
if (key === 'loc') continue;
|
|
292
|
+
const v = node[key];
|
|
293
|
+
const kids = Array.isArray(v) ? v : [v];
|
|
294
|
+
for (const kid of kids) {
|
|
295
|
+
if (kid && typeof kid.type === 'string' && descend(kid, [node, ...trail])) return true;
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
return false;
|
|
299
|
+
})(ast, []);
|
|
300
|
+
return chain;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
const FN_TYPES = new Set(['FunctionDeclaration', 'FunctionExpression', 'ArrowFunctionExpression']);
|
|
304
|
+
|
|
305
|
+
// If the chain shows `element` is the RETURNED VALUE of the innermost enclosing
|
|
306
|
+
// function (only Return/Block between them), return that function node.
|
|
307
|
+
// This gate matters: without it, a parse-breaking delete anywhere inside a
|
|
308
|
+
// named component would wrongly escalate to removing the component's usage.
|
|
309
|
+
function returningFunction(chain) {
|
|
310
|
+
for (let i = 1; i < chain.length; i++) {
|
|
311
|
+
const t = chain[i].type;
|
|
312
|
+
if (t === 'ReturnStatement' || t === 'BlockStatement') continue;
|
|
313
|
+
return FN_TYPES.has(t) ? chain[i] : null;
|
|
314
|
+
}
|
|
315
|
+
return null;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
function nameOfFunction(ast, fn) {
|
|
319
|
+
if (!fn) return null;
|
|
320
|
+
if (fn.id && fn.id.name) return fn.id.name;
|
|
321
|
+
let name = null;
|
|
322
|
+
walk(ast, (n) => {
|
|
323
|
+
if (
|
|
324
|
+
n.type === 'VariableDeclarator' &&
|
|
325
|
+
n.init && n.init.start === fn.start && n.init.end === fn.end &&
|
|
326
|
+
n.id && n.id.type === 'Identifier'
|
|
327
|
+
) name = n.id.name;
|
|
328
|
+
});
|
|
329
|
+
return name;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
// Walk up from the element looking for a `{...}` JSX expression whose whole
|
|
333
|
+
// removal parses — covers `{cond && <el/>}` (conditional) and `{arr.map(...)}`
|
|
334
|
+
// (list template). Never crosses a function boundary except an inline callback
|
|
335
|
+
// (a function that is itself a call argument, e.g. the .map render callback).
|
|
336
|
+
// For lists, also returns the map CallExpression so the caller can target the
|
|
337
|
+
// underlying data array instead of the whole list.
|
|
338
|
+
function removableExpressionContainer(chain) {
|
|
339
|
+
for (let i = 1; i < chain.length; i++) {
|
|
340
|
+
const node = chain[i];
|
|
341
|
+
if (node.type === 'JSXExpressionContainer') {
|
|
342
|
+
const inner = chain[i - 1];
|
|
343
|
+
const isList = FN_TYPES.has(inner?.type) || inner?.type === 'CallExpression';
|
|
344
|
+
return { container: node, kind: isList ? 'list' : 'conditional block', mapCall: isList ? inner : null };
|
|
345
|
+
}
|
|
346
|
+
if (FN_TYPES.has(node.type)) {
|
|
347
|
+
// crossing a function is only allowed for inline callbacks (map etc.)
|
|
348
|
+
const parent = chain[i + 1];
|
|
349
|
+
if (!parent || parent.type !== 'CallExpression') return null;
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
return null;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
// Unwrap TS wrappers around an initializer (as const / satisfies / parens).
|
|
356
|
+
function unwrapExpr(node) {
|
|
357
|
+
let n = node;
|
|
358
|
+
while (n && (n.type === 'TSAsExpression' || n.type === 'TSSatisfiesExpression' || n.type === 'TSNonNullExpression' || n.type === 'ParenthesizedExpression'))
|
|
359
|
+
n = n.expression;
|
|
360
|
+
return n;
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
// For `{items.map(cb)}`: resolve `items` to a local `const items = [...]`
|
|
364
|
+
// ArrayExpression in the same file. Returns the array node or null.
|
|
365
|
+
function resolveMappedArray(ast, mapCall) {
|
|
366
|
+
let call = mapCall;
|
|
367
|
+
// chain may have handed us the arrow (expression-body case) — find the call
|
|
368
|
+
if (call && FN_TYPES.has(call.type)) return null; // caller passes the real call below
|
|
369
|
+
if (!call || call.type !== 'CallExpression') return null;
|
|
370
|
+
const callee = call.callee;
|
|
371
|
+
if (
|
|
372
|
+
callee?.type !== 'MemberExpression' ||
|
|
373
|
+
callee.property?.type !== 'Identifier' ||
|
|
374
|
+
!['map', 'flatMap'].includes(callee.property.name) ||
|
|
375
|
+
callee.object?.type !== 'Identifier'
|
|
376
|
+
) return null;
|
|
377
|
+
const arrName = callee.object.name;
|
|
378
|
+
let arr = null;
|
|
379
|
+
walk(ast, (n) => {
|
|
380
|
+
if (arr) return;
|
|
381
|
+
if (n.type === 'VariableDeclarator' && n.id?.type === 'Identifier' && n.id.name === arrName) {
|
|
382
|
+
const init = unwrapExpr(n.init);
|
|
383
|
+
if (init?.type === 'ArrayExpression') arr = init;
|
|
384
|
+
}
|
|
385
|
+
});
|
|
386
|
+
return arr;
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
// Remove the k-th element of an ArrayExpression, handling commas and whole
|
|
390
|
+
// lines. Returns new content, or null if the result doesn't parse.
|
|
391
|
+
function removeArrayElement(content, arrayNode, k, file) {
|
|
392
|
+
const el = arrayNode.elements[k];
|
|
393
|
+
if (!el) return null;
|
|
394
|
+
let start = el.start;
|
|
395
|
+
let end = el.end;
|
|
396
|
+
const after = content.slice(end).match(/^\s*,/);
|
|
397
|
+
if (after) end += after[0].length;
|
|
398
|
+
else {
|
|
399
|
+
const before = content.slice(0, start).match(/,\s*$/);
|
|
400
|
+
if (before) start -= before[0].length;
|
|
401
|
+
}
|
|
402
|
+
const lineStart = content.lastIndexOf('\n', start - 1) + 1;
|
|
403
|
+
const trailing = content.slice(end).match(/^[ \t]*\n/);
|
|
404
|
+
if (/^[ \t]*$/.test(content.slice(lineStart, start)) && trailing) {
|
|
405
|
+
start = lineStart;
|
|
406
|
+
end += trailing[0].length;
|
|
407
|
+
}
|
|
408
|
+
const next = content.slice(0, start) + content.slice(end);
|
|
409
|
+
try {
|
|
410
|
+
parseSource(next, file);
|
|
411
|
+
return next;
|
|
412
|
+
} catch {
|
|
413
|
+
return null;
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
// Walk the project for JSX usages of <Name ...>. Returns [{file, element}].
|
|
418
|
+
function findUsages(root, name) {
|
|
419
|
+
const usages = [];
|
|
420
|
+
const skip = new Set(['node_modules', '.next', '.git', 'dist', '.turbo']);
|
|
421
|
+
const stack = [root];
|
|
422
|
+
while (stack.length) {
|
|
423
|
+
const dir = stack.pop();
|
|
424
|
+
let entries;
|
|
425
|
+
try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { continue; }
|
|
426
|
+
for (const e of entries) {
|
|
427
|
+
const full = path.join(dir, e.name);
|
|
428
|
+
if (e.isDirectory()) { if (!skip.has(e.name)) stack.push(full); continue; }
|
|
429
|
+
if (!/\.(jsx|tsx)$/.test(e.name)) continue;
|
|
430
|
+
let content, ast;
|
|
431
|
+
try {
|
|
432
|
+
content = fs.readFileSync(full, 'utf8');
|
|
433
|
+
if (!content.includes('<' + name)) continue; // cheap prefilter
|
|
434
|
+
ast = parseSource(content, e.name);
|
|
435
|
+
} catch { continue; }
|
|
436
|
+
walk(ast, (n) => {
|
|
437
|
+
if (n.type !== 'JSXElement') return;
|
|
438
|
+
const openName = n.openingElement.name;
|
|
439
|
+
if (openName.type === 'JSXIdentifier' && openName.name === name) {
|
|
440
|
+
usages.push({ file: path.relative(root, full), abs: full, content, element: n });
|
|
441
|
+
}
|
|
442
|
+
});
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
return usages;
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
/**
|
|
449
|
+
* Delete behavior, in order:
|
|
450
|
+
* 1. Normal element (has siblings): remove it in place.
|
|
451
|
+
* 2. List template (`{arr.map(...)}`): the clicked DOM instance's `index`
|
|
452
|
+
* identifies the data item — remove that ONE entry from the local array
|
|
453
|
+
* literal. Deleting one card removes one card, not the list. (To remove a
|
|
454
|
+
* whole list, select its surrounding wrapper element instead.)
|
|
455
|
+
* 3. Conditional (`{cond && <el/>}`): remove the whole conditional block.
|
|
456
|
+
* 4. Sole return of a NAMED component used in exactly one place: remove that
|
|
457
|
+
* usage at its call site.
|
|
458
|
+
* Otherwise refuse with an actionable message (ambiguous → model lane).
|
|
459
|
+
*/
|
|
460
|
+
export function applyDeleteElement(root, loc, { dryRun = false, index } = {}) {
|
|
461
|
+
// reuse loadTarget's AST — element identity must hold for the ancestor walk
|
|
462
|
+
const { abs, content, element, file, ast } = loadTarget(root, loc);
|
|
463
|
+
|
|
464
|
+
const inPlace = removeElementFromContent(content, element, file);
|
|
465
|
+
if (inPlace !== null) {
|
|
466
|
+
if (dryRun) return { abs, before: content, after: inPlace, wouldWrite: false };
|
|
467
|
+
return writeChecked(abs, content, inPlace);
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
// In-place removal breaks the file — walk up for a deletable wrapper.
|
|
471
|
+
const chain = ancestorChain(ast, element);
|
|
472
|
+
|
|
473
|
+
const wrapper = removableExpressionContainer(chain);
|
|
474
|
+
if (wrapper && wrapper.kind === 'list') {
|
|
475
|
+
const arr = resolveMappedArray(ast, wrapper.mapCall);
|
|
476
|
+
if (arr && Number.isInteger(index) && index >= 0 && index < arr.elements.length) {
|
|
477
|
+
const next = removeArrayElement(content, arr, index, file);
|
|
478
|
+
if (next !== null) {
|
|
479
|
+
const detail = `item ${index + 1} of ${arr.elements.length}`;
|
|
480
|
+
if (dryRun) return { abs, before: content, after: next, wouldWrite: false, removedBlock: detail };
|
|
481
|
+
return { ...writeChecked(abs, content, next), removedBlock: detail };
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
// data not editable deterministically (imported/computed array, or the
|
|
485
|
+
// instance index is unknown) — never silently delete the whole list
|
|
486
|
+
throw new Error(
|
|
487
|
+
"this is one item in a rendered list whose data I can't safely edit here. Describe the change (e.g. \"remove the second stat\") and it'll route through the model — or select the list's surrounding container to delete the whole list."
|
|
488
|
+
);
|
|
489
|
+
}
|
|
490
|
+
if (wrapper) {
|
|
491
|
+
const next = removeElementFromContent(content, wrapper.container, file);
|
|
492
|
+
if (next !== null) {
|
|
493
|
+
if (dryRun) return { abs, before: content, after: next, wouldWrite: false, removedBlock: wrapper.kind };
|
|
494
|
+
return { ...writeChecked(abs, content, next), removedBlock: wrapper.kind };
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
// Not a removable wrapper — usage removal, but ONLY when the element really
|
|
499
|
+
// is the returned output of a named component.
|
|
500
|
+
const fn = returningFunction(chain);
|
|
501
|
+
const name = nameOfFunction(ast, fn);
|
|
502
|
+
if (!name || !/^[A-Z]/.test(name)) {
|
|
503
|
+
throw new Error(
|
|
504
|
+
"can't delete this in place — it's the only thing rendered here and there's no removable wrapper. Describe the change instead (e.g. \"remove this from the list\") and it'll route through the model."
|
|
505
|
+
);
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
const usages = findUsages(root, name).filter(
|
|
509
|
+
(u) => !(u.abs === abs && u.element.start === element.start && u.element.end === element.end)
|
|
510
|
+
);
|
|
511
|
+
if (usages.length === 0) {
|
|
512
|
+
throw new Error(
|
|
513
|
+
`can't find where <${name}> is used, so there's no usage to remove. Describe the change instead and it'll route through the model.`
|
|
514
|
+
);
|
|
515
|
+
}
|
|
516
|
+
if (usages.length > 1) {
|
|
517
|
+
throw new Error(
|
|
518
|
+
`<${name}> is used in ${usages.length} places — deleting one is ambiguous. Select the specific <${name}> instance you want removed, or describe the change.`
|
|
519
|
+
);
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
const usage = usages[0];
|
|
523
|
+
const removed = removeElementFromContent(usage.content, usage.element, usage.file);
|
|
524
|
+
if (removed === null) {
|
|
525
|
+
throw new Error(
|
|
526
|
+
`removing the <${name}> usage would break ${usage.file}. Describe the change instead.`
|
|
527
|
+
);
|
|
528
|
+
}
|
|
529
|
+
if (dryRun) return { abs: usage.abs, before: usage.content, after: removed, wouldWrite: false, removedUsage: name };
|
|
530
|
+
return { ...writeChecked(usage.abs, usage.content, removed), removedUsage: name };
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
// ---------- reorder / move ----------
|
|
534
|
+
|
|
535
|
+
const DIR_STEP = { up: -1, left: -1, prev: -1, down: 1, right: 1, next: 1 };
|
|
536
|
+
|
|
537
|
+
// The JSX-element siblings of `element` (elements sharing its parent), plus the
|
|
538
|
+
// index of `element` among them. Only when the parent is itself JSX.
|
|
539
|
+
function jsxElementSiblings(ast, element) {
|
|
540
|
+
const chain = ancestorChain(ast, element);
|
|
541
|
+
const parent = chain[1];
|
|
542
|
+
if (!parent || (parent.type !== 'JSXElement' && parent.type !== 'JSXFragment')) return null;
|
|
543
|
+
const list = (parent.children || []).filter((c) => c.type === 'JSXElement');
|
|
544
|
+
const pos = list.findIndex((n) => n.start === element.start && n.end === element.end);
|
|
545
|
+
if (pos < 0) return null;
|
|
546
|
+
return { list, pos };
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
// The full source block of a node: its own span, extended to swallow the
|
|
550
|
+
// leading indent on its line and one trailing newline, so a moved element
|
|
551
|
+
// carries its own line(s) cleanly.
|
|
552
|
+
function nodeBlock(content, node) {
|
|
553
|
+
let start = node.start;
|
|
554
|
+
let end = node.end;
|
|
555
|
+
const lineStart = content.lastIndexOf('\n', start - 1) + 1;
|
|
556
|
+
if (/^[ \t]*$/.test(content.slice(lineStart, start))) start = lineStart;
|
|
557
|
+
const after = content.slice(end).match(/^[ \t]*\n/);
|
|
558
|
+
if (after) end += after[0].length;
|
|
559
|
+
return { start, end, text: content.slice(start, end) };
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
// Move sibling nodes[from] to position `to` by cutting its line-block and
|
|
563
|
+
// re-inserting it before/after the anchor. Returns new content, or null if it
|
|
564
|
+
// doesn't parse.
|
|
565
|
+
function moveNode(content, siblings, from, to, file) {
|
|
566
|
+
if (from === to || to < 0 || to >= siblings.length) return null;
|
|
567
|
+
const block = nodeBlock(content, siblings[from]);
|
|
568
|
+
const removedLen = block.end - block.start;
|
|
569
|
+
const without = content.slice(0, block.start) + content.slice(block.end);
|
|
570
|
+
const adj = (p) => (p > block.start ? p - removedLen : p);
|
|
571
|
+
const anchor = siblings[to];
|
|
572
|
+
const aBlock = nodeBlock(without, { start: adj(anchor.start), end: adj(anchor.end) });
|
|
573
|
+
const insertAt = to > from ? aBlock.end : aBlock.start;
|
|
574
|
+
const next = without.slice(0, insertAt) + block.text + without.slice(insertAt);
|
|
575
|
+
try { parseSource(next, file); return next; } catch { return null; }
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
// Reorder the elements of an array literal, preserving inline vs. multiline
|
|
579
|
+
// formatting. Returns new content, or null if it doesn't parse.
|
|
580
|
+
function moveArrayElement(content, arr, from, to, file) {
|
|
581
|
+
const els = arr.elements;
|
|
582
|
+
if (from === to || from < 0 || to < 0 || from >= els.length || to >= els.length) return null;
|
|
583
|
+
const texts = els.map((n) => content.slice(n.start, n.end));
|
|
584
|
+
const [m] = texts.splice(from, 1);
|
|
585
|
+
texts.splice(to, 0, m);
|
|
586
|
+
const first = els[0], last = els[els.length - 1];
|
|
587
|
+
const interior = content.slice(first.start, last.end);
|
|
588
|
+
let joined;
|
|
589
|
+
if (interior.includes('\n')) {
|
|
590
|
+
const ls = content.lastIndexOf('\n', first.start - 1) + 1;
|
|
591
|
+
const indent = content.slice(ls, first.start).match(/^\s*/)[0];
|
|
592
|
+
joined = texts.join(',\n' + indent);
|
|
593
|
+
} else {
|
|
594
|
+
joined = texts.join(', ');
|
|
595
|
+
}
|
|
596
|
+
const next = content.slice(0, first.start) + joined + content.slice(last.end);
|
|
597
|
+
try { parseSource(next, file); return next; } catch { return null; }
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
// The name a file's default export renders as (`export default function Hero`).
|
|
601
|
+
function defaultExportName(ast) {
|
|
602
|
+
let name = null;
|
|
603
|
+
walk(ast, (n) => {
|
|
604
|
+
if (name || n.type !== 'ExportDefaultDeclaration') return;
|
|
605
|
+
const d = n.declaration;
|
|
606
|
+
if (d.type === 'FunctionDeclaration' && d.id) name = d.id.name;
|
|
607
|
+
else if (d.type === 'Identifier') name = d.name;
|
|
608
|
+
});
|
|
609
|
+
return name;
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
/**
|
|
613
|
+
* Reorder the selected element among its siblings.
|
|
614
|
+
* - map item (`{arr.map(...)}`) → reorder the backing array literal
|
|
615
|
+
* - JSX-element siblings in the file → move the element among them
|
|
616
|
+
* - otherwise (section root) → move THIS file's component usage
|
|
617
|
+
* (`<Hero/>`) among its siblings in the importing file (e.g. page.tsx)
|
|
618
|
+
* `dir` is up/down/left/right (prev/next); `toIndex` gives an absolute target
|
|
619
|
+
* (drag). `index` is the clicked instance index for mapped items.
|
|
620
|
+
*/
|
|
621
|
+
export function applyMove(root, loc, { dir, index, toIndex, dryRun = false } = {}) {
|
|
622
|
+
const { abs, content, element, file, ast } = loadTarget(root, loc);
|
|
623
|
+
const step = DIR_STEP[dir] ?? 1;
|
|
624
|
+
const finish = (writeAbs, before, after, detail) => {
|
|
625
|
+
if (after == null) throw new Error(detail || "can't move this the way you dragged it.");
|
|
626
|
+
if (dryRun) return { abs: writeAbs, before, after, wouldWrite: false, moved: detail };
|
|
627
|
+
return { ...writeChecked(writeAbs, before, after), moved: detail };
|
|
628
|
+
};
|
|
629
|
+
|
|
630
|
+
const chain = ancestorChain(ast, element);
|
|
631
|
+
|
|
632
|
+
// Case B: one item of a rendered list → reorder the data array.
|
|
633
|
+
const wrapper = removableExpressionContainer(chain);
|
|
634
|
+
if (wrapper && wrapper.kind === 'list') {
|
|
635
|
+
const arr = resolveMappedArray(ast, wrapper.mapCall);
|
|
636
|
+
if (!arr) throw new Error("this list's data isn't a local array I can safely reorder here.");
|
|
637
|
+
const from = Number.isInteger(index) ? index : 0;
|
|
638
|
+
const to = toIndex != null ? toIndex : from + step;
|
|
639
|
+
if (to < 0 || to >= arr.elements.length) throw new Error('already at the end of the list.');
|
|
640
|
+
const next = moveArrayElement(content, arr, from, to, file);
|
|
641
|
+
return finish(abs, content, next, `item ${from + 1} → ${to + 1} of ${arr.elements.length}`);
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
// Case A: JSX-element siblings in this same file.
|
|
645
|
+
const sib = jsxElementSiblings(ast, element);
|
|
646
|
+
if (sib && sib.list.length > 1) {
|
|
647
|
+
const from = sib.pos;
|
|
648
|
+
const to = toIndex != null ? toIndex : from + step;
|
|
649
|
+
if (to < 0 || to >= sib.list.length) throw new Error('already at the edge — nothing to swap with.');
|
|
650
|
+
const next = moveNode(content, sib.list, from, to, file);
|
|
651
|
+
return finish(abs, content, next, `${from + 1} → ${to + 1} of ${sib.list.length}`);
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
// Case C: no movable siblings here — treat it as a section and move THIS
|
|
655
|
+
// file's component usage among its siblings in the importing file.
|
|
656
|
+
const name = defaultExportName(ast) || nameOfFunction(ast, returningFunction(chain));
|
|
657
|
+
if (!name || !/^[A-Z]/.test(name)) {
|
|
658
|
+
throw new Error("there's nothing to reorder here — this element has no movable siblings. Select the section's outer container (or a card in a row).");
|
|
659
|
+
}
|
|
660
|
+
const usages = findUsages(root, name);
|
|
661
|
+
if (usages.length === 0) throw new Error(`can't find where <${name}> is placed, so there's no section order to change.`);
|
|
662
|
+
if (usages.length > 1) throw new Error(`<${name}> is used in ${usages.length} places — reordering is ambiguous.`);
|
|
663
|
+
const u = usages[0];
|
|
664
|
+
const uAst = parseSource(u.content, u.file);
|
|
665
|
+
let uEl = null;
|
|
666
|
+
walk(uAst, (n) => {
|
|
667
|
+
if (uEl || n.type !== 'JSXElement') return;
|
|
668
|
+
const nm = n.openingElement.name;
|
|
669
|
+
if (nm.type === 'JSXIdentifier' && nm.name === name && n.start === u.element.start) uEl = n;
|
|
670
|
+
});
|
|
671
|
+
const usib = uEl && jsxElementSiblings(uAst, uEl);
|
|
672
|
+
if (!usib || usib.list.length < 2) throw new Error(`<${name}> has no sibling sections to reorder with.`);
|
|
673
|
+
const from = usib.pos;
|
|
674
|
+
const to = toIndex != null ? toIndex : from + step;
|
|
675
|
+
if (to < 0 || to >= usib.list.length) throw new Error(`<${name}> is already at the ${step < 0 ? 'top' : 'bottom'}.`);
|
|
676
|
+
const next = moveNode(u.content, usib.list, from, to, u.file);
|
|
677
|
+
return finish(u.abs, u.content, next, `${name}: section ${from + 1} → ${to + 1} of ${usib.list.length}`);
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
/** Returns null if the file parses, else the parse error message. */
|
|
681
|
+
export function checkSyntax(root, file) {
|
|
682
|
+
const abs = path.resolve(root, file);
|
|
683
|
+
try {
|
|
684
|
+
parseSource(fs.readFileSync(abs, 'utf8'), file);
|
|
685
|
+
return null;
|
|
686
|
+
} catch (e) {
|
|
687
|
+
return e.message;
|
|
688
|
+
}
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
function writeChecked(abs, before, after) {
|
|
692
|
+
fs.writeFileSync(abs, after);
|
|
693
|
+
return { abs, before, after };
|
|
694
|
+
}
|