Skip to content

Commit bc8002b

Browse files
committed
valid-schema-translations theme check
1 parent b1bca3f commit bc8002b

File tree

8 files changed

+289
-28
lines changed

8 files changed

+289
-28
lines changed

.changeset/loud-hotels-teach.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+
New theme check to ensure translation values inside of `schema` tag are valid

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,7 @@ import { ValidLocalBlocks } from './valid-local-blocks';
5959
import { ValidRenderSnippetArgumentTypes } from './valid-render-snippet-argument-types';
6060
import { ValidSchema } from './valid-schema';
6161
import { ValidSchemaName } from './valid-schema-name';
62+
import { ValidSchemaTranslations } from './valid-schema-translations';
6263
import { ValidSettingsKey } from './valid-settings-key';
6364
import { ValidStaticBlockType } from './valid-static-block-type';
6465
import { ValidVisibleIf, ValidVisibleIfSettingsSchema } from './valid-visible-if';
@@ -133,6 +134,7 @@ export const allChecks: (LiquidCheckDefinition | JSONCheckDefinition)[] = [
133134
ValidVisibleIfSettingsSchema,
134135
VariableName,
135136
ValidSchemaName,
137+
ValidSchemaTranslations,
136138
];
137139

138140
/**

packages/theme-check-common/src/checks/valid-schema-name/index.spec.ts

Lines changed: 0 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -69,26 +69,6 @@ describe('Module: ValidSchemaName', () => {
6969
expect(offenses).toHaveLength(0);
7070
});
7171

72-
it('reports an offense with schema name translation is missing', async () => {
73-
const offenses = await check(
74-
{
75-
'locales/en.default.schema.json': '{ "another_translation_key": "Another translation"}',
76-
'sections/file.liquid': `
77-
{% schema %}
78-
{
79-
"name": "t:my_translation_key"
80-
}
81-
{% endschema %}`,
82-
},
83-
[ValidSchemaName],
84-
);
85-
86-
expect(offenses).toHaveLength(1);
87-
expect(offenses[0].message).toEqual(
88-
"'t:my_translation_key' does not have a matching entry in 'locales/en.default.schema.json'",
89-
);
90-
});
91-
9272
it('reports an offense with schema name translation that exists and is over 25 chars long', async () => {
9373
const offenses = await check(
9474
{

packages/theme-check-common/src/checks/valid-schema-name/index.ts

Lines changed: 0 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -47,14 +47,6 @@ export const ValidSchemaName: LiquidCheckDefinition = {
4747
const defaultTranslations = await context.getDefaultSchemaTranslations();
4848
const translation = deepGet(defaultTranslations, key.split('.'));
4949

50-
if (translation === undefined) {
51-
context.report({
52-
message: `'${name}' does not have a matching entry in 'locales/${defaultLocale}.default.schema.json'`,
53-
startIndex,
54-
endIndex,
55-
});
56-
}
57-
5850
if (translation !== undefined && translation.length > MAX_SCHEMA_NAME_LENGTH) {
5951
context.report({
6052
message: `Schema name '${translation}' from 'locales/${defaultLocale}.default.schema.json' is too long (max 25 characters)`,
Lines changed: 178 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,178 @@
1+
import { expect, describe, it } from 'vitest';
2+
import { highlightedOffenses, check } from '../../test';
3+
import { ValidSchemaTranslations } from './index';
4+
5+
describe('Module: ValidSchemaTranslations', () => {
6+
it('reports no offense when schema has no translation keys', async () => {
7+
const offenses = await check(
8+
{
9+
'locales/en.default.schema.json': '{}',
10+
'sections/file.liquid': `
11+
{% schema %}
12+
{
13+
"name": "My Section",
14+
"settings": [
15+
{
16+
"type": "text",
17+
"id": "title",
18+
"label": "Title"
19+
}
20+
]
21+
}
22+
{% endschema %}`,
23+
},
24+
[ValidSchemaTranslations],
25+
);
26+
27+
expect(offenses).toHaveLength(0);
28+
});
29+
30+
it('reports no offense when all translation keys exist', async () => {
31+
const offenses = await check(
32+
{
33+
'locales/en.default.schema.json': JSON.stringify({
34+
sections: {
35+
header: {
36+
name: 'Header',
37+
settings: {
38+
title: {
39+
label: 'Title',
40+
info: 'Enter a title',
41+
},
42+
},
43+
},
44+
},
45+
}),
46+
'sections/file.liquid': `
47+
{% schema %}
48+
{
49+
"name": "t:sections.header.name",
50+
"settings": [
51+
{
52+
"type": "text",
53+
"id": "title",
54+
"label": "t:sections.header.settings.title.label",
55+
"info": "t:sections.header.settings.title.info"
56+
}
57+
]
58+
}
59+
{% endschema %}`,
60+
},
61+
[ValidSchemaTranslations],
62+
);
63+
64+
expect(offenses).toHaveLength(0);
65+
});
66+
67+
it('reports an offense when a translation key is missing', async () => {
68+
const themeFiles = {
69+
'locales/en.default.schema.json': JSON.stringify({
70+
sections: {
71+
header: {
72+
name: 'Header',
73+
},
74+
},
75+
}),
76+
'sections/file.liquid': `
77+
{% schema %}
78+
{
79+
"name": "t:sections.header.missing_key"
80+
}
81+
{% endschema %}`,
82+
};
83+
84+
const offenses = await check(themeFiles, [ValidSchemaTranslations]);
85+
86+
expect(offenses).toHaveLength(1);
87+
expect(offenses[0].message).toEqual(
88+
"'t:sections.header.missing_key' does not have a matching entry in 'locales/en.default.schema.json'",
89+
);
90+
91+
const highlights = highlightedOffenses(themeFiles, offenses);
92+
expect(highlights).toHaveLength(1);
93+
expect(highlights[0]).toBe('"t:sections.header.missing_key"');
94+
});
95+
96+
it('reports multiple offenses when multiple translation keys are missing', async () => {
97+
const offenses = await check(
98+
{
99+
'locales/en.default.schema.json': JSON.stringify({
100+
sections: {
101+
header: {
102+
name: 'Header',
103+
},
104+
},
105+
}),
106+
'sections/file.liquid': `
107+
{% schema %}
108+
{
109+
"name": "t:sections.header.name",
110+
"settings": [
111+
{
112+
"type": "text",
113+
"id": "title",
114+
"label": "t:sections.header.settings.title.label",
115+
"info": "t:sections.header.settings.title.info"
116+
}
117+
]
118+
}
119+
{% endschema %}`,
120+
},
121+
[ValidSchemaTranslations],
122+
);
123+
124+
expect(offenses).toHaveLength(2);
125+
expect(offenses[0].message).toEqual(
126+
"'t:sections.header.settings.title.label' does not have a matching entry in 'locales/en.default.schema.json'",
127+
);
128+
expect(offenses[1].message).toEqual(
129+
"'t:sections.header.settings.title.info' does not have a matching entry in 'locales/en.default.schema.json'",
130+
);
131+
});
132+
133+
it('reports offense for missing translation in nested arrays', async () => {
134+
const themeFiles = {
135+
'locales/en.default.schema.json': JSON.stringify({
136+
sections: {
137+
header: {
138+
name: 'Header',
139+
},
140+
},
141+
}),
142+
'sections/file.liquid': `
143+
{% schema %}
144+
{
145+
"name": "t:sections.header.name",
146+
"blocks": [
147+
{
148+
"type": "text",
149+
"name": "t:sections.header.blocks.text.name"
150+
}
151+
]
152+
}
153+
{% endschema %}`,
154+
};
155+
156+
const offenses = await check(themeFiles, [ValidSchemaTranslations]);
157+
158+
expect(offenses).toHaveLength(1);
159+
expect(offenses[0].message).toEqual(
160+
"'t:sections.header.blocks.text.name' does not have a matching entry in 'locales/en.default.schema.json'",
161+
);
162+
});
163+
164+
it('reports no offense when schema is invalid JSON', async () => {
165+
const offenses = await check(
166+
{
167+
'locales/en.default.schema.json': '{}',
168+
'sections/file.liquid': `
169+
{% schema %}
170+
{ invalid json }
171+
{% endschema %}`,
172+
},
173+
[ValidSchemaTranslations],
174+
);
175+
176+
expect(offenses).toHaveLength(0);
177+
});
178+
});
Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
import { getLocEnd, getLocStart } from '../../json';
2+
import { getSchema } from '../../to-schema';
3+
import { LiquidCheckDefinition, Severity, SourceCodeType } from '../../types';
4+
import { deepGet } from '../../utils';
5+
import { JSONNode, LiteralNode } from '../../jsonc/types';
6+
7+
export const ValidSchemaTranslations: LiquidCheckDefinition = {
8+
meta: {
9+
code: 'ValidSchemaTranslations',
10+
name: 'Reports missing translation keys in schema',
11+
docs: {
12+
description:
13+
'This check ensures all translation keys (t:) in schema have matching entries in the default schema translations file.',
14+
recommended: true,
15+
url: 'https://shopify.dev/docs/storefronts/themes/tools/theme-check/checks/valid-schema-translations',
16+
},
17+
type: SourceCodeType.LiquidHtml,
18+
severity: Severity.ERROR,
19+
schema: {},
20+
targets: [],
21+
},
22+
23+
create(context) {
24+
return {
25+
async LiquidRawTag(node) {
26+
if (node.name !== 'schema' || node.body.kind !== 'json') {
27+
return;
28+
}
29+
30+
const offset = node.blockStartPosition.end;
31+
const schema = await getSchema(context);
32+
const { ast } = schema ?? {};
33+
if (!ast || ast instanceof Error) return;
34+
35+
const defaultLocale = await context.getDefaultLocale();
36+
const defaultTranslations = await context.getDefaultSchemaTranslations();
37+
38+
// Find all string values that start with 't:' and check if they have translations
39+
const translationNodes = findTranslationKeys(ast);
40+
41+
for (const { value, node: literalNode } of translationNodes) {
42+
const key = value.replace('t:', '');
43+
const translation = deepGet(defaultTranslations, key.split('.'));
44+
45+
if (translation === undefined) {
46+
const startIndex = offset + getLocStart(literalNode);
47+
const endIndex = offset + getLocEnd(literalNode);
48+
49+
context.report({
50+
message: `'${value}' does not have a matching entry in 'locales/${defaultLocale}.default.schema.json'`,
51+
startIndex,
52+
endIndex,
53+
});
54+
}
55+
}
56+
},
57+
};
58+
},
59+
};
60+
61+
/**
62+
* Recursively find all string literal nodes that start with 't:' (translation keys)
63+
*/
64+
function findTranslationKeys(node: JSONNode): { value: string; node: LiteralNode }[] {
65+
const results: { value: string; node: LiteralNode }[] = [];
66+
67+
switch (node.type) {
68+
case 'Literal': {
69+
if (typeof node.value === 'string' && node.value.startsWith('t:')) {
70+
results.push({ value: node.value, node });
71+
}
72+
break;
73+
}
74+
75+
case 'Object': {
76+
for (const property of node.children) {
77+
results.push(...findTranslationKeys(property.value));
78+
}
79+
break;
80+
}
81+
82+
case 'Array': {
83+
for (const child of node.children) {
84+
results.push(...findTranslationKeys(child));
85+
}
86+
break;
87+
}
88+
89+
case 'Property':
90+
case 'Identifier': {
91+
// Keys don't contain translation values
92+
break;
93+
}
94+
}
95+
96+
return results;
97+
}

packages/theme-check-node/configs/all.yml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -193,6 +193,9 @@ ValidSchema:
193193
ValidSchemaName:
194194
enabled: true
195195
severity: 0
196+
ValidSchemaTranslations:
197+
enabled: true
198+
severity: 0
196199
ValidSettingsKey:
197200
enabled: true
198201
severity: 0

packages/theme-check-node/configs/recommended.yml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -171,6 +171,9 @@ ValidSchema:
171171
ValidSchemaName:
172172
enabled: true
173173
severity: 0
174+
ValidSchemaTranslations:
175+
enabled: true
176+
severity: 0
174177
ValidSettingsKey:
175178
enabled: true
176179
severity: 0

0 commit comments

Comments
 (0)