Skip to content
Merged
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
6 changes: 6 additions & 0 deletions .changeset/loud-hotels-teach.md
Original file line number Diff line number Diff line change
@@ -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
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 @@ -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';
Expand Down Expand Up @@ -133,6 +134,7 @@ export const allChecks: (LiquidCheckDefinition | JSONCheckDefinition)[] = [
ValidVisibleIfSettingsSchema,
VariableName,
ValidSchemaName,
ValidSchemaTranslations,
];

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)`,
Expand Down
Original file line number Diff line number Diff line change
@@ -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);
});
});
Original file line number Diff line number Diff line change
@@ -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;
}
3 changes: 3 additions & 0 deletions packages/theme-check-node/configs/all.yml
Original file line number Diff line number Diff line change
Expand Up @@ -193,6 +193,9 @@ ValidSchema:
ValidSchemaName:
enabled: true
severity: 0
ValidSchemaTranslations:
enabled: true
severity: 0
ValidSettingsKey:
enabled: true
severity: 0
Expand Down
3 changes: 3 additions & 0 deletions packages/theme-check-node/configs/recommended.yml
Original file line number Diff line number Diff line change
Expand Up @@ -171,6 +171,9 @@ ValidSchema:
ValidSchemaName:
enabled: true
severity: 0
ValidSchemaTranslations:
enabled: true
severity: 0
ValidSettingsKey:
enabled: true
severity: 0
Expand Down