@upstart.gg/vite-plugins 0.1.29 → 0.1.30

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 (50) hide show
  1. package/dist/upstart-editor-api.d.ts +13 -1
  2. package/dist/upstart-editor-api.d.ts.map +1 -1
  3. package/dist/upstart-editor-api.js +59 -1
  4. package/dist/upstart-editor-api.js.map +1 -1
  5. package/dist/vite-plugin-upstart-attrs.d.ts +12 -7
  6. package/dist/vite-plugin-upstart-attrs.d.ts.map +1 -1
  7. package/dist/vite-plugin-upstart-attrs.js +195 -3
  8. package/dist/vite-plugin-upstart-attrs.js.map +1 -1
  9. package/dist/vite-plugin-upstart-branding/plugin.d.ts +3 -3
  10. package/dist/vite-plugin-upstart-branding/plugin.d.ts.map +1 -1
  11. package/dist/vite-plugin-upstart-branding/plugin.js.map +1 -1
  12. package/dist/vite-plugin-upstart-branding/runtime.js.map +1 -1
  13. package/dist/vite-plugin-upstart-editor/plugin.d.ts +3 -3
  14. package/dist/vite-plugin-upstart-editor/plugin.d.ts.map +1 -1
  15. package/dist/vite-plugin-upstart-editor/plugin.js +4 -1
  16. package/dist/vite-plugin-upstart-editor/plugin.js.map +1 -1
  17. package/dist/vite-plugin-upstart-editor/runtime/click-handler.js +27 -16
  18. package/dist/vite-plugin-upstart-editor/runtime/click-handler.js.map +1 -1
  19. package/dist/vite-plugin-upstart-editor/runtime/error-handler.js.map +1 -1
  20. package/dist/vite-plugin-upstart-editor/runtime/hover-overlay.js +1 -1
  21. package/dist/vite-plugin-upstart-editor/runtime/hover-overlay.js.map +1 -1
  22. package/dist/vite-plugin-upstart-editor/runtime/index.d.ts +1 -1
  23. package/dist/vite-plugin-upstart-editor/runtime/index.js +14 -2
  24. package/dist/vite-plugin-upstart-editor/runtime/index.js.map +1 -1
  25. package/dist/{src/vite-plugin-upstart-editor → vite-plugin-upstart-editor}/runtime/state.d.ts +1 -1
  26. package/dist/vite-plugin-upstart-editor/runtime/state.d.ts.map +1 -0
  27. package/dist/vite-plugin-upstart-editor/runtime/state.js.map +1 -0
  28. package/dist/vite-plugin-upstart-editor/runtime/text-editor.d.ts.map +1 -1
  29. package/dist/vite-plugin-upstart-editor/runtime/text-editor.js +212 -28
  30. package/dist/vite-plugin-upstart-editor/runtime/text-editor.js.map +1 -1
  31. package/dist/vite-plugin-upstart-editor/runtime/types.d.ts +16 -3
  32. package/dist/vite-plugin-upstart-editor/runtime/types.d.ts.map +1 -1
  33. package/dist/vite-plugin-upstart-editor/runtime/utils.js.map +1 -1
  34. package/dist/vite-plugin-upstart-theme.d.ts +3 -3
  35. package/dist/vite-plugin-upstart-theme.d.ts.map +1 -1
  36. package/dist/vite-plugin-upstart-theme.js +2 -2
  37. package/dist/vite-plugin-upstart-theme.js.map +1 -1
  38. package/package.json +7 -7
  39. package/src/tests/vite-plugin-upstart-attrs.test.ts +298 -37
  40. package/src/upstart-editor-api.ts +71 -0
  41. package/src/vite-plugin-upstart-attrs.ts +293 -5
  42. package/src/vite-plugin-upstart-editor/plugin.ts +11 -1
  43. package/src/vite-plugin-upstart-editor/runtime/click-handler.ts +35 -21
  44. package/src/vite-plugin-upstart-editor/runtime/index.ts +21 -1
  45. package/src/vite-plugin-upstart-editor/runtime/text-editor.ts +260 -41
  46. package/src/vite-plugin-upstart-editor/runtime/types.ts +17 -4
  47. package/src/vite-plugin-upstart-theme.ts +4 -1
  48. package/dist/src/vite-plugin-upstart-editor/runtime/state.d.ts.map +0 -1
  49. package/dist/src/vite-plugin-upstart-editor/runtime/state.js.map +0 -1
  50. /package/dist/{src/vite-plugin-upstart-editor → vite-plugin-upstart-editor}/runtime/state.js +0 -0
