Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .changeset/empty-clouds-share.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
'@shopify/theme-check-common': minor
'@shopify/theme-check-node': minor
'@shopify/theme-language-server-common': patch
---

Add an `UnusedTranslationKey` check for default locale keys that are not statically referenced.

Use preloaded theme files when collecting translation references for language server diagnostics.
2 changes: 2 additions & 0 deletions packages/theme-check-common/src/checks/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ import { UnrecognizedRenderSnippetArguments } from './unrecognized-render-snippe
import { UnusedAssign } from './unused-assign';
import { UnsupportedDocTag } from './unsupported-doc-tag';
import { UnusedDocParam } from './unused-doc-param';
import { UnusedTranslationKey } from './unused-translation-key';
import { ValidContentForArguments } from './valid-content-for-arguments';
import { ValidContentForArgumentTypes } from './valid-content-for-argument-types';
import { ValidBlockTarget } from './valid-block-target';
Expand Down Expand Up @@ -122,6 +123,7 @@ export const allChecks: (LiquidCheckDefinition | JSONCheckDefinition)[] = [
UnsupportedDocTag,
UnusedAssign,
UnusedDocParam,
UnusedTranslationKey,
ValidBlockTarget,
ValidHTMLTranslation,
ValidContentForArguments,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,373 @@
import { expect, describe, it } from 'vitest';
import { check } from '../../test';
import { UnusedTranslationKey } from '.';

describe('Module: UnusedTranslationKey', () => {
it('reports unused default locale keys', async () => {
const offenses = await check(
{
'locales/en.default.json': JSON.stringify({
actions: {
add: 'Add',
remove: 'Remove',
},
}),
'snippets/cart.liquid': `{{ 'actions.add' | t }}`,
},
[UnusedTranslationKey],
);

expect(offenses).to.have.length(1);
expect(offenses).to.containOffense({
check: UnusedTranslationKey.meta.code,
message: "Translation key 'actions.remove' is not statically referenced",
uri: 'file:///locales/en.default.json',
});
expect(offenses[0]!).to.suggest(
JSON.stringify({
actions: {
add: 'Add',
remove: 'Remove',
},
}),
'Delete unused translation key',
{
startIndex: 0,
endIndex: JSON.stringify({
actions: {
add: 'Add',
remove: 'Remove',
},
}).length,
insert: JSON.stringify({ actions: { add: 'Add' } }, null, 2),
},
);
});

it('does not report non-default locale keys', async () => {
const offenses = await check(
{
'locales/en.default.json': JSON.stringify({
actions: {
add: 'Add',
},
}),
'locales/fr.json': JSON.stringify({
actions: {
add: 'Ajouter',
remove: 'Supprimer',
},
}),
},
[UnusedTranslationKey],
);

expect(offenses).to.have.length(1);
expect(offenses[0].uri).to.equal('file:///locales/en.default.json');
});

it('does not report keys referenced with the translate filter', async () => {
const offenses = await check(
{
'locales/en.default.json': JSON.stringify({
actions: {
add: 'Add',
},
}),
'snippets/cart.liquid': `{{ 'actions.add' | translate }}`,
},
[UnusedTranslationKey],
);

expect(offenses).to.have.length(0);
});

it('does not report keys referenced by static append chains', async () => {
const offenses = await check(
{
'locales/en.default.json': JSON.stringify({
products: {
product: {
add_to_cart: 'Add to cart',
},
},
}),
'snippets/product-form.liquid': `
{{ 'products.' | append: 'product.' | append: 'add_to_cart' | t }}
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@karreiro This is pretty nice, but I'm starting to see how this could scale poorly and get pretty complex. I'm wondering if we should scope this down and just have this valid static keys and to be more aggressive with what we can We consider as dynamic translations which turn off this check

`,
},
[UnusedTranslationKey],
);

expect(offenses).to.have.length(0);
});

it('does not report keys below a statically known prefix', async () => {
const offenses = await check(
{
'locales/en.default.json': JSON.stringify({
products: {
product: {
add_to_cart: 'Add to cart',
},
},
cart: {
title: 'Cart',
},
}),
'snippets/product-form.liquid': `{{ 'products.product.' | append: button_state | t }}`,
},
[UnusedTranslationKey],
);

expect(offenses).to.have.length(1);
expect(offenses).to.containOffense("Translation key 'cart.title' is not statically referenced");
});

it('does not report storefront locale keys when a dynamic translation key has no static prefix', async () => {
const offenses = await check(
{
'locales/en.default.json': JSON.stringify({
actions: {
add: 'Add',
},
}),
'snippets/cart.liquid': `{{ translation_key | t }}`,
},
[UnusedTranslationKey],
);

expect(offenses).to.have.length(0);
});

it('does not infer a static prefix from assigned dynamic translation keys', async () => {
const offenses = await check(
{
'locales/en.default.json': JSON.stringify({
actions: {
add: 'Add',
},
}),
'snippets/cart.liquid': `
{% assign translation_key = 'actions.' | append: action %}
{{ translation_key | t }}
`,
},
[UnusedTranslationKey],
);

expect(offenses).to.have.length(0);
});
Comment on lines +143 to +160
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is it a "as soon as you have a dynamic, we can't report errors" kind of situation?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Unfortunately, yes.
I did a best effort approach for suporting append and prepend tags (if they're provided static values), but I'm questioning whether it's worth doing so

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No I think it's fine as is. Honestly this is a tough nut. I'm glad there's this there because otherwise we'd get false positives and those are worse than no red lines at all IMO.


it('treats pluralization leaves as used when their parent key is referenced', async () => {
const offenses = await check(
{
'locales/en.default.json': JSON.stringify({
cart: {
items: {
one: '{{ count }} item',
other: '{{ count }} items',
},
},
}),
'snippets/cart.liquid': `{{ 'cart.items' | t: count: cart.item_count }}`,
},
[UnusedTranslationKey],
);

expect(offenses).to.have.length(0);
});

it('does not report schema locale keys referenced by schema t-prefixed values', async () => {
const offenses = await check(
{
'locales/en.default.schema.json': JSON.stringify({
sections: {
header: {
name: 'Header',
settings: {
title: {
label: 'Title',
info: 'Info',
},
},
},
},
}),
'sections/header.liquid': `
{% schema %}
{
"name": "t:sections.header.name",
"settings": [
{
"type": "text",
"id": "title",
"label": "t:sections.header.settings.title.label"
}
]
}
{% endschema %}
`,
},
[UnusedTranslationKey],
);

expect(offenses).to.have.length(1);
expect(offenses).to.containOffense(
"Translation key 'sections.header.settings.title.info' is not statically referenced",
);
});

it('does not report schema locale keys referenced by settings_schema.json t-prefixed values', async () => {
const offenses = await check(
{
'locales/en.default.schema.json': JSON.stringify({
theme_settings: {
colors: {
name: 'Colors',
accent: {
label: 'Accent',
info: 'Choose an accent color',
},
},
},
}),
'config/settings_schema.json': JSON.stringify([
{
name: 't:theme_settings.colors.name',
settings: [
{
type: 'color',
id: 'accent',
label: 't:theme_settings.colors.accent.label',
},
],
},
]),
},
[UnusedTranslationKey],
);

expect(offenses).to.have.length(1);
expect(offenses).to.containOffense(
"Translation key 'theme_settings.colors.accent.info' is not statically referenced",
);
});

it('does not count schema translation references as storefront locale references', async () => {
const offenses = await check(
{
'locales/en.default.json': JSON.stringify({
sections: {
header: {
name: 'Header',
},
},
}),
'locales/en.default.schema.json': JSON.stringify({
sections: {
header: {
name: 'Header',
},
},
}),
'sections/header.liquid': `
{% schema %}
{
"name": "t:sections.header.name"
}
{% endschema %}
`,
},
[UnusedTranslationKey],
);

expect(offenses).to.have.length(1);
expect(offenses).to.containOffense({
message: "Translation key 'sections.header.name' is not statically referenced",
uri: 'file:///locales/en.default.json',
});
});

it('still reports schema locale keys when a storefront dynamic key has no static prefix', async () => {
const offenses = await check(
{
'locales/en.default.json': JSON.stringify({
actions: {
add: 'Add',
},
}),
'locales/en.default.schema.json': JSON.stringify({
sections: {
header: {
name: 'Header',
settings: {
title: {
label: 'Title',
},
},
},
},
}),
'snippets/cart.liquid': `{{ translation_key | t }}`,
'sections/header.liquid': `
{% schema %}
{
"name": "t:sections.header.name"
}
{% endschema %}
`,
},
[UnusedTranslationKey],
);

expect(offenses).to.have.length(1);
expect(offenses).to.containOffense({
message:
"Translation key 'sections.header.settings.title.label' is not statically referenced",
uri: 'file:///locales/en.default.schema.json',
});
});

it('ignores configured translation key patterns', async () => {
const offenses = await check(
{
'locales/en.default.json': JSON.stringify({
dynamic: {
managed_elsewhere: 'Managed elsewhere',
},
}),
},
[UnusedTranslationKey],
{},
{
UnusedTranslationKey: {
enabled: true,
ignoreKeys: ['dynamic.*'],
},
},
);

expect(offenses).to.have.length(0);
});

it('ignores Shopify-provided translation key namespaces by default', async () => {
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@karreiro please sanity check this

const offenses = await check(
{
'locales/en.default.json': JSON.stringify({
shopify: {
sentence: {
words_connector: ', ',
},
},
customer_accounts: {
sign_in: 'Sign in',
},
}),
},
[UnusedTranslationKey],
);

expect(offenses).to.have.length(0);
});
});
Loading
Loading