@upstart.gg/vite-plugins 0.0.139

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 (63) hide show
  1. package/dist/vite-plugin-upstart-attrs.d.ts +29 -0
  2. package/dist/vite-plugin-upstart-attrs.d.ts.map +1 -0
  3. package/dist/vite-plugin-upstart-attrs.js +286 -0
  4. package/dist/vite-plugin-upstart-attrs.js.map +1 -0
  5. package/dist/vite-plugin-upstart-editor/plugin.d.ts +15 -0
  6. package/dist/vite-plugin-upstart-editor/plugin.d.ts.map +1 -0
  7. package/dist/vite-plugin-upstart-editor/plugin.js +55 -0
  8. package/dist/vite-plugin-upstart-editor/plugin.js.map +1 -0
  9. package/dist/vite-plugin-upstart-editor/runtime/click-handler.d.ts +12 -0
  10. package/dist/vite-plugin-upstart-editor/runtime/click-handler.d.ts.map +1 -0
  11. package/dist/vite-plugin-upstart-editor/runtime/click-handler.js +57 -0
  12. package/dist/vite-plugin-upstart-editor/runtime/click-handler.js.map +1 -0
  13. package/dist/vite-plugin-upstart-editor/runtime/hover-overlay.d.ts +12 -0
  14. package/dist/vite-plugin-upstart-editor/runtime/hover-overlay.d.ts.map +1 -0
  15. package/dist/vite-plugin-upstart-editor/runtime/hover-overlay.js +91 -0
  16. package/dist/vite-plugin-upstart-editor/runtime/hover-overlay.js.map +1 -0
  17. package/dist/vite-plugin-upstart-editor/runtime/index.d.ts +22 -0
  18. package/dist/vite-plugin-upstart-editor/runtime/index.d.ts.map +1 -0
  19. package/dist/vite-plugin-upstart-editor/runtime/index.js +62 -0
  20. package/dist/vite-plugin-upstart-editor/runtime/index.js.map +1 -0
  21. package/dist/vite-plugin-upstart-editor/runtime/text-editor.d.ts +15 -0
  22. package/dist/vite-plugin-upstart-editor/runtime/text-editor.d.ts.map +1 -0
  23. package/dist/vite-plugin-upstart-editor/runtime/text-editor.js +292 -0
  24. package/dist/vite-plugin-upstart-editor/runtime/text-editor.js.map +1 -0
  25. package/dist/vite-plugin-upstart-editor/runtime/types.d.ts +126 -0
  26. package/dist/vite-plugin-upstart-editor/runtime/types.d.ts.map +1 -0
  27. package/dist/vite-plugin-upstart-editor/runtime/types.js +1 -0
  28. package/dist/vite-plugin-upstart-editor/runtime/utils.d.ts +15 -0
  29. package/dist/vite-plugin-upstart-editor/runtime/utils.d.ts.map +1 -0
  30. package/dist/vite-plugin-upstart-editor/runtime/utils.js +26 -0
  31. package/dist/vite-plugin-upstart-editor/runtime/utils.js.map +1 -0
  32. package/dist/vite-plugin-upstart-routes.d.ts +20 -0
  33. package/dist/vite-plugin-upstart-routes.d.ts.map +1 -0
  34. package/dist/vite-plugin-upstart-routes.js +143 -0
  35. package/dist/vite-plugin-upstart-routes.js.map +1 -0
  36. package/dist/vite-plugin-upstart-theme.d.ts +22 -0
  37. package/dist/vite-plugin-upstart-theme.d.ts.map +1 -0
  38. package/dist/vite-plugin-upstart-theme.js +180 -0
  39. package/dist/vite-plugin-upstart-theme.js.map +1 -0
  40. package/package.json +63 -0
  41. package/src/tests/fixtures/routes/default-layout.tsx +10 -0
  42. package/src/tests/fixtures/routes/dynamic-route.tsx +10 -0
  43. package/src/tests/fixtures/routes/missing-attributes.tsx +8 -0
  44. package/src/tests/fixtures/routes/missing-path.tsx +9 -0
  45. package/src/tests/fixtures/routes/valid-full.tsx +15 -0
  46. package/src/tests/fixtures/routes/valid-minimal.tsx +10 -0
  47. package/src/tests/fixtures/routes/with-comments.tsx +12 -0
  48. package/src/tests/fixtures/routes/with-nested-objects.tsx +15 -0
  49. package/src/tests/upstart-editor-api.test.ts +367 -0
  50. package/src/tests/vite-plugin-upstart-attrs.test.ts +957 -0
  51. package/src/tests/vite-plugin-upstart-editor.test.ts +81 -0
  52. package/src/tests/vite-plugin-upstart-routes.test.ts +220 -0
  53. package/src/upstart-editor-api.ts +204 -0
  54. package/src/vite-plugin-upstart-attrs.ts +616 -0
  55. package/src/vite-plugin-upstart-editor/PLAN.md +1391 -0
  56. package/src/vite-plugin-upstart-editor/plugin.ts +73 -0
  57. package/src/vite-plugin-upstart-editor/runtime/click-handler.ts +80 -0
  58. package/src/vite-plugin-upstart-editor/runtime/hover-overlay.ts +135 -0
  59. package/src/vite-plugin-upstart-editor/runtime/index.ts +90 -0
  60. package/src/vite-plugin-upstart-editor/runtime/text-editor.ts +401 -0
  61. package/src/vite-plugin-upstart-editor/runtime/types.ts +120 -0
  62. package/src/vite-plugin-upstart-editor/runtime/utils.ts +34 -0
  63. package/src/vite-plugin-upstart-theme.ts +321 -0
