From 168657268d833f6b8ca8baf015631c3a2ba80f83 Mon Sep 17 00:00:00 2001 From: Cristofer Hippleheuser Date: Mon, 8 Jun 2026 19:25:58 -0500 Subject: [PATCH] Fix JSONMissingBlock app block validation --- .changeset/calm-penguins-check.md | 5 + .../checks/json-missing-block/index.spec.ts | 154 ++++++++++++++++++ .../json-missing-block/missing-block-utils.ts | 19 ++- 3 files changed, 173 insertions(+), 5 deletions(-) create mode 100644 .changeset/calm-penguins-check.md diff --git a/.changeset/calm-penguins-check.md b/.changeset/calm-penguins-check.md new file mode 100644 index 000000000..994cf2751 --- /dev/null +++ b/.changeset/calm-penguins-check.md @@ -0,0 +1,5 @@ +--- +'@shopify/theme-check-common': patch +--- + +Avoid reporting missing block offenses for app block references when the containing schema allows app blocks. diff --git a/packages/theme-check-common/src/checks/json-missing-block/index.spec.ts b/packages/theme-check-common/src/checks/json-missing-block/index.spec.ts index 1082f97c3..cf4fcd583 100644 --- a/packages/theme-check-common/src/checks/json-missing-block/index.spec.ts +++ b/packages/theme-check-common/src/checks/json-missing-block/index.spec.ts @@ -80,6 +80,119 @@ describe('Module: JsonMissingBlock', () => { expect(offenses).to.be.empty; }); + it('should not report an offense for an app block when the section allows @app and @theme', async () => { + const theme: MockTheme = { + 'templates/product.app-block.json': `{ + "sections": { + "main": { + "type": "main-product", + "blocks": { + "some_app_block": { + "type": "shopify://apps/some-app/blocks/example/1234" + } + }, + "block_order": ["some_app_block"] + } + }, + "order": ["main"] + }`, + 'sections/main-product.liquid': ` + {% schema %} + { + "name": "Main product", + "blocks": [ + { + "type": "@app" + }, + { + "type": "@theme" + } + ] + } + {% endschema %} + `, + }; + + const offenses = await check(theme, [JSONMissingBlock]); + expect(offenses).to.be.empty; + }); + + it('should not report an offense for an app block when the section allows @app', async () => { + const theme: MockTheme = { + 'templates/product.app-block.json': `{ + "sections": { + "main": { + "type": "main-product", + "blocks": { + "some_app_block": { + "type": "shopify://apps/some-app/blocks/example/1234" + } + }, + "block_order": ["some_app_block"] + } + }, + "order": ["main"] + }`, + 'sections/main-product.liquid': ` + {% schema %} + { + "name": "Main product", + "blocks": [ + { + "type": "@app" + } + ] + } + {% endschema %} + `, + }; + + const offenses = await check(theme, [JSONMissingBlock]); + expect(offenses).to.be.empty; + }); + + it('should report an offense for a local block when the section allows only @app', async () => { + const theme: MockTheme = { + 'templates/product.local-block.json': `{ + "sections": { + "main": { + "type": "main-product", + "blocks": { + "some_local_block": { + "type": "text" + } + }, + "block_order": ["some_local_block"] + } + }, + "order": ["main"] + }`, + 'sections/main-product.liquid': ` + {% schema %} + { + "name": "Main product", + "blocks": [ + { + "type": "@app" + } + ] + } + {% endschema %} + `, + 'blocks/text.liquid': '', + }; + + const offenses = await check(theme, [JSONMissingBlock]); + expect(offenses).to.have.length(1); + expect(offenses[0].message).to.equal( + "Block type 'text' is not allowed in 'sections/main-product.liquid'.", + ); + + const content = theme['templates/product.local-block.json']; + const erroredContent = content.slice(offenses[0].start.index, offenses[0].end.index); + expect(erroredContent).to.equal('"text"'); + }); + it('should report an offense when a nested block does not exist', async () => { const theme: MockTheme = { 'templates/product.nested.json': `{ @@ -140,6 +253,47 @@ describe('Module: JsonMissingBlock', () => { }); describe('Allowed block type validation', () => { + it('should report an offense for an app block when the section does not allow @app', async () => { + const theme: MockTheme = { + 'templates/product.app-block.json': `{ + "sections": { + "main": { + "type": "main-product", + "blocks": { + "some_app_block": { + "type": "shopify://apps/some-app/blocks/example/1234" + } + }, + "block_order": ["some_app_block"] + } + }, + "order": ["main"] + }`, + 'sections/main-product.liquid': ` + {% schema %} + { + "name": "Main product", + "blocks": [ + { + "type": "@theme" + } + ] + } + {% endschema %} + `, + }; + + const offenses = await check(theme, [JSONMissingBlock]); + expect(offenses).to.have.length(1); + expect(offenses[0].message).to.equal( + "Block type 'shopify://apps/some-app/blocks/example/1234' is not allowed in 'sections/main-product.liquid'.", + ); + + const content = theme['templates/product.app-block.json']; + const erroredContent = content.slice(offenses[0].start.index, offenses[0].end.index); + expect(erroredContent).to.equal('"shopify://apps/some-app/blocks/example/1234"'); + }); + it('should report an offense when block exists but is not in the section liquid schema', async () => { const theme: MockTheme = { 'templates/product.valid.json': `{ diff --git a/packages/theme-check-common/src/checks/json-missing-block/missing-block-utils.ts b/packages/theme-check-common/src/checks/json-missing-block/missing-block-utils.ts index a45ee31a2..d633b4d37 100644 --- a/packages/theme-check-common/src/checks/json-missing-block/missing-block-utils.ts +++ b/packages/theme-check-common/src/checks/json-missing-block/missing-block-utils.ts @@ -11,6 +11,10 @@ function isNestedBlock(currentPath: string[]): boolean { return currentPath.filter((segment) => segment === 'blocks').length > 1; } +function isShopifyAppBlockType(blockType: string): boolean { + return blockType.startsWith('shopify://apps/'); +} + function reportWarning( message: string, offset: number, @@ -28,7 +32,7 @@ async function validateBlockFileExistence( blockType: string, context: Context, ): Promise { - if (blockType === '@theme' || blockType === '@app') { + if (blockType === '@theme' || blockType === '@app' || isShopifyAppBlockType(blockType)) { return true; } const blockPath = `blocks/${blockType}.liquid`; @@ -53,7 +57,7 @@ async function getThemeBlocks( if (Array.isArray(validSchema.blocks)) { validSchema.blocks.forEach((block) => { - if (!('name' in block) && block.type !== '@app') { + if (!('name' in block)) { themeBlocks.push(block.type); } }); @@ -85,13 +89,18 @@ async function validateBlock( // Static blocks are not required to be in the schema blocks array return; } else { + const isAppBlock = isShopifyAppBlockType(blockType); const isPrivateBlock = blockType.startsWith('_'); + const schemaIncludesAtApp = themeBlocks.includes('@app'); const schemaIncludesAtTheme = themeBlocks.includes('@theme'); const schemaIncludesBlockType = themeBlocks.includes(blockType); + const schemaAllowsBlockType = isAppBlock + ? schemaIncludesAtApp + : !isPrivateBlock + ? schemaIncludesBlockType || schemaIncludesAtTheme + : schemaIncludesBlockType; - if ( - !isPrivateBlock ? schemaIncludesBlockType || schemaIncludesAtTheme : schemaIncludesBlockType - ) { + if (schemaAllowsBlockType) { return; } else { const location = isNestedBlock(currentPath) ? 'blocks' : 'sections';