@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.
- package/dist/vite-plugin-upstart-attrs.d.ts.map +1 -1
- package/dist/vite-plugin-upstart-attrs.js +130 -10
- package/dist/vite-plugin-upstart-attrs.js.map +1 -1
- package/dist/vite-plugin-upstart-editor/runtime/text-editor.d.ts.map +1 -1
- package/dist/vite-plugin-upstart-editor/runtime/text-editor.js +139 -14
- package/dist/vite-plugin-upstart-editor/runtime/text-editor.js.map +1 -1
- package/package.json +3 -3
- package/src/tests/vite-plugin-upstart-attrs.test.ts +224 -13
- package/src/vite-plugin-upstart-attrs.ts +253 -14
- package/src/vite-plugin-upstart-editor/runtime/text-editor.ts +141 -14
|
@@ -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
|
|
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: "
|
|
1713
|
-
//
|
|
1714
|
-
expect(result).
|
|
1715
|
-
expect(result).
|
|
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
|
-
//
|
|
1739
|
-
expect(result).toContain('data-upstart-i18n="app:welcome
|
|
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(
|
|
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=" (")"');
|
|
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(
|
|
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
|
|
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
|
-
|
|
1975
|
-
expect(result).toContain(
|
|
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
|
-
|
|
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
|
-
|
|
323
|
-
|
|
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) {
|