From d0bf6f39b13842c9b30c6fd23062264c03103cd2 Mon Sep 17 00:00:00 2001 From: Oliver Slapinski Date: Sun, 23 Aug 2026 13:44:45 -0400 Subject: [PATCH] fix theme graph traversal for contextual JSON Signed-off-by: Oliver Slapinski --- .changeset/quiet-themes-inherit.md | 5 + .../src/graph/contextual-json.spec.ts | 114 ++++++++++++++++++ packages/theme-graph/src/graph/traverse.ts | 27 +++-- 3 files changed, 134 insertions(+), 12 deletions(-) create mode 100644 .changeset/quiet-themes-inherit.md create mode 100644 packages/theme-graph/src/graph/contextual-json.spec.ts diff --git a/.changeset/quiet-themes-inherit.md b/.changeset/quiet-themes-inherit.md new file mode 100644 index 000000000..6196175f2 --- /dev/null +++ b/.changeset/quiet-themes-inherit.md @@ -0,0 +1,5 @@ +--- +'@shopify/theme-graph': patch +--- + +Skip inherited contextual JSON entries without an explicit type when building the theme graph. diff --git a/packages/theme-graph/src/graph/contextual-json.spec.ts b/packages/theme-graph/src/graph/contextual-json.spec.ts new file mode 100644 index 000000000..57f93484e --- /dev/null +++ b/packages/theme-graph/src/graph/contextual-json.spec.ts @@ -0,0 +1,114 @@ +import { path as pathUtils } from '@shopify/theme-check-common'; +import { MockFileSystem } from '@shopify/theme-check-common/src/test'; +import { describe, expect, it } from 'vitest'; +import { buildThemeGraph } from '../index'; +import { ThemeGraph } from '../types'; +import { getDependencies } from './test-helpers'; + +describe('contextual JSON modules', () => { + const rootUri = 'file:///theme'; + const p = (part: string) => pathUtils.join(rootUri, ...part.split('/')); + + async function buildJsonModule( + relativePath: string, + source: object, + options: { additionalFiles?: Record; explicitEntry?: boolean } = {}, + ): Promise { + const fs = new MockFileSystem( + { + 'assets/theme.js': '', + 'layout/theme.liquid': '', + 'sections/hero.liquid': `{% schema %} + { "name": "Hero", "blocks": [{ "type": "@theme" }] } + {% endschema %}`, + 'blocks/group.liquid': `{% schema %} + { "name": "Group", "blocks": [{ "type": "@theme" }] } + {% endschema %}`, + ...options.additionalFiles, + [relativePath]: JSON.stringify(source), + }, + rootUri, + ); + const dependencies = await getDependencies(rootUri, fs); + const entryPoints = options.explicitEntry === false ? undefined : [p(relativePath)]; + + return buildThemeGraph(rootUri, dependencies, entryPoints); + } + + function expectNoUndefinedModules(graph: ThemeGraph) { + expect(Object.keys(graph.modules)).not.toContain(p('sections/undefined.liquid')); + expect(Object.keys(graph.modules)).not.toContain(p('blocks/undefined.liquid')); + } + + it('skips a contextual template section that inherits its type', async () => { + const baseTemplateUri = p('templates/index.json'); + const graph = await buildJsonModule( + 'templates/index.context.ca.json', + { + context: { market: 'ca' }, + parent: 'index.json', + sections: { hero: { settings: { heading: 'Canada' } } }, + }, + { + additionalFiles: { + 'templates/index.json': JSON.stringify({ sections: { hero: { type: 'hero' } } }), + }, + explicitEntry: false, + }, + ); + + expect(graph.modules[baseTemplateUri].dependencies.map(({ target }) => target.uri)).toContain( + p('sections/hero.liquid'), + ); + expectNoUndefinedModules(graph); + }); + + it('skips a contextual section-group section that inherits its type', async () => { + const graph = await buildJsonModule('sections/header-group.context.ca.json', { + context: { market: 'ca' }, + parent: 'header-group.json', + sections: { header: { settings: { sticky: true } } }, + }); + + expectNoUndefinedModules(graph); + }); + + it('keeps the section edge while skipping a block that inherits its type', async () => { + const moduleUri = p('sections/header-group.context.ca.json'); + const graph = await buildJsonModule('sections/header-group.context.ca.json', { + sections: { + header: { + type: 'hero', + blocks: { inherited: { settings: { text: 'Canada' } } }, + }, + }, + }); + + expect(graph.modules[moduleUri].dependencies.map(({ target }) => target.uri)).toContain( + p('sections/hero.liquid'), + ); + expectNoUndefinedModules(graph); + }); + + it('keeps valid edges while skipping a nested block that inherits its type', async () => { + const moduleUri = p('sections/header-group.context.ca.json'); + const graph = await buildJsonModule('sections/header-group.context.ca.json', { + sections: { + header: { + type: 'hero', + blocks: { + group: { + type: 'group', + blocks: { inherited: { settings: { text: 'Canada' } } }, + }, + }, + }, + }, + }); + + expect(graph.modules[moduleUri].dependencies.map(({ target }) => target.uri)).toEqual( + expect.arrayContaining([p('sections/hero.liquid'), p('blocks/group.liquid')]), + ); + expectNoUndefinedModules(graph); + }); +}); diff --git a/packages/theme-graph/src/graph/traverse.ts b/packages/theme-graph/src/graph/traverse.ts index 6a3c52362..7af5dae79 100644 --- a/packages/theme-graph/src/graph/traverse.ts +++ b/packages/theme-graph/src/graph/traverse.ts @@ -464,11 +464,12 @@ async function traverseJsonModule( const template = parseJSON(sourceCode.source) as Template.Template; const promises: Promise[] = []; for (const [key, section] of Object.entries(template.sections)) { - const sectionType = section.type; const path = ['sections', key]; const node = nodeAtPath(ast, path)! as ObjectNode; - const sectionModule = getSectionModule(themeGraph, sectionType); - const typeProperty = node.children.find((child) => child.key.value === 'type')!; + const typeProperty = node.children.find((child) => child.key.value === 'type'); + if (!typeProperty) continue; + + const sectionModule = getSectionModule(themeGraph, section.type); const start = typeProperty.loc.start.offset; const end = typeProperty.loc.end.offset; const sourceRange: Range = [start, end]; @@ -505,12 +506,12 @@ async function traverseJsonModule( const sectionGroup = parseJSON(sourceCode.source) as Template.SectionGroup; const promises: Promise[] = []; for (const [key, section] of Object.entries(sectionGroup.sections)) { - const sectionType = section.type; const path = ['sections', key]; const node = nodeAtPath(ast, path)! as ObjectNode; - const sectionModule = getSectionModule(themeGraph, sectionType); + const typeProperty = node.children.find((child) => child.key.value === 'type'); + if (!typeProperty) continue; - const typeProperty = node.children.find((child) => child.key.value === 'type')!; + const sectionModule = getSectionModule(themeGraph, section.type); const start = typeProperty.loc.start.offset; const end = typeProperty.loc.end.offset; const sourceRange: Range = [start, end]; @@ -554,11 +555,12 @@ async function traverseSectionReferences( const promises: Promise[] = []; for (const [key, block] of Object.entries(section.blocks)) { - const blockType = block.type; - const blockModule = getThemeBlockModule(themeGraph, blockType); const path = [...nodePath, 'blocks', key]; const node = nodeAtPath(sourceAst, path)! as ObjectNode; - const typeProperty = node.children.find((child) => child.key.value === 'type')!; + const typeProperty = node.children.find((child) => child.key.value === 'type'); + if (!typeProperty) continue; + + const blockModule = getThemeBlockModule(themeGraph, block.type); const start = typeProperty.loc.start.offset; const end = typeProperty.loc.end.offset; const sourceRange: Range = [start, end]; @@ -587,11 +589,12 @@ async function traverseBlockReferences( const promises: Promise[] = []; for (const [key, childBlock] of Object.entries(block.blocks)) { - const childBlockType = childBlock.type; - const childBlockModule = getThemeBlockModule(themeGraph, childBlockType); const path = [...nodePath, 'blocks', key]; const node = nodeAtPath(sourceAst, path)! as ObjectNode; - const typeProperty = node.children.find((child) => child.key.value === 'type')!; + const typeProperty = node.children.find((child) => child.key.value === 'type'); + if (!typeProperty) continue; + + const childBlockModule = getThemeBlockModule(themeGraph, childBlock.type); const start = typeProperty.loc.start.offset; const end = typeProperty.loc.end.offset; const sourceRange: Range = [start, end];