From 1af3e30aa6d5ef4db7a2491668056a990dd285a5 Mon Sep 17 00:00:00 2001 From: James Meng Date: Wed, 27 May 2026 15:02:56 -0700 Subject: [PATCH] Add unused translation key check --- .changeset/empty-clouds-share.md | 9 + .../theme-check-common/src/checks/index.ts | 2 + .../unused-translation-key/index.spec.ts | 373 ++++++++++++++++++ .../checks/unused-translation-key/index.ts | 73 ++++ packages/theme-check-common/src/index.ts | 6 + packages/theme-check-common/src/types.ts | 10 + .../theme-check-common/src/utils/index.ts | 1 + .../src/utils/translation-references.spec.ts | 213 ++++++++++ .../src/utils/translation-references.ts | 259 ++++++++++++ packages/theme-check-node/configs/all.yml | 6 + .../src/diagnostics/runChecks.ts | 12 + 11 files changed, 964 insertions(+) create mode 100644 .changeset/empty-clouds-share.md create mode 100644 packages/theme-check-common/src/checks/unused-translation-key/index.spec.ts create mode 100644 packages/theme-check-common/src/checks/unused-translation-key/index.ts create mode 100644 packages/theme-check-common/src/utils/translation-references.spec.ts create mode 100644 packages/theme-check-common/src/utils/translation-references.ts diff --git a/.changeset/empty-clouds-share.md b/.changeset/empty-clouds-share.md new file mode 100644 index 000000000..ee80dc4f7 --- /dev/null +++ b/.changeset/empty-clouds-share.md @@ -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. diff --git a/packages/theme-check-common/src/checks/index.ts b/packages/theme-check-common/src/checks/index.ts index be0370c41..b4922efaf 100644 --- a/packages/theme-check-common/src/checks/index.ts +++ b/packages/theme-check-common/src/checks/index.ts @@ -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'; @@ -122,6 +123,7 @@ export const allChecks: (LiquidCheckDefinition | JSONCheckDefinition)[] = [ UnsupportedDocTag, UnusedAssign, UnusedDocParam, + UnusedTranslationKey, ValidBlockTarget, ValidHTMLTranslation, ValidContentForArguments, diff --git a/packages/theme-check-common/src/checks/unused-translation-key/index.spec.ts b/packages/theme-check-common/src/checks/unused-translation-key/index.spec.ts new file mode 100644 index 000000000..f03515ac8 --- /dev/null +++ b/packages/theme-check-common/src/checks/unused-translation-key/index.spec.ts @@ -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 }} + `, + }, + [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); + }); + + 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 () => { + 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); + }); +}); diff --git a/packages/theme-check-common/src/checks/unused-translation-key/index.ts b/packages/theme-check-common/src/checks/unused-translation-key/index.ts new file mode 100644 index 000000000..c7967a881 --- /dev/null +++ b/packages/theme-check-common/src/checks/unused-translation-key/index.ts @@ -0,0 +1,73 @@ +import { minimatch } from 'minimatch'; +import { JSONCheckDefinition, SchemaProp, Severity, SourceCodeType } from '../../types'; +import { + isTerminalTranslationNode, + isTranslationKeyUsed, + jsonPath, +} from '../../utils/translation-references'; + +const schema = { + ignoreKeys: SchemaProp.array(SchemaProp.string(), ['shopify.*', 'customer_accounts.*']), +}; + +export const UnusedTranslationKey: JSONCheckDefinition = { + meta: { + code: 'UnusedTranslationKey', + name: 'Reports unused translation keys', + docs: { + description: 'Reports translation keys in default locale files that are not referenced', + recommended: false, + url: 'https://shopify.dev/docs/storefronts/themes/tools/theme-check/checks/unused-translation-key', + }, + type: SourceCodeType.JSON, + severity: Severity.WARNING, + schema, + targets: [], + }, + + create(context) { + const relativePath = context.toRelativePath(context.file.uri); + const isSchemaTranslationFile = relativePath.endsWith('.default.schema.json'); + const isDefaultLocaleFile = + relativePath.startsWith('locales/') && + (relativePath.endsWith('.default.json') || isSchemaTranslationFile); + + if (!isDefaultLocaleFile || context.file.ast instanceof Error) { + return {}; + } + + function isIgnored(path: string) { + return context.settings.ignoreKeys.some((pattern) => minimatch(path, pattern)); + } + + return { + async Property(node, ancestors) { + if (!isTerminalTranslationNode(node.value)) return; + + const path = jsonPath(ancestors, node); + if (!path) return; + if (isIgnored(path)) return; + + const references = await context.getTranslationReferences?.(); + if (!references) return; + if (!isSchemaTranslationFile && references.hasDynamicReferences) return; + + if (isTranslationKeyUsed(path, references, isSchemaTranslationFile)) return; + + context.report({ + message: `Translation key '${path}' is not statically referenced`, + startIndex: node.loc!.start.offset, + endIndex: node.loc!.end.offset, + suggest: [ + { + message: 'Delete unused translation key', + fix(corrector) { + corrector.remove(path); + }, + }, + ], + }); + }, + }; + }, +}; diff --git a/packages/theme-check-common/src/index.ts b/packages/theme-check-common/src/index.ts index ba2464ce1..fceda4156 100644 --- a/packages/theme-check-common/src/index.ts +++ b/packages/theme-check-common/src/index.ts @@ -36,6 +36,8 @@ import { ValidateJSON, } from './types'; import { getPosition } from './utils'; +import { memo } from './utils/memo'; +import { findTranslationReferences } from './utils/translation-references'; import { visitJSON, visitLiquid } from './visitors'; export * from './AbstractFileSystem'; @@ -58,6 +60,7 @@ export * from './utils/types'; export * from './utils/object'; export * from './utils/styles'; export * from './utils/traversal'; +export * from './utils/translation-references'; export * from './visitor'; export * from './liquid-doc/liquidDoc'; export { getBlockName } from './liquid-doc/arguments'; @@ -87,6 +90,9 @@ export async function check( getDefaultSchemaTranslations: makeGetDefaultSchemaTranslations(fs, theme, rootUri), getMetafieldDefinitions: injectedDependencies.getMetafieldDefinitions ?? makeGetMetafieldDefinitions(fs), + getTranslationReferences: + injectedDependencies.getTranslationReferences ?? + memo(() => Promise.resolve(findTranslationReferences(theme))), }; const { DisabledChecksVisitor, isDisabled } = createDisabledChecksModule(); diff --git a/packages/theme-check-common/src/types.ts b/packages/theme-check-common/src/types.ts index 692c92617..e9b24016f 100644 --- a/packages/theme-check-common/src/types.ts +++ b/packages/theme-check-common/src/types.ts @@ -18,6 +18,7 @@ import { import { JsonValidationSet, ThemeDocset } from './types/theme-liquid-docs'; import { AppBlockSchema, SectionSchema, ThemeBlockSchema } from './types/theme-schemas'; import { DocDefinition } from './liquid-doc/liquidDoc'; +import type { TranslationReferences } from './utils/translation-references'; export * from './jsonc/types'; export * from './types/schema-prop-factory'; @@ -406,6 +407,15 @@ export interface Dependencies { * Returns an empty array if no dependencies found */ getDependencies?: (uri: string) => Promise; + + /** + * Get translation references collected across the full theme. + * + * Locale JSON checks use this as the translation-key counterpart to + * getReferences/getDependencies: it provides cross-file Liquid/schema usage + * without modeling individual translation keys as file graph references. + */ + getTranslationReferences?: () => Promise; } export type ValidateJSON = ( diff --git a/packages/theme-check-common/src/utils/index.ts b/packages/theme-check-common/src/utils/index.ts index c3f506fc0..bf2b5cd58 100644 --- a/packages/theme-check-common/src/utils/index.ts +++ b/packages/theme-check-common/src/utils/index.ts @@ -8,3 +8,4 @@ export * from './indexBy'; export * from './block'; export * from './styles'; export * from './traversal'; +export * from './translation-references'; diff --git a/packages/theme-check-common/src/utils/translation-references.spec.ts b/packages/theme-check-common/src/utils/translation-references.spec.ts new file mode 100644 index 000000000..977d1db7b --- /dev/null +++ b/packages/theme-check-common/src/utils/translation-references.spec.ts @@ -0,0 +1,213 @@ +import { describe, expect, it } from 'vitest'; +import { getTheme } from '../test'; +import { findTranslationReferences, isTranslationKeyUsed } from './translation-references'; + +describe('findTranslationReferences', () => { + it('collects literal t and translate filter keys', () => { + const result = findTranslationReferences( + getTheme({ + 'snippets/cart.liquid': ` + {{ 'actions.add' | t }} + {{ "actions.remove" | translate }} + `, + }), + ); + + expect(result.keys).toEqual(new Set(['actions.add', 'actions.remove'])); + expect(result.schemaKeys).toEqual(new Set()); + expect(result.prefixes).toEqual(new Set()); + expect(result.hasDynamicReferences).toBe(false); + }); + + it('collects schema locale references separately from storefront locale references', () => { + const result = findTranslationReferences( + getTheme({ + 'sections/header.liquid': ` + {% schema %} + { + "name": "t:sections.header.name", + "settings": [ + { + "type": "text", + "id": "title", + "label": "t:sections.header.settings.title.label" + } + ] + } + {% endschema %} + `, + }), + ); + + expect(result.keys).toEqual(new Set()); + expect(result.schemaKeys).toEqual( + new Set(['sections.header.name', 'sections.header.settings.title.label']), + ); + }); + + it('collects schema locale references from settings_schema.json', () => { + const result = findTranslationReferences( + getTheme({ + 'config/settings_schema.json': JSON.stringify([ + { + name: 't:theme_settings.colors.name', + settings: [ + { + type: 'color', + id: 'accent', + label: 't:theme_settings.colors.accent.label', + }, + ], + }, + ]), + }), + ); + + expect(result.keys).toEqual(new Set()); + expect(result.schemaKeys).toEqual( + new Set(['theme_settings.colors.name', 'theme_settings.colors.accent.label']), + ); + expect(result.prefixes).toEqual(new Set()); + expect(result.hasDynamicReferences).toBe(false); + }); + + it('does not collect schema locale references from arbitrary JSON files', () => { + const result = findTranslationReferences( + getTheme({ + 'locales/en.default.schema.json': JSON.stringify({ + sections: { + header: { + name: 't:sections.header.name', + }, + }, + }), + }), + ); + + expect(result.schemaKeys).toEqual(new Set()); + }); + + it('resolves static append filters', () => { + const result = findTranslationReferences( + getTheme({ + 'snippets/product-form.liquid': ` + {{ 'product.' | append: 'title' | t }} + `, + }), + ); + + expect(result.keys).toEqual(new Set(['product.title'])); + expect(result.prefixes).toEqual(new Set()); + expect(result.hasDynamicReferences).toBe(false); + }); + + it('resolves static prepend filters in prefix-first order', () => { + const result = findTranslationReferences( + getTheme({ + 'snippets/product-form.liquid': ` + {{ 'title' | prepend: 'product.' | translate }} + `, + }), + ); + + expect(result.keys).toEqual(new Set(['product.title'])); + expect(result.prefixes).toEqual(new Set()); + expect(result.hasDynamicReferences).toBe(false); + }); + + it('records the last statically known prefix for dynamic append chains', () => { + const result = findTranslationReferences( + getTheme({ + 'snippets/product-form.liquid': ` + {{ 'products.product.' | append: button_state | t }} + `, + }), + ); + + expect(result.keys).toEqual(new Set()); + expect(result.prefixes).toEqual(new Set(['products.product.'])); + expect(result.hasDynamicReferences).toBe(false); + }); + + it('does not keep a prefix when a dynamic prepend changes the beginning of the key', () => { + const result = findTranslationReferences( + getTheme({ + 'snippets/product-form.liquid': ` + {{ 'title' | prepend: product_type | t }} + `, + }), + ); + + expect(result.keys).toEqual(new Set()); + expect(result.prefixes).toEqual(new Set()); + expect(result.hasDynamicReferences).toBe(true); + }); + + it('updates a dynamic chain prefix when a static prepend adds a known beginning', () => { + const result = findTranslationReferences( + getTheme({ + 'snippets/product-form.liquid': ` + {{ 'title.' | append: product_state | prepend: 'products.' | t }} + `, + }), + ); + + expect(result.keys).toEqual(new Set()); + expect(result.prefixes).toEqual(new Set(['products.title.'])); + expect(result.hasDynamicReferences).toBe(false); + }); + + it('marks keys transformed by unsupported filters as dynamic references', () => { + const result = findTranslationReferences( + getTheme({ + 'snippets/product-form.liquid': ` + {{ 'products.product.' | replace: 'product.', '' | append: button_state | t }} + `, + }), + ); + + expect(result.keys).toEqual(new Set()); + expect(result.prefixes).toEqual(new Set()); + expect(result.hasDynamicReferences).toBe(true); + }); + + it('marks dynamic translation keys with no static prefix as dynamic references', () => { + const result = findTranslationReferences( + getTheme({ + 'snippets/cart.liquid': `{{ translation_key | t }}`, + }), + ); + + expect(result.keys).toEqual(new Set()); + expect(result.prefixes).toEqual(new Set()); + expect(result.hasDynamicReferences).toBe(true); + }); +}); + +describe('isTranslationKeyUsed', () => { + it('treats pluralization leaves as used when their parent key is referenced', () => { + const refs = findTranslationReferences( + getTheme({ + 'snippets/cart.liquid': `{{ 'cart.items' | t: count: cart.item_count }}`, + }), + ); + + expect(isTranslationKeyUsed('cart.items.one', refs)).toBe(true); + expect(isTranslationKeyUsed('cart.items.other', refs)).toBe(true); + }); + + it('uses schema keys only for schema translation files', () => { + const refs = findTranslationReferences( + getTheme({ + 'sections/header.liquid': ` + {% schema %} + { "name": "t:sections.header.name" } + {% endschema %} + `, + }), + ); + + expect(isTranslationKeyUsed('sections.header.name', refs)).toBe(false); + expect(isTranslationKeyUsed('sections.header.name', refs, true)).toBe(true); + }); +}); diff --git a/packages/theme-check-common/src/utils/translation-references.ts b/packages/theme-check-common/src/utils/translation-references.ts new file mode 100644 index 000000000..57052a7e9 --- /dev/null +++ b/packages/theme-check-common/src/utils/translation-references.ts @@ -0,0 +1,259 @@ +import { + type LiquidArgument, + type LiquidExpression, + type LiquidFilter, + type LiquidVariable, + NodeTypes, +} from '@shopify/liquid-html-parser'; +import { toJSONAST } from '../to-source-code'; +import { + type JSONNode, + type LiquidHtmlNode, + type LiteralNode, + type PropertyNode, + SourceCodeType, + type Theme, +} from '../types'; +import { visit } from '../visitor'; + +const TRANSLATION_FILTERS = new Set(['t', 'translate']); +const STATIC_STRING_FILTERS = new Set(['append', 'prepend']); + +export type TranslationReferences = { + /** + * Storefront locale keys that are statically known to be used by Liquid. + */ + keys: Set; + + /** + * Schema locale keys that are statically known to be used by schema t: values. + */ + schemaKeys: Set; + + /** + * Translation key prefixes that could be used by a partially dynamic key. + * For example, `'products.' | append: product_type | t` protects `products.*`. + */ + prefixes: Set; + + /** + * Whether the theme contains translation filter usage that cannot be resolved + * to either a key or a static prefix. + */ + hasDynamicReferences: boolean; +}; + +type ResolvedTranslationKey = + | { type: 'key'; value: string } + | { type: 'prefix'; value: string } + | { type: 'dynamic' }; + +export function findTranslationReferences(theme: Theme): TranslationReferences { + const references: TranslationReferences = { + keys: new Set(), + schemaKeys: new Set(), + prefixes: new Set(), + hasDynamicReferences: false, + }; + + for (const file of theme) { + if (file.ast instanceof Error) continue; + + if (file.type === SourceCodeType.LiquidHtml) { + collectLiquidTranslationReferences(file.ast, references); + collectLiquidSchemaTranslationReferences(file.ast, references); + } else if (isSettingsSchemaFile(file.uri)) { + collectSchemaTranslationReferences(file.ast, references); + } + } + + return references; +} + +function collectLiquidTranslationReferences( + ast: LiquidHtmlNode, + references: TranslationReferences, +) { + visit(ast, { + LiquidVariable(node) { + const translationFilterIndex = node.filters.findIndex(({ name }) => + TRANSLATION_FILTERS.has(name), + ); + if (translationFilterIndex === -1) return; + + addResolvedReference( + references, + resolveTranslationKey(node, node.filters.slice(0, translationFilterIndex)), + ); + }, + }); +} + +function collectLiquidSchemaTranslationReferences( + ast: LiquidHtmlNode, + references: TranslationReferences, +) { + visit(ast, { + LiquidRawTag(node) { + if (node.name !== 'schema' || node.body.kind !== 'json') return; + + const jsonAst = toJSONAST(node.body.value); + if (jsonAst instanceof Error) return; + + collectSchemaTranslationReferences(jsonAst, references); + }, + }); +} + +function collectSchemaTranslationReferences(ast: JSONNode, references: TranslationReferences) { + const schemaKeys = visit(ast, { + Literal(node) { + if (typeof node.value === 'string' && node.value.startsWith('t:')) { + return node.value.slice(2); + } + }, + }); + + schemaKeys.forEach((key) => references.schemaKeys.add(key)); +} + +function isSettingsSchemaFile(uri: string) { + return uri.endsWith('/config/settings_schema.json'); +} + +function addResolvedReference(references: TranslationReferences, result: ResolvedTranslationKey) { + switch (result.type) { + case 'key': + references.keys.add(result.value); + return; + + case 'prefix': + references.prefixes.add(result.value); + return; + + case 'dynamic': + references.hasDynamicReferences = true; + return; + } +} + +function resolveTranslationKey( + node: LiquidVariable, + filtersBeforeTranslation: LiquidFilter[], +): ResolvedTranslationKey { + let current = expressionToStaticString(node.expression); + let safePrefix = current.dynamic ? '' : current.value; + + for (const filter of filtersBeforeTranslation) { + if (!STATIC_STRING_FILTERS.has(filter.name)) { + return { type: 'dynamic' }; + } + + const arg = filter.args[0]; + const argValue = arg ? argumentToStaticString(arg) : { value: '', dynamic: true }; + + if (filter.name === 'append') { + if (!current.dynamic && !argValue.dynamic) { + safePrefix = current.value + argValue.value; + } + + current = { + value: current.value + argValue.value, + dynamic: current.dynamic || argValue.dynamic, + }; + } else { + if (!current.dynamic && !argValue.dynamic) { + safePrefix = argValue.value + current.value; + } else if (!argValue.dynamic && safePrefix) { + safePrefix = argValue.value + safePrefix; + } else if (argValue.dynamic) { + safePrefix = ''; + } + + current = { + value: argValue.value + current.value, + dynamic: current.dynamic || argValue.dynamic, + }; + } + } + + if (current.dynamic) { + return safePrefix ? { type: 'prefix', value: safePrefix } : { type: 'dynamic' }; + } + + return { type: 'key', value: current.value }; +} + +function argumentToStaticString(arg: LiquidArgument) { + if (arg.type === NodeTypes.NamedArgument) { + return { value: '', dynamic: true }; + } + + return expressionToStaticString(arg); +} + +function expressionToStaticString(expression: LiquidVariable['expression'] | LiquidExpression): { + value: string; + dynamic: boolean; +} { + switch (expression.type) { + case NodeTypes.String: + return { value: expression.value, dynamic: false }; + + case NodeTypes.Number: + return { value: expression.value, dynamic: false }; + + case NodeTypes.LiquidLiteral: + return { value: String(expression.value ?? ''), dynamic: false }; + + default: + return { value: '', dynamic: true }; + } +} + +export function isTranslationKeyUsed( + path: string, + references: TranslationReferences, + isSchemaTranslationFile = false, +): boolean { + if (isSchemaTranslationFile) { + return references.schemaKeys.has(path); + } + + return ( + references.keys.has(path) || + isPluralizationPathUsed(path, references) || + hasMatchingPrefix(path, references.prefixes) + ); +} + +function hasMatchingPrefix(path: string, prefixes: Set): boolean { + for (const prefix of prefixes) { + if (path.startsWith(prefix)) return true; + } + + return false; +} + +const PLURALIZATION_KEYS = new Set(['zero', 'one', 'two', 'few', 'many', 'other']); + +function isPluralizationPathUsed(path: string, references: TranslationReferences) { + const parts = path.split('.'); + const pluralKey = parts[parts.length - 1]; + + if (!pluralKey || !PLURALIZATION_KEYS.has(pluralKey)) return false; + + return references.keys.has(parts.slice(0, -1).join('.')); +} + +export function jsonPath(ancestors: JSONNode[], node: JSONNode): string { + return ancestors + .concat(node) + .filter((node): node is PropertyNode => node.type === 'Property') + .map((node) => node.key.value) + .join('.'); +} + +export function isTerminalTranslationNode(node: JSONNode): node is LiteralNode { + return node.type === 'Literal'; +} diff --git a/packages/theme-check-node/configs/all.yml b/packages/theme-check-node/configs/all.yml index 426115894..b7290c055 100644 --- a/packages/theme-check-node/configs/all.yml +++ b/packages/theme-check-node/configs/all.yml @@ -166,6 +166,12 @@ UnusedAssign: UnusedDocParam: enabled: true severity: 1 +UnusedTranslationKey: + enabled: true + severity: 1 + ignoreKeys: + - shopify.* + - customer_accounts.* ValidBlockTarget: enabled: true severity: 0 diff --git a/packages/theme-language-server-common/src/diagnostics/runChecks.ts b/packages/theme-language-server-common/src/diagnostics/runChecks.ts index 1fcf5afa4..815b1ead6 100644 --- a/packages/theme-language-server-common/src/diagnostics/runChecks.ts +++ b/packages/theme-language-server-common/src/diagnostics/runChecks.ts @@ -3,7 +3,10 @@ import { extractCSSClassesFromLiquidUri, extractCSSClassesFromAssetUri, findRoot, + findTranslationReferences, + isIgnored, makeFileExists, + memo, Offense, path, Reference, @@ -56,6 +59,14 @@ export function makeRunChecks( async function runChecksForRoot(configFileRootUri: string) { const config = await loadConfig(configFileRootUri, fs); const theme = documentManager.theme(config.rootUri); + const getTranslationReferences = memo(async () => { + await documentManager.preload(config.rootUri); + const fullTheme = documentManager + .theme(config.rootUri, true) + .filter((sourceCode) => !isIgnored(sourceCode.uri, config)); + + return findTranslationReferences(fullTheme); + }); const cssOffenses = cssLanguageService ? await Promise.all( @@ -68,6 +79,7 @@ export function makeRunChecks( themeDocset, jsonValidationSet, getMetafieldDefinitions, + getTranslationReferences, async getReferences(uri: string): Promise { if (!themeGraphManager) return [];