diff --git a/.changeset/theme-graph-document-links.md b/.changeset/theme-graph-document-links.md
new file mode 100644
index 000000000..0cc67e879
--- /dev/null
+++ b/.changeset/theme-graph-document-links.md
@@ -0,0 +1,5 @@
+---
+'@shopify/theme-language-server-common': minor
+---
+
+Add document links for theme graph references, including schema block types and custom element definitions.
diff --git a/packages/theme-language-server-common/src/documentLinks/DocumentLinksProvider.spec.ts b/packages/theme-language-server-common/src/documentLinks/DocumentLinksProvider.spec.ts
index c0e641924..b93b832f8 100644
--- a/packages/theme-language-server-common/src/documentLinks/DocumentLinksProvider.spec.ts
+++ b/packages/theme-language-server-common/src/documentLinks/DocumentLinksProvider.spec.ts
@@ -1,5 +1,11 @@
+import { path } from '@shopify/theme-check-common';
+import { MockFileSystem } from '@shopify/theme-check-common/src/test';
import { beforeEach, describe, expect, it } from 'vitest';
+import { Range } from 'vscode-languageserver';
+import { TextDocument } from 'vscode-languageserver-textdocument';
import { DocumentManager } from '../documents';
+import { ThemeGraphManager } from '../server/ThemeGraphManager';
+import { mockConnection } from '../test/MockConnection';
import { DocumentLinksProvider } from './DocumentLinksProvider';
describe('DocumentLinksProvider', () => {
@@ -9,32 +15,27 @@ describe('DocumentLinksProvider', () => {
let uriString: string;
beforeEach(() => {
+ rootUri = path.normalize('file:///path/to/project');
documentManager = new DocumentManager();
- documentLinksProvider = new DocumentLinksProvider(documentManager, async () => rootUri);
+ documentLinksProvider = new DocumentLinksProvider(documentManager, async () => []);
});
it('should return an empty array for non-LiquidHtml documents', async () => {
- uriString = 'file:///path/to/non-liquid-html-document.txt';
- rootUri = 'file:///path/to/project';
-
- documentManager.open(uriString, 'Sample plain text content', 1);
+ uriString = path.join(rootUri, 'templates/index.json');
+ documentManager.open(uriString, '{"sections": {}}', 1);
const result = await documentLinksProvider.documentLinks(uriString);
expect(result).toEqual([]);
});
it('should return an empty array for non-existent documents', async () => {
- uriString = 'file:///path/to/non-existent-document.txt';
- rootUri = 'file:///path/to/project';
+ uriString = path.join(rootUri, 'sections/non-existent.liquid');
const result = await documentLinksProvider.documentLinks(uriString);
expect(result).toEqual([]);
});
- it('should return a list of document links with correct URLs for a LiquidHtml document', async () => {
- uriString = 'file:///path/to/liquid-html-document.liquid';
- rootUri = 'file:///path/to/project';
-
+ it('should create links for graph dependencies in a LiquidHtml document', async () => {
const liquidHtmlContent = `
{% render 'snippet1' %}
{% include 'snippet2' %}
@@ -46,49 +47,145 @@ describe('DocumentLinksProvider', () => {
{% block 'container' %}{% endblock %}
{% block '_private' %}{% endblock %}
{% block 'with_args', block.settings.alignment: 'center' %}{% endblock %}
+
`;
+ const theme = createTheme(liquidHtmlContent, rootUri, {
+ 'snippets/snippet1.liquid': '',
+ 'snippets/snippet2.liquid': '',
+ 'sections/header.liquid': '',
+ 'assets/echo.js': '',
+ 'assets/assign.css': '',
+ 'assets/asset.js': '',
+ 'assets/product-gallery.js':
+ "customElements.define('product-gallery', class extends HTMLElement {});",
+ 'blocks/block_name.liquid': '',
+ 'blocks/container.liquid': '',
+ 'blocks/_private.liquid': '',
+ 'blocks/with_args.liquid': '',
+ });
+ uriString = theme.uri;
+ documentManager = theme.documentManager;
+ documentLinksProvider = theme.documentLinksProvider;
- documentManager.open(uriString, liquidHtmlContent, 1);
+ const result = await documentLinksProvider.documentLinks(uriString);
+
+ expect(result.map(({ target }) => target)).toEqual([
+ path.join(rootUri, 'snippets/snippet1.liquid'),
+ path.join(rootUri, 'snippets/snippet2.liquid'),
+ path.join(rootUri, 'sections/header.liquid'),
+ path.join(rootUri, 'assets/echo.js'),
+ path.join(rootUri, 'assets/assign.css'),
+ path.join(rootUri, 'assets/asset.js'),
+ path.join(rootUri, 'blocks/block_name.liquid'),
+ path.join(rootUri, 'blocks/container.liquid'),
+ path.join(rootUri, 'blocks/_private.liquid'),
+ path.join(rootUri, 'blocks/with_args.liquid'),
+ path.join(rootUri, 'assets/product-gallery.js'),
+ ]);
+ });
+
+ it('should link schema block types and custom elements to their definitions', async () => {
+ const liquidHtmlContent = `{% schema %}
+ {"blocks":[{"type":"feature"}],"presets":[{"name":"Feature","blocks":[{"type":"feature"}]}]}
+ {% endschema %}
+ `;
+ const theme = createTheme(liquidHtmlContent, rootUri, {
+ 'blocks/feature.liquid': '',
+ 'assets/product-gallery.js':
+ "customElements.define('product-gallery', class extends HTMLElement {});",
+ });
+ uriString = theme.uri;
+ documentManager = theme.documentManager;
+ documentLinksProvider = theme.documentLinksProvider;
const result = await documentLinksProvider.documentLinks(uriString);
- const expectedUrls = [
- 'file:///path/to/project/snippets/snippet1.liquid',
- 'file:///path/to/project/snippets/snippet2.liquid',
- 'file:///path/to/project/sections/header.liquid',
- 'file:///path/to/project/assets/echo.js',
- 'file:///path/to/project/assets/assign.css',
- 'file:///path/to/project/assets/asset.js',
- 'file:///path/to/project/blocks/block_name.liquid',
- 'file:///path/to/project/blocks/container.liquid',
- 'file:///path/to/project/blocks/_private.liquid',
- 'file:///path/to/project/blocks/with_args.liquid',
- ];
-
- expect(result.length).toBe(expectedUrls.length);
- for (let i = 0; i < expectedUrls.length; i++) {
- expect(result[i].target).toBe(expectedUrls[i]);
- }
+
+ const featureUri = path.join(rootUri, 'blocks/feature.liquid');
+ const featureLinks = result.filter(({ target }) => target === featureUri);
+ const textDocument = TextDocument.create(uriString, 'liquid', 1, liquidHtmlContent);
+ const expectedFeatureRanges = [...liquidHtmlContent.matchAll(/"type":"feature"/g)]
+ .map(([value], index, matches) => {
+ const start = matches[index].index!;
+ return Range.create(
+ textDocument.positionAt(start),
+ textDocument.positionAt(start + value.length),
+ );
+ })
+ .sort((a, b) => a.start.line - b.start.line || a.start.character - b.start.character);
+ const actualFeatureRanges = featureLinks
+ .map(({ range }) => range)
+ .sort((a, b) => a.start.line - b.start.line || a.start.character - b.start.character);
+ const galleryLink = result.find(
+ ({ target }) => target === path.join(rootUri, 'assets/product-gallery.js'),
+ );
+ const galleryStart = liquidHtmlContent.indexOf(' {
+ const liquidHtmlContent = `{% schema %}{"blocks":[{"type":"@theme"}]}{% endschema %}`;
+ const theme = createTheme(liquidHtmlContent, rootUri, {
+ 'blocks/feature.liquid': '',
+ });
+
+ const result = await theme.documentLinksProvider.documentLinks(theme.uri);
+
+ expect(result).toEqual([]);
});
it.each([
"{% block 'shopify://apps/example_app/blocks/example-block/00000000-0000-4000-8000-000000000000' %}{% endblock %}",
"{% liquid\n block 'shopify://apps/example_app/blocks/example-block/00000000-0000-4000-8000-000000000000'\n endblock\n %}",
])('should skip app block links while preserving theme block links: %s', async (appBlock) => {
- rootUri = 'file:///path/to/project';
- uriString = `${rootUri}/templates/index.liquid`;
- documentManager.open(uriString, `${appBlock}{% block 'container' %}{% endblock %}`, 1);
-
- const result = await documentLinksProvider.documentLinks(uriString);
- expect(result.map((link) => link.target)).toEqual([`${rootUri}/blocks/container.liquid`]);
+ const theme = createTheme(`${appBlock}{% block 'container' %}{% endblock %}`, rootUri, {
+ 'blocks/container.liquid': '',
+ });
+
+ const result = await theme.documentLinksProvider.documentLinks(theme.uri);
+ expect(result.map((link) => link.target)).toEqual([
+ path.join(rootUri, 'blocks/container.liquid'),
+ ]);
});
it('should not create a link for the {% partial %} tag', async () => {
- uriString = 'file:///path/to/liquid-html-document.liquid';
- rootUri = 'file:///path/to/project';
-
- documentManager.open(uriString, `{% partial 'x' %}{% endpartial %}`, 1);
-
- const result = await documentLinksProvider.documentLinks(uriString);
+ const theme = createTheme(`{% partial 'x' %}{% endpartial %}`, rootUri, {});
+ const result = await theme.documentLinksProvider.documentLinks(theme.uri);
expect(result).toEqual([]);
});
});
+
+function createTheme(source: string, rootUri: string, files: Record) {
+ const uri = path.join(rootUri, 'sections/main.liquid');
+ const fs = new MockFileSystem({ ...files, 'sections/main.liquid': source }, rootUri);
+ const connection = mockConnection(rootUri);
+ const getModeForURI = async () => 'theme' as const;
+ const documentManager = new DocumentManager(
+ fs,
+ connection,
+ undefined,
+ getModeForURI,
+ async () => true,
+ );
+ const themeGraphManager = new ThemeGraphManager(
+ connection,
+ documentManager,
+ fs,
+ async () => rootUri,
+ getModeForURI,
+ );
+ const documentLinksProvider = new DocumentLinksProvider(documentManager, (uri) =>
+ themeGraphManager.getDependencies(uri, undefined, { includeIndirect: false }),
+ );
+
+ documentManager.open(uri, source, 1);
+
+ return { uri, documentManager, documentLinksProvider };
+}
diff --git a/packages/theme-language-server-common/src/documentLinks/DocumentLinksProvider.ts b/packages/theme-language-server-common/src/documentLinks/DocumentLinksProvider.ts
index 615c03ce3..fce0832c4 100644
--- a/packages/theme-language-server-common/src/documentLinks/DocumentLinksProvider.ts
+++ b/packages/theme-language-server-common/src/documentLinks/DocumentLinksProvider.ts
@@ -1,21 +1,18 @@
-import { LiquidHtmlNode, LiquidString, NamedTags, NodeTypes } from '@shopify/liquid-html-parser';
import { SourceCodeType } from '@shopify/theme-check-common';
import { DocumentLink, Range } from 'vscode-languageserver';
-import { TextDocument } from 'vscode-languageserver-textdocument';
-import { URI, Utils } from 'vscode-uri';
-
-import { visit, Visitor } from '@shopify/theme-check-common';
import { DocumentManager } from '../documents';
-import { FindThemeRootURI } from '../internal-types';
+import { AugmentedReference } from '../types';
+
+type GetDependencies = (uri: string) => Promise;
export class DocumentLinksProvider {
constructor(
private documentManager: DocumentManager,
- private findThemeRootURI: FindThemeRootURI,
+ private getDependencies: GetDependencies,
) {}
- async documentLinks(uriString: string): Promise {
- const sourceCode = this.documentManager.get(uriString);
+ async documentLinks(uri: string): Promise {
+ const sourceCode = this.documentManager.get(uri);
if (
!sourceCode ||
sourceCode.type !== SourceCodeType.LiquidHtml ||
@@ -24,93 +21,15 @@ export class DocumentLinksProvider {
return [];
}
- const rootUri = await this.findThemeRootURI(uriString);
- if (!rootUri) {
- return [];
- }
-
- const visitor = documentLinksVisitor(sourceCode.textDocument, URI.parse(rootUri));
- return visit(sourceCode.ast, visitor);
- }
-}
-
-function documentLinksVisitor(
- textDocument: TextDocument,
- root: URI,
-): Visitor {
- return {
- LiquidTag(node) {
- // {% render 'snippet' %}
- // {% include 'snippet' %}
- if (
- (node.name === 'render' || node.name === 'include') &&
- typeof node.markup !== 'string' &&
- isLiquidString(node.markup.snippet)
- ) {
- const snippet = node.markup.snippet;
- return DocumentLink.create(
- range(textDocument, snippet),
- Utils.resolvePath(root, 'snippets', snippet.value + '.liquid').toString(),
- );
- }
-
- // {% section 'section' %}
- if (node.name === 'section' && typeof node.markup !== 'string') {
- const sectionName = node.markup.name;
- return DocumentLink.create(
- range(textDocument, sectionName),
- Utils.resolvePath(root, 'sections', sectionName.value + '.liquid').toString(),
- );
- }
+ const dependencies = await this.getDependencies(uri);
+ return dependencies.flatMap(({ source, target }) => {
+ if (!source.range) return [];
- // {% block 'name' %}
- if (node.name === NamedTags.block && typeof node.markup !== 'string') {
- const blockName = node.markup.name;
- // App blocks belong to an extension, not the theme's blocks directory.
- if (blockName.value.startsWith('shopify://apps/')) return;
- return DocumentLink.create(
- range(textDocument, blockName),
- Utils.resolvePath(root, 'blocks', blockName.value + '.liquid').toString(),
- );
- }
-
- // {% content_for 'block', type: 'block_name' %}
- if (node.name === NamedTags.content_for && typeof node.markup !== 'string') {
- const typeArg = node.markup.args.find((arg) => arg.name === 'type');
- if (typeArg && typeArg.value.type === 'String') {
- return DocumentLink.create(
- range(textDocument, typeArg.value),
- Utils.resolvePath(root, 'blocks', typeArg.value.value + '.liquid').toString(),
- );
- }
- }
- },
-
- // {{ 'theme.js' | asset_url }}
- LiquidVariable(node) {
- if (node.filters.length === 0 || node.filters[0].name !== 'asset_url') {
- return;
- }
-
- if (!isLiquidString(node.expression)) {
- return;
- }
-
- const expression = node.expression;
- return DocumentLink.create(
- range(textDocument, node.expression),
- Utils.resolvePath(root, 'assets', expression.value).toString(),
+ const range = Range.create(
+ sourceCode.textDocument.positionAt(source.range[0]),
+ sourceCode.textDocument.positionAt(source.range[1]),
);
- },
- };
-}
-
-function range(textDocument: TextDocument, node: { position: LiquidHtmlNode['position'] }): Range {
- const start = textDocument.positionAt(node.position.start + 1);
- const end = textDocument.positionAt(node.position.end - 1);
- return Range.create(start, end);
-}
-
-function isLiquidString(node: LiquidHtmlNode): node is LiquidString {
- return node.type === NodeTypes.String;
+ return [DocumentLink.create(range, target.uri)];
+ });
+ }
}
diff --git a/packages/theme-language-server-common/src/server/startServer.ts b/packages/theme-language-server-common/src/server/startServer.ts
index 00026e797..33352009b 100644
--- a/packages/theme-language-server-common/src/server/startServer.ts
+++ b/packages/theme-language-server-common/src/server/startServer.ts
@@ -123,7 +123,9 @@ export function startServer(
getModeForURI,
);
const diagnosticsManager = new DiagnosticsManager(connection);
- const documentLinksProvider = new DocumentLinksProvider(documentManager, findThemeRootURI);
+ const documentLinksProvider = new DocumentLinksProvider(documentManager, (uri) =>
+ themeGraphManager.getDependencies(uri, undefined, { includeIndirect: false }),
+ );
const codeActionsProvider = new CodeActionsProvider(documentManager, diagnosticsManager);
const onTypeFormattingProvider = new OnTypeFormattingProvider(
documentManager,