@upstart.gg/vite-plugins 0.1.41 → 0.1.42

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.
Files changed (37) hide show
  1. package/dist/upstart-editor-api.d.ts +59 -1
  2. package/dist/upstart-editor-api.d.ts.map +1 -1
  3. package/dist/upstart-editor-api.js +273 -4
  4. package/dist/upstart-editor-api.js.map +1 -1
  5. package/dist/vite-plugin-upstart-attrs.d.ts +2 -1
  6. package/dist/vite-plugin-upstart-attrs.d.ts.map +1 -1
  7. package/dist/vite-plugin-upstart-attrs.js +91 -4
  8. package/dist/vite-plugin-upstart-attrs.js.map +1 -1
  9. package/dist/vite-plugin-upstart-editor/runtime/array-controls.js +342 -0
  10. package/dist/vite-plugin-upstart-editor/runtime/array-controls.js.map +1 -0
  11. package/dist/vite-plugin-upstart-editor/runtime/click-handler.d.ts.map +1 -1
  12. package/dist/vite-plugin-upstart-editor/runtime/click-handler.js +29 -2
  13. package/dist/vite-plugin-upstart-editor/runtime/click-handler.js.map +1 -1
  14. package/dist/vite-plugin-upstart-editor/runtime/form-guard.js +80 -0
  15. package/dist/vite-plugin-upstart-editor/runtime/form-guard.js.map +1 -0
  16. package/dist/vite-plugin-upstart-editor/runtime/hover-overlay.d.ts.map +1 -1
  17. package/dist/vite-plugin-upstart-editor/runtime/hover-overlay.js +2 -1
  18. package/dist/vite-plugin-upstart-editor/runtime/hover-overlay.js.map +1 -1
  19. package/dist/vite-plugin-upstart-editor/runtime/index.d.ts.map +1 -1
  20. package/dist/vite-plugin-upstart-editor/runtime/index.js +26 -4
  21. package/dist/vite-plugin-upstart-editor/runtime/index.js.map +1 -1
  22. package/dist/vite-plugin-upstart-editor/runtime/text-editor.d.ts.map +1 -1
  23. package/dist/vite-plugin-upstart-editor/runtime/text-editor.js +40 -36
  24. package/dist/vite-plugin-upstart-editor/runtime/text-editor.js.map +1 -1
  25. package/dist/vite-plugin-upstart-editor/runtime/types.d.ts +20 -4
  26. package/dist/vite-plugin-upstart-editor/runtime/types.d.ts.map +1 -1
  27. package/package.json +3 -3
  28. package/src/tests/vite-plugin-upstart-attrs.test.ts +412 -0
  29. package/src/upstart-editor-api.ts +314 -5
  30. package/src/vite-plugin-upstart-attrs.ts +154 -4
  31. package/src/vite-plugin-upstart-editor/runtime/array-controls.ts +478 -0
  32. package/src/vite-plugin-upstart-editor/runtime/click-handler.ts +43 -0
  33. package/src/vite-plugin-upstart-editor/runtime/form-guard.ts +121 -0
  34. package/src/vite-plugin-upstart-editor/runtime/hover-overlay.ts +6 -1
  35. package/src/vite-plugin-upstart-editor/runtime/index.ts +20 -4
  36. package/src/vite-plugin-upstart-editor/runtime/text-editor.ts +49 -58
  37. package/src/vite-plugin-upstart-editor/runtime/types.ts +31 -4
@@ -1,9 +1,84 @@
1
1
  import MagicString from "magic-string";
2
+ import { parseSync } from "oxc-parser";
2
3
  import fs from "fs/promises";
3
4
  import path from "path";
4
5
  import z from "zod";
5
6
  import type { EditableEntry } from "./vite-plugin-upstart-attrs";
6
7
 
