Skip to content

Commit 201bcb2

Browse files
committed
Add unused translation key check
1 parent 03165bd commit 201bcb2

10 files changed

Lines changed: 816 additions & 0 deletions

File tree

.changeset/empty-clouds-share.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
'@shopify/theme-check-common': minor
3+
'@shopify/theme-check-node': minor
4+
---
5+
6+
Add an `UnusedTranslationKey` check for default locale keys that are not statically referenced.

packages/theme-check-common/src/checks/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ import { UndefinedObject } from './undefined-object';
4545
import { UniqueDocParamNames } from './unique-doc-param-names';
4646
import { UniqueStaticBlockId } from './unique-static-block-id';
4747
import { UnknownFilter } from './unknown-filter';
48+
import { UnusedTranslationKey } from './unused-translation-key';
4849
import { UnrecognizedContentForArguments } from './unrecognized-content-for-arguments';
4950
import { UnrecognizedRenderSnippetArguments } from './unrecognized-render-snippet-arguments';
5051
import { UnusedAssign } from './unused-assign';
@@ -117,6 +118,7 @@ export const allChecks: (LiquidCheckDefinition | JSONCheckDefinition)[] = [
117118
UniqueSettingIds,
118119
UniqueStaticBlockId,
119120
UnknownFilter,
121+
UnusedTranslationKey,
120122
UnrecognizedContentForArguments,
121123
UnrecognizedRenderSnippetArguments,
122124
UnsupportedDocTag,
Lines changed: 337 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,337 @@
1+
import { expect, describe, it } from 'vitest';
2+
import { check } from '../../test';
3+
import { UnusedTranslationKey } from '.';
4+
5+
describe('Module: UnusedTranslationKey', () => {
6+
it('reports unused default locale keys', async () => {
7+
const offenses = await check(
8+
{
9+
'locales/en.default.json': JSON.stringify({
10+
actions: {
11+
add: 'Add',
12+
remove: 'Remove',
13+
},
14+
}),
15+
'snippets/cart.liquid': `{{ 'actions.add' | t }}`,
16+
},
17+
[UnusedTranslationKey],
18+
);
19+
20+
expect(offenses).to.have.length(1);
21+
expect(offenses).to.containOffense({
22+
check: UnusedTranslationKey.meta.code,
23+
message: "Translation key 'actions.remove' is not statically referenced",
24+
uri: 'file:///locales/en.default.json',
25+
});
26+
expect(offenses[0]!).to.suggest(
27+
JSON.stringify({
28+
actions: {
29+
add: 'Add',
30+
remove: 'Remove',
31+
},
32+
}),
33+
'Delete unused translation key',
34+
{
35+
startIndex: 0,
36+
endIndex: JSON.stringify({
37+
actions: {
38+
add: 'Add',
39+
remove: 'Remove',
40+
},
41+
}).length,
42+
insert: JSON.stringify({ actions: { add: 'Add' } }, null, 2),
43+
},
44+
);
45+
});
46+
47+
it('does not report non-default locale keys', async () => {
48+
const offenses = await check(
49+
{
50+
'locales/en.default.json': JSON.stringify({
51+
actions: {
52+
add: 'Add',
53+
},
54+
}),
55+
'locales/fr.json': JSON.stringify({
56+
actions: {
57+
add: 'Ajouter',
58+
remove: 'Supprimer',
59+
},
60+
}),
61+
},
62+
[UnusedTranslationKey],
63+
);
64+
65+
expect(offenses).to.have.length(1);
66+
expect(offenses[0].uri).to.equal('file:///locales/en.default.json');
67+
});
68+
69+
it('does not report keys referenced with the translate filter', async () => {
70+
const offenses = await check(
71+
{
72+
'locales/en.default.json': JSON.stringify({
73+
actions: {
74+
add: 'Add',
75+
},
76+
}),
77+
'snippets/cart.liquid': `{{ 'actions.add' | translate }}`,
78+
},
79+
[UnusedTranslationKey],
80+
);
81+
82+
expect(offenses).to.have.length(0);
83+
});
84+
85+
it('does not report keys referenced by static append chains', async () => {
86+
const offenses = await check(
87+
{
88+
'locales/en.default.json': JSON.stringify({
89+
products: {
90+
product: {
91+
add_to_cart: 'Add to cart',
92+
},
93+
},
94+
}),
95+
'snippets/product-form.liquid': `
96+
{{ 'products.' | append: 'product.' | append: 'add_to_cart' | t }}
97+
`,
98+
},
99+
[UnusedTranslationKey],
100+
);
101+
102+
expect(offenses).to.have.length(0);
103+
});
104+
105+
it('does not report keys below a statically known prefix', async () => {
106+
const offenses = await check(
107+
{
108+
'locales/en.default.json': JSON.stringify({
109+
products: {
110+
product: {
111+
add_to_cart: 'Add to cart',
112+
},
113+
},
114+
cart: {
115+
title: 'Cart',
116+
},
117+
}),
118+
'snippets/product-form.liquid': `{{ 'products.product.' | append: button_state | t }}`,
119+
},
120+
[UnusedTranslationKey],
121+
);
122+
123+
expect(offenses).to.have.length(1);
124+
expect(offenses).to.containOffense("Translation key 'cart.title' is not statically referenced");
125+
});
126+
127+
it('does not report storefront locale keys when a dynamic translation key has no static prefix', async () => {
128+
const offenses = await check(
129+
{
130+
'locales/en.default.json': JSON.stringify({
131+
actions: {
132+
add: 'Add',
133+
},
134+
}),
135+
'snippets/cart.liquid': `{{ translation_key | t }}`,
136+
},
137+
[UnusedTranslationKey],
138+
);
139+
140+
expect(offenses).to.have.length(0);
141+
});
142+
143+
it('does not infer a static prefix from assigned dynamic translation keys', async () => {
144+
const offenses = await check(
145+
{
146+
'locales/en.default.json': JSON.stringify({
147+
actions: {
148+
add: 'Add',
149+
},
150+
}),
151+
'snippets/cart.liquid': `
152+
{% assign translation_key = 'actions.' | append: action %}
153+
{{ translation_key | t }}
154+
`,
155+
},
156+
[UnusedTranslationKey],
157+
);
158+
159+
expect(offenses).to.have.length(0);
160+
});
161+
162+
it('treats pluralization leaves as used when their parent key is referenced', async () => {
163+
const offenses = await check(
164+
{
165+
'locales/en.default.json': JSON.stringify({
166+
cart: {
167+
items: {
168+
one: '{{ count }} item',
169+
other: '{{ count }} items',
170+
},
171+
},
172+
}),
173+
'snippets/cart.liquid': `{{ 'cart.items' | t: count: cart.item_count }}`,
174+
},
175+
[UnusedTranslationKey],
176+
);
177+
178+
expect(offenses).to.have.length(0);
179+
});
180+
181+
it('does not report schema locale keys referenced by schema t-prefixed values', async () => {
182+
const offenses = await check(
183+
{
184+
'locales/en.default.schema.json': JSON.stringify({
185+
sections: {
186+
header: {
187+
name: 'Header',
188+
settings: {
189+
title: {
190+
label: 'Title',
191+
info: 'Info',
192+
},
193+
},
194+
},
195+
},
196+
}),
197+
'sections/header.liquid': `
198+
{% schema %}
199+
{
200+
"name": "t:sections.header.name",
201+
"settings": [
202+
{
203+
"type": "text",
204+
"id": "title",
205+
"label": "t:sections.header.settings.title.label"
206+
}
207+
]
208+
}
209+
{% endschema %}
210+
`,
211+
},
212+
[UnusedTranslationKey],
213+
);
214+
215+
expect(offenses).to.have.length(1);
216+
expect(offenses).to.containOffense(
217+
"Translation key 'sections.header.settings.title.info' is not statically referenced",
218+
);
219+
});
220+
221+
it('does not count schema translation references as storefront locale references', async () => {
222+
const offenses = await check(
223+
{
224+
'locales/en.default.json': JSON.stringify({
225+
sections: {
226+
header: {
227+
name: 'Header',
228+
},
229+
},
230+
}),
231+
'locales/en.default.schema.json': JSON.stringify({
232+
sections: {
233+
header: {
234+
name: 'Header',
235+
},
236+
},
237+
}),
238+
'sections/header.liquid': `
239+
{% schema %}
240+
{
241+
"name": "t:sections.header.name"
242+
}
243+
{% endschema %}
244+
`,
245+
},
246+
[UnusedTranslationKey],
247+
);
248+
249+
expect(offenses).to.have.length(1);
250+
expect(offenses).to.containOffense({
251+
message: "Translation key 'sections.header.name' is not statically referenced",
252+
uri: 'file:///locales/en.default.json',
253+
});
254+
});
255+
256+
it('still reports schema locale keys when a storefront dynamic key has no static prefix', async () => {
257+
const offenses = await check(
258+
{
259+
'locales/en.default.json': JSON.stringify({
260+
actions: {
261+
add: 'Add',
262+
},
263+
}),
264+
'locales/en.default.schema.json': JSON.stringify({
265+
sections: {
266+
header: {
267+
name: 'Header',
268+
settings: {
269+
title: {
270+
label: 'Title',
271+
},
272+
},
273+
},
274+
},
275+
}),
276+
'snippets/cart.liquid': `{{ translation_key | t }}`,
277+
'sections/header.liquid': `
278+
{% schema %}
279+
{
280+
"name": "t:sections.header.name"
281+
}
282+
{% endschema %}
283+
`,
284+
},
285+
[UnusedTranslationKey],
286+
);
287+
288+
expect(offenses).to.have.length(1);
289+
expect(offenses).to.containOffense({
290+
message:
291+
"Translation key 'sections.header.settings.title.label' is not statically referenced",
292+
uri: 'file:///locales/en.default.schema.json',
293+
});
294+
});
295+
296+
it('ignores configured translation key patterns', async () => {
297+
const offenses = await check(
298+
{
299+
'locales/en.default.json': JSON.stringify({
300+
dynamic: {
301+
managed_elsewhere: 'Managed elsewhere',
302+
},
303+
}),
304+
},
305+
[UnusedTranslationKey],
306+
{},
307+
{
308+
UnusedTranslationKey: {
309+
enabled: true,
310+
ignoreKeys: ['dynamic.*'],
311+
},
312+
},
313+
);
314+
315+
expect(offenses).to.have.length(0);
316+
});
317+
318+
it('ignores Shopify-provided translation key namespaces by default', async () => {
319+
const offenses = await check(
320+
{
321+
'locales/en.default.json': JSON.stringify({
322+
shopify: {
323+
sentence: {
324+
words_connector: ', ',
325+
},
326+
},
327+
customer_accounts: {
328+
sign_in: 'Sign in',
329+
},
330+
}),
331+
},
332+
[UnusedTranslationKey],
333+
);
334+
335+
expect(offenses).to.have.length(0);
336+
});
337+
});

0 commit comments

Comments
 (0)