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
5 changes: 5 additions & 0 deletions .changeset/theme-graph-document-links.md
Original file line number Diff line number Diff line change
@@ -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.
Original file line number Diff line number Diff line change
@@ -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', () => {
Expand All @@ -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' %}
Expand All @@ -46,49 +47,145 @@ describe('DocumentLinksProvider', () => {
{% block 'container' %}{% endblock %}
{% block '_private' %}{% endblock %}
{% block 'with_args', block.settings.alignment: 'center' %}{% endblock %}
<product-gallery></product-gallery>
`;
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 %}
<product-gallery></product-gallery>`;
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('<product-gallery');

expect(featureLinks).toHaveLength(2);
expect(actualFeatureRanges).toEqual(expectedFeatureRanges);
expect(galleryLink?.range).toEqual(
Range.create(
textDocument.positionAt(galleryStart),
textDocument.positionAt(galleryStart + '<product-gallery'.length),
),
);
});

it('should not create multiple links for the special @theme block type', async () => {
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<string, string>) {
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 };
}
Original file line number Diff line number Diff line change
@@ -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<AugmentedReference[]>;

export class DocumentLinksProvider {
constructor(
private documentManager: DocumentManager,
private findThemeRootURI: FindThemeRootURI,
private getDependencies: GetDependencies,
) {}

async documentLinks(uriString: string): Promise<DocumentLink[]> {
const sourceCode = this.documentManager.get(uriString);
async documentLinks(uri: string): Promise<DocumentLink[]> {
const sourceCode = this.documentManager.get(uri);
if (
!sourceCode ||
sourceCode.type !== SourceCodeType.LiquidHtml ||
Expand All @@ -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<SourceCodeType.LiquidHtml, DocumentLink> {
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)];
});
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Loading