From 75c15a5cca65e9ebbd4c1d39d62055bfcf783073 Mon Sep 17 00:00:00 2001 From: Gray Gilmore Date: Mon, 23 Mar 2026 11:46:24 -0700 Subject: [PATCH 1/2] Thread project mode through TypeSystem and UndefinedObject check MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The contextual objects available in a Liquid file (e.g. section, block, recommendations) are currently determined solely by file path. This makes it impossible to vary behavior based on whether the project is a theme or a theme app extension. This adds an optional getModeForURI callback to TypeSystem, CompletionsProvider, and HoverProvider, and passes context.mode into the UndefinedObject check's getContextualObjects. No behavior changes yet — mode is accepted but not acted on. Co-Authored-By: Claude Opus 4.6 --- .../src/checks/undefined-object/index.ts | 10 +++++----- packages/theme-check-common/src/test/test-helper.ts | 7 ++++++- .../theme-language-server-common/src/TypeSystem.ts | 10 +++++++--- .../src/completions/CompletionsProvider.ts | 4 ++++ .../src/hover/HoverProvider.ts | 3 +++ .../src/server/startServer.ts | 2 ++ 6 files changed, 27 insertions(+), 9 deletions(-) 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 f9d322a8b..9226eac55 100644 --- a/packages/theme-check-common/src/checks/undefined-object/index.ts +++ b/packages/theme-check-common/src/checks/undefined-object/index.ts @@ -13,7 +13,7 @@ import { NodeTypes, Position, } from '@shopify/liquid-html-parser'; -import { LiquidCheckDefinition, Severity, SourceCodeType, ThemeDocset } from '../../types'; +import { LiquidCheckDefinition, Mode, Severity, SourceCodeType, ThemeDocset } from '../../types'; import { isError, last } from '../../utils'; import { hasLiquidDoc } from '../../liquid-doc/liquidDoc'; import { isWithinRawTagThatDoesNotParseItsContents } from '../utils'; @@ -146,7 +146,7 @@ export const UndefinedObject: LiquidCheckDefinition = { }, async onCodePathEnd() { - const objects = await globalObjects(themeDocset, relativePath); + const objects = await globalObjects(themeDocset, relativePath, context.mode); objects.forEach((obj) => fileScopedVariables.add(obj.name)); @@ -172,9 +172,9 @@ export const UndefinedObject: LiquidCheckDefinition = { }, }; -async function globalObjects(themeDocset: ThemeDocset, relativePath: string) { +async function globalObjects(themeDocset: ThemeDocset, relativePath: string, mode: Mode = 'theme') { const objects = await themeDocset.objects(); - const contextualObjects = getContextualObjects(relativePath); + const contextualObjects = getContextualObjects(relativePath, mode); const globalObjects = objects.filter(({ access, name }) => { return ( @@ -188,7 +188,7 @@ async function globalObjects(themeDocset: ThemeDocset, relativePath: string) { return globalObjects; } -function getContextualObjects(relativePath: string): string[] { +function getContextualObjects(relativePath: string, mode: Mode = 'theme'): string[] { if (relativePath.startsWith('layout/checkout.liquid')) { return [ 'locale', diff --git a/packages/theme-check-common/src/test/test-helper.ts b/packages/theme-check-common/src/test/test-helper.ts index 2eb5feaaf..880fce8f0 100644 --- a/packages/theme-check-common/src/test/test-helper.ts +++ b/packages/theme-check-common/src/test/test-helper.ts @@ -15,6 +15,7 @@ import { JSONCorrector, JSONSourceCode, LiquidSourceCode, + Mode, Offense, recommended, SectionSchema, @@ -44,10 +45,11 @@ export async function check( checks: CheckDefinition[] = recommended, mockDependencies: Partial = {}, checkSettings: ChecksSettings = {}, + mode: Mode = 'theme', ): Promise { const theme = getTheme(themeDesc); const config: Config = { - context: 'theme', + context: mode, settings: { ...checkSettings }, checks, rootUri, @@ -236,11 +238,14 @@ export async function runLiquidCheck( fileName: string = 'file.liquid', mockDependencies: Partial = {}, existingThemeFiles?: MockTheme, + mode: Mode = 'theme', ): Promise { const offenses = await check( { ...existingThemeFiles, [fileName]: sourceCode }, [checkDef], mockDependencies, + {}, + mode, ); return offenses.filter((offense) => offense.uri === path.join(rootUri, fileName)); } diff --git a/packages/theme-language-server-common/src/TypeSystem.ts b/packages/theme-language-server-common/src/TypeSystem.ts index e02c7302a..9bb1d1a80 100644 --- a/packages/theme-language-server-common/src/TypeSystem.ts +++ b/packages/theme-language-server-common/src/TypeSystem.ts @@ -18,6 +18,7 @@ import { FilterEntry, MetafieldDefinitionMap, MetafieldDefinition, + Mode, ObjectEntry, ReturnType, SourceCodeType, @@ -39,11 +40,14 @@ import { import { findLast, memo } from './utils'; import { visit } from '@shopify/theme-check-common'; +export type GetModeForURI = (uri: string) => Promise; + export class TypeSystem { constructor( private readonly themeDocset: ThemeDocset, private readonly getThemeSettingsSchemaForURI: GetThemeSettingsSchemaForURI, private readonly getMetafieldDefinitions: (rootUri: string) => Promise, + private readonly getModeForURI: GetModeForURI = async () => 'theme', ) {} async inferType( @@ -321,8 +325,8 @@ export class TypeSystem { }); private contextualVariables = async (uri: string) => { - const entries = await this.objectEntries(); - const contextualEntries = getContextualEntries(uri); + const [entries, mode] = await Promise.all([this.objectEntries(), this.getModeForURI(uri)]); + const contextualEntries = getContextualEntries(uri, mode); return entries.filter((entry) => contextualEntries.includes(entry.name)); }; } @@ -332,7 +336,7 @@ const BLOCK_FILE_REGEX = /blocks[\/\\][^.\\\/]*\.liquid$/; const SNIPPET_FILE_REGEX = /snippets[\/\\][^.\\\/]*\.liquid$/; const LAYOUT_FILE_REGEX = /layout[\/\\]checkout\.liquid$/; -function getContextualEntries(uri: string): string[] { +function getContextualEntries(uri: string, mode: Mode = 'theme'): string[] { const normalizedUri = path.normalize(uri); if (LAYOUT_FILE_REGEX.test(normalizedUri)) { return [ diff --git a/packages/theme-language-server-common/src/completions/CompletionsProvider.ts b/packages/theme-language-server-common/src/completions/CompletionsProvider.ts index b961040bd..64704916d 100644 --- a/packages/theme-language-server-common/src/completions/CompletionsProvider.ts +++ b/packages/theme-language-server-common/src/completions/CompletionsProvider.ts @@ -1,6 +1,7 @@ import { GetDocDefinitionForURI, MetafieldDefinitionMap, + Mode, SourceCodeType, ThemeDocset, } from '@shopify/theme-check-common'; @@ -40,6 +41,7 @@ export interface CompletionProviderDependencies { getMetafieldDefinitions: (rootUri: string) => Promise; getDocDefinitionForURI?: GetDocDefinitionForURI; getThemeBlockNames?: (rootUri: string, includePrivate: boolean) => Promise; + getModeForURI?: (uri: string) => Promise; log?: (message: string) => void; } @@ -58,6 +60,7 @@ export class CompletionsProvider { getThemeSettingsSchemaForURI = async () => [], getDocDefinitionForURI = async (uri, _relativePath) => ({ uri }), getThemeBlockNames = async (_rootUri: string, _includePrivate: boolean) => [], + getModeForURI, log = () => {}, }: CompletionProviderDependencies) { this.documentManager = documentManager; @@ -67,6 +70,7 @@ export class CompletionsProvider { themeDocset, getThemeSettingsSchemaForURI, getMetafieldDefinitions, + getModeForURI, ); this.providers = [ diff --git a/packages/theme-language-server-common/src/hover/HoverProvider.ts b/packages/theme-language-server-common/src/hover/HoverProvider.ts index f74b884c4..22210611c 100644 --- a/packages/theme-language-server-common/src/hover/HoverProvider.ts +++ b/packages/theme-language-server-common/src/hover/HoverProvider.ts @@ -1,6 +1,7 @@ import { GetDocDefinitionForURI, MetafieldDefinitionMap, + Mode, SourceCodeType, ThemeDocset, } from '@shopify/theme-check-common'; @@ -37,11 +38,13 @@ export class HoverProvider { readonly getTranslationsForURI: GetTranslationsForURI = async () => ({}), readonly getSettingsSchemaForURI: GetThemeSettingsSchemaForURI = async () => [], readonly getDocDefinitionForURI: GetDocDefinitionForURI = async () => undefined, + readonly getModeForURI: (uri: string) => Promise = async () => 'theme', ) { const typeSystem = new TypeSystem( themeDocset, getSettingsSchemaForURI, getMetafieldDefinitions, + getModeForURI, ); this.providers = [ new ContentForArgumentHoverProvider(getDocDefinitionForURI), diff --git a/packages/theme-language-server-common/src/server/startServer.ts b/packages/theme-language-server-common/src/server/startServer.ts index b0ad21d93..bce45b2f2 100644 --- a/packages/theme-language-server-common/src/server/startServer.ts +++ b/packages/theme-language-server-common/src/server/startServer.ts @@ -339,6 +339,7 @@ export function startServer( getThemeBlockNames, getMetafieldDefinitions, getDocDefinitionForURI, + getModeForURI, }); const hoverProvider = new HoverProvider( documentManager, @@ -347,6 +348,7 @@ export function startServer( getTranslationsForURI, getThemeSettingsSchemaForURI, getDocDefinitionForURI, + getModeForURI, ); const executeCommandProvider = new ExecuteCommandProvider( From f0de2f8d6097268b2d727f61e5e6c3fea406a485 Mon Sep 17 00:00:00 2001 From: Gray Gilmore Date: Mon, 23 Mar 2026 11:46:36 -0700 Subject: [PATCH 2/2] Surface block-level contextual objects in TAE snippets In a theme app extension, snippets can only be rendered from blocks/*.liquid files, so the ambient context always includes the block-level objects (section, block, recommendations, app). Previously, the completion and diagnostic systems only provided 'app' for snippets regardless of project mode, which meant developers got no autocomplete or hover for section, block, or recommendations in TAE snippet files. Now when mode is 'app', snippets get the same contextual objects as blocks. Closes https://github.com/Shopify/theme-tools/issues/346 Co-Authored-By: Claude Opus 4.6 --- .../src/checks/undefined-object/index.spec.ts | 29 +++++++++++ .../src/checks/undefined-object/index.ts | 7 ++- .../src/TypeSystem.ts | 7 ++- .../ObjectCompletionProvider.spec.ts | 48 +++++++++++++++++++ 4 files changed, 89 insertions(+), 2 deletions(-) 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 d44ff5582..8c16228e4 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 @@ -327,6 +327,35 @@ describe('Module: UndefinedObject', () => { } }); + it('should allow block-level objects in snippets when in app mode', async () => { + const blockLevelObjects = ['section', 'block', 'recommendations', 'app']; + for (const object of blockLevelObjects) { + const sourceCode = `{% doc %} @param {string} x - x {% enddoc %}{{ ${object} }}`; + const offenses = await runLiquidCheck( + UndefinedObject, + sourceCode, + 'snippets/my-snippet.liquid', + {}, + undefined, + 'app', + ); + expect(offenses, `Expected no offense for '${object}' in app mode snippet`).toHaveLength(0); + } + }); + + it('should still flag block-level objects in snippets when in theme mode', async () => { + const blockOnlyObjects = ['section', 'block', 'recommendations']; + for (const object of blockOnlyObjects) { + const sourceCode = `{% doc %} @param {string} x - x {% enddoc %}{{ ${object} }}`; + const offenses = await runLiquidCheck( + UndefinedObject, + sourceCode, + 'snippets/my-snippet.liquid', + ); + expect(offenses, `Expected offense for '${object}' in theme mode snippet`).toHaveLength(1); + } + }); + it('should support contextual exceptions for checkout.liquid', async () => { let offenses: Offense[]; const contexts: [string, string][] = [ 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 9226eac55..9057bbb94 100644 --- a/packages/theme-check-common/src/checks/undefined-object/index.ts +++ b/packages/theme-check-common/src/checks/undefined-object/index.ts @@ -188,6 +188,8 @@ async function globalObjects(themeDocset: ThemeDocset, relativePath: string, mod return globalObjects; } +const BLOCK_CONTEXTUAL_OBJECTS = ['app', 'section', 'recommendations', 'block']; + function getContextualObjects(relativePath: string, mode: Mode = 'theme'): string[] { if (relativePath.startsWith('layout/checkout.liquid')) { return [ @@ -211,10 +213,13 @@ function getContextualObjects(relativePath: string, mode: Mode = 'theme'): strin } if (relativePath.startsWith('blocks/')) { - return ['app', 'section', 'recommendations', 'block']; + return BLOCK_CONTEXTUAL_OBJECTS; } if (relativePath.startsWith('snippets/')) { + // In a theme app extension, snippets can only be rendered from blocks, + // so they have access to the same contextual objects as blocks. + if (mode === 'app') return BLOCK_CONTEXTUAL_OBJECTS; return ['app']; } diff --git a/packages/theme-language-server-common/src/TypeSystem.ts b/packages/theme-language-server-common/src/TypeSystem.ts index 9bb1d1a80..217bf1957 100644 --- a/packages/theme-language-server-common/src/TypeSystem.ts +++ b/packages/theme-language-server-common/src/TypeSystem.ts @@ -336,6 +336,8 @@ const BLOCK_FILE_REGEX = /blocks[\/\\][^.\\\/]*\.liquid$/; const SNIPPET_FILE_REGEX = /snippets[\/\\][^.\\\/]*\.liquid$/; const LAYOUT_FILE_REGEX = /layout[\/\\]checkout\.liquid$/; +const BLOCK_CONTEXTUAL_ENTRIES = ['app', 'section', 'recommendations', 'block']; + function getContextualEntries(uri: string, mode: Mode = 'theme'): string[] { const normalizedUri = path.normalize(uri); if (LAYOUT_FILE_REGEX.test(normalizedUri)) { @@ -359,9 +361,12 @@ function getContextualEntries(uri: string, mode: Mode = 'theme'): string[] { return ['section', 'predictive_search', 'recommendations', 'comment']; } if (BLOCK_FILE_REGEX.test(normalizedUri)) { - return ['app', 'section', 'recommendations', 'block']; + return BLOCK_CONTEXTUAL_ENTRIES; } if (SNIPPET_FILE_REGEX.test(normalizedUri)) { + // In a theme app extension, snippets can only be rendered from blocks, + // so they have access to the same contextual objects as blocks. + if (mode === 'app') return BLOCK_CONTEXTUAL_ENTRIES; return ['app']; } return []; diff --git a/packages/theme-language-server-common/src/completions/providers/ObjectCompletionProvider.spec.ts b/packages/theme-language-server-common/src/completions/providers/ObjectCompletionProvider.spec.ts index 58c2718a3..60e84a2b6 100644 --- a/packages/theme-language-server-common/src/completions/providers/ObjectCompletionProvider.spec.ts +++ b/packages/theme-language-server-common/src/completions/providers/ObjectCompletionProvider.spec.ts @@ -42,6 +42,14 @@ describe('Module: ObjectCompletionProvider', async () => { parents: [], }, }, + { + name: 'app', + access: { + global: false, + template: [], + parents: [], + }, + }, { name: 'product', properties: [ @@ -216,6 +224,46 @@ describe('Module: ObjectCompletionProvider', async () => { } }); + it('should complete block-level contextual variables in snippets when in app mode', async () => { + const appModeProvider = new CompletionsProvider({ + documentManager: new DocumentManager(), + themeDocset: provider.themeDocset, + getMetafieldDefinitions: async () => + ({ + article: [], + blog: [], + collection: [], + company: [], + company_location: [], + location: [], + market: [], + order: [], + page: [], + product: [], + variant: [], + shop: [], + }) as MetafieldDefinitionMap, + getModeForURI: async () => 'app', + }); + + const blockLevelObjects = ['section', 'block', 'recommendations', 'app']; + for (const object of blockLevelObjects) { + const source = `{{ ${object}█ }}`; + await expect(appModeProvider, source).to.complete( + { source, relativePath: 'snippets/my-snippet.liquid' }, + expect.arrayContaining([expect.objectContaining({ label: object })]), + ); + } + }); + + it('should not complete block-level contextual variables in snippets when in theme mode', async () => { + const source = `{{ section█ }}`; + await expect(provider, source).to.complete( + { source, relativePath: 'snippets/my-snippet.liquid' }, + [], + ); + }); + it('should not complete anything if there is nothing to complete', async () => { await expect(provider).to.complete('{% assign x = "█" %}', []); });