@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.
- package/dist/upstart-editor-api.d.ts +59 -1
- package/dist/upstart-editor-api.d.ts.map +1 -1
- package/dist/upstart-editor-api.js +273 -4
- package/dist/upstart-editor-api.js.map +1 -1
- package/dist/vite-plugin-upstart-attrs.d.ts +2 -1
- package/dist/vite-plugin-upstart-attrs.d.ts.map +1 -1
- package/dist/vite-plugin-upstart-attrs.js +91 -4
- package/dist/vite-plugin-upstart-attrs.js.map +1 -1
- package/dist/vite-plugin-upstart-editor/runtime/array-controls.js +342 -0
- package/dist/vite-plugin-upstart-editor/runtime/array-controls.js.map +1 -0
- package/dist/vite-plugin-upstart-editor/runtime/click-handler.d.ts.map +1 -1
- package/dist/vite-plugin-upstart-editor/runtime/click-handler.js +29 -2
- package/dist/vite-plugin-upstart-editor/runtime/click-handler.js.map +1 -1
- package/dist/vite-plugin-upstart-editor/runtime/form-guard.js +80 -0
- package/dist/vite-plugin-upstart-editor/runtime/form-guard.js.map +1 -0
- package/dist/vite-plugin-upstart-editor/runtime/hover-overlay.d.ts.map +1 -1
- package/dist/vite-plugin-upstart-editor/runtime/hover-overlay.js +2 -1
- package/dist/vite-plugin-upstart-editor/runtime/hover-overlay.js.map +1 -1
- package/dist/vite-plugin-upstart-editor/runtime/index.d.ts.map +1 -1
- package/dist/vite-plugin-upstart-editor/runtime/index.js +26 -4
- package/dist/vite-plugin-upstart-editor/runtime/index.js.map +1 -1
- package/dist/vite-plugin-upstart-editor/runtime/text-editor.d.ts.map +1 -1
- package/dist/vite-plugin-upstart-editor/runtime/text-editor.js +40 -36
- package/dist/vite-plugin-upstart-editor/runtime/text-editor.js.map +1 -1
- package/dist/vite-plugin-upstart-editor/runtime/types.d.ts +20 -4
- package/dist/vite-plugin-upstart-editor/runtime/types.d.ts.map +1 -1
- package/package.json +3 -3
- package/src/tests/vite-plugin-upstart-attrs.test.ts +412 -0
- package/src/upstart-editor-api.ts +314 -5
- package/src/vite-plugin-upstart-attrs.ts +154 -4
- package/src/vite-plugin-upstart-editor/runtime/array-controls.ts +478 -0
- package/src/vite-plugin-upstart-editor/runtime/click-handler.ts +43 -0
- package/src/vite-plugin-upstart-editor/runtime/form-guard.ts +121 -0
- package/src/vite-plugin-upstart-editor/runtime/hover-overlay.ts +6 -1
- package/src/vite-plugin-upstart-editor/runtime/index.ts +20 -4
- package/src/vite-plugin-upstart-editor/runtime/text-editor.ts +49 -58
- 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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
439
|
-
|
|
440
|
-
|
|
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;
|