8
+ /**
9
+ * Escape user-typed text so it can be safely written as the BODY of a JS string
10
+ * literal delimited by `quote` (" ' or `). Escapes the delimiter, backslashes,
11
+ * line terminators, and — for template literals — `${` interpolation starts.
12
+ * The surrounding quotes themselves are NOT included.
13
+ */
14
+ export function escapeStringLiteralBody(value: string, quote: string): string {
15
+ let out = value.replace(/\\/g, "\\\\");
16
+ if (quote === "`") {
17
+ out = out.replace(/`/g, "\\`").replace(/\$\{/g, "\\${");
18
+ } else {
19
+ out = out.split(quote).join("\\" + quote);
20
+ }
21
+ return out.replace(/\n/g, "\\n").replace(/\r/g, "\\r");
22
+ }
23
+
24
+ interface AstNode {
25
+ type: string;
26
+ start: number;
27
+ end: number;
28
+ [key: string]: unknown;
29
+ }
30
+
31
+ /**
32
+ * Locate the inline ArrayExpression that starts at `offset` by re-parsing the file.
33
+ * Returns its bounds and element nodes (elisions become null), or null if not found.
34
+ */
35
+ function findArrayExpressionAt(
36
+ code: string,
37
+ filePath: string,
38
+ offset: number,
39
+ ): { start: number; end: number; elements: (AstNode | null)[] } | null {
40
+ const ast = parseSync(filePath, code, { sourceType: "module" });
41
+ if (!ast.program) return null;
42
+
43
+ let found: AstNode | null = null;
44
+ const visit = (node: unknown): void => {
45
+ if (found || !node || typeof node !== "object") return;
46
+ if (Array.isArray(node)) {
47
+ for (const child of node) visit(child);
48
+ return;
49
+ }
50
+ const n = node as AstNode;
51
+ if (n.type === "ArrayExpression" && n.start === offset) {
52
+ found = n;
53
+ return;
54
+ }
55
+ for (const key in n) {
56
+ if (key === "type" || key === "start" || key === "end") continue;
57
+ const value = n[key];
58
+ if (value && typeof value === "object") visit(value);
59
+ }
60
+ };
61
+ visit(ast.program);
62
+
63
+ if (!found) return null;
64
+ return {
65
+ start: (found as AstNode).start,
66
+ end: (found as AstNode).end,
67
+ elements: ((found as AstNode).elements as (AstNode | null)[]) ?? [],
68
+ };
69
+ }
70
+
71
+ /** Infer the quote char used by the array's string literals (defaults to "). */
72
+ function inferArrayQuote(code: string, elements: AstNode[]): string {
73
+ for (const el of elements) {
74
+ if (el.type === "Literal" && typeof el.value === "string") {
75
+ const q = code[el.start];
76
+ if (q === '"' || q === "'" || q === "`") return q;
77
+ }
78
+ }
79
+ return '"';
80
+ }
81
+
7
82
  export const payloadEditText = z.object({
8
83
  action: z.literal("editText"),
9
84
  language: z
@@ -33,6 +108,44 @@ export const payloadEditClassName = z.object({
33
108
 
34
109
  export type PayloadEditClassName = z.infer<typeof payloadEditClassName>;
35
110
 
111
+ export const payloadEditImage = z.object({
112
+ action: z.literal("editImage"),
113
+ id: z.string().min(1),
114
+ // New src value to write into the <img> source, e.g. "/images/fashion-10.webp"
115
+ src: z.string().min(1),
116
+ });
117
+
118
+ export type PayloadEditImage = z.infer<typeof payloadEditImage>;
119
+
120
+ // arrayId is "<relativeFile>:<arrayStartOffset>" — identifies the inline array literal.
121
+ export const payloadArrayItemAdd = z.object({
122
+ action: z.literal("arrayItemAdd"),
123
+ arrayId: z.string().min(1),
124
+ // Default content for the inserted element (the editor sends "New item").
125
+ content: z.string().default("New item"),
126
+ });
127
+
128
+ export type PayloadArrayItemAdd = z.infer<typeof payloadArrayItemAdd>;
129
+
130
+ export const payloadArrayItemDelete = z.object({
131
+ action: z.literal("arrayItemDelete"),
132
+ arrayId: z.string().min(1),
133
+ index: z.number().int().min(0),
134
+ });
135
+
136
+ export type PayloadArrayItemDelete = z.infer<typeof payloadArrayItemDelete>;
137
+
138
+ // Replace the entire contents of an inline array literal in one edit — used by the
139
+ // editor's batched "apply" (✓) control so a whole add/delete session is a single
140
+ // source change + rebuild instead of one per item.
141
+ export const payloadArraySet = z.object({
142
+ action: z.literal("arraySet"),
143
+ arrayId: z.string().min(1),
144
+ items: z.array(z.string()).min(1),
145
+ });
146
+
147
+ export type PayloadArraySet = z.infer<typeof payloadArraySet>;
148
+
36
149
  export interface EditableRegistry {
37
150
  version: number;
38
151
  generatedAt: string;
@@ -168,7 +281,10 @@ export class UpstartEditorAPI {
168
281
  if (entry.type === "mixed-text") {
169
282
  return this.applyMixedTextEdit(id, entry, content);
170
283
  }
171
- return this.applyEdit(id, entry, content);
284
+ // Text entries that map to a JS string literal (e.g. inline array items) must be
285
+ // escaped for the literal's quote style before being written between the quotes.
286
+ const finalContent = entry.quote ? escapeStringLiteralBody(content, entry.quote) : content;
287
+ return this.applyEdit(id, entry, finalContent);
172
288
  }
173
289
 
174
290
  /**
@@ -200,6 +316,192 @@ export class UpstartEditorAPI {
200
316
  return this.applyEdit(id, entry, newClassName);
201
317
  }
202
318
 
319
+ /**
320
+ * Edit the `src` of an <img> element directly in the source TSX file.
321
+ * The byte range points at the string literal between the quotes, so we only
322
+ * rewrite the path itself. Copying the asset into the project is handled by
323
+ * the caller (the sandbox server, which has bucket access).
324
+ */
325
+ async editImage(params: PayloadEditImage): Promise<EditResult> {
326
+ const parsed = payloadEditImage.safeParse(params);
327
+ if (!parsed.success) {
328
+ return { success: false, error: `Invalid payload: ${parsed.error.message}` };
329
+ }
330
+ const { id, src } = parsed.data;
331
+ if (!this.registry) {
332
+ try {
333
+ await this.loadRegistry();
334
+ } catch (err) {
335
+ return { success: false, error: `Failed to load registry: ${err}` };
336
+ }
337
+ }
338
+
339
+ const entry = this.registry!.elements[id];
340
+ if (!entry) {
341
+ return { success: false, error: `Element ${id} not found in registry` };
342
+ }
343
+
344
+ if (entry.type !== "image") {
345
+ return { success: false, error: `Element ${id} is not an image element (type: ${entry.type})` };
346
+ }
347
+
348
+ return this.applyEdit(id, entry, src);
349
+ }
350
+
351
+ /**
352
+ * Add a new element to an inline array literal (e.g. `["a", "b"]`), used by the
353
+ * editor's "+" control on editable .map() lists. The inserted element matches
354
+ * the quote style of the existing string elements and reuses their separator so
355
+ * multi-line indentation is preserved.
356
+ */
357
+ async arrayItemAdd(params: PayloadArrayItemAdd): Promise<EditResult> {
358
+ const parsed = payloadArrayItemAdd.safeParse(params);
359
+ if (!parsed.success) {
360
+ return { success: false, error: `Invalid payload: ${parsed.error.message}` };
361
+ }
362
+ const { arrayId, content } = parsed.data;
363
+ const loc = this.resolveArrayId(arrayId);
364
+ if (!loc) return { success: false, error: `Invalid arrayId: ${arrayId}` };
365
+
366
+ try {
367
+ const code = await fs.readFile(loc.filePath, "utf-8");
368
+ const arr = findArrayExpressionAt(code, loc.relativeFile, loc.offset);
369
+ if (!arr) {
370
+ return { success: false, error: `Array not found at ${arrayId}. The file may have been modified.` };
371
+ }
372
+ const els = arr.elements.filter((e): e is AstNode => e !== null);
373
+ const quote = inferArrayQuote(code, els);
374
+ const literal = `${quote}${escapeStringLiteralBody(content, quote)}${quote}`;
375
+
376
+ const s = new MagicString(code);
377
+ if (els.length === 0) {
378
+ s.appendLeft(arr.start + 1, literal);
379
+ } else {
380
+ const last = els[els.length - 1];
381
+ // Reuse the separator between the last two elements (keeps multi-line
382
+ // indentation); fall back to ", " for a single-element array.
383
+ const sep = els.length >= 2 ? code.slice(els[els.length - 2].end, last.start) : ", ";
384
+ s.appendLeft(last.end, `${sep}${literal}`);
385
+ }
386
+
387
+ await fs.writeFile(loc.filePath, s.toString());
388
+ // Element count changed — drop the cached registry so it reloads after the
389
+ // dev pipeline re-transforms the file.
390
+ this.registry = null;
391
+ return { success: true, filePath: loc.filePath };
392
+ } catch (err) {
393
+ return { success: false, error: String(err) };
394
+ }
395
+ }
396
+
397
+ /**
398
+ * Delete the element at `index` from an inline array literal. Refuses to remove
399
+ * the last remaining element (which would leave an empty, un-addressable array).
400
+ */
401
+ async arrayItemDelete(params: PayloadArrayItemDelete): Promise<EditResult> {
402
+ const parsed = payloadArrayItemDelete.safeParse(params);
403
+ if (!parsed.success) {
404
+ return { success: false, error: `Invalid payload: ${parsed.error.message}` };
405
+ }
406
+ const { arrayId, index } = parsed.data;
407
+ const loc = this.resolveArrayId(arrayId);
408
+ if (!loc) return { success: false, error: `Invalid arrayId: ${arrayId}` };
409
+
410
+ try {
411
+ const code = await fs.readFile(loc.filePath, "utf-8");
412
+ const arr = findArrayExpressionAt(code, loc.relativeFile, loc.offset);
413
+ if (!arr) {
414
+ return { success: false, error: `Array not found at ${arrayId}. The file may have been modified.` };
415
+ }
416
+ const els = arr.elements.filter((e): e is AstNode => e !== null);
417
+ if (index >= els.length) {
418
+ return { success: false, error: `Index ${index} out of range for array at ${arrayId}` };
419
+ }
420
+ if (els.length <= 1) {
421
+ return { success: false, error: "Cannot delete the last remaining array item" };
422
+ }
423
+
424
+ const s = new MagicString(code);
425
+ const el = els[index];
426
+ if (index > 0) {
427
+ // Remove the separator before the element + the element itself.
428
+ s.remove(els[index - 1].end, el.end);
429
+ } else {
430
+ // First element: remove it + the separator after it.
431
+ s.remove(el.start, els[1].start);
432
+ }
433
+
434
+ await fs.writeFile(loc.filePath, s.toString());
435
+ this.registry = null;
436
+ return { success: true, filePath: loc.filePath };
437
+ } catch (err) {
438
+ return { success: false, error: String(err) };
439
+ }
440
+ }
441
+
442
+ /**
443
+ * Replace the entire contents of an inline array literal with `items`, preserving
444
+ * the source's quote style and multi-line indentation. Used by the editor's
445
+ * batched ✓ apply so a whole add/delete session is a single edit + rebuild.
446
+ */
447
+ async arraySet(params: PayloadArraySet): Promise<EditResult> {
448
+ const parsed = payloadArraySet.safeParse(params);
449
+ if (!parsed.success) {
450
+ return { success: false, error: `Invalid payload: ${parsed.error.message}` };
451
+ }
452
+ const { arrayId, items } = parsed.data;
453
+ const loc = this.resolveArrayId(arrayId);
454
+ if (!loc) return { success: false, error: `Invalid arrayId: ${arrayId}` };
455
+
456
+ try {
457
+ const code = await fs.readFile(loc.filePath, "utf-8");
458
+ const arr = findArrayExpressionAt(code, loc.relativeFile, loc.offset);
459
+ if (!arr) {
460
+ return { success: false, error: `Array not found at ${arrayId}. The file may have been modified.` };
461
+ }
462
+ const els = arr.elements.filter((e): e is AstNode => e !== null);
463
+ const quote = inferArrayQuote(code, els);
464
+ const literal = (v: string) => `${quote}${escapeStringLiteralBody(v, quote)}${quote}`;
465
+
466
+ const innerStart = arr.start + 1;
467
+ const innerEnd = arr.end - 1;
468
+ const innerSrc = code.slice(innerStart, innerEnd);
469
+
470
+ let inner: string;
471
+ if (innerSrc.includes("\n")) {
472
+ // Multi-line: reuse the item indentation and the closing-bracket indent,
473
+ // and keep a trailing comma (matches the common prettier/biome style).
474
+ const itemIndent = innerSrc.match(/\n([ \t]*)\S/)?.[1] ?? " ";
475
+ const outerIndent = innerSrc.match(/\n([ \t]*)$/)?.[1] ?? "";
476
+ inner = `\n${items.map((it) => itemIndent + literal(it)).join(",\n")},\n${outerIndent}`;
477
+ } else {
478
+ inner = items.map(literal).join(", ");
479
+ }
480
+
481
+ const s = new MagicString(code);
482
+ if (innerStart === innerEnd) {
483
+ s.appendLeft(innerStart, inner);
484
+ } else {
485
+ s.overwrite(innerStart, innerEnd, inner);
486
+ }
487
+ await fs.writeFile(loc.filePath, s.toString());
488
+ this.registry = null;
489
+ return { success: true, filePath: loc.filePath };
490
+ } catch (err) {
491
+ return { success: false, error: String(err) };
492
+ }
493
+ }
494
+
495
+ /** Parse "<relativeFile>:<offset>" into an absolute path + numeric offset. */
496
+ private resolveArrayId(arrayId: string): { filePath: string; relativeFile: string; offset: number } | null {
497
+ const sep = arrayId.lastIndexOf(":");
498
+ if (sep < 0) return null;
499
+ const relativeFile = arrayId.slice(0, sep);
500
+ const offset = Number(arrayId.slice(sep + 1));
501
+ if (!relativeFile || !Number.isInteger(offset) || offset < 0) return null;
502
+ return { filePath: path.join(this.projectRoot, relativeFile), relativeFile, offset };
503
+ }
504
+
203
505
  /**
204
506
  * Apply an edit to a source file
205
507
  */
@@ -216,8 +518,10 @@ export class UpstartEditorAPI {
216
518
  let actualEnd = entry.endOffset;
217
519
 
218
520
  if (currentContent !== entry.originalContent) {
219
- // Content has shifted - try to find it by searching
220
- const searchIndex = code.indexOf(entry.originalContent);
521
+ // Content has shifted - try to find it by searching. An empty original
522
+ // can't be located by search (indexOf("") === 0 would be a false match),
523
+ // so treat it as not found rather than inserting at offset 0.
524
+ const searchIndex = entry.originalContent !== "" ? code.indexOf(entry.originalContent) : -1;
221
525
  if (searchIndex === -1) {
222
526
  return {
223
527
  success: false,
@@ -228,9 +532,14 @@ export class UpstartEditorAPI {
228
532
  actualEnd = searchIndex + entry.originalContent.length;
229
533
  }
230
534
 
231
- // Apply the edit using MagicString
535
+ // Apply the edit using MagicString. A zero-length range (e.g. an empty
536
+ // string literal "") cannot be overwritten — insert the content instead.
232
537
  const s = new MagicString(code);
233
- s.overwrite(actualStart, actualEnd, newContent);
538
+ if (actualStart === actualEnd) {
539
+ s.appendLeft(actualStart, newContent);
540
+ } else {
541
+ s.overwrite(actualStart, actualEnd, newContent);
542
+ }
234
543
 
235
544
  // Write the modified file
236
545
  await fs.writeFile(filePath, s.toString());
@@ -40,6 +40,8 @@ interface LoopContext {
40
40
  itemName: string;
41
41
  indexName: string | null;
42
42
  arrayExpr: string;
43
+ /** The `.map()` callee object AST node — used to edit inline array-literal items in place. */
44
+ arrayNode: Node | null;
43
45
  }
44
46
 
45
47
  interface I18nKeyInfo {
@@ -68,12 +70,16 @@ export interface EditableSegment {
68
70
  // Registry entry for editable elements (text, rich-text, className, mixed-text)
69
71
  export interface EditableEntry {
70
72
  file: string;
71
- type: "text" | "rich-text" | "className" | "mixed-text";
73
+ type: "text" | "rich-text" | "className" | "mixed-text" | "image";
72
74
  startOffset: number;
73
75
  endOffset: number;
74
76
  originalContent: string;
75
77
  // Only present for mixed-text entries
76
78
  segments?: EditableSegment[];
79
+ // For text entries that map to a JS string literal (e.g. an inline array item),
80
+ // the source quote character (" ' or `). When set, the server escapes the new
81
+ // content for that quote style before writing it back between the quotes.
82
+ quote?: string;
77
83
  context: { parentTag: string };
78
84
  }
79
85
 
@@ -435,9 +441,43 @@ export function transformWithOxc(code: string, filePath: string) {
435
441
  attributes.push(`data-upstart-mixed-template="${escapeProp(templateStr.trim())}"`);
436
442
  }
437
443
  }
438
- // Step 3: Non-leaf elements with visible text content (expressions, mixed content)
439
- else if (!hasI18n && hasVisibleTextContent(jsxNode)) {
440
- attributes.push('data-upstart-editable-text="false"');
444
+ // Step 2d: Inline array-literal map item <span>{item}</span> where `item` is the
445
+ // param of a .map() over an inline array of literals. Each instance edits the
446
+ // matching source string literal directly, addressed via the loop index.
447
+ else if (!hasI18n) {
448
+ const arrayItem = detectArrayLiteralItem(jsxNode, state);
449
+ if (arrayItem) {
450
+ attributes.push('data-upstart-editable-text="true"');
451
+ // "plain" mode + a data-upstart-id routes saves through editTextDirect while
452
+ // keeping the editor plain-text (no rich HTML written into the string literal).
453
+ attributes.push('data-upstart-editable-text-mode="plain"');
454
+
455
+ for (const el of arrayItem.elements) {
456
+ editableRegistry.set(el.id, {
457
+ file: state.filePath,
458
+ type: "text",
459
+ startOffset: el.startOffset,
460
+ endOffset: el.endOffset,
461
+ originalContent: el.originalContent,
462
+ quote: el.quote,
463
+ context: { parentTag: tagName || "unknown" },
464
+ });
465
+ }
466
+
467
+ // Each rendered instance resolves its own id via the loop index, so the
468
+ // edit targets the correct array element in source.
469
+ const idsArray = `[${arrayItem.elements.map((e) => JSON.stringify(e.id)).join(",")}]`;
470
+ attributes.push(`data-upstart-id={${idsArray}[${arrayItem.indexName}]}`);
471
+
472
+ // Array identity + per-instance index drive the editor's add/delete (×/+)
473
+ // controls, which add or remove elements of the inline array literal.
474
+ const arrayId = `${state.filePath}:${arrayItem.arrayStart}`;
475
+ attributes.push(`data-upstart-array-id="${arrayId}"`);
476
+ attributes.push(`data-upstart-array-index={${arrayItem.indexName}}`);
477
+ } else if (hasVisibleTextContent(jsxNode)) {
478
+ // Step 3: Non-leaf / dynamic text we cannot edit in place.
479
+ attributes.push('data-upstart-editable-text="false"');
480
+ }
441
481
  }
442
482
 
443
483
  // Track className attribute if it's a string literal
@@ -467,6 +507,36 @@ export function transformWithOxc(code: string, filePath: string) {
467
507
  attributes.push(`data-upstart-classname-id="${id}"`);
468
508
  }
469
509
 
510
+ // Track <img src="..."> when src is a string literal → makes the image
511
+ // swappable from the editor (mirrors the className tracking above).
512
+ if (tagName === "img") {
513
+ const srcAttr = opening.attributes.find(
514
+ (attr): attr is JSXAttribute =>
515
+ attr.type === "JSXAttribute" &&
516
+ attr.name.type === "JSXIdentifier" &&
517
+ attr.name.name === "src" &&
518
+ attr.value?.type === "Literal" &&
519
+ typeof (attr.value as any).value === "string",
520
+ );
521
+
522
+ if (srcAttr && srcAttr.value && hasRange(srcAttr.value)) {
523
+ const id = generateId(state.filePath, srcAttr.value);
524
+ const srcValue = (srcAttr.value as any).value as string;
525
+
526
+ // +1 / -1 to exclude the surrounding quotes, like className above
527
+ editableRegistry.set(id, {
528
+ file: state.filePath,
529
+ type: "image",
530
+ startOffset: srcAttr.value.start + 1,
531
+ endOffset: srcAttr.value.end - 1,
532
+ originalContent: srcValue,
533
+ context: { parentTag: tagName || "unknown" },
534
+ });
535
+
536
+ attributes.push(`data-upstart-image-id="${id}"`);
537
+ }
538
+ }
539
+
470
540
  // Process PascalCase components for additional tracking
471
541
  if (tagName && /^[A-Z]/.test(tagName)) {
472
542
  // File and component tracking
@@ -740,6 +810,7 @@ function detectAndPatchMapCall(node: CallExpression, state: TransformState): Loo
740
810
  itemName,
741
811
  indexName,
742
812
  arrayExpr: exprToString(node.callee.object as Expression, state.code),
813
+ arrayNode: node.callee.object as Node,
743
814
  };
744
815
  }
745
816
 
@@ -763,6 +834,7 @@ function detectAndPatchMapCall(node: CallExpression, state: TransformState): Loo
763
834
  itemName,
764
835
  indexName,
765
836
  arrayExpr,
837
+ arrayNode: node.callee.object as Node,
766
838
  };
767
839
  }
768
840
 
@@ -1313,4 +1385,82 @@ function hasVisibleTextContent(jsxElement: JSXElement): boolean {
1313
1385
  return false;
1314
1386
  }
1315
1387
 
1388
+ // Per-element data needed to make an inline array-literal map item editable.
1389
+ interface ArrayLiteralItemInfo {
1390
+ /** Loop index variable name used to pick the matching id at runtime, e.g. "__i". */
1391
+ indexName: string;
1392
+ /** Source start offset of the `[` — identifies the array for add/delete operations. */
1393
+ arrayStart: number;
1394
+ elements: {
1395
+ id: string;
1396
+ startOffset: number;
1397
+ endOffset: number;
1398
+ originalContent: string;
1399
+ /** Source quote char for string literals (" ' or `); undefined for numbers. */
1400
+ quote?: string;
1401
+ }[];
1402
+ }
1403
+
1404
+ // Detect `<span>{item}</span>` where `item` is the item param of an enclosing
1405
+ // `.map()` over an INLINE array literal of string/number literals, e.g.:
1406
+ // {["TypeScript", "React"].map((item) => <span>{item}</span>)}
1407
+ // In that case each rendered instance maps 1:1 (via the loop index) to a source
1408
+ // string literal we can edit directly. Returns null for anything not statically
1409
+ // resolvable (array from props/variable, transformed item, non-literal elements).
1410
+ function detectArrayLiteralItem(jsxElement: JSXElement, state: TransformState): ArrayLiteralItemInfo | null {
1411
+ // The element's only meaningful child must be a single {identifier} expression.
1412
+ let identName: string | null = null;
1413
+ for (const child of jsxElement.children) {
1414
+ if (child.type === "JSXText") {
1415
+ if (normalizeJSXText((child as any).value as string).length > 0) return null;
1416
+ continue;
1417
+ }
1418
+ if (child.type === "JSXExpressionContainer") {
1419
+ const expr = (child as any).expression;
1420
+ if (!expr || expr.type === "JSXEmptyExpression") continue;
1421
+ if (expr.type !== "Identifier") return null;
1422
+ if (identName !== null) return null; // more than one expression — not a plain item render
1423
+ identName = expr.name as string;
1424
+ continue;
1425
+ }
1426
+ // Any nested element/fragment/spread disqualifies.
1427
+ return null;
1428
+ }
1429
+ if (!identName) return null;
1430
+
1431
+ // Find the enclosing loop whose item param matches the identifier (innermost first).
1432
+ for (let i = state.loopStack.length - 1; i >= 0; i--) {
1433
+ const loop = state.loopStack[i];
1434
+ if (loop.itemName !== identName) continue;
1435
+ // Need a stable per-instance index and an inline array of literals.
1436
+ if (!loop.indexName) return null;
1437
+ const arr = loop.arrayNode as any;
1438
+ if (!arr || arr.type !== "ArrayExpression") return null;
1439
+
1440
+ const elements: ArrayLiteralItemInfo["elements"] = [];
1441
+ for (const el of arr.elements) {
1442
+ // Only plain string/number literals can be rewritten in place.
1443
+ if (!el || el.type !== "Literal" || !hasRange(el)) return null;
1444
+ const value = (el as any).value;
1445
+ if (typeof value !== "string" && typeof value !== "number") return null;
1446
+ // For strings, the inner range excludes the surrounding quotes so we only
1447
+ // overwrite the content (mirrors how <img src> edits work).
1448
+ const isString = typeof value === "string";
1449
+ const startOffset = isString ? el.start + 1 : el.start;
1450
+ const endOffset = isString ? el.end - 1 : el.end;
1451
+ elements.push({
1452
+ id: `${state.filePath}:${el.start}`,
1453
+ startOffset,
1454
+ endOffset,
1455
+ originalContent: state.code.slice(startOffset, endOffset),
1456
+ // The opening quote sits at el.start for string literals.
1457
+ quote: isString ? state.code[el.start] : undefined,
1458
+ });
1459
+ }
1460
+ if (elements.length === 0) return null;
1461
+ return { indexName: loop.indexName, arrayStart: arr.start, elements };
1462
+ }
1463
+ return null;
1464
+ }
1465
+
1316
1466
  export default upstartEditor.vite;