Skip to content

Commit a399a85

Browse files
committed
feat: add optional configurable JSX placeholder naming
1 parent 1597e3a commit a399a85

8 files changed

Lines changed: 489 additions & 5 deletions

File tree

packages/babel-plugin-lingui-macro/src/index.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -232,6 +232,8 @@ export default function ({
232232
path.traverse(
233233
{
234234
JSXElement(path, state) {
235+
const linguiConfig = state.get("linguiConfig") as LinguiConfigNormalized
236+
235237
const macro = new MacroJSX(
236238
{ types: t },
237239
{
@@ -244,6 +246,8 @@ export default function ({
244246
),
245247
isLinguiIdentifier: (node: Identifier, macro) =>
246248
isLinguiIdentifier(path, node, macro),
249+
jsxPlaceholderAttribute: linguiConfig.macro?.jsxPlaceholderAttribute,
250+
jsxPlaceholderDefaults: linguiConfig.macro?.jsxPlaceholderDefaults,
247251
},
248252
)
249253

packages/babel-plugin-lingui-macro/src/macroJsx.ts

Lines changed: 95 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -49,13 +49,18 @@ function maybeNodeValue(node: Node): { text: string; loc: SourceLocation } {
4949
export type MacroJsxContext = MacroJsContext & {
5050
elementIndex: () => number
5151
transImportName: string
52+
jsxPlaceholderAttribute?: string
53+
jsxPlaceholderDefaults?: Record<string, string>
54+
elementsTracking: Map<string, JSXElement>
5255
}
5356

5457
export type MacroJsxOpts = {
5558
stripNonEssentialProps: boolean
5659
stripMessageProp: boolean
5760
transImportName: string
5861
isLinguiIdentifier: (node: Identifier, macro: JsMacroName) => boolean
62+
jsxPlaceholderAttribute?: string
63+
jsxPlaceholderDefaults?: Record<string, string>
5964
}
6065

6166
const choiceComponentAttributesWhitelist = [
@@ -86,6 +91,9 @@ export class MacroJSX {
8691
),
8792
transImportName: opts.transImportName,
8893
elementIndex: makeCounter(),
94+
jsxPlaceholderAttribute: opts.jsxPlaceholderAttribute,
95+
jsxPlaceholderDefaults: opts.jsxPlaceholderDefaults,
96+
elementsTracking: new Map(),
8997
}
9098
}
9199

@@ -351,18 +359,100 @@ export class MacroJSX {
351359
}
352360

353361
tokenizeElement = (path: NodePath<JSXElement>): ElementToken => {
354-
// !!! Important: Calculate element index before traversing children.
355-
// That way outside elements are numbered before inner elements. (...and it looks pretty).
356-
const name = this.ctx.elementIndex()
362+
const { jsxPlaceholderAttribute, jsxPlaceholderDefaults, elementsTracking } = this.ctx
363+
364+
const node = path.node
365+
const openingElement = node.openingElement
366+
367+
let value: typeof node = { ...node }
368+
let newOpeningElement: typeof openingElement = { ...openingElement }
369+
let baseName: string | undefined = undefined
370+
371+
if (jsxPlaceholderAttribute) {
372+
const attributes = newOpeningElement.attributes
373+
const attrIndex = attributes.findIndex(
374+
(attr) =>
375+
attr.type === "JSXAttribute" &&
376+
attr.name.name === jsxPlaceholderAttribute
377+
)
378+
379+
if (attrIndex !== -1) {
380+
const attr = attributes[attrIndex] as JSXAttribute
381+
if (attr.value && attr.value.type === "StringLiteral") {
382+
baseName = attr.value.value
383+
}
384+
385+
const newAttributes = [...attributes]
386+
newAttributes.splice(attrIndex, 1)
387+
388+
newOpeningElement = {
389+
...newOpeningElement,
390+
attributes: newAttributes,
391+
}
392+
value = {
393+
...value,
394+
openingElement: newOpeningElement,
395+
}
396+
}
397+
}
398+
399+
if (!baseName && jsxPlaceholderDefaults) {
400+
const tagName = newOpeningElement.name
401+
if (tagName.type === "JSXIdentifier") {
402+
const defaultName = jsxPlaceholderDefaults[tagName.name]
403+
if (defaultName) {
404+
baseName = defaultName
405+
}
406+
}
407+
}
408+
409+
let name: string | number
410+
if (!baseName) {
411+
name = this.ctx.elementIndex()
412+
elementsTracking.set(String(name), value)
413+
} else {
414+
let suffix = 1
415+
let testName = baseName
416+
let existingElement = elementsTracking.get(testName)
417+
418+
const areAttributesEqual = (
419+
attrs1: typeof newOpeningElement.attributes,
420+
attrs2: typeof newOpeningElement.attributes
421+
) => {
422+
if (attrs1.length !== attrs2.length) return false
423+
return attrs1.every((attr, i) =>
424+
this.types.isNodesEquivalent(attr, attrs2[i])
425+
)
426+
}
427+
428+
while (existingElement) {
429+
if (
430+
areAttributesEqual(
431+
existingElement.openingElement.attributes,
432+
newOpeningElement.attributes
433+
)
434+
) {
435+
break
436+
}
437+
suffix++
438+
testName = `${baseName}${suffix}`
439+
existingElement = elementsTracking.get(testName)
440+
}
441+
442+
name = testName
443+
if (!existingElement) {
444+
elementsTracking.set(name, value)
445+
}
446+
}
357447

358448
return {
359449
type: "element",
360450
name,
361451
value: {
362-
...path.node,
452+
...value,
363453
children: [],
364454
openingElement: {
365-
...path.node.openingElement,
455+
...newOpeningElement,
366456
selfClosing: true,
367457
},
368458
},
Lines changed: 196 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,196 @@
1+
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
2+
3+
exports[`Basic named placeholders 1`] = `
4+
import { Trans } from "@lingui/react/macro";
5+
<Trans>
6+
Hello <strong _t="em">world</strong>!
7+
</Trans>;
8+
9+
↓ ↓ ↓ ↓ ↓ ↓
10+
11+
import { Trans as _Trans } from "@lingui/react";
12+
<_Trans
13+
{
14+
/*i18n*/
15+
...{
16+
id: "6N0Fej",
17+
message: "Hello <em>world</em>!",
18+
components: {
19+
em: <strong />,
20+
},
21+
}
22+
}
23+
/>;
24+
25+
`;
26+
27+
exports[`Deduplication: Different props use consecutive suffix starting with 2 1`] = `
28+
import { Trans } from "@lingui/react/macro";
29+
<Trans>
30+
Hello <a href="/a">link 1</a>, normal, <a href="/b">link 2</a>.
31+
</Trans>;
32+
33+
↓ ↓ ↓ ↓ ↓ ↓
34+
35+
import { Trans as _Trans } from "@lingui/react";
36+
<_Trans
37+
{
38+
/*i18n*/
39+
...{
40+
id: "Qb0oHL",
41+
message: "Hello <a>link 1</a>, normal, <a2>link 2</a2>.",
42+
components: {
43+
a: <a href="/a" />,
44+
a2: <a href="/b" />,
45+
},
46+
}
47+
}
48+
/>;
49+
50+
`;
51+
52+
exports[`Deduplication: Identical props reuse name 1`] = `
53+
import { Trans } from "@lingui/react/macro";
54+
<Trans>
55+
Hello <em>emphasis</em>, normal, <em>more emphasis</em>.
56+
</Trans>;
57+
58+
↓ ↓ ↓ ↓ ↓ ↓
59+
60+
import { Trans as _Trans } from "@lingui/react";
61+
<_Trans
62+
{
63+
/*i18n*/
64+
...{
65+
id: "idxihm",
66+
message: "Hello <em>emphasis</em>, normal, <em>more emphasis</em>.",
67+
components: {
68+
em: <em />,
69+
},
70+
}
71+
}
72+
/>;
73+
74+
`;
75+
76+
exports[`Deduplication: Same but no suffix applied when suffix logic handles identical elements with _t prop stripped 1`] = `
77+
import { Trans } from "@lingui/react/macro";
78+
<Trans>
79+
Hello{" "}
80+
<a _t="link" href="/a">
81+
link 1
82+
</a>
83+
, normal,{" "}
84+
<a _t="link" href="/a">
85+
link 1 copy
86+
</a>{" "}
87+
and{" "}
88+
<a _t="link" href="/b">
89+
link 2
90+
</a>
91+
.
92+
</Trans>;
93+
94+
↓ ↓ ↓ ↓ ↓ ↓
95+
96+
import { Trans as _Trans } from "@lingui/react";
97+
<_Trans
98+
{
99+
/*i18n*/
100+
...{
101+
id: "gG0lnu",
102+
message:
103+
"Hello <link>link 1</link>, normal, <link>link 1 copy</link> and <link2>link 2</link2>.",
104+
components: {
105+
link: <a href="/a" />,
106+
link2: <a href="/b" />,
107+
},
108+
}
109+
}
110+
/>;
111+
112+
`;
113+
114+
exports[`Fallback to default placeholders 1`] = `
115+
import { Trans } from "@lingui/react/macro";
116+
<Trans>
117+
Here's a <a>link</a> and <em>emphasis</em>.
118+
</Trans>;
119+
120+
↓ ↓ ↓ ↓ ↓ ↓
121+
122+
import { Trans as _Trans } from "@lingui/react";
123+
<_Trans
124+
{
125+
/*i18n*/
126+
...{
127+
id: "Jg_WOt",
128+
message: "Here's a <link>link</link> and <em>emphasis</em>.",
129+
components: {
130+
link: <a />,
131+
em: <em />,
132+
},
133+
}
134+
}
135+
/>;
136+
137+
`;
138+
139+
exports[`Placeholder attribute is stripped from AST 1`] = `
140+
import { Trans } from "@lingui/react/macro";
141+
<Trans>
142+
<a _t="link" href="/about">
143+
About
144+
</a>
145+
</Trans>;
146+
147+
↓ ↓ ↓ ↓ ↓ ↓
148+
149+
import { Trans as _Trans } from "@lingui/react";
150+
<_Trans
151+
{
152+
/*i18n*/
153+
...{
154+
id: "Ym2S6K",
155+
message: "<link>About</link>",
156+
components: {
157+
link: <a href="/about" />,
158+
},
159+
}
160+
}
161+
/>;
162+
163+
`;
164+
165+
exports[`Tag-name defaults and explicit _t together 1`] = `
166+
import { Trans } from "@lingui/react/macro";
167+
<Trans>
168+
Hello{" "}
169+
<a _t="link1" href="/a">
170+
link 1
171+
</a>
172+
, normal,{" "}
173+
<a _t="link2" href="/b">
174+
link 2
175+
</a>
176+
.
177+
</Trans>;
178+
179+
↓ ↓ ↓ ↓ ↓ ↓
180+
181+
import { Trans as _Trans } from "@lingui/react";
182+
<_Trans
183+
{
184+
/*i18n*/
185+
...{
186+
id: "luK8sm",
187+
message: "Hello <link1>link 1</link1>, normal, <link2>link 2</link2>.",
188+
components: {
189+
link1: <a href="/a" />,
190+
link2: <a href="/b" />,
191+
},
192+
}
193+
}
194+
/>;
195+
196+
`;
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
2+
3+
exports[`JSX order prop differences test 1`] = `
4+
import { Trans } from "@lingui/react/macro";
5+
<Trans>
6+
Hello{" "}
7+
<a _t="link" href="/a" class="foo">
8+
link 1
9+
</a>
10+
, normal,{" "}
11+
<a _t="link" class="foo" href="/a">
12+
link 1 copy
13+
</a>
14+
.
15+
</Trans>;
16+
17+
↓ ↓ ↓ ↓ ↓ ↓
18+
19+
import { Trans as _Trans } from "@lingui/react";
20+
<_Trans
21+
{
22+
/*i18n*/
23+
...{
24+
id: "jTjLWk",
25+
message: "Hello <link>link 1</link>, normal, <link2>link 1 copy</link2>.",
26+
components: {
27+
link: <a href="/a" class="foo" />,
28+
link2: <a class="foo" href="/a" />,
29+
},
30+
}
31+
}
32+
/>;
33+
34+
`;

0 commit comments

Comments
 (0)