@@ -78,7 +78,7 @@ describe("upstart-editor-vite-plugin", () => {
78
78
  });
79
79
 
80
80
  describe("Static Text Detection", () => {
81
- test("should mark leaf elements with static text as non-editable", () => {
81
+ test("should mark leaf elements with static text as directly editable (direct mode)", () => {
82
82
  const code = `
83
83
  export default function App() {
84
84
  return <div>Hello World</div>;
@@ -87,10 +87,12 @@ describe("upstart-editor-vite-plugin", () => {
87
87
 
88
88
  const result = transform(code);
89
89
 
90
- expect(result).toContain('data-upstart-editable-text="false"');
90
+ // Text-only leaf elements are editable directly in the TSX source
91
+ expect(result).toContain('data-upstart-editable-text="true"');
92
+ expect(result).toContain('data-upstart-editable-text-mode="direct"');
91
93
  });
92
94
 
93
- test("should mark span with static text as non-editable", () => {
95
+ test("should mark span with static text as directly editable (direct mode)", () => {
94
96
  const code = `
95
97
  export default function App() {
96
98
  return <span>Click here</span>;
@@ -99,10 +101,11 @@ describe("upstart-editor-vite-plugin", () => {
99
101
 
100
102
  const result = transform(code);
101
103
 
102
- expect(result).toContain('data-upstart-editable-text="false"');
104
+ expect(result).toContain('data-upstart-editable-text="true"');
105
+ expect(result).toContain('data-upstart-editable-text-mode="direct"');
103
106
  });
104
107
 
105
- test("should mark PascalCase component with static text as non-editable AND track component", () => {
108
+ test("should mark PascalCase component with static text as directly editable AND track component", () => {
106
109
  const code = `
107
110
  export default function App() {
108
111
  return <Button>Submit</Button>;
@@ -111,12 +114,12 @@ describe("upstart-editor-vite-plugin", () => {
111
114
 
112
115
  const result = transform(code);
113
116
 
114
- // Should have non-editable text AND component tracking
115
- expect(result).toContain('data-upstart-editable-text="false"');
117
+ expect(result).toContain('data-upstart-editable-text="true"');
118
+ expect(result).toContain('data-upstart-editable-text-mode="direct"');
116
119
  expect(result).toContain('data-upstart-component="Button"');
117
120
  });
118
121
 
119
- test("should NOT mark elements with nested elements (but mark the nested leaf)", () => {
122
+ test("should mark outer div with inline child as almost-leaf (rich-panel) and inner span as direct", () => {
120
123
  const code = `
121
124
  export default function App() {
122
125
  return <div><span>nested</span></div>;
@@ -125,10 +128,13 @@ describe("upstart-editor-vite-plugin", () => {
125
128
 
126
129
  const result = transform(code);
127
130
 
128
- // The outer div should NOT be marked (no direct text content)
129
- // The inner span SHOULD be marked as non-editable (text leaf without Trans)
130
- const matches = result?.match(/data-upstart-editable-text="false"/g);
131
- expect(matches?.length).toBe(1);
131
+ // <span> is an inline formatting tag outer div is "almost-leaf" → rich-panel
132
+ expect(result).not.toContain('data-upstart-editable-text="false"');
133
+ // Both elements get "true": outer div (rich-panel) + inner span (direct)
134
+ const trueMatches = result?.match(/data-upstart-editable-text="true"/g);
135
+ expect(trueMatches?.length).toBe(2);
136
+ expect(result).toContain('data-upstart-editable-text-mode="rich-panel"');
137
+ expect(result).toContain('data-upstart-editable-text-mode="direct"');
132
138
  });
133
139
 
134
140
  test("should mark elements with dynamic text expressions as non-editable", () => {
@@ -141,12 +147,12 @@ describe("upstart-editor-vite-plugin", () => {
141
147
 
142
148
  const result = transform(code);
143
149
 
144
- // Should have non-editable text (Identifier expression = text-producing)
150
+ // Identifier expression = text-producing but not statically editable → "false"
145
151
  expect(result).toContain('data-upstart-editable-text="false"');
146
152
  expect(result).toContain("data-upstart-hash");
147
153
  });
148
154
 
149
- test("should mark elements with mixed content as non-editable", () => {
155
+ test("should mark mixed-content outer as almost-leaf (rich-panel) and text-only child as direct", () => {
150
156
  const code = `
151
157
  export default function App() {
152
158
  return <div>Text <b>bold</b></div>;
@@ -155,9 +161,13 @@ describe("upstart-editor-vite-plugin", () => {
155
161
 
156
162
  const result = transform(code);
157
163
 
158
- // Both the outer div (has JSXText "Text ") and inner b (text leaf) get "false"
159
- const matches = result?.match(/data-upstart-editable-text="false"/g);
160
- expect(matches?.length).toBe(2);
164
+ // <div> has JSXText "Text " + <b> (inline tag) almost-leaf → rich-panel
165
+ expect(result).not.toContain('data-upstart-editable-text="false"');
166
+ // Both outer div (rich-panel) + inner <b> (direct) get "true"
167
+ const trueMatches = result?.match(/data-upstart-editable-text="true"/g);
168
+ expect(trueMatches?.length).toBe(2);
169
+ expect(result).toContain('data-upstart-editable-text-mode="rich-panel"');
170
+ expect(result).toContain('data-upstart-editable-text-mode="direct"');
161
171
  });
162
172
 
163
173
  test("should NOT mark empty elements as editable", () => {
@@ -190,7 +200,7 @@ describe("upstart-editor-vite-plugin", () => {
190
200
  expect(result).toContain("data-upstart-hash");
191
201
  });
192
202
 
193
- test("should handle multiple text leaf elements", () => {
203
+ test("should mark multiple text leaf elements as directly editable", () => {
194
204
  const code = `
195
205
  export default function App() {
196
206
  return (
@@ -204,9 +214,10 @@ describe("upstart-editor-vite-plugin", () => {
204
214
 
205
215
  const result = transform(code);
206
216
 
207
- // Both h1 and p should be marked as non-editable
208
- const matches = result?.match(/data-upstart-editable-text="false"/g);
209
- expect(matches?.length).toBe(2);
217
+ // Both h1 and p are text leaves direct mode
218
+ const trueMatches = result?.match(/data-upstart-editable-text="true"/g);
219
+ expect(trueMatches?.length).toBe(2);
220
+ expect(result).not.toContain('data-upstart-editable-text="false"');
210
221
  });
211
222
  });
212
223
 
@@ -283,7 +294,7 @@ describe("upstart-editor-vite-plugin", () => {
283
294
  expect(result).not.toContain("data-upstart-editable-text");
284
295
  });
285
296
 
286
- test("Trans should take priority over non-editable text", () => {
297
+ test("should NOT be editable when Trans is mixed with a non-i18n variable", () => {
287
298
  const code = `
288
299
  import { Trans } from "react-i18next";
289
300
  export default function App() {
@@ -291,9 +302,10 @@ describe("upstart-editor-vite-plugin", () => {
291
302
  }
292
303
  `;
293
304
  const result = transform(code);
294
- // Trans wins: should be "true", not "false"
295
- expect(result).toContain('data-upstart-editable-text="true"');
296
- expect(result).not.toContain('data-upstart-editable-text="false"');
305
+ // {prefix} is a text-producing Identifier alongside Trans mixed NOT editable
306
+ // Saving would incorrectly include the rendered prefix in the translation value
307
+ expect(result).not.toContain('data-upstart-editable-text="true"');
308
+ expect(result).not.toContain("data-upstart-i18n");
297
309
  });
298
310
  });
299
311
 
@@ -774,7 +786,7 @@ describe("upstart-editor-vite-plugin", () => {
774
786
  clearRegistry();
775
787
  });
776
788
 
777
- test("should add data-upstart-id to text leaf elements", () => {
789
+ test("should add data-upstart-id to text leaf elements (direct mode)", () => {
778
790
  const code = `
779
791
  export default function App() {
780
792
  return <div>Hello World</div>;
@@ -782,12 +794,13 @@ describe("upstart-editor-vite-plugin", () => {
782
794
  `;
783
795
  const result = transform(code);
784
796
 
785
- // Should have non-editable text AND an ID
786
- expect(result).toContain('data-upstart-editable-text="false"');
797
+ // Text leaf elements are directly editable (direct mode) and get an ID
798
+ expect(result).toContain('data-upstart-editable-text="true"');
799
+ expect(result).toContain('data-upstart-editable-text-mode="direct"');
787
800
  expect(result).toMatch(/data-upstart-id="test\.tsx:\d+"/);
788
801
  });
789
802
 
790
- test("should generate unique IDs for multiple text elements", () => {
803
+ test("should generate unique IDs for multiple text leaf elements", () => {
791
804
  const code = `
792
805
  export default function App() {
793
806
  return (
@@ -800,9 +813,10 @@ describe("upstart-editor-vite-plugin", () => {
800
813
  `;
801
814
  const result = transform(code);
802
815
 
803
- // Should have two different IDs
816
+ // Each span is a text leaf → direct mode with a unique ID
817
+ // Note: the outer div is an "almost-leaf" (span is inline) → rich-panel, gets its own ID too
804
818
  const ids = result?.match(/data-upstart-id="test\.tsx:\d+"/g);
805
- expect(ids?.length).toBe(2);
819
+ expect(ids?.length).toBeGreaterThanOrEqual(2);
806
820
  expect(ids?.[0]).not.toBe(ids?.[1]);
807
821
  });
808
822
 
@@ -1008,14 +1022,18 @@ describe("upstart-editor-vite-plugin", () => {
1008
1022
  const registry = getRegistry();
1009
1023
  const entries = Object.values(registry);
1010
1024
 
1011
- // Should have 2 text entries + 2 className entries = 4 total
1025
+ // 2 text (spans) + 2 className (spans); the outer div is NOT almost-leaf because
1026
+ // its <span> children have attributes (className), which disqualifies them as
1027
+ // simple inline formatting wrappers.
1012
1028
  expect(entries.length).toBe(4);
1013
1029
 
1014
1030
  const textEntries = entries.filter((e) => e.type === "text");
1015
1031
  const classEntries = entries.filter((e) => e.type === "className");
1032
+ const richTextEntries = entries.filter((e) => e.type === "rich-text");
1016
1033
 
1017
1034
  expect(textEntries.length).toBe(2);
1018
1035
  expect(classEntries.length).toBe(2);
1036
+ expect(richTextEntries.length).toBe(0);
1019
1037
  });
1020
1038
  });
1021
1039
 
@@ -1271,7 +1289,7 @@ describe("upstart-editor-vite-plugin", () => {
1271
1289
  expect(result).toContain('data-upstart-editable-text="true"');
1272
1290
  });
1273
1291
 
1274
- test("should promote i18n attrs to parent even with mixed content", () => {
1292
+ test("should NOT be editable when Trans is mixed with static text", () => {
1275
1293
  const code = `
1276
1294
  import { Trans } from "react-i18next";
1277
1295
  export default function App() {
@@ -1279,10 +1297,10 @@ describe("upstart-editor-vite-plugin", () => {
1279
1297
  }
1280
1298
  `;
1281
1299
  const result = transform(code);
1282
- // The parent div should have the i18n key (promoted from Trans child)
1283
- const matches = result?.match(/data-upstart-i18n="translation:key"/g);
1284
- expect(matches?.length).toBe(1);
1285
- expect(result).toContain('data-upstart-editable-text="true"');
1300
+ // "Prefix: " is non-whitespace JSXText alongside Trans mixed NOT editable
1301
+ // Saving would incorrectly include "Prefix: " in the translation value
1302
+ expect(result).not.toContain('data-upstart-editable-text="true"');
1303
+ expect(result).not.toContain("data-upstart-i18n");
1286
1304
  });
1287
1305
 
1288
1306
  test("should promote <Trans> from inside && expression to parent", () => {
@@ -1346,6 +1364,90 @@ describe("upstart-editor-vite-plugin", () => {
1346
1364
  const result = transform(code);
1347
1365
  expect(result).toContain('data-upstart-i18n="translation:nested"');
1348
1366
  });
1367
+
1368
+ describe("Mixed i18n + non-i18n content detection", () => {
1369
+ test("should NOT be editable when JSXText static content precedes Trans", () => {
1370
+ const code = `
1371
+ import { Trans } from "react-i18next";
1372
+ export default function App() {
1373
+ return (
1374
+ <p>
1375
+ © {new Date().getFullYear()} John Doe.{" "}
1376
+ <Trans i18nKey="footer.rights" />
1377
+ </p>
1378
+ );
1379
+ }
1380
+ `;
1381
+ const result = transform(code);
1382
+ // "© ... John Doe." is non-whitespace JSXText alongside Trans
1383
+ expect(result).not.toContain('data-upstart-editable-text="true"');
1384
+ expect(result).not.toContain("data-upstart-i18n");
1385
+ });
1386
+
1387
+ test("should NOT be editable when a text variable is alongside Trans", () => {
1388
+ const code = `
1389
+ import { Trans } from "react-i18next";
1390
+ export default function App() {
1391
+ return <p>{userName}: <Trans i18nKey="greeting" /></p>;
1392
+ }
1393
+ `;
1394
+ const result = transform(code);
1395
+ // {userName} is an Identifier (text-producing) alongside Trans
1396
+ expect(result).not.toContain('data-upstart-editable-text="true"');
1397
+ expect(result).not.toContain("data-upstart-i18n");
1398
+ });
1399
+
1400
+ test("should NOT be editable when a member expression is alongside Trans", () => {
1401
+ const code = `
1402
+ import { Trans } from "react-i18next";
1403
+ export default function App() {
1404
+ return <span>{user.name} — <Trans i18nKey="role" /></span>;
1405
+ }
1406
+ `;
1407
+ const result = transform(code);
1408
+ // {user.name} is a MemberExpression (text-producing) alongside Trans
1409
+ expect(result).not.toContain('data-upstart-editable-text="true"');
1410
+ });
1411
+
1412
+ test("should still be editable when Trans is inside && guard (boolean, not text)", () => {
1413
+ const code = `
1414
+ import { Trans } from "react-i18next";
1415
+ export default function App() {
1416
+ return <p>{showBanner && <Trans i18nKey="banner.text" />}</p>;
1417
+ }
1418
+ `;
1419
+ const result = transform(code);
1420
+ // showBanner is a boolean guard, not text content — still pure i18n
1421
+ expect(result).toContain('data-upstart-editable-text="true"');
1422
+ expect(result).toContain('data-upstart-i18n="translation:banner.text"');
1423
+ });
1424
+
1425
+ test("should still be editable when Trans is in a ternary between two i18n values", () => {
1426
+ const code = `
1427
+ import { Trans } from "react-i18next";
1428
+ export default function App() {
1429
+ return <p>{isAdmin ? <Trans i18nKey="role.admin" /> : <Trans i18nKey="role.user" />}</p>;
1430
+ }
1431
+ `;
1432
+ const result = transform(code);
1433
+ // Both branches are i18n — no non-i18n text produced
1434
+ expect(result).toContain('data-upstart-editable-text="true"');
1435
+ expect(result).toContain('data-upstart-i18n="translation:role.admin,translation:role.user"');
1436
+ });
1437
+
1438
+ test("should still be editable when only whitespace {' '} separates Trans from nothing", () => {
1439
+ const code = `
1440
+ import { Trans } from "react-i18next";
1441
+ export default function App() {
1442
+ return <span>{" "}<Trans i18nKey="label" /></span>;
1443
+ }
1444
+ `;
1445
+ const result = transform(code);
1446
+ // {" "} is whitespace-only — harmless
1447
+ expect(result).toContain('data-upstart-editable-text="true"');
1448
+ expect(result).toContain('data-upstart-i18n="translation:label"');
1449
+ });
1450
+ });
1349
1451
  });
1350
1452
 
1351
1453
  describe("useTranslation t() Function Detection", () => {
@@ -1503,4 +1605,163 @@ describe("upstart-editor-vite-plugin", () => {
1503
1605
  expect(result).toContain('data-upstart-i18n="app:greeting"');
1504
1606
  });
1505
1607
  });
1608
+
1609
+ describe("Trans values prop — template variable detection", () => {
1610
+ test("should emit data-i18n-values for shorthand values prop (single key)", () => {
1611
+ const code = `
1612
+ import { Trans } from "react-i18next";
1613
+ export default function App() {
1614
+ const year = 2026;
1615
+ return <p><Trans i18nKey="footer.copyright" values={{ year }} /></p>;
1616
+ }
1617
+ `;
1618
+ const result = transform(code);
1619
+ expect(result).toContain('data-i18n-values="year"');
1620
+ expect(result).toContain('data-upstart-i18n="translation:footer.copyright"');
1621
+ expect(result).toContain('data-upstart-editable-text="true"');
1622
+ });
1623
+
1624
+ test("should emit data-i18n-values for explicit key-value pair", () => {
1625
+ const code = `
1626
+ import { Trans } from "react-i18next";
1627
+ export default function App() {
1628
+ return <span><Trans i18nKey="greeting" values={{ name: userName }} /></span>;
1629
+ }
1630
+ `;
1631
+ const result = transform(code);
1632
+ expect(result).toContain('data-i18n-values="name"');
1633
+ });
1634
+
1635
+ test("should emit data-i18n-values for multiple keys (shorthand)", () => {
1636
+ const code = `
1637
+ import { Trans } from "react-i18next";
1638
+ export default function App() {
1639
+ const year = 2026;
1640
+ const month = "January";
1641
+ return <p><Trans i18nKey="date.label" values={{ year, month }} /></p>;
1642
+ }
1643
+ `;
1644
+ const result = transform(code);
1645
+ expect(result).toContain('data-i18n-values="year,month"');
1646
+ });
1647
+
1648
+ test("should emit data-i18n-values for multiple explicit key-value pairs", () => {
1649
+ const code = `
1650
+ import { Trans } from "react-i18next";
1651
+ export default function App() {
1652
+ return <p><Trans i18nKey="user.info" values={{ firstName: user.first, lastName: user.last }} /></p>;
1653
+ }
1654
+ `;
1655
+ const result = transform(code);
1656
+ expect(result).toContain('data-i18n-values="firstName,lastName"');
1657
+ });
1658
+
1659
+ test("should NOT emit data-i18n-values when values prop is absent", () => {
1660
+ const code = `
1661
+ import { Trans } from "react-i18next";
1662
+ export default function App() {
1663
+ return <h3><Trans i18nKey="features.title" /></h3>;
1664
+ }
1665
+ `;
1666
+ const result = transform(code);
1667
+ expect(result).not.toContain("data-i18n-values");
1668
+ });
1669
+
1670
+ test("should NOT emit data-i18n-values when values prop is empty object", () => {
1671
+ const code = `
1672
+ import { Trans } from "react-i18next";
1673
+ export default function App() {
1674
+ return <p><Trans i18nKey="simple" values={{}} /></p>;
1675
+ }
1676
+ `;
1677
+ const result = transform(code);
1678
+ expect(result).not.toContain("data-i18n-values");
1679
+ });
1680
+
1681
+ test("should NOT crash and NOT emit data-i18n-values when values uses spread", () => {
1682
+ const code = `
1683
+ import { Trans } from "react-i18next";
1684
+ export default function App() {
1685
+ return <p><Trans i18nKey="msg" values={{ ...extraVars }} /></p>;
1686
+ }
1687
+ `;
1688
+ expect(() => transform(code)).not.toThrow();
1689
+ const result = transform(code);
1690
+ expect(result).not.toContain("data-i18n-values");
1691
+ });
1692
+
1693
+ test("should emit data-i18n-values with custom namespace", () => {
1694
+ const code = `
1695
+ import { Trans } from "react-i18next";
1696
+ export default function App() {
1697
+ const count = 5;
1698
+ return <span><Trans i18nKey="items.count" ns="dashboard" values={{ count }} /></span>;
1699
+ }
1700
+ `;
1701
+ const result = transform(code);
1702
+ expect(result).toContain('data-i18n-values="count"');
1703
+ expect(result).toContain('data-upstart-i18n="dashboard:items.count"');
1704
+ });
1705
+
1706
+ test("should deduplicate value keys across multiple Trans components in same parent", () => {
1707
+ const code = `
1708
+ import { Trans } from "react-i18next";
1709
+ export default function App() {
1710
+ const year = 2026;
1711
+ return (
1712
+ <div>
1713
+ {flag
1714
+ ? <Trans i18nKey="a" values={{ year }} />
1715
+ : <Trans i18nKey="b" values={{ year }} />}
1716
+ </div>
1717
+ );
1718
+ }
1719
+ `;
1720
+ const result = transform(code);
1721
+ // "year" appears in both Trans but should only be emitted once
1722
+ const matches = result?.match(/data-i18n-values="year"/g);
1723
+ expect(matches?.length).toBe(1);
1724
+ });
1725
+
1726
+ test("should merge value keys from multiple Trans with different keys", () => {
1727
+ const code = `
1728
+ import { Trans } from "react-i18next";
1729
+ export default function App() {
1730
+ const year = 2026;
1731
+ const name = "Alice";
1732
+ return (
1733
+ <div>
1734
+ {flag
1735
+ ? <Trans i18nKey="a" values={{ year }} />
1736
+ : <Trans i18nKey="b" values={{ name }} />}
1737
+ </div>
1738
+ );
1739
+ }
1740
+ `;
1741
+ const result = transform(code);
1742
+ expect(result).toContain("data-i18n-values=");
1743
+ const valuesAttr = result?.match(/data-i18n-values="([^"]+)"/)?.[1];
1744
+ const keys = valuesAttr?.split(",") ?? [];
1745
+ expect(keys).toContain("year");
1746
+ expect(keys).toContain("name");
1747
+ });
1748
+
1749
+ test("data-i18n-values should be on parent element, not on Trans", () => {
1750
+ const code = `
1751
+ import { Trans } from "react-i18next";
1752
+ export default function App() {
1753
+ const year = 2026;
1754
+ return <footer><Trans i18nKey="copyright" values={{ year }} /></footer>;
1755
+ }
1756
+ `;
1757
+ const result = transform(code);
1758
+ // data-i18n-values must appear inside the <footer ...> opening tag, not inside <Trans>
1759
+ // Rough check: values attr and i18n attr should be on the same tag
1760
+ expect(result).toContain('data-i18n-values="year"');
1761
+ expect(result).toContain('data-upstart-i18n="translation:copyright"');
1762
+ // Trans should have no injected attrs (no hash)
1763
+ const hashMatches = result?.match(/data-upstart-hash="[a-f0-9]+"/g);
1764
+ expect(hashMatches?.length).toBe(1);
1765
+ });
1766
+ });
1506
1767
  });
@@ -17,6 +17,14 @@ export const payloadEditText = z.object({
17
17
 
18
18
  export type PayloadEditText = z.infer<typeof payloadEditText>;
19
19
 
20
+ export const payloadEditTextDirect = z.object({
21
+ action: z.literal("editTextDirect"),
22
+ id: z.string().min(1),
23
+ content: z.string(),
24
+ });
25
+
26
+ export type PayloadEditTextDirect = z.infer<typeof payloadEditTextDirect>;
27
+
20
28
  export const payloadEditClassName = z.object({
21
29
  action: z.literal("editClassName"),
22
30
  id: z.string().min(1),
@@ -133,6 +141,36 @@ export class UpstartEditorAPI {
133
141
  return { success: true, filePath };
134
142
  }
135
143
 
144
+ /**
145
+ * Edit a plain-text or rich-text JSX node directly in the source TSX file.
146
+ * For rich-text entries, `content` should be the inner JSX/HTML of the element's children.
147
+ */
148
+ async editTextDirect(params: PayloadEditTextDirect): Promise<EditResult> {
149
+ const parsed = payloadEditTextDirect.safeParse(params);
150
+ if (!parsed.success) {
151
+ return { success: false, error: `Invalid payload: ${parsed.error.message}` };
152
+ }
153
+ const { id, content } = parsed.data;
154
+ if (!this.registry) {
155
+ try {
156
+ await this.loadRegistry();
157
+ } catch (err) {
158
+ return { success: false, error: `Failed to load registry: ${err}` };
159
+ }
160
+ }
161
+ const entry = this.registry!.elements[id];
162
+ if (!entry) {
163
+ return { success: false, error: `Element ${id} not found in registry` };
164
+ }
165
+ if (entry.type !== "text" && entry.type !== "rich-text" && entry.type !== "mixed-text") {
166
+ return { success: false, error: `Element ${id} is not a text element (type: ${entry.type})` };
167
+ }
168
+ if (entry.type === "mixed-text") {
169
+ return this.applyMixedTextEdit(id, entry, content);
170
+ }
171
+ return this.applyEdit(id, entry, content);
172
+ }
173
+
136
174
  /**
137
175
  * Edit the className of an element
138
176
  */
@@ -222,6 +260,39 @@ export class UpstartEditorAPI {
222
260
  }
223
261
  }
224
262
 
263
+ /**
264
+ * Reconstruct JSX children from a user-edited template and the original expression segments,
265
+ * then write the result back to the TSX source file.
266
+ *
267
+ * `template` uses `{{N}}` placeholders for expressions, e.g.:
268
+ * "© {{0}} Alex's Kitchen. All rights reserved."
269
+ * The server replaces each placeholder with the original expression source from the registry.
270
+ */
271
+ private async applyMixedTextEdit(id: string, entry: EditableEntry, template: string): Promise<EditResult> {
272
+ const exprSegments = (entry.segments ?? []).filter((s) => s.type === "expr");
273
+
274
+ // Split template by {{N}} placeholders — odd indices are expression indices
275
+ const parts = template.split(/\{\{(\d+)\}\}/);
276
+
277
+ // Reconstruct inner JSX: text parts interleaved with original expression sources
278
+ let inner = "";
279
+ for (let i = 0; i < parts.length; i++) {
280
+ if (i % 2 === 0) {
281
+ inner += parts[i];
282
+ } else {
283
+ const exprSeg = exprSegments[parseInt(parts[i])];
284
+ inner += exprSeg ? exprSeg.raw : `{{${parts[i]}}}`;
285
+ }
286
+ }
287
+
288
+ // Preserve leading/trailing JSX whitespace (indentation) from the original source
289
+ const leadingWS = entry.originalContent.match(/^(\s*)/)?.[1] ?? "";
290
+ const trailingWS = entry.originalContent.match(/(\s*)$/)?.[1] ?? "";
291
+ const newContent = leadingWS + inner + trailingWS;
292
+
293
+ return this.applyEdit(id, entry, newContent);
294
+ }
295
+
225
296
  /**
226
297
  * Get element info by ID
227
298
  */