From 54c5cc81a9b502e953db7d505ec70fc645d54dfc Mon Sep 17 00:00:00 2001 From: Luke Hickin Date: Wed, 26 Aug 2026 11:20:28 -0700 Subject: [PATCH] Add JSONMissingSection check --- .changeset/json-missing-section-check.md | 8 + .../theme-check-common/src/checks/index.ts | 2 + .../checks/json-missing-section/index.spec.ts | 247 ++++++++++++++++++ .../src/checks/json-missing-section/index.ts | 69 +++++ packages/theme-check-node/configs/all.yml | 3 + .../theme-check-node/configs/recommended.yml | 3 + 6 files changed, 332 insertions(+) create mode 100644 .changeset/json-missing-section-check.md create mode 100644 packages/theme-check-common/src/checks/json-missing-section/index.spec.ts create mode 100644 packages/theme-check-common/src/checks/json-missing-section/index.ts diff --git a/.changeset/json-missing-section-check.md b/.changeset/json-missing-section-check.md new file mode 100644 index 000000000..1d86ba416 --- /dev/null +++ b/.changeset/json-missing-section-check.md @@ -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`. diff --git a/packages/theme-check-common/src/checks/index.ts b/packages/theme-check-common/src/checks/index.ts index 74ad5779e..388410a76 100644 --- a/packages/theme-check-common/src/checks/index.ts +++ b/packages/theme-check-common/src/checks/index.ts @@ -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'; @@ -112,6 +113,7 @@ export const allChecks: (LiquidCheckDefinition | JSONCheckDefinition)[] = [ HardcodedRoutes, ImgWidthAndHeight, JSONMissingBlock, + JSONMissingSection, JSONSyntaxError, LiquidFreeSettings, LiquidHTMLSyntaxError, diff --git a/packages/theme-check-common/src/checks/json-missing-section/index.spec.ts b/packages/theme-check-common/src/checks/json-missing-section/index.spec.ts new file mode 100644 index 000000000..084195c73 --- /dev/null +++ b/packages/theme-check-common/src/checks/json-missing-section/index.spec.ts @@ -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; + }); + }); +}); diff --git a/packages/theme-check-common/src/checks/json-missing-section/index.ts b/packages/theme-check-common/src/checks/json-missing-section/index.ts new file mode 100644 index 000000000..3b3ee3d94 --- /dev/null +++ b/packages/theme-check-common/src/checks/json-missing-section/index.ts @@ -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), + }); + }), + ); + }, + }; + }, +}; diff --git a/packages/theme-check-node/configs/all.yml b/packages/theme-check-node/configs/all.yml index c80341be9..1a33c2a75 100644 --- a/packages/theme-check-node/configs/all.yml +++ b/packages/theme-check-node/configs/all.yml @@ -86,6 +86,9 @@ ImgWidthAndHeight: JSONMissingBlock: enabled: true severity: 0 +JSONMissingSection: + enabled: true + severity: 0 JSONSyntaxError: enabled: true severity: 0 diff --git a/packages/theme-check-node/configs/recommended.yml b/packages/theme-check-node/configs/recommended.yml index 065a10050..34b458654 100644 --- a/packages/theme-check-node/configs/recommended.yml +++ b/packages/theme-check-node/configs/recommended.yml @@ -64,6 +64,9 @@ ImgWidthAndHeight: JSONMissingBlock: enabled: true severity: 0 +JSONMissingSection: + enabled: true + severity: 0 JSONSyntaxError: enabled: true severity: 0