From bc8002ba729ec8c2c5506629627c90194fc8cfa8 Mon Sep 17 00:00:00 2001 From: Alok Swamy Date: Thu, 5 Feb 2026 14:33:13 -0500 Subject: [PATCH] valid-schema-translations theme check --- .changeset/loud-hotels-teach.md | 6 + .../theme-check-common/src/checks/index.ts | 2 + .../checks/valid-schema-name/index.spec.ts | 20 -- .../src/checks/valid-schema-name/index.ts | 8 - .../valid-schema-translations/index.spec.ts | 178 ++++++++++++++++++ .../checks/valid-schema-translations/index.ts | 97 ++++++++++ packages/theme-check-node/configs/all.yml | 3 + .../theme-check-node/configs/recommended.yml | 3 + 8 files changed, 289 insertions(+), 28 deletions(-) create mode 100644 .changeset/loud-hotels-teach.md create mode 100644 packages/theme-check-common/src/checks/valid-schema-translations/index.spec.ts create mode 100644 packages/theme-check-common/src/checks/valid-schema-translations/index.ts diff --git a/.changeset/loud-hotels-teach.md b/.changeset/loud-hotels-teach.md new file mode 100644 index 000000000..c0d46c4b7 --- /dev/null +++ b/.changeset/loud-hotels-teach.md @@ -0,0 +1,6 @@ +--- +"@shopify/theme-check-common": minor +"@shopify/theme-check-node": minor +--- + +New theme check to ensure translation values inside of `schema` tag are valid diff --git a/packages/theme-check-common/src/checks/index.ts b/packages/theme-check-common/src/checks/index.ts index af8c49436..7ca5119fa 100644 --- a/packages/theme-check-common/src/checks/index.ts +++ b/packages/theme-check-common/src/checks/index.ts @@ -59,6 +59,7 @@ import { ValidLocalBlocks } from './valid-local-blocks'; import { ValidRenderSnippetArgumentTypes } from './valid-render-snippet-argument-types'; import { ValidSchema } from './valid-schema'; import { ValidSchemaName } from './valid-schema-name'; +import { ValidSchemaTranslations } from './valid-schema-translations'; import { ValidSettingsKey } from './valid-settings-key'; import { ValidStaticBlockType } from './valid-static-block-type'; import { ValidVisibleIf, ValidVisibleIfSettingsSchema } from './valid-visible-if'; @@ -133,6 +134,7 @@ export const allChecks: (LiquidCheckDefinition | JSONCheckDefinition)[] = [ ValidVisibleIfSettingsSchema, VariableName, ValidSchemaName, + ValidSchemaTranslations, ]; /** diff --git a/packages/theme-check-common/src/checks/valid-schema-name/index.spec.ts b/packages/theme-check-common/src/checks/valid-schema-name/index.spec.ts index 080069292..bd7b9938f 100644 --- a/packages/theme-check-common/src/checks/valid-schema-name/index.spec.ts +++ b/packages/theme-check-common/src/checks/valid-schema-name/index.spec.ts @@ -69,26 +69,6 @@ describe('Module: ValidSchemaName', () => { expect(offenses).toHaveLength(0); }); - it('reports an offense with schema name translation is missing', async () => { - const offenses = await check( - { - 'locales/en.default.schema.json': '{ "another_translation_key": "Another translation"}', - 'sections/file.liquid': ` - {% schema %} - { - "name": "t:my_translation_key" - } - {% endschema %}`, - }, - [ValidSchemaName], - ); - - expect(offenses).toHaveLength(1); - expect(offenses[0].message).toEqual( - "'t:my_translation_key' does not have a matching entry in 'locales/en.default.schema.json'", - ); - }); - it('reports an offense with schema name translation that exists and is over 25 chars long', async () => { const offenses = await check( { diff --git a/packages/theme-check-common/src/checks/valid-schema-name/index.ts b/packages/theme-check-common/src/checks/valid-schema-name/index.ts index 714cd43ec..9bc309413 100644 --- a/packages/theme-check-common/src/checks/valid-schema-name/index.ts +++ b/packages/theme-check-common/src/checks/valid-schema-name/index.ts @@ -47,14 +47,6 @@ export const ValidSchemaName: LiquidCheckDefinition = { const defaultTranslations = await context.getDefaultSchemaTranslations(); const translation = deepGet(defaultTranslations, key.split('.')); - if (translation === undefined) { - context.report({ - message: `'${name}' does not have a matching entry in 'locales/${defaultLocale}.default.schema.json'`, - startIndex, - endIndex, - }); - } - if (translation !== undefined && translation.length > MAX_SCHEMA_NAME_LENGTH) { context.report({ message: `Schema name '${translation}' from 'locales/${defaultLocale}.default.schema.json' is too long (max 25 characters)`, diff --git a/packages/theme-check-common/src/checks/valid-schema-translations/index.spec.ts b/packages/theme-check-common/src/checks/valid-schema-translations/index.spec.ts new file mode 100644 index 000000000..bef08641c --- /dev/null +++ b/packages/theme-check-common/src/checks/valid-schema-translations/index.spec.ts @@ -0,0 +1,178 @@ +import { expect, describe, it } from 'vitest'; +import { highlightedOffenses, check } from '../../test'; +import { ValidSchemaTranslations } from './index'; + +describe('Module: ValidSchemaTranslations', () => { + it('reports no offense when schema has no translation keys', async () => { + const offenses = await check( + { + 'locales/en.default.schema.json': '{}', + 'sections/file.liquid': ` + {% schema %} + { + "name": "My Section", + "settings": [ + { + "type": "text", + "id": "title", + "label": "Title" + } + ] + } + {% endschema %}`, + }, + [ValidSchemaTranslations], + ); + + expect(offenses).toHaveLength(0); + }); + + it('reports no offense when all translation keys exist', async () => { + const offenses = await check( + { + 'locales/en.default.schema.json': JSON.stringify({ + sections: { + header: { + name: 'Header', + settings: { + title: { + label: 'Title', + info: 'Enter a title', + }, + }, + }, + }, + }), + 'sections/file.liquid': ` + {% schema %} + { + "name": "t:sections.header.name", + "settings": [ + { + "type": "text", + "id": "title", + "label": "t:sections.header.settings.title.label", + "info": "t:sections.header.settings.title.info" + } + ] + } + {% endschema %}`, + }, + [ValidSchemaTranslations], + ); + + expect(offenses).toHaveLength(0); + }); + + it('reports an offense when a translation key is missing', async () => { + const themeFiles = { + 'locales/en.default.schema.json': JSON.stringify({ + sections: { + header: { + name: 'Header', + }, + }, + }), + 'sections/file.liquid': ` + {% schema %} + { + "name": "t:sections.header.missing_key" + } + {% endschema %}`, + }; + + const offenses = await check(themeFiles, [ValidSchemaTranslations]); + + expect(offenses).toHaveLength(1); + expect(offenses[0].message).toEqual( + "'t:sections.header.missing_key' does not have a matching entry in 'locales/en.default.schema.json'", + ); + + const highlights = highlightedOffenses(themeFiles, offenses); + expect(highlights).toHaveLength(1); + expect(highlights[0]).toBe('"t:sections.header.missing_key"'); + }); + + it('reports multiple offenses when multiple translation keys are missing', async () => { + const offenses = await check( + { + 'locales/en.default.schema.json': JSON.stringify({ + sections: { + header: { + name: 'Header', + }, + }, + }), + 'sections/file.liquid': ` + {% schema %} + { + "name": "t:sections.header.name", + "settings": [ + { + "type": "text", + "id": "title", + "label": "t:sections.header.settings.title.label", + "info": "t:sections.header.settings.title.info" + } + ] + } + {% endschema %}`, + }, + [ValidSchemaTranslations], + ); + + expect(offenses).toHaveLength(2); + expect(offenses[0].message).toEqual( + "'t:sections.header.settings.title.label' does not have a matching entry in 'locales/en.default.schema.json'", + ); + expect(offenses[1].message).toEqual( + "'t:sections.header.settings.title.info' does not have a matching entry in 'locales/en.default.schema.json'", + ); + }); + + it('reports offense for missing translation in nested arrays', async () => { + const themeFiles = { + 'locales/en.default.schema.json': JSON.stringify({ + sections: { + header: { + name: 'Header', + }, + }, + }), + 'sections/file.liquid': ` + {% schema %} + { + "name": "t:sections.header.name", + "blocks": [ + { + "type": "text", + "name": "t:sections.header.blocks.text.name" + } + ] + } + {% endschema %}`, + }; + + const offenses = await check(themeFiles, [ValidSchemaTranslations]); + + expect(offenses).toHaveLength(1); + expect(offenses[0].message).toEqual( + "'t:sections.header.blocks.text.name' does not have a matching entry in 'locales/en.default.schema.json'", + ); + }); + + it('reports no offense when schema is invalid JSON', async () => { + const offenses = await check( + { + 'locales/en.default.schema.json': '{}', + 'sections/file.liquid': ` + {% schema %} + { invalid json } + {% endschema %}`, + }, + [ValidSchemaTranslations], + ); + + expect(offenses).toHaveLength(0); + }); +}); diff --git a/packages/theme-check-common/src/checks/valid-schema-translations/index.ts b/packages/theme-check-common/src/checks/valid-schema-translations/index.ts new file mode 100644 index 000000000..7dcb14126 --- /dev/null +++ b/packages/theme-check-common/src/checks/valid-schema-translations/index.ts @@ -0,0 +1,97 @@ +import { getLocEnd, getLocStart } from '../../json'; +import { getSchema } from '../../to-schema'; +import { LiquidCheckDefinition, Severity, SourceCodeType } from '../../types'; +import { deepGet } from '../../utils'; +import { JSONNode, LiteralNode } from '../../jsonc/types'; + +export const ValidSchemaTranslations: LiquidCheckDefinition = { + meta: { + code: 'ValidSchemaTranslations', + name: 'Reports missing translation keys in schema', + docs: { + description: + 'This check ensures all translation keys (t:) in schema have matching entries in the default schema translations file.', + recommended: true, + url: 'https://shopify.dev/docs/storefronts/themes/tools/theme-check/checks/valid-schema-translations', + }, + type: SourceCodeType.LiquidHtml, + severity: Severity.ERROR, + schema: {}, + targets: [], + }, + + create(context) { + return { + async LiquidRawTag(node) { + if (node.name !== 'schema' || node.body.kind !== 'json') { + return; + } + + const offset = node.blockStartPosition.end; + const schema = await getSchema(context); + const { ast } = schema ?? {}; + if (!ast || ast instanceof Error) return; + + const defaultLocale = await context.getDefaultLocale(); + const defaultTranslations = await context.getDefaultSchemaTranslations(); + + // Find all string values that start with 't:' and check if they have translations + const translationNodes = findTranslationKeys(ast); + + for (const { value, node: literalNode } of translationNodes) { + const key = value.replace('t:', ''); + const translation = deepGet(defaultTranslations, key.split('.')); + + if (translation === undefined) { + const startIndex = offset + getLocStart(literalNode); + const endIndex = offset + getLocEnd(literalNode); + + context.report({ + message: `'${value}' does not have a matching entry in 'locales/${defaultLocale}.default.schema.json'`, + startIndex, + endIndex, + }); + } + } + }, + }; + }, +}; + +/** + * Recursively find all string literal nodes that start with 't:' (translation keys) + */ +function findTranslationKeys(node: JSONNode): { value: string; node: LiteralNode }[] { + const results: { value: string; node: LiteralNode }[] = []; + + switch (node.type) { + case 'Literal': { + if (typeof node.value === 'string' && node.value.startsWith('t:')) { + results.push({ value: node.value, node }); + } + break; + } + + case 'Object': { + for (const property of node.children) { + results.push(...findTranslationKeys(property.value)); + } + break; + } + + case 'Array': { + for (const child of node.children) { + results.push(...findTranslationKeys(child)); + } + break; + } + + case 'Property': + case 'Identifier': { + // Keys don't contain translation values + break; + } + } + + return results; +} diff --git a/packages/theme-check-node/configs/all.yml b/packages/theme-check-node/configs/all.yml index 09e603ef7..b74b3741e 100644 --- a/packages/theme-check-node/configs/all.yml +++ b/packages/theme-check-node/configs/all.yml @@ -193,6 +193,9 @@ ValidSchema: ValidSchemaName: enabled: true severity: 0 +ValidSchemaTranslations: + enabled: true + severity: 0 ValidSettingsKey: enabled: true severity: 0 diff --git a/packages/theme-check-node/configs/recommended.yml b/packages/theme-check-node/configs/recommended.yml index 8f2f2569b..0ebc31660 100644 --- a/packages/theme-check-node/configs/recommended.yml +++ b/packages/theme-check-node/configs/recommended.yml @@ -171,6 +171,9 @@ ValidSchema: ValidSchemaName: enabled: true severity: 0 +ValidSchemaTranslations: + enabled: true + severity: 0 ValidSettingsKey: enabled: true severity: 0