@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,1189 @@
1
+ import { describe, test, expect, beforeEach } from "vitest";
2
+ import { transformWithOxc, getRegistry, clearRegistry } from "../vite-plugin-upstart-attrs";
3
+
4
+ // Helper to run transformation
5
+ function transform(code: string, filePath = "test.tsx"): string | null {
6
+ const result = transformWithOxc(code, filePath);
7
+ return result ? result.code : null;
8
+ }
9
+
10
+ describe("upstart-editor-vite-plugin", () => {
11
+ describe("JSX Element Attribute Injection", () => {
12
+ test("should inject data attributes into PascalCase components", () => {
13
+ const code = `
14
+ export default function App() {
15
+ return <MyComponent foo="bar" />;
16
+ }
17
+ `;
18
+
19
+ const result = transform(code);
20
+
21
+ expect(result).toContain('data-upstart-file="test.tsx"');
22
+ expect(result).toContain('data-upstart-component="MyComponent"');
23
+ });
24
+
25
+ test("should not inject attributes into lowercase HTML elements", () => {
26
+ const code = `
27
+ export default function App() {
28
+ return <div><MyComponent /></div>;
29
+ }
30
+ `;
31
+
32
+ const result = transform(code);
33
+
34
+ // div should not have data-upstart-component, only MyComponent should
35
+ expect(result).not.toContain('data-upstart-component="div"');
36
+ expect(result).toContain('data-upstart-component="MyComponent"');
37
+ });
38
+
39
+ test("should track prop bindings with member expressions", () => {
40
+ const code = `
41
+ export default function App() {
42
+ const user = { name: "Alice" };
43
+ return <MyComponent name={user.name} />;
44
+ }
45
+ `;
46
+
47
+ const result = transform(code);
48
+
49
+ expect(result).toContain('data-upstart-prop-name="user.name"');
50
+ expect(result).toContain('data-upstart-datasource-name="user"');
51
+ });
52
+
53
+ test("should track prop bindings with identifiers", () => {
54
+ const code = `
55
+ export default function App() {
56
+ const userName = "Alice";
57
+ return <MyComponent name={userName} />;
58
+ }
59
+ `;
60
+
61
+ const result = transform(code);
62
+
63
+ expect(result).toContain('data-upstart-prop-name="userName"');
64
+ });
65
+
66
+ test("should track record IDs for datasource bindings", () => {
67
+ const code = `
68
+ export default function App() {
69
+ const user = { id: 1, name: "Alice" };
70
+ return <MyComponent name={user.name} />;
71
+ }
72
+ `;
73
+
74
+ const result = transform(code);
75
+
76
+ expect(result).toContain("data-upstart-record-id-name={user.id}");
77
+ });
78
+
79
+ test("should handle conditional prop expressions", () => {
80
+ const code = `
81
+ export default function App() {
82
+ const isActive = true;
83
+ const user = { name: "Alice" };
84
+ return <MyComponent name={isActive ? user.name : "Guest"} />;
85
+ }
86
+ `;
87
+
88
+ const result = transform(code);
89
+
90
+ expect(result).toContain('data-upstart-conditional-name="true"');
91
+ });
92
+
93
+ test("should handle logical prop expressions", () => {
94
+ const code = `
95
+ export default function App() {
96
+ const user = { name: "Alice" };
97
+ return <MyComponent name={user && user.name} />;
98
+ }
99
+ `;
100
+
101
+ const result = transform(code);
102
+
103
+ expect(result).toContain('data-upstart-conditional-name="true"');
104
+ });
105
+ });
106
+
107
+ describe("Static Text Detection", () => {
108
+ test("should mark leaf elements with static text as editable", () => {
109
+ const code = `
110
+ export default function App() {
111
+ return <div>Hello World</div>;
112
+ }
113
+ `;
114
+
115
+ const result = transform(code);
116
+
117
+ expect(result).toContain('data-upstart-editable-text="true"');
118
+ });
119
+
120
+ test("should mark span with static text as editable", () => {
121
+ const code = `
122
+ export default function App() {
123
+ return <span>Click here</span>;
124
+ }
125
+ `;
126
+
127
+ const result = transform(code);
128
+
129
+ expect(result).toContain('data-upstart-editable-text="true"');
130
+ });
131
+
132
+ test("should mark PascalCase component with static text as editable AND track component", () => {
133
+ const code = `
134
+ export default function App() {
135
+ return <Button>Submit</Button>;
136
+ }
137
+ `;
138
+
139
+ const result = transform(code);
140
+
141
+ // Should have both editable text AND component tracking
142
+ expect(result).toContain('data-upstart-editable-text="true"');
143
+ expect(result).toContain('data-upstart-component="Button"');
144
+ });
145
+
146
+ test("should NOT mark elements with nested elements", () => {
147
+ const code = `
148
+ export default function App() {
149
+ return <div><span>nested</span></div>;
150
+ }
151
+ `;
152
+
153
+ const result = transform(code);
154
+
155
+ // The outer div should NOT be marked (has nested element)
156
+ // But the inner span SHOULD be marked (it's a leaf with text)
157
+ expect(result).toContain('data-upstart-editable-text="true"');
158
+ // Count occurrences - should only be 1 (for the span)
159
+ const matches = result?.match(/data-upstart-editable-text="true"/g);
160
+ expect(matches?.length).toBe(1);
161
+ });
162
+
163
+ test("should NOT mark elements with dynamic expressions", () => {
164
+ const code = `
165
+ export default function App() {
166
+ const name = "World";
167
+ return <div>{name}</div>;
168
+ }
169
+ `;
170
+
171
+ const result = transform(code);
172
+
173
+ // Should NOT have editable-text (has dynamic expression)
174
+ // But SHOULD have hash (all elements get hashes)
175
+ expect(result).not.toContain('data-upstart-editable-text');
176
+ expect(result).toContain('data-upstart-hash');
177
+ });
178
+
179
+ test("should NOT mark elements with mixed content", () => {
180
+ const code = `
181
+ export default function App() {
182
+ return <div>Text <b>bold</b></div>;
183
+ }
184
+ `;
185
+
186
+ const result = transform(code);
187
+
188
+ // The outer div should NOT be marked (has nested element)
189
+ // But the inner b SHOULD be marked
190
+ const matches = result?.match(/data-upstart-editable-text="true"/g);
191
+ expect(matches?.length).toBe(1);
192
+ });
193
+
194
+ test("should NOT mark empty elements as editable", () => {
195
+ const code = `
196
+ export default function App() {
197
+ return <div></div>;
198
+ }
199
+ `;
200
+
201
+ const result = transform(code);
202
+
203
+ // Should NOT have editable-text (empty)
204
+ // But SHOULD have hash
205
+ expect(result).not.toContain('data-upstart-editable-text');
206
+ expect(result).toContain('data-upstart-hash');
207
+ });
208
+
209
+ test("should NOT mark whitespace-only elements as editable", () => {
210
+ const code = `
211
+ export default function App() {
212
+ return <div> </div>;
213
+ }
214
+ `;
215
+
216
+ const result = transform(code);
217
+
218
+ // Should NOT have editable-text (whitespace only)
219
+ // But SHOULD have hash
220
+ expect(result).not.toContain('data-upstart-editable-text');
221
+ expect(result).toContain('data-upstart-hash');
222
+ });
223
+
224
+ test("should handle multiple text leaf elements", () => {
225
+ const code = `
226
+ export default function App() {
227
+ return (
228
+ <div>
229
+ <h1>Title</h1>
230
+ <p>Description</p>
231
+ </div>
232
+ );
233
+ }
234
+ `;
235
+
236
+ const result = transform(code);
237
+
238
+ // Both h1 and p should be marked
239
+ const matches = result?.match(/data-upstart-editable-text="true"/g);
240
+ expect(matches?.length).toBe(2);
241
+ });
242
+ });
243
+
244
+ describe("Content Hash", () => {
245
+ test("should add data-upstart-hash to elements", () => {
246
+ const code = `
247
+ export default function App() {
248
+ return <div>Hello World</div>;
249
+ }
250
+ `;
251
+
252
+ const result = transform(code);
253
+
254
+ expect(result).toMatch(/data-upstart-hash="[a-f0-9]+"/);
255
+ });
256
+
257
+ test("should produce stable hash for same content", () => {
258
+ const code = `
259
+ export default function App() {
260
+ return <div>Hello World</div>;
261
+ }
262
+ `;
263
+
264
+ const result1 = transform(code);
265
+ const result2 = transform(code);
266
+
267
+ // Extract the hash values
268
+ const hash1 = result1?.match(/data-upstart-hash="([a-f0-9]+)"/)?.[1];
269
+ const hash2 = result2?.match(/data-upstart-hash="([a-f0-9]+)"/)?.[1];
270
+
271
+ expect(hash1).toBe(hash2);
272
+ });
273
+
274
+ test("should produce different hash for different content", () => {
275
+ const code1 = `
276
+ export default function App() {
277
+ return <div>Hello</div>;
278
+ }
279
+ `;
280
+
281
+ const code2 = `
282
+ export default function App() {
283
+ return <div>World</div>;
284
+ }
285
+ `;
286
+
287
+ const result1 = transform(code1);
288
+ const result2 = transform(code2);
289
+
290
+ const hash1 = result1?.match(/data-upstart-hash="([a-f0-9]+)"/)?.[1];
291
+ const hash2 = result2?.match(/data-upstart-hash="([a-f0-9]+)"/)?.[1];
292
+
293
+ expect(hash1).not.toBe(hash2);
294
+ });
295
+
296
+ test("should change hash when children change", () => {
297
+ const code1 = `
298
+ export default function App() {
299
+ return <div><span>Original</span></div>;
300
+ }
301
+ `;
302
+
303
+ const code2 = `
304
+ export default function App() {
305
+ return <div><span>Modified</span></div>;
306
+ }
307
+ `;
308
+
309
+ const result1 = transform(code1);
310
+ const result2 = transform(code2);
311
+
312
+ // Get the outer div's hash (first match)
313
+ const divHash1 = result1?.match(/data-upstart-hash="([a-f0-9]+)"/)?.[1];
314
+ const divHash2 = result2?.match(/data-upstart-hash="([a-f0-9]+)"/)?.[1];
315
+
316
+ // Parent hash should change because child content changed
317
+ expect(divHash1).not.toBe(divHash2);
318
+ });
319
+
320
+ test("should have unique hashes for different elements", () => {
321
+ const code = `
322
+ export default function App() {
323
+ return (
324
+ <div>
325
+ <span>First</span>
326
+ <span>Second</span>
327
+ </div>
328
+ );
329
+ }
330
+ `;
331
+
332
+ const result = transform(code);
333
+
334
+ // Find all hashes
335
+ const hashes = result?.match(/data-upstart-hash="([a-f0-9]+)"/g);
336
+ const uniqueHashes = new Set(hashes);
337
+
338
+ // Should have 3 unique hashes (div + 2 spans)
339
+ expect(uniqueHashes.size).toBe(3);
340
+ });
341
+ });
342
+
343
+ describe("Loop Context Tracking", () => {
344
+ test("should track .map() call context", () => {
345
+ const code = `
346
+ export default function App() {
347
+ const items = ["a", "b", "c"];
348
+ return (
349
+ <div>
350
+ {items.map((item, index) => (
351
+ <Item key={index} name={item} />
352
+ ))}
353
+ </div>
354
+ );
355
+ }
356
+ `;
357
+
358
+ const result = transform(code);
359
+
360
+ expect(result).toContain('data-upstart-loop-item="item"');
361
+ expect(result).toContain("data-upstart-loop-index={index}");
362
+ expect(result).toContain('data-upstart-loop-array="items"');
363
+ });
364
+
365
+ test("should handle nested loops", () => {
366
+ const code = `
367
+ export default function App() {
368
+ const groups = [[1, 2], [3, 4]];
369
+ return (
370
+ <div>
371
+ {groups.map((group, i) => (
372
+ <div key={i}>
373
+ {group.map((item, j) => (
374
+ <Item key={j} value={item} />
375
+ ))}
376
+ </div>
377
+ ))}
378
+ </div>
379
+ );
380
+ }
381
+ `;
382
+
383
+ const result = transform(code);
384
+
385
+ // Should track the innermost loop
386
+ expect(result).toContain('data-upstart-loop-item="item"');
387
+ expect(result).toContain("data-upstart-loop-index={j}");
388
+ });
389
+
390
+ test("should extract parameter names correctly", () => {
391
+ const code = `
392
+ export default function App() {
393
+ const users = [];
394
+ return (
395
+ <div>
396
+ {users.map((user, idx) => (
397
+ <User key={idx} data={user} />
398
+ ))}
399
+ </div>
400
+ );
401
+ }
402
+ `;
403
+
404
+ const result = transform(code);
405
+
406
+ expect(result).toContain('data-upstart-loop-item="user"');
407
+ expect(result).toContain("data-upstart-loop-index={idx}");
408
+ expect(result).toContain('data-upstart-loop-array="users"');
409
+ });
410
+
411
+ test("should handle .map() with only item parameter", () => {
412
+ const code = `
413
+ export default function App() {
414
+ const items = ["a", "b"];
415
+ return (
416
+ <div>
417
+ {items.map((item) => (
418
+ <Item key={item} name={item} />
419
+ ))}
420
+ </div>
421
+ );
422
+ }
423
+ `;
424
+
425
+ const result = transform(code);
426
+
427
+ expect(result).toContain('data-upstart-loop-item="item"');
428
+ expect(result).toContain('data-upstart-loop-array="items"');
429
+ });
430
+
431
+ test("should handle complex array expressions", () => {
432
+ const code = `
433
+ export default function App() {
434
+ const data = { users: [] };
435
+ return (
436
+ <div>
437
+ {data.users.map((user) => (
438
+ <User key={user.id} data={user} />
439
+ ))}
440
+ </div>
441
+ );
442
+ }
443
+ `;
444
+
445
+ const result = transform(code);
446
+
447
+ expect(result).toContain('data-upstart-loop-item="user"');
448
+ expect(result).toContain('data-upstart-loop-array="data.users"');
449
+ });
450
+ });
451
+
452
+ describe("Edge Cases", () => {
453
+ test("should return null for files without JSX", () => {
454
+ const code = `const x = 1;`;
455
+ const result = transform(code);
456
+ expect(result).toBeNull();
457
+ });
458
+
459
+ test("should handle syntax errors gracefully", () => {
460
+ const code = `export default function App() { return <div>`;
461
+ // Should not throw, but may return null or error
462
+ expect(() => transform(code)).not.toThrow();
463
+ });
464
+
465
+ test("should handle empty JSX elements", () => {
466
+ const code = `
467
+ export default function App() {
468
+ return <MyComponent />;
469
+ }
470
+ `;
471
+
472
+ const result = transform(code);
473
+
474
+ expect(result).toContain('data-upstart-component="MyComponent"');
475
+ });
476
+
477
+ test("should handle self-closing HTML elements", () => {
478
+ const code = `
479
+ export default function App() {
480
+ return <div><br /><hr /></div>;
481
+ }
482
+ `;
483
+
484
+ const result = transform(code);
485
+
486
+ // All elements should have hashes
487
+ expect(result).toContain('data-upstart-hash');
488
+ // Self-closing elements should not have editable-text (no text content)
489
+ const matches = result?.match(/data-upstart-editable-text/g);
490
+ expect(matches).toBeNull();
491
+ });
492
+
493
+ test("should handle JSX fragments", () => {
494
+ const code = `
495
+ export default function App() {
496
+ return (
497
+ <>
498
+ <MyComponent />
499
+ <AnotherComponent />
500
+ </>
501
+ );
502
+ }
503
+ `;
504
+
505
+ const result = transform(code);
506
+
507
+ // Should process components inside fragments
508
+ expect(result).toContain('data-upstart-component="MyComponent"');
509
+ expect(result).toContain('data-upstart-component="AnotherComponent"');
510
+ });
511
+
512
+ test("should handle deeply nested structures", () => {
513
+ const code = `
514
+ export default function App() {
515
+ return (
516
+ <Outer>
517
+ <Middle>
518
+ <Inner>
519
+ <Leaf />
520
+ </Inner>
521
+ </Middle>
522
+ </Outer>
523
+ );
524
+ }
525
+ `;
526
+
527
+ const result = transform(code);
528
+
529
+ // All components should be tracked
530
+ expect(result).toContain('data-upstart-component="Outer"');
531
+ expect(result).toContain('data-upstart-component="Middle"');
532
+ expect(result).toContain('data-upstart-component="Inner"');
533
+ expect(result).toContain('data-upstart-component="Leaf"');
534
+ });
535
+
536
+ test("should handle JSXMemberExpression components", () => {
537
+ const code = `
538
+ export default function App() {
539
+ return <Foo.Bar.Baz />;
540
+ }
541
+ `;
542
+
543
+ const result = transform(code);
544
+
545
+ // Should track the rightmost part
546
+ expect(result).toContain('data-upstart-component="Baz"');
547
+ });
548
+
549
+ test("should handle empty expressions", () => {
550
+ const code = `
551
+ export default function App() {
552
+ return <div>{/* comment */}</div>;
553
+ }
554
+ `;
555
+
556
+ const result = transform(code);
557
+
558
+ // Should have hash but no editable-text (comment is not editable text)
559
+ expect(result).toContain('data-upstart-hash');
560
+ expect(result).not.toContain('data-upstart-editable-text');
561
+ });
562
+
563
+ test("should handle logical expression containing JSX elements", () => {
564
+ // This was causing "Cannot split a chunk that has already been edited" error
565
+ const code = `
566
+ export default function App() {
567
+ const stack = "error stack";
568
+ return (
569
+ <div>
570
+ {stack && (
571
+ <Pre className="w-full p-4 overflow-x-auto">
572
+ <Code>{stack}</Code>
573
+ </Pre>
574
+ )}
575
+ </div>
576
+ );
577
+ }
578
+ `;
579
+
580
+ // Should not throw
581
+ expect(() => transform(code)).not.toThrow();
582
+ const result = transform(code);
583
+
584
+ // Components inside the conditional should be processed
585
+ expect(result).toContain('data-upstart-component="Pre"');
586
+ expect(result).toContain('data-upstart-component="Code"');
587
+ });
588
+
589
+ test("should handle conditional expression containing JSX elements", () => {
590
+ const code = `
591
+ export default function App() {
592
+ const isError = true;
593
+ const message = "Error occurred";
594
+ return (
595
+ <div>
596
+ {isError ? (
597
+ <ErrorMessage text={message} />
598
+ ) : (
599
+ <SuccessMessage />
600
+ )}
601
+ </div>
602
+ );
603
+ }
604
+ `;
605
+
606
+ // Should not throw
607
+ expect(() => transform(code)).not.toThrow();
608
+ const result = transform(code);
609
+
610
+ // Components inside the conditional should be processed
611
+ expect(result).toContain('data-upstart-component="ErrorMessage"');
612
+ expect(result).toContain('data-upstart-component="SuccessMessage"');
613
+ });
614
+
615
+ test("should handle nested logical expressions with JSX", () => {
616
+ const code = `
617
+ export default function App() {
618
+ const a = true;
619
+ const b = true;
620
+ return (
621
+ <div>
622
+ {a && b && <Nested />}
623
+ </div>
624
+ );
625
+ }
626
+ `;
627
+
628
+ expect(() => transform(code)).not.toThrow();
629
+ const result = transform(code);
630
+
631
+ expect(result).toContain('data-upstart-component="Nested"');
632
+ });
633
+
634
+ test("should handle conditional with JSX in both branches", () => {
635
+ const code = `
636
+ export default function App() {
637
+ const showA = true;
638
+ return (
639
+ <div>
640
+ {showA ? <ComponentA /> : <ComponentB />}
641
+ </div>
642
+ );
643
+ }
644
+ `;
645
+
646
+ expect(() => transform(code)).not.toThrow();
647
+ const result = transform(code);
648
+
649
+ expect(result).toContain('data-upstart-component="ComponentA"');
650
+ expect(result).toContain('data-upstart-component="ComponentB"');
651
+ });
652
+
653
+ });
654
+
655
+ describe("Element ID Tracking", () => {
656
+ beforeEach(() => {
657
+ clearRegistry();
658
+ });
659
+
660
+ test("should add data-upstart-id to text-editable elements", () => {
661
+ const code = `
662
+ export default function App() {
663
+ return <div>Hello World</div>;
664
+ }
665
+ `;
666
+ const result = transform(code);
667
+
668
+ // Should have both editable-text AND an ID
669
+ expect(result).toContain('data-upstart-editable-text="true"');
670
+ expect(result).toMatch(/data-upstart-id="test\.tsx:\d+"/);
671
+ });
672
+
673
+ test("should generate unique IDs for multiple text elements", () => {
674
+ const code = `
675
+ export default function App() {
676
+ return (
677
+ <div>
678
+ <span>First</span>
679
+ <span>Second</span>
680
+ </div>
681
+ );
682
+ }
683
+ `;
684
+ const result = transform(code);
685
+
686
+ // Should have two different IDs
687
+ const ids = result?.match(/data-upstart-id="test\.tsx:\d+"/g);
688
+ expect(ids?.length).toBe(2);
689
+ expect(ids?.[0]).not.toBe(ids?.[1]);
690
+ });
691
+
692
+ test("should generate stable IDs across transforms", () => {
693
+ const code = `
694
+ export default function App() {
695
+ return <div>Hello</div>;
696
+ }
697
+ `;
698
+
699
+ clearRegistry();
700
+ const result1 = transform(code);
701
+ clearRegistry();
702
+ const result2 = transform(code);
703
+
704
+ const id1 = result1?.match(/data-upstart-id="([^"]+)"/)?.[1];
705
+ const id2 = result2?.match(/data-upstart-id="([^"]+)"/)?.[1];
706
+
707
+ expect(id1).toBe(id2);
708
+ });
709
+
710
+ test("should not add ID to non-text elements", () => {
711
+ const code = `
712
+ export default function App() {
713
+ return <div></div>;
714
+ }
715
+ `;
716
+ const result = transform(code);
717
+
718
+ // Empty element should NOT have an ID
719
+ expect(result).not.toContain('data-upstart-id');
720
+ });
721
+ });
722
+
723
+ describe("ClassName ID Tracking", () => {
724
+ beforeEach(() => {
725
+ clearRegistry();
726
+ });
727
+
728
+ test("should add data-upstart-classname-id for string literal className", () => {
729
+ const code = `
730
+ export default function App() {
731
+ return <div className="px-4 py-2">Hello</div>;
732
+ }
733
+ `;
734
+ const result = transform(code);
735
+
736
+ expect(result).toMatch(/data-upstart-classname-id="test\.tsx:\d+"/);
737
+ });
738
+
739
+ test("should NOT add classname-id for dynamic className", () => {
740
+ const code = `
741
+ export default function App() {
742
+ const classes = "px-4";
743
+ return <div className={classes}>Hello</div>;
744
+ }
745
+ `;
746
+ const result = transform(code);
747
+
748
+ expect(result).not.toContain('data-upstart-classname-id');
749
+ });
750
+
751
+ test("should NOT add classname-id for template literal className", () => {
752
+ const code = `
753
+ export default function App() {
754
+ return <div className={\`px-4 py-2\`}>Hello</div>;
755
+ }
756
+ `;
757
+ const result = transform(code);
758
+
759
+ expect(result).not.toContain('data-upstart-classname-id');
760
+ });
761
+
762
+ test("should add both text-id and classname-id to same element", () => {
763
+ const code = `
764
+ export default function App() {
765
+ return <span className="text-lg font-bold">Hello</span>;
766
+ }
767
+ `;
768
+ const result = transform(code);
769
+
770
+ expect(result).toMatch(/data-upstart-id="test\.tsx:\d+"/);
771
+ expect(result).toMatch(/data-upstart-classname-id="test\.tsx:\d+"/);
772
+
773
+ // IDs should be different
774
+ const textId = result?.match(/data-upstart-id="([^"]+)"/)?.[1];
775
+ const classId = result?.match(/data-upstart-classname-id="([^"]+)"/)?.[1];
776
+ expect(textId).not.toBe(classId);
777
+ });
778
+
779
+ test("should add classname-id to elements without text", () => {
780
+ const code = `
781
+ export default function App() {
782
+ return <div className="container"><span>child</span></div>;
783
+ }
784
+ `;
785
+ const result = transform(code);
786
+
787
+ // The div has className but no direct text, should still get classname-id
788
+ expect(result).toMatch(/data-upstart-classname-id="test\.tsx:\d+"/);
789
+ });
790
+ });
791
+
792
+ describe("Registry Generation", () => {
793
+ beforeEach(() => {
794
+ clearRegistry();
795
+ });
796
+
797
+ test("should collect text entries in registry", () => {
798
+ const code = `
799
+ export default function App() {
800
+ return <div>Hello World</div>;
801
+ }
802
+ `;
803
+
804
+ transform(code);
805
+ const registry = getRegistry();
806
+
807
+ const entries = Object.values(registry);
808
+ const textEntry = entries.find(e => e.type === "text");
809
+
810
+ expect(textEntry).toBeDefined();
811
+ expect(textEntry?.originalContent).toBe("Hello World");
812
+ expect(textEntry?.file).toBe("test.tsx");
813
+ });
814
+
815
+ test("should collect className entries in registry", () => {
816
+ const code = `
817
+ export default function App() {
818
+ return <div className="px-4 py-2">Hello</div>;
819
+ }
820
+ `;
821
+
822
+ transform(code);
823
+ const registry = getRegistry();
824
+
825
+ const entries = Object.values(registry);
826
+ const classEntry = entries.find(e => e.type === "className");
827
+
828
+ expect(classEntry).toBeDefined();
829
+ expect(classEntry?.originalContent).toBe("px-4 py-2");
830
+ });
831
+
832
+ test("should track correct byte offsets for text", () => {
833
+ const code = `<div>Hello</div>`;
834
+
835
+ transform(code);
836
+ const registry = getRegistry();
837
+
838
+ const entry = Object.values(registry).find(e => e.type === "text");
839
+ const extractedText = code.slice(entry!.startOffset, entry!.endOffset);
840
+
841
+ expect(extractedText).toBe("Hello");
842
+ });
843
+
844
+ test("should track correct byte offsets for className (excluding quotes)", () => {
845
+ const code = `<div className="my-class">Hi</div>`;
846
+
847
+ transform(code);
848
+ const registry = getRegistry();
849
+
850
+ const entry = Object.values(registry).find(e => e.type === "className");
851
+ const extractedClass = code.slice(entry!.startOffset, entry!.endOffset);
852
+
853
+ expect(extractedClass).toBe("my-class");
854
+ });
855
+
856
+ test("should track parent tag in context", () => {
857
+ const code = `<span>Some text</span>`;
858
+
859
+ transform(code);
860
+ const registry = getRegistry();
861
+
862
+ const entry = Object.values(registry).find(e => e.type === "text");
863
+ expect(entry?.context.parentTag).toBe("span");
864
+ });
865
+
866
+ test("should clear registry between builds", () => {
867
+ const code = `<div>First</div>`;
868
+ transform(code);
869
+
870
+ let registry = getRegistry();
871
+ expect(Object.keys(registry).length).toBeGreaterThan(0);
872
+
873
+ clearRegistry();
874
+ registry = getRegistry();
875
+ expect(Object.keys(registry).length).toBe(0);
876
+ });
877
+
878
+ test("should track multiple elements in same file", () => {
879
+ const code = `
880
+ export default function App() {
881
+ return (
882
+ <div>
883
+ <span className="a">One</span>
884
+ <span className="b">Two</span>
885
+ </div>
886
+ );
887
+ }
888
+ `;
889
+
890
+ transform(code);
891
+ const registry = getRegistry();
892
+ const entries = Object.values(registry);
893
+
894
+ // Should have 2 text entries + 2 className entries = 4 total
895
+ expect(entries.length).toBe(4);
896
+
897
+ const textEntries = entries.filter(e => e.type === "text");
898
+ const classEntries = entries.filter(e => e.type === "className");
899
+
900
+ expect(textEntries.length).toBe(2);
901
+ expect(classEntries.length).toBe(2);
902
+ });
903
+ });
904
+
905
+ describe("Integration Tests", () => {
906
+ test("should transform real-world component correctly", () => {
907
+ const code = `
908
+ export default function UserProfile() {
909
+ const user = { id: 123, name: "Alice", role: "Admin" };
910
+ const isActive = true;
911
+
912
+ return (
913
+ <ProfileCard>
914
+ <Avatar src={user.avatar} />
915
+ <div>
916
+ <h2>{user.name}</h2>
917
+ <p>{isActive ? user.role : "Inactive"}</p>
918
+ {user.posts.map((post, index) => (
919
+ <Post key={post.id} title={post.title} index={index} />
920
+ ))}
921
+ </div>
922
+ </ProfileCard>
923
+ );
924
+ }
925
+ `;
926
+
927
+ const result = transform(code);
928
+
929
+ // Check component tracking
930
+ expect(result).toContain('data-upstart-component="ProfileCard"');
931
+ expect(result).toContain('data-upstart-component="Avatar"');
932
+ expect(result).toContain('data-upstart-component="Post"');
933
+
934
+ // Check prop tracking
935
+ expect(result).toContain('data-upstart-prop-src="user.avatar"');
936
+ expect(result).toContain('data-upstart-prop-title="post.title"');
937
+
938
+ // Check loop tracking
939
+ expect(result).toContain('data-upstart-loop-item="post"');
940
+ expect(result).toContain('data-upstart-loop-array="user.posts"');
941
+ });
942
+
943
+ test("should preserve source maps", () => {
944
+ const code = `
945
+ export default function App() {
946
+ return <MyComponent>Hello</MyComponent>;
947
+ }
948
+ `;
949
+
950
+ const result = transformWithOxc(code, "test.tsx");
951
+
952
+ expect(result).not.toBeNull();
953
+ expect(result?.map).toBeTruthy();
954
+ expect(result?.map.toString()).toContain('"sources"');
955
+ });
956
+ });
957
+
958
+ describe("i18n <Trans> Component Detection", () => {
959
+ test("should detect <Trans> component with default namespace", () => {
960
+ const code = `
961
+ import { Trans } from "react-i18next";
962
+ export default function App() {
963
+ return <h3><Trans i18nKey="features.title" /></h3>;
964
+ }
965
+ `;
966
+ const result = transform(code);
967
+ expect(result).toContain('data-i18n-key="translation:features.title"');
968
+ expect(result).toContain('data-upstart-editable-text="true"');
969
+ });
970
+
971
+ test("should detect <Trans> component with custom namespace", () => {
972
+ const code = `
973
+ import { Trans } from "react-i18next";
974
+ export default function App() {
975
+ return <p><Trans i18nKey="welcome.message" ns="dashboard" /></p>;
976
+ }
977
+ `;
978
+ const result = transform(code);
979
+ expect(result).toContain('data-i18n-key="dashboard:welcome.message"');
980
+ expect(result).toContain('data-upstart-editable-text="true"');
981
+ });
982
+
983
+ test("should detect standalone <Trans> component", () => {
984
+ const code = `
985
+ import { Trans } from "react-i18next";
986
+ export default function App() {
987
+ return <Trans i18nKey="standalone.key" />;
988
+ }
989
+ `;
990
+ const result = transform(code);
991
+ expect(result).toContain('data-i18n-key="translation:standalone.key"');
992
+ expect(result).toContain('data-upstart-editable-text="true"');
993
+ });
994
+
995
+ test("should detect multiple <Trans> components in one file", () => {
996
+ const code = `
997
+ import { Trans } from "react-i18next";
998
+ export default function App() {
999
+ return (
1000
+ <div>
1001
+ <h1><Trans i18nKey="title" /></h1>
1002
+ <p><Trans i18nKey="description" /></p>
1003
+ </div>
1004
+ );
1005
+ }
1006
+ `;
1007
+ const result = transform(code);
1008
+ expect(result).toContain('data-i18n-key="translation:title"');
1009
+ expect(result).toContain('data-i18n-key="translation:description"');
1010
+ const editableMatches = result?.match(/data-upstart-editable-text="true"/g);
1011
+ expect(editableMatches?.length).toBe(2);
1012
+ });
1013
+
1014
+ test("should detect <Trans> with values prop", () => {
1015
+ const code = `
1016
+ import { Trans } from "react-i18next";
1017
+ export default function App() {
1018
+ const name = "Alice";
1019
+ return <span><Trans i18nKey="greeting" values={{ name }} /></span>;
1020
+ }
1021
+ `;
1022
+ const result = transform(code);
1023
+ expect(result).toContain('data-i18n-key="translation:greeting"');
1024
+ });
1025
+
1026
+ test("should detect <Trans> with components prop", () => {
1027
+ const code = `
1028
+ import { Trans } from "react-i18next";
1029
+ export default function App() {
1030
+ return (
1031
+ <p>
1032
+ <Trans
1033
+ i18nKey="terms"
1034
+ components={{ link: <a href="/terms" /> }}
1035
+ />
1036
+ </p>
1037
+ );
1038
+ }
1039
+ `;
1040
+ const result = transform(code);
1041
+ expect(result).toContain('data-i18n-key="translation:terms"');
1042
+ });
1043
+
1044
+ test("should NOT detect <Trans> without i18nKey prop", () => {
1045
+ const code = `
1046
+ import { Trans } from "react-i18next";
1047
+ export default function App() {
1048
+ return <Trans>Static text</Trans>;
1049
+ }
1050
+ `;
1051
+ const result = transform(code);
1052
+ expect(result).not.toContain('data-i18n-key');
1053
+ });
1054
+
1055
+ test("should still add hash to <Trans> elements", () => {
1056
+ const code = `
1057
+ import { Trans } from "react-i18next";
1058
+ export default function App() {
1059
+ return <h1><Trans i18nKey="title" /></h1>;
1060
+ }
1061
+ `;
1062
+ const result = transform(code);
1063
+ expect(result).toMatch(/data-upstart-hash="[a-f0-9]+"/);
1064
+ expect(result).toContain('data-i18n-key="translation:title"');
1065
+ });
1066
+
1067
+ test("should handle PascalCase component with <Trans> child", () => {
1068
+ const code = `
1069
+ import { Trans } from "react-i18next";
1070
+ export default function App() {
1071
+ return <Button><Trans i18nKey="submit" ns="ui" /></Button>;
1072
+ }
1073
+ `;
1074
+ const result = transform(code);
1075
+ expect(result).toContain('data-i18n-key="ui:submit"');
1076
+ expect(result).toContain('data-upstart-editable-text="true"');
1077
+ expect(result).toContain('data-upstart-component="Button"');
1078
+ });
1079
+
1080
+ test("should handle <Trans> inside a .map() loop", () => {
1081
+ const code = `
1082
+ import { Trans } from "react-i18next";
1083
+ export default function App() {
1084
+ const items = ["a", "b"];
1085
+ return (
1086
+ <div>
1087
+ {items.map((item) => (
1088
+ <Card key={item}>
1089
+ <span><Trans i18nKey="item.label" /></span>
1090
+ </Card>
1091
+ ))}
1092
+ </div>
1093
+ );
1094
+ }
1095
+ `;
1096
+ const result = transform(code);
1097
+ expect(result).toContain('data-i18n-key="translation:item.label"');
1098
+ expect(result).toContain('data-upstart-loop-item="item"');
1099
+ });
1100
+
1101
+ test("should NOT detect parent element with mixed content (<Trans> + text)", () => {
1102
+ const code = `
1103
+ import { Trans } from "react-i18next";
1104
+ export default function App() {
1105
+ return <div>Prefix: <Trans i18nKey="key" /></div>;
1106
+ }
1107
+ `;
1108
+ const result = transform(code);
1109
+ // The div should not have data-i18n-key (mixed content)
1110
+ // But the Trans itself should have it
1111
+ const matches = result?.match(/data-i18n-key="translation:key"/g);
1112
+ expect(matches?.length).toBe(1); // Only on Trans, not on parent div
1113
+ });
1114
+ });
1115
+
1116
+ describe("Forbidden useTranslation Enforcement", () => {
1117
+ test("should throw error when destructuring t from useTranslation", () => {
1118
+ const code = `
1119
+ import { useTranslation } from "react-i18next";
1120
+ export default function App() {
1121
+ const { t } = useTranslation();
1122
+ return <div>Hello</div>;
1123
+ }
1124
+ `;
1125
+ expect(() => transform(code)).toThrow(/useTranslation hook is forbidden/);
1126
+ });
1127
+
1128
+ test("should throw error when destructuring t with alias", () => {
1129
+ const code = `
1130
+ import { useTranslation } from "react-i18next";
1131
+ export default function App() {
1132
+ const { t: translate } = useTranslation();
1133
+ return <div>Hello</div>;
1134
+ }
1135
+ `;
1136
+ expect(() => transform(code)).toThrow(/useTranslation hook is forbidden/);
1137
+ });
1138
+
1139
+ test("should throw error when destructuring both t and i18n", () => {
1140
+ const code = `
1141
+ import { useTranslation } from "react-i18next";
1142
+ export default function App() {
1143
+ const { t, i18n } = useTranslation();
1144
+ return <div>Hello</div>;
1145
+ }
1146
+ `;
1147
+ expect(() => transform(code)).toThrow(/useTranslation hook is forbidden/);
1148
+ });
1149
+
1150
+ test("should NOT throw when only destructuring i18n (allowed for language switching)", () => {
1151
+ const code = `
1152
+ import { useTranslation } from "react-i18next";
1153
+ export default function App() {
1154
+ const { i18n } = useTranslation();
1155
+ return (
1156
+ <button onClick={() => i18n.changeLanguage("fr")}>
1157
+ Switch to French
1158
+ </button>
1159
+ );
1160
+ }
1161
+ `;
1162
+ expect(() => transform(code)).not.toThrow();
1163
+ });
1164
+
1165
+ test("should include file path in error message", () => {
1166
+ const code = `
1167
+ import { useTranslation } from "react-i18next";
1168
+ export default function App() {
1169
+ const { t } = useTranslation();
1170
+ return <div>Hello</div>;
1171
+ }
1172
+ `;
1173
+ expect(() => transform(code, "components/MyComponent.tsx")).toThrow(
1174
+ /\[components\/MyComponent\.tsx\]/
1175
+ );
1176
+ });
1177
+
1178
+ test("should suggest using <Trans> in error message", () => {
1179
+ const code = `
1180
+ import { useTranslation } from "react-i18next";
1181
+ export default function App() {
1182
+ const { t } = useTranslation();
1183
+ return <div>Hello</div>;
1184
+ }
1185
+ `;
1186
+ expect(() => transform(code)).toThrow(/<Trans i18nKey="\.\.\."/);
1187
+ });
1188
+ });
1189
+ });