From 36f422c00a4be5fcc84154d8ded50d08ce818417 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Anders=20S=C3=B8gaard?= <9662430+andershagbard@users.noreply.github.com> Date: Tue, 9 Jun 2026 12:48:06 +0200 Subject: [PATCH] Add MaxSchemaSettings check Enforces a configurable maximum number of actionable settings in a section or block schema. Display-only setting types (header, paragraph) are excluded from the count. Co-Authored-By: Claude Sonnet 4.6 (1M context) --- .changeset/max-schema-settings.md | 5 + .../theme-check-common/src/checks/index.ts | 2 + .../checks/max-schema-settings/index.spec.ts | 162 ++++++++++++++++++ .../src/checks/max-schema-settings/index.ts | 56 ++++++ 4 files changed, 225 insertions(+) create mode 100644 .changeset/max-schema-settings.md create mode 100644 packages/theme-check-common/src/checks/max-schema-settings/index.spec.ts create mode 100644 packages/theme-check-common/src/checks/max-schema-settings/index.ts diff --git a/.changeset/max-schema-settings.md b/.changeset/max-schema-settings.md new file mode 100644 index 000000000..69efa80ba --- /dev/null +++ b/.changeset/max-schema-settings.md @@ -0,0 +1,5 @@ +--- +'@shopify/theme-check-common': minor +--- + +Add `MaxSchemaSettings` check that enforces a maximum number of actionable settings in a section or block schema, excluding display-only types (`header` and `paragraph`). diff --git a/packages/theme-check-common/src/checks/index.ts b/packages/theme-check-common/src/checks/index.ts index be0370c41..4bc49ef99 100644 --- a/packages/theme-check-common/src/checks/index.ts +++ b/packages/theme-check-common/src/checks/index.ts @@ -26,6 +26,7 @@ import { JSONSyntaxError } from './json-syntax-error'; import { LiquidFreeSettings } from './liquid-free-settings'; import { LiquidHTMLSyntaxError } from './liquid-html-syntax-error'; import { MatchingTranslations } from './matching-translations'; +import { MaxSchemaSettings } from './max-schema-settings'; import { MissingAsset } from './missing-asset'; import { MissingContentForArguments } from './missing-content-for-arguments'; import { MissingRenderSnippetArguments } from './missing-render-snippet-arguments'; @@ -96,6 +97,7 @@ export const allChecks: (LiquidCheckDefinition | JSONCheckDefinition)[] = [ LiquidFreeSettings, LiquidHTMLSyntaxError, MatchingTranslations, + MaxSchemaSettings, MissingAsset, MissingContentForArguments, MissingRenderSnippetArguments, diff --git a/packages/theme-check-common/src/checks/max-schema-settings/index.spec.ts b/packages/theme-check-common/src/checks/max-schema-settings/index.spec.ts new file mode 100644 index 000000000..55f12124c --- /dev/null +++ b/packages/theme-check-common/src/checks/max-schema-settings/index.spec.ts @@ -0,0 +1,162 @@ +import { expect, describe, it } from 'vitest'; +import { check } from '../../test'; +import { MaxSchemaSettings } from './index'; + +const sectionFile = 'sections/my-section.liquid'; +const blockFile = 'blocks/my-block.liquid'; + +function makeSchema(settings: object[]): string { + return [ + '
', + '{% schema %}', + JSON.stringify({ name: 'My section', settings }), + '{% endschema %}', + ].join('\n'); +} + +function makeSetting(id: string, type = 'text'): object { + return { type, id, label: id }; +} + +describe('Module: MaxSchemaSettings', () => { + it('does not report when settings are within the default limit', async () => { + const settings = Array.from({ length: 20 }, (_, i) => makeSetting(`setting_${i}`)); + const offenses = await check( + { [sectionFile]: makeSchema(settings) }, + [MaxSchemaSettings], + {}, + { MaxSchemaSettings: { enabled: true } }, + ); + expect(offenses).toHaveLength(0); + }); + + it('reports when settings exceed the default limit', async () => { + const settings = Array.from({ length: 21 }, (_, i) => makeSetting(`setting_${i}`)); + const offenses = await check( + { [sectionFile]: makeSchema(settings) }, + [MaxSchemaSettings], + {}, + { MaxSchemaSettings: { enabled: true } }, + ); + expect(offenses).toHaveLength(1); + expect(offenses.at(0)?.message).toMatch(/21.*20/); + }); + + it('reports with a custom max', async () => { + const settings = Array.from({ length: 6 }, (_, i) => makeSetting(`setting_${i}`)); + const offenses = await check( + { [sectionFile]: makeSchema(settings) }, + [MaxSchemaSettings], + {}, + { MaxSchemaSettings: { enabled: true, max: 5 } }, + ); + expect(offenses).toHaveLength(1); + expect(offenses.at(0)?.message).toMatch(/6.*5/); + }); + + it('does not report when settings equal the max', async () => { + const settings = Array.from({ length: 5 }, (_, i) => makeSetting(`setting_${i}`)); + const offenses = await check( + { [sectionFile]: makeSchema(settings) }, + [MaxSchemaSettings], + {}, + { MaxSchemaSettings: { enabled: true, max: 5 } }, + ); + expect(offenses).toHaveLength(0); + }); + + it('does not count header settings', async () => { + const settings = [ + { type: 'header', content: 'Section heading' }, + ...Array.from({ length: 5 }, (_, i) => makeSetting(`setting_${i}`)), + ]; + const offenses = await check( + { [sectionFile]: makeSchema(settings) }, + [MaxSchemaSettings], + {}, + { MaxSchemaSettings: { enabled: true, max: 5 } }, + ); + expect(offenses).toHaveLength(0); + }); + + it('does not count paragraph settings', async () => { + const settings = [ + { type: 'paragraph', content: 'Some description text.' }, + ...Array.from({ length: 5 }, (_, i) => makeSetting(`setting_${i}`)), + ]; + const offenses = await check( + { [sectionFile]: makeSchema(settings) }, + [MaxSchemaSettings], + {}, + { MaxSchemaSettings: { enabled: true, max: 5 } }, + ); + expect(offenses).toHaveLength(0); + }); + + it('does not count multiple headers and paragraphs', async () => { + const settings = [ + { type: 'header', content: 'Group 1' }, + makeSetting('text_1'), + makeSetting('text_2'), + { type: 'paragraph', content: 'Note about group 2' }, + { type: 'header', content: 'Group 2' }, + makeSetting('text_3'), + ]; + const offenses = await check( + { [sectionFile]: makeSchema(settings) }, + [MaxSchemaSettings], + {}, + { MaxSchemaSettings: { enabled: true, max: 3 } }, + ); + expect(offenses).toHaveLength(0); + }); + + it('reports on block files', async () => { + const settings = Array.from({ length: 6 }, (_, i) => makeSetting(`setting_${i}`)); + const source = [ + '{% schema %}', + JSON.stringify({ name: 'My block', settings }), + '{% endschema %}', + ].join('\n'); + const offenses = await check( + { [blockFile]: source }, + [MaxSchemaSettings], + {}, + { MaxSchemaSettings: { enabled: true, max: 5 } }, + ); + expect(offenses).toHaveLength(1); + expect(offenses.at(0)?.message).toMatch(/6.*5/); + }); + + it('does not report on non-section/block files', async () => { + const settings = Array.from({ length: 6 }, (_, i) => makeSetting(`setting_${i}`)); + const source = [ + '{% schema %}', + JSON.stringify({ name: 'Not a section', settings }), + '{% endschema %}', + ].join('\n'); + const offenses = await check( + { 'snippets/my-snippet.liquid': source }, + [MaxSchemaSettings], + {}, + { MaxSchemaSettings: { enabled: true, max: 5 } }, + ); + expect(offenses).toHaveLength(0); + }); + + it('does not report when schema has no settings', async () => { + const source = [ + '
', + '{% schema %}', + JSON.stringify({ name: 'My section' }), + '{% endschema %}', + ].join('\n'); + const offenses = await check( + { [sectionFile]: source }, + [MaxSchemaSettings], + {}, + { MaxSchemaSettings: { enabled: true, max: 0 } }, + ); + expect(offenses).toHaveLength(0); + }); +}); diff --git a/packages/theme-check-common/src/checks/max-schema-settings/index.ts b/packages/theme-check-common/src/checks/max-schema-settings/index.ts new file mode 100644 index 000000000..cf9bc4128 --- /dev/null +++ b/packages/theme-check-common/src/checks/max-schema-settings/index.ts @@ -0,0 +1,56 @@ +import { getSchema, isBlock, isSection } from '../../to-schema'; +import { LiquidCheckDefinition, SchemaProp, Severity, Setting, SourceCodeType } from '../../types'; + +const schema = { + max: SchemaProp.number(20), +}; + +const DISPLAY_ONLY_TYPES = new Set(['header', 'paragraph'] as Setting.Type[]); + +export const MaxSchemaSettings: LiquidCheckDefinition = { + meta: { + code: 'MaxSchemaSettings', + name: 'Max Schema Settings', + docs: { + description: + 'Enforce a maximum number of settings in a schema block to keep sections and blocks focused.', + recommended: false, + }, + type: SourceCodeType.LiquidHtml, + severity: Severity.WARNING, + schema, + targets: [], + }, + + create(context) { + if (!isSection(context.file.uri) && !isBlock(context.file.uri)) { + return {}; + } + + return { + async LiquidRawTag(node) { + if (node.name !== 'schema' || node.body.kind !== 'json') { + return; + } + + const schemaData = await getSchema(context); + if (!schemaData) return; + + const { validSchema } = schemaData; + if (!validSchema || validSchema instanceof Error) return; + + const { max } = context.settings; + const settings = validSchema.settings ?? []; + const countableSettings = settings.filter((s) => !DISPLAY_ONLY_TYPES.has(s.type)); + + if (countableSettings.length <= max) return; + + context.report({ + message: `Schema has too many settings (${countableSettings.length}). Maximum allowed is ${max}.`, + startIndex: node.blockStartPosition.start, + endIndex: node.blockStartPosition.end, + }); + }, + }; + }, +};