Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/max-schema-settings.md
Original file line number Diff line number Diff line change
@@ -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`).
2 changes: 2 additions & 0 deletions packages/theme-check-common/src/checks/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -96,6 +97,7 @@ export const allChecks: (LiquidCheckDefinition | JSONCheckDefinition)[] = [
LiquidFreeSettings,
LiquidHTMLSyntaxError,
MatchingTranslations,
MaxSchemaSettings,
MissingAsset,
MissingContentForArguments,
MissingRenderSnippetArguments,
Expand Down
Original file line number Diff line number Diff line change
@@ -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 [
'<div></div>',
'{% 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 = [
'<div></div>',
'{% 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);
});
});
Original file line number Diff line number Diff line change
@@ -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<Setting.Type>(['header', 'paragraph'] as Setting.Type[]);

export const MaxSchemaSettings: LiquidCheckDefinition<typeof schema> = {
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,
});
},
};
},
};
Loading