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
8 changes: 8 additions & 0 deletions .changeset/json-missing-section-check.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
'@shopify/theme-check-common': minor
'@shopify/theme-check-node': minor
---

Add `JSONMissingSection` check

Reports section types in JSON templates (`templates/*.json`) and section groups (`sections/*.json`) that do not refer to an existing section file, matching the platform validation that rejects theme publishes with the error `Section type 'x' does not refer to an existing section file`.
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 @@ -22,6 +22,7 @@ import { EmptyBlockContent } from './empty-block-content';
import { HardcodedRoutes } from './hardcoded-routes';
import { ImgWidthAndHeight } from './img-width-and-height';
import { JSONMissingBlock } from './json-missing-block';
import { JSONMissingSection } from './json-missing-section';
import { JSONSyntaxError } from './json-syntax-error';
import { LiquidFreeSettings } from './liquid-free-settings';
import { LiquidHTMLSyntaxError } from './liquid-html-syntax-error';
Expand Down Expand Up @@ -112,6 +113,7 @@ export const allChecks: (LiquidCheckDefinition | JSONCheckDefinition)[] = [
HardcodedRoutes,
ImgWidthAndHeight,
JSONMissingBlock,
JSONMissingSection,
JSONSyntaxError,
LiquidFreeSettings,
LiquidHTMLSyntaxError,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,247 @@
import { expect, describe, it } from 'vitest';
import { JSONMissingSection } from './index';
import { check, MockTheme } from '../../test';

describe('Module: JSONMissingSection', () => {
describe('JSON templates', () => {
it('should report an offense when a section file does not exist', async () => {
const theme: MockTheme = {
'templates/index.json': `{
"sections": {
"hero_AAAAAA": {
"type": "featured-collection"
}
},
"order": ["hero_AAAAAA"]
}`,
};

const offenses = await check(theme, [JSONMissingSection]);
expect(offenses).to.have.length(1);
expect(offenses[0].message).to.equal(
"Section type 'featured-collection' does not refer to an existing section file",
);

const content = theme['templates/index.json'];
const erroredContent = content.slice(offenses[0].start.index, offenses[0].end.index);
expect(erroredContent).to.equal('"featured-collection"');
});

it('should not report an offense when the section file exists', async () => {
const theme: MockTheme = {
'templates/index.json': `{
"sections": {
"hero_AAAAAA": {
"type": "featured-collection"
}
},
"order": ["hero_AAAAAA"]
}`,
'sections/featured-collection.liquid': '',
};

const offenses = await check(theme, [JSONMissingSection]);
expect(offenses).to.be.empty;
});

it('should report an offense for each missing section', async () => {
const theme: MockTheme = {
'templates/index.json': `{
"sections": {
"hero_AAAAAA": {
"type": "missing-one"
},
"main": {
"type": "existing-section"
},
"footer_BBBBBB": {
"type": "missing-two"
}
},
"order": ["hero_AAAAAA", "main", "footer_BBBBBB"]
}`,
'sections/existing-section.liquid': '',
};

const offenses = await check(theme, [JSONMissingSection]);
expect(offenses).to.have.length(2);
expect(offenses[0].message).to.equal(
"Section type 'missing-one' does not refer to an existing section file",
);
expect(offenses[1].message).to.equal(
"Section type 'missing-two' does not refer to an existing section file",
);
});

it('should not report an offense for section types with platform defaults', async () => {
const theme: MockTheme = {
'templates/index.json': `{
"sections": {
"apps_section": {
"type": "apps"
},
"blocks_section": {
"type": "_blocks"
}
},
"order": ["apps_section", "blocks_section"]
}`,
};

const offenses = await check(theme, [JSONMissingSection]);
expect(offenses).to.be.empty;
});

it('should not report an offense when a section entry has no type', async () => {
const theme: MockTheme = {
'templates/index.json': `{
"sections": {
"inherited_section": {
"settings": {}
}
},
"order": ["inherited_section"]
}`,
};

const offenses = await check(theme, [JSONMissingSection]);
expect(offenses).to.be.empty;
});
});

describe('Section groups', () => {
it('should report an offense when a section file does not exist', async () => {
const theme: MockTheme = {
'sections/header-group.json': `{
"type": "header",
"name": "Header group",
"sections": {
"announcement": {
"type": "missing-announcement-bar"
}
},
"order": ["announcement"]
}`,
};

const offenses = await check(theme, [JSONMissingSection]);
expect(offenses).to.have.length(1);
expect(offenses[0].message).to.equal(
"Section type 'missing-announcement-bar' does not refer to an existing section file",
);

const content = theme['sections/header-group.json'];
const erroredContent = content.slice(offenses[0].start.index, offenses[0].end.index);
expect(erroredContent).to.equal('"missing-announcement-bar"');
});

it('should not report an offense when the section file exists', async () => {
const theme: MockTheme = {
'sections/header-group.json': `{
"type": "header",
"name": "Header group",
"sections": {
"announcement": {
"type": "announcement-bar"
}
},
"order": ["announcement"]
}`,
'sections/announcement-bar.liquid': '',
};

const offenses = await check(theme, [JSONMissingSection]);
expect(offenses).to.be.empty;
});

it('should not report an offense for the section group type itself', async () => {
const theme: MockTheme = {
'sections/header-group.json': `{
"type": "header",
"name": "Header group",
"sections": {},
"order": []
}`,
};

const offenses = await check(theme, [JSONMissingSection]);
expect(offenses).to.be.empty;
});
});

describe('Edge cases', () => {
it('should ignore JSON files outside templates/ and sections/', async () => {
const theme: MockTheme = {
'config/settings_data.json': `{
"sections": {
"main": {
"type": "nonexistent"
}
}
}`,
'locales/en.default.json': `{
"sections": {
"main": { "type": "nonexistent" }
}
}`,
};

const offenses = await check(theme, [JSONMissingSection]);
expect(offenses).to.be.empty;
});

it('should not report an offense when sections is not an object', async () => {
const theme: MockTheme = {
'templates/index.json': `{
"sections": "not-an-object",
"order": []
}`,
};

const offenses = await check(theme, [JSONMissingSection]);
expect(offenses).to.be.empty;
});

it('should report each duplicate section key at its own location', async () => {
const theme: MockTheme = {
'templates/index.json': `{
"sections": {
"main": {
"type": "missing-one"
},
"main": {
"type": "missing-two"
}
},
"order": ["main"]
}`,
};

const offenses = await check(theme, [JSONMissingSection]);
expect(offenses).to.have.length(2);

const content = theme['templates/index.json'];
expect(offenses[0].message).to.equal(
"Section type 'missing-one' does not refer to an existing section file",
);
expect(content.slice(offenses[0].start.index, offenses[0].end.index)).to.equal(
'"missing-one"',
);
expect(offenses[1].message).to.equal(
"Section type 'missing-two' does not refer to an existing section file",
);
expect(content.slice(offenses[1].start.index, offenses[1].end.index)).to.equal(
'"missing-two"',
);
});

it('should not report an offense when the JSON is invalid', async () => {
const theme: MockTheme = {
'templates/index.json': `{ "sections": { "main": { "type": `,
};

const offenses = await check(theme, [JSONMissingSection]);
expect(offenses).to.be.empty;
});
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import { getLocEnd, getLocStart, nodeAtPath } from '../../json';
import { getSchemaFromJSON } from '../../to-schema';
import { JSONCheckDefinition, Severity, SourceCodeType } from '../../types';
import { doesFileExist } from '../../utils/file-utils';

const SECTION_TYPES_WITH_PLATFORM_DEFAULTS = ['apps', '_blocks'];

export const JSONMissingSection: JSONCheckDefinition = {
meta: {
code: 'JSONMissingSection',
name: 'Check for missing section files in JSON templates and section groups',
docs: {
description:
'This check ensures that section types in JSON templates and section groups refer to existing section files.',
recommended: true,
url: 'https://shopify.dev/docs/storefronts/themes/tools/theme-check/checks/json-missing-section',
},
type: SourceCodeType.JSON,
severity: Severity.ERROR,
schema: {},
targets: [],
},

create(context) {
const relativePath = context.toRelativePath(context.file.uri);
const isJsonTemplate = relativePath.startsWith('templates/');
const isSectionGroup = relativePath.startsWith('sections/');
if (!isJsonTemplate && !isSectionGroup) return {};

return {
async onCodePathEnd() {
const schema = await getSchemaFromJSON(context);
const { ast } = schema ?? {};
if (!ast || ast instanceof Error) return;

const sectionsNode = nodeAtPath(ast, ['sections']);
if (!sectionsNode || sectionsNode.type !== 'Object') return;

await Promise.all(
sectionsNode.children.map(async (property) => {
const sectionNode = property.value;
if (sectionNode.type !== 'Object') return;

const typeNode = sectionNode.children.find(
(child) => child.key.value === 'type',
)?.value;
if (!typeNode || typeNode.type !== 'Literal') return;

const sectionType = typeNode.value;
if (typeof sectionType !== 'string') return;
if (SECTION_TYPES_WITH_PLATFORM_DEFAULTS.includes(sectionType)) return;

const sectionFileExists = await doesFileExist(
context,
`sections/${sectionType}.liquid`,
);
if (sectionFileExists) return;

context.report({
message: `Section type '${sectionType}' does not refer to an existing section file`,
startIndex: getLocStart(typeNode),
endIndex: getLocEnd(typeNode),
});
}),
);
},
};
},
};
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 @@ -86,6 +86,9 @@ ImgWidthAndHeight:
JSONMissingBlock:
enabled: true
severity: 0
JSONMissingSection:
enabled: true
severity: 0
JSONSyntaxError:
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 @@ -64,6 +64,9 @@ ImgWidthAndHeight:
JSONMissingBlock:
enabled: true
severity: 0
JSONMissingSection:
enabled: true
severity: 0
JSONSyntaxError:
enabled: true
severity: 0
Expand Down
Loading