From 7b60c0e86322aab631a9f1152cbdc2a0210212d6 Mon Sep 17 00:00:00 2001 From: Oliver Slapinski Date: Tue, 25 Aug 2026 14:49:37 -0400 Subject: [PATCH] Fix local block target validation Signed-off-by: Oliver Slapinski --- .changeset/calm-blocks-validate.md | 5 + .../checks/valid-block-target/index.spec.ts | 103 ++++++++++++++++++ .../src/checks/valid-block-target/index.ts | 18 ++- .../theme-check-common/src/utils/block.ts | 12 +- 4 files changed, 129 insertions(+), 9 deletions(-) create mode 100644 .changeset/calm-blocks-validate.md diff --git a/.changeset/calm-blocks-validate.md b/.changeset/calm-blocks-validate.md new file mode 100644 index 000000000..578a5f592 --- /dev/null +++ b/.changeset/calm-blocks-validate.md @@ -0,0 +1,5 @@ +--- +'@shopify/theme-check-common': patch +--- + +Validate local section block references in presets and default configurations. diff --git a/packages/theme-check-common/src/checks/valid-block-target/index.spec.ts b/packages/theme-check-common/src/checks/valid-block-target/index.spec.ts index 4a848b698..95d3872dc 100644 --- a/packages/theme-check-common/src/checks/valid-block-target/index.spec.ts +++ b/packages/theme-check-common/src/checks/valid-block-target/index.spec.ts @@ -258,6 +258,109 @@ describe('Module: ValidBlockTarget', () => { const offenses = await check(theme, [ValidBlockTarget]); expect(offenses).to.be.empty; }); + + it('should report an unknown locally scoped block in a preset', async () => { + const theme: MockTheme = { + 'sections/local-blocks.liquid': ` + {% schema %} + { + "name": "Section name", + "blocks": [ + { + "type": "local_block", + "name": "Local block" + } + ], + "presets": [ + { + "name": "Default preset", + "blocks": [ + { + "type": "missing_block" + } + ] + } + ] + } + {% endschema %} + `, + }; + + const offenses = await check(theme, [ValidBlockTarget]); + expect(offenses).to.have.length(1); + expect(offenses).to.containOffense( + 'Section block type "missing_block" must be defined in "blocks" at the root of this schema.', + ); + }); + + it('should report an unknown locally scoped block in a default', async () => { + const theme: MockTheme = { + 'sections/local-blocks.liquid': ` + {% schema %} + { + "name": "Section name", + "blocks": [ + { + "type": "local_block", + "name": "Local block" + } + ], + "default": { + "blocks": [ + { + "type": "missing_block" + } + ] + } + } + {% endschema %} + `, + }; + + const offenses = await check(theme, [ValidBlockTarget]); + expect(offenses).to.have.length(1); + expect(offenses).to.containOffense( + 'Section block type "missing_block" must be defined in "blocks" at the root of this schema.', + ); + }); + + it('should allow locally scoped blocks declared for presets and defaults', async () => { + const theme: MockTheme = { + 'sections/local-blocks.liquid': ` + {% schema %} + { + "name": "Section name", + "blocks": [ + { + "type": "local_block", + "name": "Local block" + } + ], + "presets": [ + { + "name": "Default preset", + "blocks": [ + { + "type": "local_block" + } + ] + } + ], + "default": { + "blocks": [ + { + "type": "local_block" + } + ] + } + } + {% endschema %} + `, + }; + + const offenses = await check(theme, [ValidBlockTarget]); + expect(offenses).to.be.empty; + }); }); describe('Allowed Targeting Tests', () => { diff --git a/packages/theme-check-common/src/checks/valid-block-target/index.ts b/packages/theme-check-common/src/checks/valid-block-target/index.ts index f08f4c8cd..84555289d 100644 --- a/packages/theme-check-common/src/checks/valid-block-target/index.ts +++ b/packages/theme-check-common/src/checks/valid-block-target/index.ts @@ -54,7 +54,23 @@ export const ValidBlockTarget: LiquidCheckDefinition = { hasRootBlocksDeclaration, } = getBlocks(validSchema); - if (rootLevelLocalBlocks.length > 0) return; + if (rootLevelLocalBlocks.length > 0) { + const localBlockTypes = new Set(rootLevelLocalBlocks.map(({ node }) => node.type)); + const configuredBlocks = [...(presetLevelBlocks[0] ?? []), ...defaultLevelBlocks]; + + for (const { node, path } of configuredBlocks) { + if (!localBlockTypes.has(node.type)) { + const typeNode = nodeAtPath(ast, path)! as LiteralNode; + reportWarning( + `Section block type "${node.type}" must be defined in "blocks" at the root of this schema.`, + offset, + typeNode, + context, + ); + } + } + return; + } let errorsInRootLevelBlocks = false; await Promise.all( diff --git a/packages/theme-check-common/src/utils/block.ts b/packages/theme-check-common/src/utils/block.ts index d87e60970..7e7134aaa 100644 --- a/packages/theme-check-common/src/utils/block.ts +++ b/packages/theme-check-common/src/utils/block.ts @@ -82,14 +82,10 @@ export function getBlocks(validSchema: ThemeBlock.Schema | Section.Schema) { } function categorizeDefaultLevelBlocks(block: Preset.Block, index: number) { - const hasName = 'name' in block; - - if (hasName) { - defaultLevelBlocks.push({ - node: block, - path: ['default', 'blocks', String(index), 'type'], - }); - } + defaultLevelBlocks.push({ + node: block, + path: ['default', 'blocks', String(index), 'type'], + }); } if (Array.isArray(rootLevelBlocks)) {