From 6bd033fe0001ee46a6b2f4a9866d8679e0f53bb3 Mon Sep 17 00:00:00 2001 From: "Charles-P. Clermont" Date: Thu, 1 Oct 2026 10:16:30 -0400 Subject: [PATCH] Recognize schema settings as block variables in UndefinedObject --- ...undefined-object-block-schema-variables.md | 5 + .../src/checks/undefined-object/index.spec.ts | 146 ++++++++++++++++++ .../src/checks/undefined-object/index.ts | 26 +++- 3 files changed, 176 insertions(+), 1 deletion(-) create mode 100644 .changeset/undefined-object-block-schema-variables.md diff --git a/.changeset/undefined-object-block-schema-variables.md b/.changeset/undefined-object-block-schema-variables.md new file mode 100644 index 000000000..ee2a762e6 --- /dev/null +++ b/.changeset/undefined-object-block-schema-variables.md @@ -0,0 +1,5 @@ +--- +'@shopify/theme-check-common': patch +--- + +Stop `UndefinedObject` from reporting schema settings used as bare variables in theme blocks. A block file can now use `{{ heading }}` for a `heading` setting without repeating it as a LiquidDoc `@param`. diff --git a/packages/theme-check-common/src/checks/undefined-object/index.spec.ts b/packages/theme-check-common/src/checks/undefined-object/index.spec.ts index dbec713f3..0f2ea6ebc 100644 --- a/packages/theme-check-common/src/checks/undefined-object/index.spec.ts +++ b/packages/theme-check-common/src/checks/undefined-object/index.spec.ts @@ -466,6 +466,148 @@ describe('Module: UndefinedObject', () => { expect(offenses).toMatchObject([{ message: "Unknown object 'content' used." }]); }); + describe('block parameters in a theme block file', () => { + it('does not report a schema setting used as a bare variable', async () => { + const sourceCode = ` +

{{ heading }}

+ ${schema([{ type: 'text', id: 'heading', label: 'Heading' }])} + `; + + const offenses = await runLiquidCheck(UndefinedObject, sourceCode, 'blocks/card.liquid'); + + expect(offenses).toEqual([]); + }); + + it('does not report a schema setting and a LiquidDoc-only parameter in the same block', async () => { + const sourceCode = ` + {% doc %} + @param {string} [eyebrow] + {% enddoc %} +

{{ eyebrow }}

+

{{ heading }}

+ ${schema([{ type: 'text', id: 'heading', label: 'Heading' }])} + `; + + const offenses = await runLiquidCheck(UndefinedObject, sourceCode, 'blocks/card.liquid'); + + expect(offenses).toEqual([]); + }); + + it('does not report a name declared by both schema and LiquidDoc', async () => { + const sourceCode = ` + {% doc %} + @param {string} heading + {% enddoc %} +

{{ heading }}

+ ${schema([{ type: 'text', id: 'heading', label: 'Heading' }])} + `; + + const offenses = await runLiquidCheck(UndefinedObject, sourceCode, 'blocks/card.liquid'); + + expect(offenses).toEqual([]); + }); + + it('does not report content alongside schema and LiquidDoc variables', async () => { + const sourceCode = ` + {% doc %} + @param {string} [eyebrow] + {% enddoc %} +

{{ eyebrow }}

+

{{ heading }}

+
{{ content }}
+ ${schema([{ type: 'text', id: 'heading', label: 'Heading' }])} + `; + + const offenses = await runLiquidCheck(UndefinedObject, sourceCode, 'blocks/card.liquid'); + + expect(offenses).toEqual([]); + }); + + it('reports an unknown root alongside schema and LiquidDoc variables', async () => { + const sourceCode = ` + {% doc %} + @param {string} [eyebrow] + {% enddoc %} +

{{ eyebrow }}

+

{{ heading }}

+

{{ subtitle }}

+ ${schema([{ type: 'text', id: 'heading', label: 'Heading' }])} + `; + + const offenses = await runLiquidCheck(UndefinedObject, sourceCode, 'blocks/card.liquid'); + + expect(offenses).toMatchObject([{ message: "Unknown object 'subtitle' used." }]); + }); + + it('reports names from non-input schema entries and setting types', async () => { + const sourceCode = ` +
+ {{ layout_heading }} + {{ header }} + {{ color_background }} +
+ ${schema([ + { type: 'header', id: 'layout_heading', content: 'Layout' }, + { type: 'color_background', id: 'background', label: 'Background' }, + ])} + `; + + const offenses = await runLiquidCheck(UndefinedObject, sourceCode, 'blocks/card.liquid'); + + expect(offenses).toMatchObject([ + { message: "Unknown object 'layout_heading' used." }, + { message: "Unknown object 'header' used." }, + { message: "Unknown object 'color_background' used." }, + ]); + }); + + it('keeps LiquidDoc parameters and content defined when the schema is invalid', async () => { + const sourceCode = ` + {% doc %} + @param {string} [eyebrow] + {% enddoc %} +

{{ eyebrow }}

+
{{ content }}
+ {% schema %} + { "name": "Card", "settings": [ + {% endschema %} + `; + + const offenses = await runLiquidCheck(UndefinedObject, sourceCode, 'blocks/card.liquid'); + + expect(offenses).toEqual([]); + }); + + it('reports schema settings used as bare variables in a section file', async () => { + const sourceCode = ` +

{{ heading }}

+ ${schema([{ type: 'text', id: 'heading', label: 'Heading' }])} + `; + + const offenses = await runLiquidCheck(UndefinedObject, sourceCode, 'sections/hero.liquid'); + + expect(offenses).toMatchObject([{ message: "Unknown object 'heading' used." }]); + }); + + it('reports schema settings used as bare variables in an app block', async () => { + const sourceCode = ` +

{{ heading }}

+ ${schema([{ type: 'text', id: 'heading', label: 'Heading' }])} + `; + + const offenses = await runLiquidCheck( + UndefinedObject, + sourceCode, + 'blocks/card.liquid', + {}, + undefined, + 'app', + ); + + expect(offenses).toMatchObject([{ message: "Unknown object 'heading' used." }]); + }); + }); + it('should not report an offense when a self defined variable is defined with a @param tag', async () => { const sourceCode = ` {% doc %} @@ -519,3 +661,7 @@ describe('Module: UndefinedObject', () => { expect(offenses).to.be.empty; }); }); + +function schema(settings: object[]): string { + return `{% schema %}${JSON.stringify({ name: 'Card', settings })}{% endschema %}`; +} diff --git a/packages/theme-check-common/src/checks/undefined-object/index.ts b/packages/theme-check-common/src/checks/undefined-object/index.ts index e894a8180..4c04c3cc3 100644 --- a/packages/theme-check-common/src/checks/undefined-object/index.ts +++ b/packages/theme-check-common/src/checks/undefined-object/index.ts @@ -14,7 +14,16 @@ import { Position, } from '@shopify/liquid-html-parser'; import { BLOCK_CONTENT_PARAMETER } from '../../block-parameters'; -import { LiquidCheckDefinition, Mode, Severity, SourceCodeType, ThemeDocset } from '../../types'; +import * as path from '../../path'; +import { isBlock } from '../../to-schema'; +import { + Context, + LiquidCheckDefinition, + Mode, + Severity, + SourceCodeType, + ThemeDocset, +} from '../../types'; import { isError, last } from '../../utils'; import { hasLiquidDoc } from '../../liquid-doc/liquidDoc'; import { isWithinRawTagThatDoesNotParseItsContents } from '../utils'; @@ -150,6 +159,7 @@ export const UndefinedObject: LiquidCheckDefinition = { const objects = await globalObjects(themeDocset, relativePath, context.mode); objects.forEach((obj) => fileScopedVariables.add(obj.name)); + (await themeBlockParameterNames(context)).forEach((name) => fileScopedVariables.add(name)); variables.forEach((variable) => { if (!variable.name) return; @@ -194,6 +204,20 @@ function builtInVariables(relativePath: string): string[] { return relativePath.startsWith('blocks/') ? [BLOCK_CONTENT_PARAMETER] : []; } +/** + * A theme block's schema settings and LiquidDoc parameters are also plain + * variables in the block file. Returns no names when the block's parameters + * cannot be resolved, such as when its schema is invalid. + */ +async function themeBlockParameterNames( + context: Context, +): Promise { + if (!isBlock(context.file.uri)) return []; + + const parameters = await context.getBlockParameters(path.basename(context.file.uri, '.liquid')); + return [...(parameters?.keys() ?? [])]; +} + const BLOCK_CONTEXTUAL_OBJECTS = ['app', 'section', 'recommendations', 'block']; function getContextualObjects(relativePath: string, mode: Mode = 'theme'): string[] {