From 9c46aac0116f5d9630918ed4d8abbc7889062b50 Mon Sep 17 00:00:00 2001 From: Luke Hickin Date: Tue, 18 Aug 2026 12:11:51 -0700 Subject: [PATCH 1/3] Add check for standard_event_data --- .changeset/valid-standard-event-data.md | 9 ++ .../theme-check-common/src/checks/index.ts | 2 + .../valid-standard-event-data/index.spec.ts | 87 +++++++++++++++++++ .../checks/valid-standard-event-data/index.ts | 67 ++++++++++++++ packages/theme-check-node/configs/all.yml | 3 + .../theme-check-node/configs/recommended.yml | 3 + 6 files changed, 171 insertions(+) create mode 100644 .changeset/valid-standard-event-data.md create mode 100644 packages/theme-check-common/src/checks/valid-standard-event-data/index.spec.ts create mode 100644 packages/theme-check-common/src/checks/valid-standard-event-data/index.ts diff --git a/.changeset/valid-standard-event-data.md b/.changeset/valid-standard-event-data.md new file mode 100644 index 000000000..73fd2435d --- /dev/null +++ b/.changeset/valid-standard-event-data.md @@ -0,0 +1,9 @@ +--- +'@shopify/theme-check-common': minor +--- + +Add `ValidStandardEventData` check to error on invalid arguments to the `standard_event_data` filter. + +The filter's argument values are currently only validated at render time. This check catches invalid literal values statically: `view` is the only supported event type, and `context:` must be one of `page`, `search`, `collection`, `dialog`, or `recommendation`. + +Values that aren't string literals are left alone, since the type of the piped input isn't statically knowable. diff --git a/packages/theme-check-common/src/checks/index.ts b/packages/theme-check-common/src/checks/index.ts index 74ad5779e..c8db44960 100644 --- a/packages/theme-check-common/src/checks/index.ts +++ b/packages/theme-check-common/src/checks/index.ts @@ -64,6 +64,7 @@ import { ValidSchema } from './valid-schema'; import { ValidSchemaName } from './valid-schema-name'; import { ValidSchemaTranslations } from './valid-schema-translations'; import { ValidSettingsKey } from './valid-settings-key'; +import { ValidStandardEventData } from './valid-standard-event-data'; import { ValidStaticBlockType } from './valid-static-block-type'; import { ValidVisibleIf, ValidVisibleIfSettingsSchema } from './valid-visible-if'; import { VariableName } from './variable-name'; @@ -154,6 +155,7 @@ export const allChecks: (LiquidCheckDefinition | JSONCheckDefinition)[] = [ ValidRenderSnippetArgumentTypes, ValidSchema, ValidSettingsKey, + ValidStandardEventData, ValidStaticBlockType, ValidVisibleIf, ValidVisibleIfSettingsSchema, diff --git a/packages/theme-check-common/src/checks/valid-standard-event-data/index.spec.ts b/packages/theme-check-common/src/checks/valid-standard-event-data/index.spec.ts new file mode 100644 index 000000000..16e3f571b --- /dev/null +++ b/packages/theme-check-common/src/checks/valid-standard-event-data/index.spec.ts @@ -0,0 +1,87 @@ +import { describe, expect, it } from 'vitest'; +import { highlightedOffenses, runLiquidCheck } from '../../test'; +import { ValidStandardEventData } from './index'; + +describe('Module: ValidStandardEventData', () => { + it('reports an offense when the context is not a supported value', async () => { + const sourceCode = `{{ product | standard_event_data: 'view', context: 'homepage' }}`; + const offenses = await runLiquidCheck(ValidStandardEventData, sourceCode); + + expect(offenses).toHaveLength(1); + expect(offenses[0].message).toBe( + "Unsupported context 'homepage'. Valid values: page, search, collection, dialog, recommendation", + ); + + const highlights = highlightedOffenses({ 'file.liquid': sourceCode }, offenses); + expect(highlights[0]).to.eql("'homepage'"); + }); + + it('reports an offense when the context is not a supported value on a cart', async () => { + const sourceCode = `{{ cart | standard_event_data: 'view', context: 'banner' }}`; + const offenses = await runLiquidCheck(ValidStandardEventData, sourceCode); + + expect(offenses).toHaveLength(1); + expect(offenses[0].message).toBe( + "Unsupported context 'banner'. Valid values: page, search, collection, dialog, recommendation", + ); + }); + + it('does not report an offense on supported context values', async () => { + const contexts = ['page', 'search', 'collection', 'dialog', 'recommendation']; + + for (const context of contexts) { + const sourceCode = `{{ product | standard_event_data: 'view', context: '${context}' }}`; + const offenses = await runLiquidCheck(ValidStandardEventData, sourceCode); + + expect(offenses, `expected '${context}' to be a valid context`).toHaveLength(0); + } + }); + + it('does not report an offense when the context is not a string literal', async () => { + const sourceCode = `{{ product | standard_event_data: 'view', context: section.settings.context }}`; + const offenses = await runLiquidCheck(ValidStandardEventData, sourceCode); + + expect(offenses).toHaveLength(0); + }); + + it('does not report an offense when the context argument is omitted', async () => { + const sourceCode = `{{ collection | standard_event_data: 'view' }}`; + const offenses = await runLiquidCheck(ValidStandardEventData, sourceCode); + + expect(offenses).toHaveLength(0); + }); + + it('reports an offense when the event type is not supported', async () => { + const sourceCode = `{{ product | standard_event_data: 'click', context: 'page' }}`; + const offenses = await runLiquidCheck(ValidStandardEventData, sourceCode); + + expect(offenses).toHaveLength(1); + expect(offenses[0].message).toBe( + "Unsupported event type 'click'. The only supported event type is 'view'.", + ); + + const highlights = highlightedOffenses({ 'file.liquid': sourceCode }, offenses); + expect(highlights[0]).to.eql("'click'"); + }); + + it('does not report an offense when the event type is not a string literal', async () => { + const sourceCode = `{{ product | standard_event_data: event_type }}`; + const offenses = await runLiquidCheck(ValidStandardEventData, sourceCode); + + expect(offenses).toHaveLength(0); + }); + + it('reports both offenses when the event type and the context are invalid', async () => { + const sourceCode = `{{ product | standard_event_data: 'click', context: 'homepage' }}`; + const offenses = await runLiquidCheck(ValidStandardEventData, sourceCode); + + expect(offenses).toHaveLength(2); + }); + + it('does not report an offense on other filters', async () => { + const sourceCode = `{{ product | json }}{{ 'homepage' | append: 'view' }}`; + const offenses = await runLiquidCheck(ValidStandardEventData, sourceCode); + + expect(offenses).toHaveLength(0); + }); +}); diff --git a/packages/theme-check-common/src/checks/valid-standard-event-data/index.ts b/packages/theme-check-common/src/checks/valid-standard-event-data/index.ts new file mode 100644 index 000000000..8fa4b8bb8 --- /dev/null +++ b/packages/theme-check-common/src/checks/valid-standard-event-data/index.ts @@ -0,0 +1,67 @@ +import { LiquidNamedArgument, NodeTypes } from '@shopify/liquid-html-parser'; +import { LiquidCheckDefinition, Severity, SourceCodeType } from '../../types'; + +const FILTER_NAME = 'standard_event_data'; +const CONTEXT_ARGUMENT = 'context'; +const SUPPORTED_EVENT_TYPE = 'view'; +const CONTEXTS_SUPPORTED_BY_ANY_INPUT_TYPE = [ + 'page', + 'search', + 'collection', + 'dialog', + 'recommendation', +]; + +export const ValidStandardEventData: LiquidCheckDefinition = { + meta: { + code: 'ValidStandardEventData', + name: 'Prevent the use of invalid arguments to the standard_event_data filter', + docs: { + description: + 'This check is aimed at preventing the use of invalid arguments for the standard_event_data filter.', + url: 'https://shopify.dev/docs/storefronts/themes/tools/theme-check/checks/valid-standard-event-data', + recommended: true, + }, + type: SourceCodeType.LiquidHtml, + severity: Severity.ERROR, + schema: {}, + targets: [], + }, + + create(context) { + return { + async LiquidFilter(node) { + if (node.name !== FILTER_NAME) return; + + const eventType = node.args.find((arg) => arg.type !== NodeTypes.NamedArgument); + + if (eventType?.type === NodeTypes.String && eventType.value !== SUPPORTED_EVENT_TYPE) { + context.report({ + message: `Unsupported event type '${eventType.value}'. The only supported event type is '${SUPPORTED_EVENT_TYPE}'.`, + startIndex: eventType.position.start, + endIndex: eventType.position.end, + }); + } + + const contextArgument = node.args.find( + (arg): arg is LiquidNamedArgument => + arg.type === NodeTypes.NamedArgument && arg.name === CONTEXT_ARGUMENT, + ); + const contextValue = contextArgument?.value; + + if ( + contextValue?.type === NodeTypes.String && + !CONTEXTS_SUPPORTED_BY_ANY_INPUT_TYPE.includes(contextValue.value) + ) { + context.report({ + message: `Unsupported context '${ + contextValue.value + }'. Valid values: ${CONTEXTS_SUPPORTED_BY_ANY_INPUT_TYPE.join(', ')}`, + startIndex: contextValue.position.start, + endIndex: contextValue.position.end, + }); + } + }, + }; + }, +}; diff --git a/packages/theme-check-node/configs/all.yml b/packages/theme-check-node/configs/all.yml index c80341be9..1ae1e8ff0 100644 --- a/packages/theme-check-node/configs/all.yml +++ b/packages/theme-check-node/configs/all.yml @@ -262,6 +262,9 @@ ValidScopedCSSClass: ValidSettingsKey: enabled: true severity: 0 +ValidStandardEventData: + enabled: true + severity: 0 ValidStaticBlockType: enabled: true severity: 0 diff --git a/packages/theme-check-node/configs/recommended.yml b/packages/theme-check-node/configs/recommended.yml index 065a10050..0f687f95b 100644 --- a/packages/theme-check-node/configs/recommended.yml +++ b/packages/theme-check-node/configs/recommended.yml @@ -240,6 +240,9 @@ ValidScopedCSSClass: ValidSettingsKey: enabled: true severity: 0 +ValidStandardEventData: + enabled: true + severity: 0 ValidStaticBlockType: enabled: true severity: 0 From 7802dcfb658a8b3351a44a7433856871f4654e9d Mon Sep 17 00:00:00 2001 From: Luke Hickin Date: Thu, 20 Aug 2026 09:16:34 -0700 Subject: [PATCH 2/3] Report non-string literals and mention context is optional --- .../valid-standard-event-data/index.spec.ts | 44 +++++++++++++++++-- .../checks/valid-standard-event-data/index.ts | 33 ++++++++++---- 2 files changed, 64 insertions(+), 13 deletions(-) diff --git a/packages/theme-check-common/src/checks/valid-standard-event-data/index.spec.ts b/packages/theme-check-common/src/checks/valid-standard-event-data/index.spec.ts index 16e3f571b..b519a7a64 100644 --- a/packages/theme-check-common/src/checks/valid-standard-event-data/index.spec.ts +++ b/packages/theme-check-common/src/checks/valid-standard-event-data/index.spec.ts @@ -9,7 +9,7 @@ describe('Module: ValidStandardEventData', () => { expect(offenses).toHaveLength(1); expect(offenses[0].message).toBe( - "Unsupported context 'homepage'. Valid values: page, search, collection, dialog, recommendation", + "Unsupported context 'homepage'. Valid values: page, search, collection, dialog, recommendation. The 'context' argument can also be omitted.", ); const highlights = highlightedOffenses({ 'file.liquid': sourceCode }, offenses); @@ -22,7 +22,7 @@ describe('Module: ValidStandardEventData', () => { expect(offenses).toHaveLength(1); expect(offenses[0].message).toBe( - "Unsupported context 'banner'. Valid values: page, search, collection, dialog, recommendation", + "Unsupported context 'banner'. Valid values: page, search, collection, dialog, recommendation. The 'context' argument can also be omitted.", ); }); @@ -37,13 +37,26 @@ describe('Module: ValidStandardEventData', () => { } }); - it('does not report an offense when the context is not a string literal', async () => { + it('does not report an offense when the context is a variable', async () => { const sourceCode = `{{ product | standard_event_data: 'view', context: section.settings.context }}`; const offenses = await runLiquidCheck(ValidStandardEventData, sourceCode); expect(offenses).toHaveLength(0); }); + it('reports an offense when the context is a non-string literal', async () => { + const sourceCode = `{{ product | standard_event_data: 'view', context: 123 }}`; + const offenses = await runLiquidCheck(ValidStandardEventData, sourceCode); + + expect(offenses).toHaveLength(1); + expect(offenses[0].message).toBe( + "Unsupported context. Valid values: page, search, collection, dialog, recommendation. The 'context' argument can also be omitted.", + ); + + const highlights = highlightedOffenses({ 'file.liquid': sourceCode }, offenses); + expect(highlights[0]).to.eql('123'); + }); + it('does not report an offense when the context argument is omitted', async () => { const sourceCode = `{{ collection | standard_event_data: 'view' }}`; const offenses = await runLiquidCheck(ValidStandardEventData, sourceCode); @@ -64,13 +77,36 @@ describe('Module: ValidStandardEventData', () => { expect(highlights[0]).to.eql("'click'"); }); - it('does not report an offense when the event type is not a string literal', async () => { + it('does not report an offense when the event type is a variable', async () => { const sourceCode = `{{ product | standard_event_data: event_type }}`; const offenses = await runLiquidCheck(ValidStandardEventData, sourceCode); expect(offenses).toHaveLength(0); }); + it('reports an offense when the event type is a non-string literal', async () => { + const sourceCode = `{{ product | standard_event_data: 123 }}`; + const offenses = await runLiquidCheck(ValidStandardEventData, sourceCode); + + expect(offenses).toHaveLength(1); + expect(offenses[0].message).toBe( + "Unsupported event type. The only supported event type is 'view'.", + ); + + const highlights = highlightedOffenses({ 'file.liquid': sourceCode }, offenses); + expect(highlights[0]).to.eql('123'); + }); + + it('reports an offense when the event type is a boolean literal', async () => { + const sourceCode = `{{ product | standard_event_data: true }}`; + const offenses = await runLiquidCheck(ValidStandardEventData, sourceCode); + + expect(offenses).toHaveLength(1); + expect(offenses[0].message).toBe( + "Unsupported event type. The only supported event type is 'view'.", + ); + }); + it('reports both offenses when the event type and the context are invalid', async () => { const sourceCode = `{{ product | standard_event_data: 'click', context: 'homepage' }}`; const offenses = await runLiquidCheck(ValidStandardEventData, sourceCode); diff --git a/packages/theme-check-common/src/checks/valid-standard-event-data/index.ts b/packages/theme-check-common/src/checks/valid-standard-event-data/index.ts index 8fa4b8bb8..c50ac26a6 100644 --- a/packages/theme-check-common/src/checks/valid-standard-event-data/index.ts +++ b/packages/theme-check-common/src/checks/valid-standard-event-data/index.ts @@ -1,4 +1,4 @@ -import { LiquidNamedArgument, NodeTypes } from '@shopify/liquid-html-parser'; +import { LiquidExpression, LiquidNamedArgument, NodeTypes } from '@shopify/liquid-html-parser'; import { LiquidCheckDefinition, Severity, SourceCodeType } from '../../types'; const FILTER_NAME = 'standard_event_data'; @@ -12,6 +12,15 @@ const CONTEXTS_SUPPORTED_BY_ANY_INPUT_TYPE = [ 'recommendation', ]; +function isInvalidStaticValue(value: LiquidExpression, supportedValues: string[]): boolean { + if (value.type === NodeTypes.VariableLookup) return false; + return value.type !== NodeTypes.String || !supportedValues.includes(value.value); +} + +function describeValue(value: LiquidExpression): string { + return value.type === NodeTypes.String ? ` '${value.value}'` : ''; +} + export const ValidStandardEventData: LiquidCheckDefinition = { meta: { code: 'ValidStandardEventData', @@ -33,11 +42,15 @@ export const ValidStandardEventData: LiquidCheckDefinition = { async LiquidFilter(node) { if (node.name !== FILTER_NAME) return; - const eventType = node.args.find((arg) => arg.type !== NodeTypes.NamedArgument); + const eventType = node.args.find( + (arg): arg is LiquidExpression => arg.type !== NodeTypes.NamedArgument, + ); - if (eventType?.type === NodeTypes.String && eventType.value !== SUPPORTED_EVENT_TYPE) { + if (eventType && isInvalidStaticValue(eventType, [SUPPORTED_EVENT_TYPE])) { context.report({ - message: `Unsupported event type '${eventType.value}'. The only supported event type is '${SUPPORTED_EVENT_TYPE}'.`, + message: `Unsupported event type${describeValue( + eventType, + )}. The only supported event type is '${SUPPORTED_EVENT_TYPE}'.`, startIndex: eventType.position.start, endIndex: eventType.position.end, }); @@ -50,13 +63,15 @@ export const ValidStandardEventData: LiquidCheckDefinition = { const contextValue = contextArgument?.value; if ( - contextValue?.type === NodeTypes.String && - !CONTEXTS_SUPPORTED_BY_ANY_INPUT_TYPE.includes(contextValue.value) + contextValue && + isInvalidStaticValue(contextValue, CONTEXTS_SUPPORTED_BY_ANY_INPUT_TYPE) ) { context.report({ - message: `Unsupported context '${ - contextValue.value - }'. Valid values: ${CONTEXTS_SUPPORTED_BY_ANY_INPUT_TYPE.join(', ')}`, + message: `Unsupported context${describeValue( + contextValue, + )}. Valid values: ${CONTEXTS_SUPPORTED_BY_ANY_INPUT_TYPE.join( + ', ', + )}. The '${CONTEXT_ARGUMENT}' argument can also be omitted.`, startIndex: contextValue.position.start, endIndex: contextValue.position.end, }); From c141326271cec78a8a49f0f9c234fedd8b3d0643 Mon Sep 17 00:00:00 2001 From: Luke Hickin Date: Thu, 20 Aug 2026 09:29:27 -0700 Subject: [PATCH 3/3] Branch context validation per detected input drop --- .../valid-standard-event-data/index.spec.ts | 76 +++++++++++++++++-- .../checks/valid-standard-event-data/index.ts | 55 +++++++++++--- 2 files changed, 112 insertions(+), 19 deletions(-) diff --git a/packages/theme-check-common/src/checks/valid-standard-event-data/index.spec.ts b/packages/theme-check-common/src/checks/valid-standard-event-data/index.spec.ts index b519a7a64..29e87a99c 100644 --- a/packages/theme-check-common/src/checks/valid-standard-event-data/index.spec.ts +++ b/packages/theme-check-common/src/checks/valid-standard-event-data/index.spec.ts @@ -3,40 +3,92 @@ import { highlightedOffenses, runLiquidCheck } from '../../test'; import { ValidStandardEventData } from './index'; describe('Module: ValidStandardEventData', () => { - it('reports an offense when the context is not a supported value', async () => { + it('reports an offense when the context is not a supported value for products', async () => { const sourceCode = `{{ product | standard_event_data: 'view', context: 'homepage' }}`; const offenses = await runLiquidCheck(ValidStandardEventData, sourceCode); expect(offenses).toHaveLength(1); expect(offenses[0].message).toBe( - "Unsupported context 'homepage'. Valid values: page, search, collection, dialog, recommendation. The 'context' argument can also be omitted.", + "Unsupported context 'homepage' for product. Valid values: page, search, collection, dialog, recommendation. The 'context' argument can also be omitted.", ); const highlights = highlightedOffenses({ 'file.liquid': sourceCode }, offenses); expect(highlights[0]).to.eql("'homepage'"); }); - it('reports an offense when the context is not a supported value on a cart', async () => { + it('reports an offense when the context is not a supported value for carts', async () => { const sourceCode = `{{ cart | standard_event_data: 'view', context: 'banner' }}`; const offenses = await runLiquidCheck(ValidStandardEventData, sourceCode); expect(offenses).toHaveLength(1); expect(offenses[0].message).toBe( - "Unsupported context 'banner'. Valid values: page, search, collection, dialog, recommendation. The 'context' argument can also be omitted.", + "Unsupported context 'banner' for cart. Valid values: page, dialog. The 'context' argument can also be omitted.", + ); + }); + + it('reports an offense when the context is valid for products but the input is a cart', async () => { + const sourceCode = `{{ cart | standard_event_data: 'view', context: 'recommendation' }}`; + const offenses = await runLiquidCheck(ValidStandardEventData, sourceCode); + + expect(offenses).toHaveLength(1); + expect(offenses[0].message).toBe( + "Unsupported context 'recommendation' for cart. Valid values: page, dialog. The 'context' argument can also be omitted.", ); }); - it('does not report an offense on supported context values', async () => { + it('does not report an offense on supported product context values', async () => { const contexts = ['page', 'search', 'collection', 'dialog', 'recommendation']; for (const context of contexts) { const sourceCode = `{{ product | standard_event_data: 'view', context: '${context}' }}`; const offenses = await runLiquidCheck(ValidStandardEventData, sourceCode); - expect(offenses, `expected '${context}' to be a valid context`).toHaveLength(0); + expect(offenses, `expected '${context}' to be a valid product context`).toHaveLength(0); } }); + it('does not report an offense on supported cart context values', async () => { + const contexts = ['page', 'dialog']; + + for (const context of contexts) { + const sourceCode = `{{ cart | standard_event_data: 'view', context: '${context}' }}`; + const offenses = await runLiquidCheck(ValidStandardEventData, sourceCode); + + expect(offenses, `expected '${context}' to be a valid cart context`).toHaveLength(0); + } + }); + + it('does not report an offense on collections, whose context is ignored', async () => { + const sourceCode = `{{ collection | standard_event_data: 'view', context: 'homepage' }}`; + const offenses = await runLiquidCheck(ValidStandardEventData, sourceCode); + + expect(offenses).toHaveLength(0); + }); + + it('falls back to the union of contexts when the input is not a known global', async () => { + const sourceCode = `{{ line_item.product | standard_event_data: 'view', context: 'recommendation' }}`; + const offenses = await runLiquidCheck(ValidStandardEventData, sourceCode); + + expect(offenses).toHaveLength(0); + }); + + it('falls back to the union of contexts when the filter input is chained', async () => { + const sourceCode = `{{ cart | default: other_cart | standard_event_data: 'view', context: 'recommendation' }}`; + const offenses = await runLiquidCheck(ValidStandardEventData, sourceCode); + + expect(offenses).toHaveLength(0); + }); + + it('reports an offense when the context is outside the union for an unknown input', async () => { + const sourceCode = `{{ line_item.product | standard_event_data: 'view', context: 'homepage' }}`; + const offenses = await runLiquidCheck(ValidStandardEventData, sourceCode); + + expect(offenses).toHaveLength(1); + expect(offenses[0].message).toBe( + "Unsupported context 'homepage'. Valid values: page, search, collection, dialog, recommendation. The 'context' argument can also be omitted.", + ); + }); + it('does not report an offense when the context is a variable', async () => { const sourceCode = `{{ product | standard_event_data: 'view', context: section.settings.context }}`; const offenses = await runLiquidCheck(ValidStandardEventData, sourceCode); @@ -50,7 +102,7 @@ describe('Module: ValidStandardEventData', () => { expect(offenses).toHaveLength(1); expect(offenses[0].message).toBe( - "Unsupported context. Valid values: page, search, collection, dialog, recommendation. The 'context' argument can also be omitted.", + "Unsupported context for product. Valid values: page, search, collection, dialog, recommendation. The 'context' argument can also be omitted.", ); const highlights = highlightedOffenses({ 'file.liquid': sourceCode }, offenses); @@ -77,6 +129,16 @@ describe('Module: ValidStandardEventData', () => { expect(highlights[0]).to.eql("'click'"); }); + it('reports an offense when the event type is not supported on a collection', async () => { + const sourceCode = `{{ collection | standard_event_data: 'click' }}`; + const offenses = await runLiquidCheck(ValidStandardEventData, sourceCode); + + expect(offenses).toHaveLength(1); + expect(offenses[0].message).toBe( + "Unsupported event type 'click'. The only supported event type is 'view'.", + ); + }); + it('does not report an offense when the event type is a variable', async () => { const sourceCode = `{{ product | standard_event_data: event_type }}`; const offenses = await runLiquidCheck(ValidStandardEventData, sourceCode); diff --git a/packages/theme-check-common/src/checks/valid-standard-event-data/index.ts b/packages/theme-check-common/src/checks/valid-standard-event-data/index.ts index c50ac26a6..ffbe61a66 100644 --- a/packages/theme-check-common/src/checks/valid-standard-event-data/index.ts +++ b/packages/theme-check-common/src/checks/valid-standard-event-data/index.ts @@ -1,15 +1,25 @@ -import { LiquidExpression, LiquidNamedArgument, NodeTypes } from '@shopify/liquid-html-parser'; +import { + LiquidExpression, + LiquidFilter, + LiquidHtmlNode, + LiquidNamedArgument, + NodeTypes, +} from '@shopify/liquid-html-parser'; import { LiquidCheckDefinition, Severity, SourceCodeType } from '../../types'; const FILTER_NAME = 'standard_event_data'; const CONTEXT_ARGUMENT = 'context'; const SUPPORTED_EVENT_TYPE = 'view'; + +const SUPPORTED_CONTEXTS_BY_DROP: { [drop: string]: string[] } = { + product: ['page', 'search', 'collection', 'dialog', 'recommendation'], + cart: ['page', 'dialog'], +}; + +const DROPS_THAT_IGNORE_CONTEXT = ['collection']; + const CONTEXTS_SUPPORTED_BY_ANY_INPUT_TYPE = [ - 'page', - 'search', - 'collection', - 'dialog', - 'recommendation', + ...new Set(Object.values(SUPPORTED_CONTEXTS_BY_DROP).flat()), ]; function isInvalidStaticValue(value: LiquidExpression, supportedValues: string[]): boolean { @@ -21,6 +31,20 @@ function describeValue(value: LiquidExpression): string { return value.type === NodeTypes.String ? ` '${value.value}'` : ''; } +function detectInputDrop( + node: LiquidFilter, + parent: LiquidHtmlNode | undefined, +): string | undefined { + if (parent?.type !== NodeTypes.LiquidVariable) return undefined; + if (parent.filters[0] !== node) return undefined; + + const expression = parent.expression; + if (expression.type !== NodeTypes.VariableLookup) return undefined; + if (expression.lookups.length > 0) return undefined; + + return expression.name ?? undefined; +} + export const ValidStandardEventData: LiquidCheckDefinition = { meta: { code: 'ValidStandardEventData', @@ -39,7 +63,7 @@ export const ValidStandardEventData: LiquidCheckDefinition = { create(context) { return { - async LiquidFilter(node) { + async LiquidFilter(node, ancestors) { if (node.name !== FILTER_NAME) return; const eventType = node.args.find( @@ -56,20 +80,27 @@ export const ValidStandardEventData: LiquidCheckDefinition = { }); } + const drop = detectInputDrop(node, ancestors[ancestors.length - 1]); + + if (drop && DROPS_THAT_IGNORE_CONTEXT.includes(drop)) return; + const contextArgument = node.args.find( (arg): arg is LiquidNamedArgument => arg.type === NodeTypes.NamedArgument && arg.name === CONTEXT_ARGUMENT, ); const contextValue = contextArgument?.value; + if (!contextValue) return; + + const supportedContexts = + (drop && SUPPORTED_CONTEXTS_BY_DROP[drop]) || CONTEXTS_SUPPORTED_BY_ANY_INPUT_TYPE; + + if (isInvalidStaticValue(contextValue, supportedContexts)) { + const dropDescription = drop && SUPPORTED_CONTEXTS_BY_DROP[drop] ? ` for ${drop}` : ''; - if ( - contextValue && - isInvalidStaticValue(contextValue, CONTEXTS_SUPPORTED_BY_ANY_INPUT_TYPE) - ) { context.report({ message: `Unsupported context${describeValue( contextValue, - )}. Valid values: ${CONTEXTS_SUPPORTED_BY_ANY_INPUT_TYPE.join( + )}${dropDescription}. Valid values: ${supportedContexts.join( ', ', )}. The '${CONTEXT_ARGUMENT}' argument can also be omitted.`, startIndex: contextValue.position.start,