@upstart.gg/vite-plugins 0.1.59 → 0.1.61

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.
@@ -1701,7 +1701,7 @@ describe("upstart-editor-vite-plugin", () => {
1701
1701
  expect(result).toContain('data-upstart-editable-text="true"');
1702
1702
  });
1703
1703
 
1704
- test("should NOT be editable when Trans is mixed with static text", () => {
1704
+ test("should be editable with a prefix when Trans is mixed with static text", () => {
1705
1705
  const code = `
1706
1706
  import { Trans } from "react-i18next";
1707
1707
  export default function App() {
@@ -1709,10 +1709,11 @@ describe("upstart-editor-vite-plugin", () => {
1709
1709
  }
1710
1710
  `;
1711
1711
  const result = transform(code);
1712
- // "Prefix: " is non-whitespace JSXText alongside Transmixed NOT editable
1713
- // Saving would incorrectly include "Prefix: " in the translation value
1714
- expect(result).not.toContain('data-upstart-editable-text="true"');
1715
- expect(result).not.toContain("data-upstart-i18n");
1712
+ // "Prefix: " only surrounds a single i18n node exported as a static affix so
1713
+ // the runtime editor keeps it out of the saved translation value
1714
+ expect(result).toContain('data-upstart-editable-text="true"');
1715
+ expect(result).toContain('data-upstart-i18n="translation:key"');
1716
+ expect(result).toContain('data-upstart-i18n-prefix="Prefix: "');
1716
1717
  });
1717
1718
 
1718
1719
  test("should promote <Trans> from inside && expression to parent", () => {
@@ -1735,8 +1736,8 @@ describe("upstart-editor-vite-plugin", () => {
1735
1736
  }
1736
1737
  `;
1737
1738
  const result = transform(code);
1738
- // Both keys should be comma-separated on the parent
1739
- expect(result).toContain('data-upstart-i18n="app:welcome,app:login"');
1739
+ // Only one branch renders, so the key is resolved at runtime from the condition
1740
+ expect(result).toContain('data-upstart-i18n={isLoggedIn ? "app:welcome" : "app:login"}');
1740
1741
  expect(result).toContain('data-upstart-editable-text="true"');
1741
1742
  });
1742
1743
 
@@ -1842,9 +1843,11 @@ describe("upstart-editor-vite-plugin", () => {
1842
1843
  }
1843
1844
  `;
1844
1845
  const result = transform(code);
1845
- // Both branches are i18n — no non-i18n text produced
1846
+ // Both branches are i18n — no non-i18n text produced, key resolved at runtime
1846
1847
  expect(result).toContain('data-upstart-editable-text="true"');
1847
- expect(result).toContain('data-upstart-i18n="translation:role.admin,translation:role.user"');
1848
+ expect(result).toContain(
1849
+ 'data-upstart-i18n={isAdmin ? "translation:role.admin" : "translation:role.user"}',
1850
+ );
1848
1851
  });
1849
1852
 
1850
1853
  test("should still be editable when only whitespace {' '} separates Trans from nothing", () => {
@@ -1860,6 +1863,211 @@ describe("upstart-editor-vite-plugin", () => {
1860
1863
  expect(result).toContain('data-upstart-i18n="translation:label"');
1861
1864
  });
1862
1865
  });
1866
+
1867
+ describe("i18n with static text affixes", () => {
1868
+ test("should be editable with a suffix when a bare asterisk follows Trans", () => {
1869
+ const code = `
1870
+ import { Trans } from "react-i18next";
1871
+ export default function App() {
1872
+ return (
1873
+ <label className="label">
1874
+ <Trans i18nKey="contact.form.phone" /> *
1875
+ </label>
1876
+ );
1877
+ }
1878
+ `;
1879
+ const result = transform(code);
1880
+ expect(result).toContain('data-upstart-editable-text="true"');
1881
+ expect(result).toContain('data-upstart-editable-text-mode="plain"');
1882
+ expect(result).toContain('data-upstart-i18n="translation:contact.form.phone"');
1883
+ expect(result).toContain('data-upstart-i18n-suffix=" *"');
1884
+ expect(result).not.toContain("data-upstart-i18n-prefix");
1885
+ });
1886
+
1887
+ test("should be editable with a suffix when the asterisk is wrapped in a static span", () => {
1888
+ const code = `
1889
+ import { Trans } from "react-i18next";
1890
+ export default function App() {
1891
+ return (
1892
+ <label className="label">
1893
+ <Trans i18nKey="contact.form.name" /> <span aria-hidden="true">*</span>
1894
+ </label>
1895
+ );
1896
+ }
1897
+ `;
1898
+ const result = transform(code);
1899
+ expect(result).toContain('data-upstart-editable-text="true"');
1900
+ expect(result).toContain('data-upstart-i18n="translation:contact.form.name"');
1901
+ expect(result).toContain('data-upstart-i18n-suffix=" *"');
1902
+ });
1903
+
1904
+ test("should capture a static prefix before Trans", () => {
1905
+ const code = `
1906
+ import { Trans } from "react-i18next";
1907
+ export default function App() {
1908
+ return <label>* <Trans i18nKey="form.city" /></label>;
1909
+ }
1910
+ `;
1911
+ const result = transform(code);
1912
+ expect(result).toContain('data-upstart-editable-text="true"');
1913
+ expect(result).toContain('data-upstart-i18n-prefix="* "');
1914
+ expect(result).not.toContain("data-upstart-i18n-suffix");
1915
+ });
1916
+
1917
+ test("should escape quotes in affixes", () => {
1918
+ const code = `
1919
+ import { Trans } from "react-i18next";
1920
+ export default function App() {
1921
+ return <label><Trans i18nKey="form.size" /> (")</label>;
1922
+ }
1923
+ `;
1924
+ const result = transform(code);
1925
+ expect(result).toContain('data-upstart-i18n-suffix=" (&quot;)"');
1926
+ });
1927
+
1928
+ test("should NOT emit affixes when there are two i18n sources around static text", () => {
1929
+ const code = `
1930
+ import { Trans } from "react-i18next";
1931
+ export default function App() {
1932
+ return <p><Trans i18nKey="a" /> / <Trans i18nKey="b" /></p>;
1933
+ }
1934
+ `;
1935
+ const result = transform(code);
1936
+ expect(result).not.toContain('data-upstart-editable-text="true"');
1937
+ expect(result).not.toContain("data-upstart-i18n-suffix");
1938
+ });
1939
+
1940
+ test("should NOT emit affixes when a dynamic expression sits next to Trans", () => {
1941
+ const code = `
1942
+ import { Trans } from "react-i18next";
1943
+ export default function App() {
1944
+ return <label><Trans i18nKey="form.count" /> ({count})</label>;
1945
+ }
1946
+ `;
1947
+ const result = transform(code);
1948
+ expect(result).not.toContain('data-upstart-editable-text="true"');
1949
+ expect(result).not.toContain("data-upstart-i18n-suffix");
1950
+ });
1951
+
1952
+ test("should NOT emit affixes when a sibling element is not purely static", () => {
1953
+ const code = `
1954
+ import { Trans } from "react-i18next";
1955
+ export default function App() {
1956
+ return <label><Trans i18nKey="form.city" /> <span>{hint}</span></label>;
1957
+ }
1958
+ `;
1959
+ const result = transform(code);
1960
+ expect(result).not.toContain('data-upstart-editable-text="true"');
1961
+ });
1962
+
1963
+ test("should NOT emit affixes when the element has no resolvable single key", () => {
1964
+ const code = `
1965
+ import { Trans } from "react-i18next";
1966
+ export default function App() {
1967
+ return <p><Trans i18nKey="a" /><Trans i18nKey="b" /></p>;
1968
+ }
1969
+ `;
1970
+ const result = transform(code);
1971
+ // Two translations render at once → no key can be resolved
1972
+ expect(result).not.toContain("data-upstart-i18n");
1973
+ expect(result).not.toContain('data-upstart-editable-text="true"');
1974
+ });
1975
+
1976
+ test("should emit affixes around a t() call too", () => {
1977
+ const code = `
1978
+ import { useTranslation } from "react-i18next";
1979
+ export default function App() {
1980
+ const { t } = useTranslation();
1981
+ return <label>{t("form.email")} *</label>;
1982
+ }
1983
+ `;
1984
+ const result = transform(code);
1985
+ expect(result).toContain('data-upstart-editable-text="true"');
1986
+ expect(result).toContain('data-upstart-i18n="translation:form.email"');
1987
+ expect(result).toContain('data-upstart-i18n-suffix=" *"');
1988
+ });
1989
+ });
1990
+ });
1991
+
1992
+ describe("Runtime-resolved i18n key (single key per element)", () => {
1993
+ test("should resolve the key from the condition for a submit button", () => {
1994
+ const code = `
1995
+ import { Trans } from "react-i18next";
1996
+ export default function App({ isSubmitting }) {
1997
+ return (
1998
+ <button type="submit" disabled={isSubmitting}>
1999
+ {isSubmitting ? <Trans i18nKey="form.submitting" /> : <Trans i18nKey="form.submit" />}
2000
+ </button>
2001
+ );
2002
+ }
2003
+ `;
2004
+ const result = transform(code);
2005
+ expect(result).toContain('data-upstart-editable-text="true"');
2006
+ expect(result).toContain(
2007
+ 'data-upstart-i18n={isSubmitting ? "translation:form.submitting" : "translation:form.submit"}',
2008
+ );
2009
+ // Never a comma-separated list — the runtime cannot parse those
2010
+ expect(result).not.toMatch(/data-upstart-i18n="[^"]*,/);
2011
+ });
2012
+
2013
+ test("should keep a member-expression condition intact", () => {
2014
+ const code = `
2015
+ import { Trans } from "react-i18next";
2016
+ export default function App({ nav }) {
2017
+ return <span>{nav.state === "idle" ? <Trans i18nKey="a" /> : <Trans i18nKey="b" />}</span>;
2018
+ }
2019
+ `;
2020
+ const result = transform(code);
2021
+ expect(result).toContain(
2022
+ 'data-upstart-i18n={nav.state === "idle" ? "translation:a" : "translation:b"}',
2023
+ );
2024
+ });
2025
+
2026
+ test("should mix a runtime-resolved key with a static suffix", () => {
2027
+ const code = `
2028
+ import { Trans } from "react-i18next";
2029
+ export default function App({ isSubmitting }) {
2030
+ return <label>{isSubmitting ? <Trans i18nKey="a" /> : <Trans i18nKey="b" />} *</label>;
2031
+ }
2032
+ `;
2033
+ const result = transform(code);
2034
+ expect(result).toContain('data-upstart-i18n-suffix=" *"');
2035
+ expect(result).toContain('data-upstart-i18n={isSubmitting ? "translation:a" : "translation:b"}');
2036
+ });
2037
+
2038
+ test("should NOT be editable when a Trans sits next to an i18n ternary", () => {
2039
+ const code = `
2040
+ import { Trans } from "react-i18next";
2041
+ export default function App({ flag }) {
2042
+ return <p><Trans i18nKey="lead" />{flag ? <Trans i18nKey="a" /> : <Trans i18nKey="b" />}</p>;
2043
+ }
2044
+ `;
2045
+ const result = transform(code);
2046
+ expect(result).not.toContain("data-upstart-i18n");
2047
+ expect(result).not.toContain('data-upstart-editable-text="true"');
2048
+ });
2049
+
2050
+ test("should NOT be editable when a ternary branch holds two translations", () => {
2051
+ const code = `
2052
+ import { Trans } from "react-i18next";
2053
+ export default function App({ flag }) {
2054
+ return <p>{flag ? <><Trans i18nKey="a" /><Trans i18nKey="b" /></> : <Trans i18nKey="c" />}</p>;
2055
+ }
2056
+ `;
2057
+ const result = transform(code);
2058
+ expect(result).not.toContain("data-upstart-i18n=");
2059
+ });
2060
+
2061
+ test("should still emit a plain static key for a single translation", () => {
2062
+ const code = `
2063
+ import { Trans } from "react-i18next";
2064
+ export default function App() {
2065
+ return <h1><Trans i18nKey="site.title" /></h1>;
2066
+ }
2067
+ `;
2068
+ const result = transform(code);
2069
+ expect(result).toContain('data-upstart-i18n="translation:site.title"');
2070
+ });
1863
2071
  });