@@ -0,0 +1,616 @@
1
+ import { createUnplugin } from "unplugin";
2
+ import { parseSync } from "oxc-parser";
3
+ import { walk } from "zimmerframe";
4
+ import MagicString from "magic-string";
5
+ import path from "path";
6
+ import type {
7
+ Program,
8
+ Node,
9
+ JSXElement,
10
+ JSXOpeningElement,
11
+ JSXAttribute,
12
+ Expression,
13
+ MemberExpression,
14
+ CallExpression,
15
+ } from "estree-jsx";
16
+
17
+ interface Options {
18
+ enabled: boolean;
19
+ emitRegistry?: boolean;
20
+ }
21
+
22
+ type NodeWithRange = Node & { start: number; end: number };
23
+
24
+ function hasRange(node: any): node is NodeWithRange {
25
+ return node && typeof node.start === "number" && typeof node.end === "number";
26
+ }
27
+
28
+ // Fast, stable hash function (djb2 variant)
29
+ // Produces a short hex string that's stable across rebuilds
30
+ function hashContent(content: string): string {
31
+ let hash = 5381;
32
+ for (let i = 0; i < content.length; i++) {
33
+ hash = ((hash << 5) + hash) ^ content.charCodeAt(i);
34
+ }
35
+ // Convert to unsigned 32-bit and then to hex
36
+ return (hash >>> 0).toString(16);
37
+ }
38
+
39
+ interface LoopContext {
40
+ itemName: string;
41
+ indexName: string | null;
42
+ arrayExpr: string;
43
+ }
44
+
45
+ // Registry entry for editable elements (text and className)
46
+ export interface EditableEntry {
47
+ file: string;
48
+ type: "text" | "className";
49
+ startOffset: number;
50
+ endOffset: number;
51
+ originalContent: string;
52
+ context: { parentTag: string };
53
+ }
54
+
55
+ // Module-level registry (collected across all files during build)
56
+ const editableRegistry = new Map<string, EditableEntry>();
57
+
58
+ // Generate stable ID for editable elements
59
+ // Replace the counter-based ID generation with position-based
60
+ function generateId(filePath: string, node: NodeWithRange): string {
61
+ // Use file path + start position for a stable, deterministic ID
62
+ return `${filePath}:${node.start}`;
63
+ }
64
+
65
+ // Export for testing - get a copy of the current registry
66
+ export function getRegistry(): Record<string, EditableEntry> {
67
+ return Object.fromEntries(editableRegistry);
68
+ }
69
+
70
+ // Export for testing - clear registry and counters
71
+ export function clearRegistry(): void {
72
+ editableRegistry.clear();
73
+ }
74
+
75
+ interface TransformState {
76
+ filePath: string;
77
+ code: string;
78
+ s: MagicString;
79
+ loopStack: LoopContext[];
80
+ }
81
+
82
+ export const upstartEditor = createUnplugin<Options>((options) => {
83
+ if (!options.enabled) {
84
+ return { name: "upstart-editor-disabled" };
85
+ }
86
+
87
+ const emitRegistry = options.emitRegistry ?? true;
88
+ let root = process.cwd();
89
+
90
+ return {
91
+ name: "upstart-editor",
92
+ enforce: "pre",
93
+
94
+ vite: {
95
+ configResolved(config) {
96
+ root = config.root;
97
+ },
98
+ generateBundle() {
99
+ if (!emitRegistry || editableRegistry.size === 0) {
100
+ return;
101
+ }
102
+
103
+ const registry = {
104
+ version: 1,
105
+ generatedAt: new Date().toISOString(),
106
+ elements: Object.fromEntries(editableRegistry),
107
+ };
108
+
109
+ this.emitFile({
110
+ type: "asset",
111
+ fileName: "upstart-registry.json",
112
+ source: JSON.stringify(registry, null, 2),
113
+ });
114
+
115
+ // Clear for next build
116
+ editableRegistry.clear();
117
+ },
118
+ },
119
+
120
+ transformInclude(id) {
121
+ return /\.(tsx|jsx)$/.test(id) && !id.includes("node_modules");
122
+ },
123
+
124
+ transform(code, id) {
125
+ // Fast path: skip files without JSX
126
+ if (!code.includes("<")) {
127
+ return null;
128
+ }
129
+
130
+ try {
131
+ const relativePath = path.relative(root, id);
132
+ const result = transformWithOxc(code, relativePath);
133
+
134
+ if (!result) {
135
+ return null;
136
+ }
137
+
138
+ return {
139
+ code: result.code,
140
+ map: result.map,
141
+ };
142
+ } catch (error) {
143
+ console.error(`Error transforming ${id}:`, error);
144
+ return null;
145
+ }
146
+ },
147
+ };
148
+ });
149
+
150
+ export function transformWithOxc(code: string, filePath: string) {
151
+ // Parse with oxc (super fast!)
152
+ const ast = parseSync(filePath, code, {
153
+ sourceType: "module",
154
+ });
155
+
156
+ if (!ast.program) {
157
+ return null;
158
+ }
159
+
160
+ const s = new MagicString(code);
161
+ const state: TransformState = {
162
+ filePath,
163
+ code,
164
+ s,
165
+ loopStack: [],
166
+ };
167
+
168
+ let modified = false;
169
+
170
+ // Use zimmerframe to walk and transform the AST
171
+ walk(ast.program as Program, state, {
172
+ _(node: Node, { state, next }: { state: TransformState; next: () => void }) {
173
+ // Handle .map() calls to track loops (must be before JSXElement)
174
+ if (node.type === "CallExpression") {
175
+ const loopInfo = detectMapCall(node as CallExpression, state.code);
176
+
177
+ if (loopInfo) {
178
+ // Push loop context
179
+ state.loopStack.push(loopInfo);
180
+
181
+ // Continue walking into the callback
182
+ next();
183
+
184
+ // Pop loop context after traversing
185
+ state.loopStack.pop();
186
+ return;
187
+ }
188
+ }
189
+
190
+ // Handle JSX elements
191
+ if (node.type === "JSXElement") {
192
+ const jsxNode = node as JSXElement;
193
+ const opening = jsxNode.openingElement;
194
+ const tagName = getJSXElementName(opening);
195
+ const insertPos = getAttributeInsertPosition(opening, state.code);
196
+
197
+ // Track attributes to inject
198
+ const attributes: string[] = [];
199
+
200
+ // Compute stable hash from the element's source code (includes all children)
201
+ // This hash changes if ANY part of the element or its children change
202
+ if (hasRange(jsxNode)) {
203
+ const elementSource = state.code.slice(jsxNode.start, jsxNode.end);
204
+ const hash = hashContent(elementSource);
205
+ attributes.push(`data-upstart-hash="${hash}"`);
206
+ }
207
+
208
+ // Check for text leaf elements (any element with only static text)
209
+ if (isTextLeafElement(jsxNode)) {
210
+ attributes.push('data-upstart-editable-text="true"');
211
+
212
+ // Find the actual JSXText node to track its location
213
+ const textChild = jsxNode.children.find((c) => c.type === "JSXText" && (c as any).value?.trim());
214
+
215
+ if (textChild && hasRange(textChild)) {
216
+ const id = generateId(state.filePath, textChild);
217
+ const textValue = (textChild as any).value as string;
218
+
219
+ // Calculate trimmed offsets (exclude leading/trailing whitespace)
220
+ const trimmedStart = textChild.start + (textValue.length - textValue.trimStart().length);
221
+ const trimmedEnd = textChild.end - (textValue.length - textValue.trimEnd().length);
222
+
223
+ editableRegistry.set(id, {
224
+ file: state.filePath,
225
+ type: "text",
226
+ startOffset: trimmedStart,
227
+ endOffset: trimmedEnd,
228
+ originalContent: textValue.trim(),
229
+ context: { parentTag: tagName || "unknown" },
230
+ });
231
+
232
+ attributes.push(`data-upstart-id="${id}"`);
233
+ }
234
+ }
235
+
236
+ // Track className attribute if it's a string literal
237
+ const classNameAttr = opening.attributes.find(
238
+ (attr): attr is JSXAttribute =>
239
+ attr.type === "JSXAttribute" &&
240
+ attr.name.type === "JSXIdentifier" &&
241
+ attr.name.name === "className" &&
242
+ attr.value?.type === "Literal" &&
243
+ typeof (attr.value as any).value === "string",
244
+ );
245
+
246
+ if (classNameAttr && classNameAttr.value && hasRange(classNameAttr.value)) {
247
+ const id = generateId(state.filePath, classNameAttr.value);
248
+ const classValue = (classNameAttr.value as any).value as string;
249
+
250
+ // +1 and -1 to exclude the quotes
251
+ editableRegistry.set(id, {
252
+ file: state.filePath,
253
+ type: "className",
254
+ startOffset: classNameAttr.value.start + 1,
255
+ endOffset: classNameAttr.value.end - 1,
256
+ originalContent: classValue,
257
+ context: { parentTag: tagName || "unknown" },
258
+ });
259
+
260
+ attributes.push(`data-upstart-classname-id="${id}"`);
261
+ }
262
+
263
+ // Process PascalCase components for additional tracking
264
+ if (tagName && /^[A-Z]/.test(tagName)) {
265
+ // File and component tracking
266
+ attributes.push(`data-upstart-file="${escapeProp(state.filePath)}"`);
267
+ attributes.push(`data-upstart-component="${escapeProp(tagName)}"`);
268
+
269
+ // Loop context tracking
270
+ if (state.loopStack.length > 0) {
271
+ const loop = state.loopStack[state.loopStack.length - 1];
272
+ attributes.push(`data-upstart-loop-item="${escapeProp(loop.itemName)}"`);
273
+ if (loop.indexName) {
274
+ attributes.push(`data-upstart-loop-index={${loop.indexName.toLowerCase()}}`);
275
+ }
276
+ attributes.push(`data-upstart-loop-array="${escapeProp(loop.arrayExpr)}"`);
277
+ }
278
+
279
+ // Analyze each prop for data bindings
280
+ for (const attr of opening.attributes) {
281
+ if (attr.type !== "JSXAttribute" || attr.name.type !== "JSXIdentifier") {
282
+ continue;
283
+ }
284
+
285
+ const propName = attr.name.name;
286
+ const binding = analyzeBinding(attr.value, state.code);
287
+
288
+ if (binding) {
289
+ attributes.push(`data-upstart-prop-${propName.toLowerCase()}="${escapeProp(binding.path)}"`);
290
+
291
+ if (binding.datasource) {
292
+ attributes.push(
293
+ `data-upstart-datasource-${propName.toLowerCase()}="${escapeProp(binding.datasource)}"`,
294
+ );
295
+ }
296
+
297
+ if (binding.recordId) {
298
+ attributes.push(`data-upstart-record-id-${propName.toLowerCase()}={${binding.recordId}}`);
299
+ }
300
+
301
+ // Track conditional expressions
302
+ if (binding.isConditional) {
303
+ attributes.push(`data-upstart-conditional-${propName.toLowerCase()}="true"`);
304
+ }
305
+ }
306
+ }
307
+ }
308
+
309
+ // Inject attributes if any
310
+ if (insertPos !== -1 && attributes.length > 0) {
311
+ const attrString = " " + attributes.join(" ");
312
+ state.s.appendLeft(insertPos, attrString);
313
+ modified = true;
314
+ }
315
+ }
316
+
317
+ next();
318
+ },
319
+ });
320
+
321
+ if (!modified) {
322
+ return null;
323
+ }
324
+
325
+ return {
326
+ code: s.toString(),
327
+ map: s.generateMap({ hires: true }),
328
+ };
329
+ }
330
+
331
+ // Helper: Get JSX element name
332
+ function getJSXElementName(opening: JSXOpeningElement): string | null {
333
+ if (opening.name.type === "JSXIdentifier") {
334
+ return opening.name.name;
335
+ }
336
+
337
+ // Handle JSXMemberExpression like <Foo.Bar>
338
+ if (opening.name.type === "JSXMemberExpression") {
339
+ let current = opening.name;
340
+ while (current.property) {
341
+ if (current.property.type === "JSXIdentifier") {
342
+ return current.property.name;
343
+ }
344
+ if (current.type === "JSXMemberExpression") {
345
+ current = current.property as any;
346
+ } else {
347
+ break;
348
+ }
349
+ }
350
+ }
351
+
352
+ return null;
353
+ }
354
+
355
+ // Helper: Find where to insert attributes in JSX opening tag
356
+ function getAttributeInsertPosition(opening: JSXOpeningElement, code: string): number {
357
+ // If there are existing attributes, insert before the first one
358
+ if (opening.attributes.length > 0) {
359
+ const firstAttr = opening.attributes[0];
360
+ if (hasRange(firstAttr)) {
361
+ return firstAttr.start;
362
+ }
363
+ }
364
+
365
+ // Otherwise, insert after the tag name
366
+ if (opening.name.type === "JSXIdentifier" && hasRange(opening.name)) {
367
+ return opening.name.end;
368
+ }
369
+
370
+ if (opening.name.type === "JSXMemberExpression" && hasRange(opening.name)) {
371
+ return opening.name.end;
372
+ }
373
+
374
+ return -1;
375
+ }
376
+
377
+ // Helper: Analyze a prop value to extract data binding
378
+ function analyzeBinding(
379
+ value: JSXAttribute["value"],
380
+ code: string,
381
+ ): {
382
+ path: string;
383
+ datasource?: string;
384
+ recordId?: string;
385
+ isConditional?: boolean;
386
+ } | null {
387
+ if (!value) {
388
+ return null;
389
+ }
390
+
391
+ if (value.type === "JSXExpressionContainer") {
392
+ const expr = value.expression;
393
+
394
+ if (expr.type === "JSXEmptyExpression") {
395
+ return null;
396
+ }
397
+
398
+ return analyzeExpression(expr, code);
399
+ }
400
+
401
+ return null;
402
+ }
403
+
404
+ // Helper: Analyze any expression to extract binding info
405
+ function analyzeExpression(
406
+ expr: Expression,
407
+ code: string,
408
+ ): {
409
+ path: string;
410
+ datasource?: string;
411
+ recordId?: string;
412
+ isConditional?: boolean;
413
+ } | null {
414
+ // Handle member expressions: user.name, product.price, etc.
415
+ if (expr.type === "MemberExpression") {
416
+ const path = exprToString(expr, code);
417
+ const datasource = extractDatasource(expr);
418
+ const recordId = extractRecordId(expr);
419
+
420
+ return { path, datasource, recordId };
421
+ }
422
+
423
+ // Handle identifiers: userName, price, etc.
424
+ if (expr.type === "Identifier") {
425
+ return { path: expr.name };
426
+ }
427
+
428
+ // Handle conditional expressions: condition ? value1 : value2
429
+ if (expr.type === "ConditionalExpression") {
430
+ // Try to extract from the consequent
431
+ const consequent = analyzeExpression(expr.consequent, code);
432
+ if (consequent) {
433
+ return {
434
+ ...consequent,
435
+ isConditional: true,
436
+ path: exprToString(expr, code),
437
+ };
438
+ }
439
+ }
440
+
441
+ // Handle logical expressions: value1 && value2, value1 || value2
442
+ if (expr.type === "LogicalExpression") {
443
+ const right = analyzeExpression(expr.right, code);
444
+ if (right) {
445
+ return {
446
+ ...right,
447
+ isConditional: true,
448
+ path: exprToString(expr, code),
449
+ };
450
+ }
451
+ }
452
+
453
+ // For other complex expressions, just return the path
454
+ const exprWithRange = expr as any;
455
+ if (exprWithRange.start !== undefined && exprWithRange.end !== undefined) {
456
+ return {
457
+ path: code.slice(exprWithRange.start, exprWithRange.end),
458
+ };
459
+ }
460
+
461
+ return null;
462
+ }
463
+
464
+ // Helper: Convert expression AST to string
465
+ function exprToString(expr: Expression, code: string): string {
466
+ // Use the source range to get the actual code
467
+ const exprWithRange = expr as any;
468
+ if (exprWithRange.start !== undefined && exprWithRange.end !== undefined) {
469
+ return code.slice(exprWithRange.start, exprWithRange.end);
470
+ }
471
+
472
+ // Fallback: reconstruct from AST
473
+ if (expr.type === "Identifier") {
474
+ return expr.name;
475
+ }
476
+
477
+ if (expr.type === "MemberExpression") {
478
+ const obj = exprToString(expr.object as Expression, code);
479
+ const prop =
480
+ expr.property.type === "Identifier" && !expr.computed
481
+ ? expr.property.name
482
+ : exprToString(expr.property as Expression, code);
483
+ return expr.computed ? `${obj}[${prop}]` : `${obj}.${prop}`;
484
+ }
485
+
486
+ if (expr.type === "ConditionalExpression") {
487
+ const test = exprToString(expr.test, code);
488
+ const consequent = exprToString(expr.consequent, code);
489
+ const alternate = exprToString(expr.alternate, code);
490
+ return `${test} ? ${consequent} : ${alternate}`;
491
+ }
492
+
493
+ if (expr.type === "LogicalExpression") {
494
+ const left = exprToString(expr.left, code);
495
+ const right = exprToString(expr.right, code);
496
+ return `${left} ${expr.operator} ${right}`;
497
+ }
498
+
499
+ return "";
500
+ }
501
+
502
+ // Helper: Extract datasource name from member expression
503
+ function extractDatasource(expr: MemberExpression): string | undefined {
504
+ let current: Expression = expr.object as Expression;
505
+
506
+ // Traverse to the root object
507
+ while (current.type === "MemberExpression") {
508
+ current = current.object as Expression;
509
+ }
510
+
511
+ if (current.type === "Identifier") {
512
+ return current.name;
513
+ }
514
+
515
+ return undefined;
516
+ }
517
+
518
+ // Helper: Extract record ID reference
519
+ function extractRecordId(expr: MemberExpression): string | undefined {
520
+ const obj = expr.object;
521
+
522
+ if (obj.type === "Identifier") {
523
+ // Assume the ID field is `${object}.id`
524
+ return `${obj.name}.id`;
525
+ }
526
+
527
+ if (obj.type === "MemberExpression") {
528
+ // For nested objects like user.profile.id
529
+ const datasource = extractDatasource(obj);
530
+ if (datasource) {
531
+ return `${datasource}.id`;
532
+ }
533
+ }
534
+
535
+ return undefined;
536
+ }
537
+
538
+ // Helper: Detect .map() calls and extract loop context
539
+ function detectMapCall(node: CallExpression, code: string): LoopContext | null {
540
+ // Check if this is a .map() call
541
+ if (
542
+ node.callee.type !== "MemberExpression" ||
543
+ node.callee.property.type !== "Identifier" ||
544
+ node.callee.property.name !== "map"
545
+ ) {
546
+ return null;
547
+ }
548
+
549
+ const callback = node.arguments[0];
550
+
551
+ if (!callback) {
552
+ return null;
553
+ }
554
+
555
+ // Check if callback is an arrow function or function expression
556
+ if (callback.type !== "ArrowFunctionExpression" && callback.type !== "FunctionExpression") {
557
+ return null;
558
+ }
559
+
560
+ const params = callback.params;
561
+ const itemParam = params[0];
562
+ const indexParam = params[1];
563
+
564
+ if (!itemParam) {
565
+ return null;
566
+ }
567
+
568
+ // Extract parameter names
569
+ const itemName = itemParam.type === "Identifier" ? itemParam.name : "item";
570
+ const indexName = indexParam?.type === "Identifier" ? indexParam.name : null;
571
+
572
+ // Get the array expression
573
+ const arrayExpr = exprToString(node.callee.object as Expression, code);
574
+
575
+ return {
576
+ itemName,
577
+ indexName,
578
+ arrayExpr,
579
+ };
580
+ }
581
+
582
+ // Helper: Escape prop values for JSX attributes
583
+ function escapeProp(value: string): string {
584
+ return value
585
+ .replace(/&/g, "&amp;")
586
+ .replace(/"/g, "&quot;")
587
+ .replace(/'/g, "&apos;")
588
+ .replace(/</g, "&lt;")
589
+ .replace(/>/g, "&gt;");
590
+ }
591
+
592
+ // Helper: Check if element is a "leaf" with only static text (no nested elements or expressions)
593
+ function isTextLeafElement(jsxElement: JSXElement): boolean {
594
+ let hasText = false;
595
+
596
+ for (const child of jsxElement.children) {
597
+ if (child.type === "JSXText") {
598
+ const textValue = (child as any).value;
599
+ if (textValue?.trim()) {
600
+ hasText = true;
601
+ }
602
+ } else if (
603
+ child.type === "JSXElement" ||
604
+ child.type === "JSXFragment" ||
605
+ child.type === "JSXExpressionContainer" ||
606
+ child.type === "JSXSpreadChild"
607
+ ) {
608
+ // Has non-text children, not a leaf element
609
+ return false;
610
+ }
611
+ }
612
+
613
+ return hasText;
614
+ }
615
+
616
+ export default upstartEditor.vite;