@upstart.gg/vite-plugins 0.0.37

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