1864
2072
 
1865
2073
  describe("useTranslation t() Function Detection", () => {
@@ -1921,7 +2129,9 @@ describe("upstart-editor-vite-plugin", () => {
1921
2129
  }
1922
2130
  `;
1923
2131
  const result = transform(code);
1924
- expect(result).toContain('data-upstart-i18n="translation:active,translation:inactive"');
2132
+ expect(result).toContain(
2133
+ 'data-upstart-i18n={isActive ? "translation:active" : "translation:inactive"}',
2134
+ );
1925
2135
  });
1926
2136
 
1927
2137
  test("should detect t() in logical expression", () => {
@@ -1961,7 +2171,7 @@ describe("upstart-editor-vite-plugin", () => {
1961
2171
  expect(result).toContain('data-upstart-i18n="translation:features.title"');
1962
2172
  });
1963
2173
 
1964
- test("should combine Trans and t() keys in same parent", () => {
2174
+ test("should NOT be editable when a t() call and a Trans render side by side", () => {
1965
2175
  const code = `
1966
2176
  import { useTranslation } from "react-i18next";
1967
2177
  import { Trans } from "react-i18next";
@@ -1971,8 +2181,9 @@ describe("upstart-editor-vite-plugin", () => {
1971
2181
  }
1972
2182
  `;
1973
2183
  const result = transform(code);
1974
- expect(result).toContain('data-upstart-i18n="app:suffix,app:prefix"');
1975
- expect(result).toContain('data-upstart-editable-text="true"');
2184
+ // Both translations render at once: an inline edit could not be routed to a key
2185
+ expect(result).not.toContain("data-upstart-i18n");
2186
+ expect(result).not.toContain('data-upstart-editable-text="true"');
1976
2187
  });
1977
2188
 
1978
2189
  test("should detect t() inside .map() callback on child element", () => {
@@ -314,24 +314,34 @@ export function transformWithOxc(code: string, filePath: string) {
314
314
  const tCallChildren = findTCallsInChildren(jsxNode, state.code, state);
315
315
  const allI18nKeys = [...transChildren, ...tCallChildren];
316
316
  const hasI18n = allI18nKeys.length > 0;
317
-
318
- if (hasI18n && !hasMixedNonI18nContent(jsxNode, state.code, state, state.constants)) {
317
+ const isMixed = hasI18n && hasMixedNonI18nContent(jsxNode, state.code, state, state.constants);
318
+
319
+ // Mixed content is normally disqualifying, but the very common
320
+ // `<label><Trans i18nKey="…" /> *</label>` shape is recoverable: the static
321
+ // text around the single i18n node is exported as prefix/suffix so the editor
322
+ // can keep it out of the saved translation.
323
+ const i18nAffixes = isMixed
324
+ ? detectI18nStaticAffixes(jsxNode, state.code, state, state.constants)
325
+ : null;
326
+
327
+ // The element must resolve to exactly ONE translation key at runtime, otherwise
328
+ // an inline edit could not be routed to the right key.
329
+ const i18nAttrValue = hasI18n
330
+ ? buildI18nAttributeValue(jsxNode, allI18nKeys, state.code, state, state.constants)
331
+ : null;
332
+
333
+ if (hasI18n && i18nAttrValue && (!isMixed || i18nAffixes)) {
319
334
  attributes.push('data-upstart-editable-text="true"');
320
335
  attributes.push('data-upstart-editable-text-mode="plain"');
321
336
 
322
- const hasDynamic = allI18nKeys.some((t) => t.keyExpr || t.nsExpr);
323
- if (hasDynamic) {
324
- // Build a runtime JSX template expression for dynamic i18n keys
325
- const parts = allI18nKeys.map((t) => {
326
- const nsPart = t.nsExpr ? `\${${t.nsExpr}}` : t.namespace;
327
- const keyPart = t.keyExpr ? `\${${t.keyExpr}}` : t.key;
328
- return `${nsPart}:${keyPart}`;
329
- });
330
- attributes.push(`data-upstart-i18n={\`${parts.join(",")}\`}`);
331
- } else {
332
- const keys = allI18nKeys.map((t: I18nKeyInfo) => escapeProp(t.fullKey)).join(",");
333
- attributes.push(`data-upstart-i18n="${keys}"`);
337
+ if (i18nAffixes?.prefix) {
338
+ attributes.push(`data-upstart-i18n-prefix="${escapeProp(i18nAffixes.prefix)}"`);
334
339
  }
340
+ if (i18nAffixes?.suffix) {
341
+ attributes.push(`data-upstart-i18n-suffix="${escapeProp(i18nAffixes.suffix)}"`);
342
+ }
343
+
344
+ attributes.push(`data-upstart-i18n=${i18nAttrValue}`);
335
345
 
336
346
  const allValueKeys = [...new Set(allI18nKeys.flatMap((t) => t.valueKeys ?? []))];
337
347
  if (allValueKeys.length > 0) {
@@ -1044,6 +1054,22 @@ function findTransInExpression(
1044
1054
  return;
1045
1055
  }
1046
1056
 
1057
+ // A fragment renders all of its children at once — collect every key it holds
1058
+ if (expr.type === "JSXFragment") {
1059
+ for (const child of expr.children) {
1060
+ if (child.type === "JSXElement") {
1061
+ const info = detectTransComponent(child as JSXElement, code, constants);
1062
+ if (info) results.push(info);
1063
+ } else if (child.type === "JSXExpressionContainer") {
1064
+ const inner = (child as any).expression;
1065
+ if (inner && inner.type !== "JSXEmptyExpression") {
1066
+ findTransInExpression(inner, code, results, constants);
1067
+ }
1068
+ }
1069
+ }
1070
+ return;
1071
+ }
1072
+
1047
1073
  if (expr.type === "LogicalExpression") {
1048
1074
  findTransInExpression(expr.left, code, results, constants);
1049
1075
  findTransInExpression(expr.right, code, results, constants);
@@ -1099,6 +1125,197 @@ function findTCallsInChildren(jsxElement: JSXElement, code: string, state: Trans
1099
1125
  return results;
1100
1126
  }
1101
1127
 
1128
+ // Source of a JS expression resolving to "namespace:key" for one i18n reference.
1129
+ function i18nKeyExpressionSource(info: I18nKeyInfo): string {
1130
+ if (info.keyExpr || info.nsExpr) {
1131
+ const nsPart = info.nsExpr ? `\${${info.nsExpr}}` : info.namespace;
1132
+ const keyPart = info.keyExpr ? `\${${info.keyExpr}}` : info.key;
1133
+ return `\`${nsPart}:${keyPart}\``;
1134
+ }
1135
+ return JSON.stringify(info.fullKey);
1136
+ }
1137
+
1138
+ interface I18nTernary {
1139
+ /** Source of the ternary condition, e.g. "isSubmitting" */
1140
+ test: string;
1141
+ consequent: I18nKeyInfo;
1142
+ alternate: I18nKeyInfo;
1143
+ }
1144
+
1145
+ // Detects an element whose only i18n content is a ternary between two translations,
1146
+ // e.g. `<button>{isSubmitting ? <Trans i18nKey="a" /> : <Trans i18nKey="b" />}</button>`.
1147
+ // Only one branch is ever rendered, so the key can be resolved at runtime instead of
1148
+ // being ambiguous. Returns null as soon as anything else produces i18n content.
1149
+ function detectI18nTernary(
1150
+ jsxNode: JSXElement,
1151
+ code: string,
1152
+ state: TransformState,
1153
+ constants: Map<string, string>,
1154
+ ): I18nTernary | null {
1155
+ const keysOf = (expr: unknown): I18nKeyInfo[] => {
1156
+ const found: I18nKeyInfo[] = [];
1157
+ findTransInExpression(expr, code, found, constants);
1158
+ findTCallInExpression(expr, code, found, state);
1159
+ return found;
1160
+ };
1161
+
1162
+ let ternary: I18nTernary | null = null;
1163
+
1164
+ for (const child of jsxNode.children) {
1165
+ if (child.type === "JSXElement") {
1166
+ // A <Trans> sibling renders at the same time as the ternary → ambiguous
1167
+ if (detectTransComponent(child as JSXElement, code, constants)) return null;
1168
+ continue;
1169
+ }
1170
+
1171
+ if (child.type !== "JSXExpressionContainer") continue;
1172
+
1173
+ const expr = (child as any).expression;
1174
+ if (!expr || expr.type === "JSXEmptyExpression") continue;
1175
+ if (keysOf(expr).length === 0) continue;
1176
+
1177
+ // A second i18n-producing child renders alongside the first → ambiguous
1178
+ if (ternary) return null;
1179
+ if (expr.type !== "ConditionalExpression" || !hasRange(expr.test)) return null;
1180
+
1181
+ const consequentKeys = keysOf(expr.consequent);
1182
+ const alternateKeys = keysOf(expr.alternate);
1183
+ if (consequentKeys.length !== 1 || alternateKeys.length !== 1) return null;
1184
+
1185
+ ternary = {
1186
+ test: code.slice(expr.test.start, expr.test.end),
1187
+ consequent: consequentKeys[0],
1188
+ alternate: alternateKeys[0],
1189
+ };
1190
+ }
1191
+
1192
+ return ternary;
1193
+ }
1194
+
1195
+ // Builds the source of the `data-upstart-i18n` attribute, which must resolve to a
1196
+ // single "namespace:key" at runtime so an inline edit can be routed to the right
1197
+ // translation:
1198
+ // "translation:home.title" — single static key
1199
+ // {`${ns}:${keyVar}`} — dynamic key and/or namespace
1200
+ // {isSubmitting ? "translation:a" : "translation:b"} — ternary between two translations
1201
+ // Returns null when the element renders several translations at once — the element
1202
+ // then stays non-editable rather than saving edits to a bogus key.
1203
+ function buildI18nAttributeValue(
1204
+ jsxNode: JSXElement,
1205
+ keys: I18nKeyInfo[],
1206
+ code: string,
1207
+ state: TransformState,
1208
+ constants: Map<string, string>,
1209
+ ): string | null {
1210
+ if (keys.length === 1) {
1211
+ const [info] = keys;
1212
+ if (info.keyExpr || info.nsExpr) {
1213
+ return `{${i18nKeyExpressionSource(info)}}`;
1214
+ }
1215
+ return `"${escapeProp(info.fullKey)}"`;
1216
+ }
1217
+
1218
+ const ternary = detectI18nTernary(jsxNode, code, state, constants);
1219
+ if (!ternary) return null;
1220
+
1221
+ const consequent = i18nKeyExpressionSource(ternary.consequent);
1222
+ const alternate = i18nKeyExpressionSource(ternary.alternate);
1223
+ return `{${ternary.test} ? ${consequent} : ${alternate}}`;
1224
+ }
1225
+
1226
+ // Returns the visible text of an element whose children are exclusively JSXText
1227
+ // (e.g. the "*" of `<span aria-hidden="true">*</span>`), or null when the element
1228
+ // contains anything dynamic. Such an element renders static text, so next to an
1229
+ // i18n node it must be treated as a static affix, never as editable content.
1230
+ function getStaticElementText(jsxElement: JSXElement): string | null {
1231
+ let text = "";
1232
+ for (const child of jsxElement.children) {
1233
+ if (child.type !== "JSXText") return null;
1234
+ text += normalizeJSXText((child as any).value as string);
1235
+ }
1236
+ return text.trim().length > 0 ? text : null;
1237
+ }
1238
+
1239
+ interface I18nStaticAffixes {
1240
+ /** Static text rendered before the i18n node, e.g. "" */
1241
+ prefix: string;
1242
+ /** Static text rendered after the i18n node, e.g. " *" */
1243
+ suffix: string;
1244
+ }
1245
+
1246
+ // Detects the "single i18n source surrounded by static text" shape, e.g.
1247
+ // <label><Trans i18nKey="contact.form.phone" /> *</label>
1248
+ // Returns the static text rendered before/after the i18n node (normalised the same
1249
+ // way React normalises JSXText), or null when the element does not match: several
1250
+ // i18n sources, non-i18n elements, or dynamic expressions among the children.
1251
+ // The affixes let the runtime editor strip the static parts from the editable
1252
+ // content so they never leak into the saved translation.
1253
+ function detectI18nStaticAffixes(
1254
+ jsxNode: JSXElement,
1255
+ code: string,
1256
+ state: TransformState,
1257
+ constants: Map<string, string>,
1258
+ ): I18nStaticAffixes | null {
1259
+ let prefix = "";
1260
+ let suffix = "";
1261
+ let seenI18n = false;
1262
+
1263
+ const append = (text: string): void => {
1264
+ if (seenI18n) suffix += text;
1265
+ else prefix += text;
1266
+ };
1267
+
1268
+ for (const child of jsxNode.children) {
1269
+ if (child.type === "JSXText") {
1270
+ // React only trims JSXText around newlines; a single-line text (e.g. the " "
1271
+ // or " *" between two elements) is rendered verbatim.
1272
+ const raw = (child as any).value as string;
1273
+ const text = /[\r\n]/.test(raw) ? normalizeJSXText(raw) : raw;
1274
+ if (text) append(text);
1275
+ continue;
1276
+ }
1277
+
1278
+ if (child.type === "JSXElement") {
1279
+ if (detectTransComponent(child as JSXElement, code, constants)) {
1280
+ if (seenI18n) return null;
1281
+ seenI18n = true;
1282
+ continue;
1283
+ }
1284
+ // A purely static element (e.g. <span aria-hidden="true">*</span>) is an affix
1285
+ const staticText = getStaticElementText(child as JSXElement);
1286
+ if (staticText === null) return null;
1287
+ append(staticText);
1288
+ continue;
1289
+ }
1290
+
1291
+ if (child.type === "JSXExpressionContainer") {
1292
+ const expr = (child as any).expression;
1293
+ if (!expr || expr.type === "JSXEmptyExpression") continue;
1294
+
1295
+ // Whitespace-only literal (e.g. {" "}) renders as static text
1296
+ if (expr.type === "Literal" && typeof expr.value === "string" && expr.value.trim() === "") {
1297
+ append(expr.value);
1298
+ continue;
1299
+ }
1300
+
1301
+ const results: I18nKeyInfo[] = [];
1302
+ findTCallInExpression(expr, code, results, state);
1303
+ findTransInExpression(expr, code, results, constants);
1304
+ if (results.length === 0) return null;
1305
+ if (seenI18n) return null;
1306
+ seenI18n = true;
1307
+ continue;
1308
+ }
1309
+
1310
+ // Fragments, spread children… — too complex to reason about
1311
+ return null;
1312
+ }
1313
+
1314
+ if (!seenI18n || (!prefix && !suffix)) return null;
1315
+
1316
+ return { prefix, suffix };
1317
+ }
1318
+
1102
1319
  // Returns true if the element mixes i18n content with non-i18n text/expressions.
1103
1320
  // In that case, inline editing would incorrectly overwrite the translation with
1104
1321
  // the full rendered text (including static parts like "© 2026 Company.").
@@ -1111,6 +1328,10 @@ function findTCallsInChildren(jsxElement: JSXElement, code: string, state: Trans
1111
1328
  // Mixed patterns (flagged):
1112
1329
  // "© John Doe." + <Trans> — non-whitespace JSXText alongside i18n
1113
1330
  // {year} + <Trans> — text-producing Identifier/MemberExpression alongside i18n
1331
+ // <span>*</span> + <Trans> — static sibling element rendering its own text
1332
+ //
1333
+ // Flagged elements can still be recovered by detectI18nStaticAffixes() when the
1334
+ // static parts merely surround a single i18n node.
1114
1335
  function hasMixedNonI18nContent(
1115
1336
  jsxNode: JSXElement,
1116
1337
  code: string,
@@ -1120,6 +1341,11 @@ function hasMixedNonI18nContent(
1120
1341
  for (const child of jsxNode.children) {
1121
1342
  if (child.type === "JSXText") {
1122
1343
  if (((child as any).value as string).trim().length > 0) return true;
1344
+ } else if (child.type === "JSXElement") {
1345
+ // A sibling element that renders its own text (e.g. <span aria-hidden="true">*</span>)
1346
+ // is not part of the translation — same hazard as raw JSXText.
1347
+ if (detectTransComponent(child as JSXElement, code, constants)) continue;
1348
+ if (hasVisibleTextContent(child as JSXElement)) return true;
1123
1349
  } else if (child.type === "JSXExpressionContainer") {
1124
1350
  const expr = (child as any).expression;
1125
1351
  if (!expr || expr.type === "JSXEmptyExpression") continue;
@@ -1153,6 +1379,19 @@ function hasMixedNonI18nContent(
1153
1379
  function findTCallInExpression(expr: any, code: string, results: I18nKeyInfo[], state: TransformState): void {
1154
1380
  if (!expr || !expr.type) return;
1155
1381
 
1382
+ // A fragment renders all of its children at once — collect every key it holds
1383
+ if (expr.type === "JSXFragment") {
1384
+ for (const child of expr.children) {
1385
+ if (child.type === "JSXExpressionContainer") {
1386
+ const inner = (child as any).expression;
1387
+ if (inner && inner.type !== "JSXEmptyExpression") {
1388
+ findTCallInExpression(inner, code, results, state);
1389
+ }
1390
+ }
1391
+ }
1392
+ return;
1393
+ }
1394
+
1156
1395
  if (expr.type === "CallExpression") {
1157
1396
  const info = detectTCall(expr, code, state);
1158
1397
  if (info) {