Skip to content

Add unused translation key check#1213

Closed
jamesmengo wants to merge 1 commit into
mainfrom
jm/unused-translation-key
Closed

Add unused translation key check#1213
jamesmengo wants to merge 1 commit into
mainfrom
jm/unused-translation-key

Conversation

@jamesmengo
Copy link
Copy Markdown
Contributor

@jamesmengo jamesmengo commented May 27, 2026

What are you adding in this PR?

Adds UnusedTranslationKey, a new Theme Check rule that reports default locale keys that are not statically referenced by the theme.

This checks:

  • locales/*.default.json
  • locales/*.default.schema.json

I'm only adding this to all config for now.

In the language server, diagnostics are reported only for open files, but the check will check the entire theme.

A principle I'm following here is to try to eliminate false positives by defaulting to not reporting any errors when we can't resolve dynamic translation keys.

This has to strike a balance with making this check completely useless if it's always defaulting to silencing errors. Please scrutinize that specific choice here. Might be Worth trading off additional coverage from this check with simplicity.

Example What happens
{{ 'cart.title' | t }} Protects cart.title as a literal storefront reference.
{{ 'cart.title' | translate }} Protects cart.title; translate is handled the same as t.
"label": "t:sections.header.settings.title.label" Protects sections.header.settings.title.label for schema locale files.
{{ 'cart.' | append: 'title' | t }} Resolves the literal append chain and protects cart.title.
{{ 'title' | prepend: 'cart.' | t }} Resolves the literal prepend chain and protects cart.title.
{{ 'products.product.' | append: state | t }} Treats products.product. as a safe prefix and protects products.product.*.
{{ 'products.product.' | replace: 'product.', '' | append: state | t }} Treats the storefront reference as dynamic because unsupported filters can rewrite the known prefix.
{{ 'cart.items' | t: count: cart.item_count }} Protects pluralization leaves such as cart.items.one and cart.items.other.
{{ translation_key | t }} Treats the storefront reference as unbounded dynamic and disables storefront unused-key reporting.
{% assign key = 'actions.' | append: action %} + {{ key | t }} Also treated as unbounded dynamic; this PR does not trace Liquid assignment/data flow.
shopify.sentence.words_connector in a locale file Ignored by default; not reported as unused.
customer_accounts.order.title in a locale file Ignored by default; not reported as unused. Reviewer callout: this default is worth checking.

Unsupported filters before t / translate are treated as dynamic rather than trying to preserve a stale prefix. When a storefront reference is dynamic and cannot be resolved to either an exact key or a safe prefix, storefront unused-key reporting is disabled for that theme.

Global ignore config is respected when collecting translation references, so ignored Liquid files do not protect keys or trigger dynamic-key suppression.

What did you learn?

Resolving dynamic keys can be a much larger problem than I initially thought 😆. Oh, silly me.

Tophatting

  • I added screenshots of the changes (before and after the changes if applicable)
image

Before you deploy

  • This PR includes a new checks or changes the configuration of a check
    • I included a minor bump changeset
    • I ran the Theme Check build/config generator and confirmed there are no additional generated config changes
    • theme-app-extension.yml is not applicable here; generated config did not change
    • [Shopifolk] I've made a PR to update the shopify.dev theme check docs if applicable (link PR here).
  • My feature is backward compatible

@jamesmengo jamesmengo force-pushed the jm/unused-translation-key branch 5 times, most recently from 9bc501e to da72b57 Compare May 27, 2026 23:33
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

@jamesmengo jamesmengo force-pushed the jm/unused-translation-key branch 2 times, most recently from 201bcb2 to a8b0b2e Compare May 28, 2026 22:19
@jamesmengo jamesmengo self-assigned this May 28, 2026
@jamesmengo jamesmengo force-pushed the jm/unused-translation-key branch 2 times, most recently from f0f9845 to 830cf17 Compare May 29, 2026 00:17
@jamesmengo jamesmengo marked this pull request as ready for review May 29, 2026 00:22
@jamesmengo jamesmengo requested a review from a team as a code owner May 29, 2026 00:22

type ResolvedTranslationKey =
| { type: 'key'; value: string }
| { type: 'prefix'; value: string }
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.

no suffix because if you prepend a dynamic variable we can't determine what 'tree' / namespace to mark as 'used'

@jamesmengo jamesmengo marked this pull request as draft May 29, 2026 00:27
@jamesmengo jamesmengo force-pushed the jm/unused-translation-key branch from 830cf17 to ddd3df5 Compare May 29, 2026 00:46
@jamesmengo jamesmengo force-pushed the jm/unused-translation-key branch from ddd3df5 to 1af3e30 Compare May 29, 2026 01:02
import { visit } from '../visitor';

const TRANSLATION_FILTERS = new Set(['t', 'translate']);
const STATIC_STRING_FILTERS = new Set(['append', 'prepend']);
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.

we only statically recover append / prepend. Any other filter before t / translate is treated as dynamic, because filters like replace, remove, downcase, etc. can rewrite the known prefix

},
}),
'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

@jamesmengo jamesmengo marked this pull request as ready for review May 29, 2026 01:19
Comment on lines +143 to +160
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);
});
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.

@jamesmengo jamesmengo closed this May 29, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants