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
9 changes: 9 additions & 0 deletions .changeset/feat-liquid-schema-visitor.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
'@shopify/theme-check-common': minor
---

Add `LiquidSchema` check visitor method

Liquid checks can now declare a `LiquidSchema(node)` method in place of the repeated `LiquidRawTag` + `getSchema` + `validSchema` / `ast` preamble. The method fires once per `{% schema %}` tag in a section or theme-block file, after the schema has been JSON-parsed and validated. The payload exposes `node`, `schema`, `validSchema`, `ast`, and `offset`, with `validSchema` and `ast` guaranteed to be non-Error.

Existing `LiquidRawTag`-based checks continue to work. The two methods can be used side-by-side on the same check.
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import {
Setting,
} from '../../types';
import { nodeAtPath } from '../../json';
import { getSchema, isSectionSchema } from '../../to-schema';
import { isSectionSchema } from '../../to-schema';
import { BlockDefNodeWithPath, getBlocks, reportWarning } from '../../utils';

export const ValidSettingsKey: LiquidCheckDefinition = {
Expand All @@ -30,16 +30,7 @@ export const ValidSettingsKey: LiquidCheckDefinition = {

create(context) {
return {
async LiquidRawTag(node) {
if (node.name !== 'schema' || node.body.kind !== 'json') return;

const offset = node.blockStartPosition.end;
const schema = await getSchema(context);

const { validSchema, ast } = schema ?? {};
if (!validSchema || validSchema instanceof Error) return;
if (!ast || ast instanceof Error) return;

async LiquidSchema({ schema, validSchema, ast, offset }) {
const { rootLevelLocalBlocks, presetLevelBlocks } = getBlocks(validSchema);

// Check if presets settings match schema-level settings
Expand Down
5 changes: 4 additions & 1 deletion packages/theme-check-common/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ import {
} from './types';
import { getPosition } from './utils';
import { visitJSON, visitLiquid } from './visitors';
import { wrapLiquidSchema } from './wrap-liquid-schema';

export * from './AbstractFileSystem';
export * from './AugmentedThemeDocset';
Expand All @@ -57,6 +58,7 @@ export * from './utils/memo';
export * from './utils/types';
export * from './utils/object';
export * from './visitor';
export * from './wrap-liquid-schema';
export * from './liquid-doc/liquidDoc';
export { getBlockName } from './liquid-doc/arguments';
export * from './liquid-doc/utils';
Expand Down Expand Up @@ -192,7 +194,8 @@ function createCheck<S extends SourceCodeType>(
validateJSON?: ValidateJSON,
): Check<S> {
const context = createContext(check, file, offenses, config, dependencies, validateJSON);
return check.create(context as any) as Check<S>;
const instance = check.create(context as any) as Check<S>;
return wrapLiquidSchema(instance, context);
}

function filesOfType<S extends SourceCodeType>(type: S, sourceCodes: Theme): SourceCode<S>[] {
Expand Down
78 changes: 76 additions & 2 deletions packages/theme-check-common/src/types.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,9 @@
import { LiquidHtmlNode, NodeTypes as LiquidHtmlNodeTypes } from '@shopify/liquid-html-parser';
import {
LiquidHtmlNode,
LiquidRawTag,
NodeTypes as LiquidHtmlNodeTypes,
} from '@shopify/liquid-html-parser';
import { Section, ThemeBlock } from './types/schemas';

import { Schema, Settings } from './types/schema-prop-factory';

Expand Down Expand Up @@ -245,9 +250,78 @@ export type CheckDefinition<
* }
*/
export type Check<T> = T extends SourceCodeType
? Partial<CheckNodeMethods<T> & CheckExitMethods<T> & CheckLifecycleMethods<T>>
? Partial<
CheckNodeMethods<T> &
CheckExitMethods<T> &
CheckLifecycleMethods<T> &
CheckExtraMethods<T>
>
: never;

/**
* Payload for the synthetic `LiquidSchema` check method.
*
* Fires once per `{% schema %}` tag in a section or theme-block file, after
* the tag has been schema-validated. Abstracts the repeated preamble of
* filtering for `name === 'schema'`, calling `getSchema(context)`, and
* guarding against `undefined` / `Error` results.
*/
export interface LiquidSchemaNode {
/** The original `{% schema %}` LiquidRawTag node. */
node: LiquidRawTag;
/**
* The full schema object (`SectionSchema` or `ThemeBlockSchema`).
* Useful with `isSectionSchema(schema)` / `isBlockSchema(schema)` for
* type narrowing between section and block schemas.
*/
schema: SectionSchema | ThemeBlockSchema;
/** The validated, strongly-typed schema contents. Never an Error. */
validSchema: Section.Schema | ThemeBlock.Schema;
/** The JSON AST for the schema body. Never an Error. */
ast: JSONNode;
/**
* Character offset within the Liquid source where the JSON begins.
* Add this to JSON node positions to get document-level offsets when
* calling `context.report({ startIndex, endIndex })`.
*/
offset: number;
}

/** Extra non-node-type-based visitor methods a check can declare. */
type CheckExtraMethods<T extends SourceCodeType> = T extends SourceCodeType.LiquidHtml
? {
/**
* Called once per `{% schema %}` tag in a section or theme-block file,
* after the tag body has been parsed and schema-validated.
*
* Replaces the common preamble:
*
* ```ts
* async LiquidRawTag(node) {
* if (node.name !== 'schema' || node.body.kind !== 'json') return;
* const schema = await getSchema(context);
* const { validSchema, ast } = schema ?? {};
* if (!validSchema || validSchema instanceof Error) return;
* if (!ast || ast instanceof Error) return;
* // ...
* }
* ```
*
* with:
*
* ```ts
* async LiquidSchema({ validSchema, ast, offset }) {
* // ...
* }
* ```
*/
LiquidSchema: (
node: LiquidSchemaNode,
ancestors: LiquidHtmlNode[],
) => Promise<void>;
}
: {};

export type CheckNodeMethod<T extends SourceCodeType, NT> = (
node: NodeOfType<T, NT>,
ancestors: AST[T][],
Expand Down
162 changes: 162 additions & 0 deletions packages/theme-check-common/src/wrap-liquid-schema.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
import { describe, expect, it } from 'vitest';
import { check } from './test';
import {
LiquidCheckDefinition,
LiquidSchemaNode,
Severity,
SourceCodeType,
} from './types';

function makeSchemaCapturingCheck(captured: LiquidSchemaNode[]): LiquidCheckDefinition {
return {
meta: {
code: 'SchemaSpyCheck',
name: 'Schema Spy',
docs: {
description: 'Test check that captures every LiquidSchema invocation.',
recommended: true,
url: 'https://example.com',
},
type: SourceCodeType.LiquidHtml,
severity: Severity.ERROR,
schema: {},
targets: [],
},
create() {
return {
async LiquidSchema(node) {
captured.push(node);
},
};
},
};
}

describe('Module: wrapLiquidSchema', () => {
it('fires LiquidSchema once per valid {% schema %} tag in a section', async () => {
const captured: LiquidSchemaNode[] = [];
await check(
{
'sections/hero.liquid': `
<div></div>
{% schema %}
{
"name": "Hero",
"settings": []
}
{% endschema %}
`,
},
[makeSchemaCapturingCheck(captured)],
);

expect(captured).toHaveLength(1);
expect(captured[0].validSchema).not.toBeInstanceOf(Error);
expect(captured[0].ast).not.toBeInstanceOf(Error);
expect(captured[0].schema.type).toBe('section');
expect(captured[0].schema.name).toBe('hero');
expect(captured[0].validSchema.name).toBe('Hero');
expect(captured[0].offset).toBe(captured[0].node.blockStartPosition.end);
});

it('fires LiquidSchema for theme blocks', async () => {
const captured: LiquidSchemaNode[] = [];
await check(
{
'blocks/card.liquid': `
<div></div>
{% schema %}
{
"name": "Card"
}
{% endschema %}
`,
},
[makeSchemaCapturingCheck(captured)],
);

expect(captured).toHaveLength(1);
expect(captured[0].schema.type).toBe('block');
expect(captured[0].schema.name).toBe('card');
});

it('does not fire LiquidSchema in snippet files', async () => {
const captured: LiquidSchemaNode[] = [];
await check(
{
'snippets/utils.liquid': `
{% schema %}
{ "name": "Oops" }
{% endschema %}
`,
},
[makeSchemaCapturingCheck(captured)],
);

expect(captured).toHaveLength(0);
});

it('does not fire LiquidSchema for non-schema raw tags', async () => {
const captured: LiquidSchemaNode[] = [];
await check(
{
'sections/hero.liquid': `
{% stylesheet %}
.hero { color: red; }
{% endstylesheet %}
`,
},
[makeSchemaCapturingCheck(captured)],
);

expect(captured).toHaveLength(0);
});

it('composes with an existing LiquidRawTag method', async () => {
const rawTagNames: string[] = [];
const schemaHits: LiquidSchemaNode[] = [];

const composedCheck: LiquidCheckDefinition = {
meta: {
code: 'ComposedSpyCheck',
name: 'Composed Spy',
docs: {
description: 'Test check that uses both LiquidRawTag and LiquidSchema.',
recommended: true,
url: 'https://example.com',
},
type: SourceCodeType.LiquidHtml,
severity: Severity.ERROR,
schema: {},
targets: [],
},
create() {
return {
async LiquidRawTag(node) {
rawTagNames.push(node.name);
},
async LiquidSchema(node) {
schemaHits.push(node);
},
};
},
};

await check(
{
'sections/hero.liquid': `
{% stylesheet %}.x{}{% endstylesheet %}
{% schema %}
{ "name": "Hero" }
{% endschema %}
`,
},
[composedCheck],
);

// Both raw tags visited; only the schema tag fired LiquidSchema
expect(rawTagNames).toEqual(expect.arrayContaining(['stylesheet', 'schema']));
expect(schemaHits).toHaveLength(1);
expect(schemaHits[0].schema.name).toBe('hero');
});
});
Loading
Loading