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 42b82c2d..749c4e39 100644 --- a/packages/theme-language-server-common/src/documentLinks/DocumentLinksProvider.spec.ts +++ b/packages/theme-language-server-common/src/documentLinks/DocumentLinksProvider.spec.ts @@ -61,4 +61,20 @@ describe('DocumentLinksProvider', () => { expect(result[i].target).toBe(expectedUrls[i]); } }); + + it('should return a list of document links with correct URLs for a LiquidRawTag document', async () => { + uriString = 'file:///path/to/liquid-raw-tag-document.liquid'; + rootUri = 'file:///path/to/project'; + + const liquidRawTagContent = ` + {% schema %} + { "blocks": [{ "type": "valid" }] } + {% endschema %} + `; + + documentManager.open(uriString, liquidRawTagContent, 1); + + const result = await documentLinksProvider.documentLinks(uriString); + expect(result).toEqual([]); + }); }); diff --git a/packages/theme-language-server-common/src/documentLinks/DocumentLinksProvider.ts b/packages/theme-language-server-common/src/documentLinks/DocumentLinksProvider.ts index 3541370b..3392ee00 100644 --- a/packages/theme-language-server-common/src/documentLinks/DocumentLinksProvider.ts +++ b/packages/theme-language-server-common/src/documentLinks/DocumentLinksProvider.ts @@ -1,4 +1,4 @@ -import { LiquidHtmlNode, LiquidString, NodeTypes } from '@shopify/liquid-html-parser'; +import { LiquidHtmlNode, LiquidRawTag, LiquidString, 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'; @@ -7,6 +7,8 @@ import { URI, Utils } from 'vscode-uri'; import { DocumentManager } from '../documents'; import { visit, Visitor } from '@shopify/theme-check-common'; +import { parseTree, findNodeAtLocation, ParseError, Node as JSONNode } from 'jsonc-parser'; + export class DocumentLinksProvider { constructor( private documentManager: DocumentManager, @@ -79,6 +81,42 @@ function documentLinksVisitor( Utils.resolvePath(root, 'assets', expression.value).toString(), ); }, + + LiquidRawTag(node) { + // look for schema tags + if (node.name === 'schema') { + // parse and return a tree of the schema + const errors: ParseError[] = []; + const jsonNode = parseTree(node.body.value, errors); + if (!jsonNode || errors.length > 0) { + return []; + } + + // create an array of links so we can process all block types and preset block types in the schema + const links: DocumentLink[] = []; + + // Process top-level blocks + const blocksNode = findNodeAtLocation(jsonNode, ['blocks']); + if (blocksNode && blocksNode.type === 'array' && blocksNode.children) { + links.push(...createLinksFromBlocks(blocksNode, node, textDocument, root)); + } + + // Process presets + const presetsNode = findNodeAtLocation(jsonNode, ['presets']); + if (presetsNode && presetsNode.type === 'array' && presetsNode.children) { + presetsNode.children.forEach((presetNode) => { + // Process blocks within each preset + const presetBlocksNode = findNodeAtLocation(presetNode, ['blocks']); + if (presetBlocksNode) { + links.push(...processPresetBlocks(presetBlocksNode, node, textDocument, root)); + } + }); + } + + return links; + } + return []; + }, }; } @@ -91,3 +129,149 @@ function range(textDocument: TextDocument, node: { position: LiquidHtmlNode['pos function isLiquidString(node: LiquidHtmlNode): node is LiquidString { return node.type === NodeTypes.String; } + +function createDocumentLinkForTypeNode( + typeNode: JSONNode, + parentNode: LiquidRawTag, + textDocument: TextDocument, + root: URI, + blockType: string, +): DocumentLink | null { + const startOffset = typeNode.offset; + const endOffset = typeNode.offset + typeNode.length; + const startPos = parentNode.body.position.start + startOffset; + const endPos = parentNode.body.position.start + endOffset; + + const start = textDocument.positionAt(startPos); + const end = textDocument.positionAt(endPos); + + return DocumentLink.create( + Range.create(start, end), + Utils.resolvePath(root, 'blocks', `${blockType}.liquid`).toString(), + ); +} + +function processPresetBlocks( + blocksNode: JSONNode, + parentNode: LiquidRawTag, + textDocument: TextDocument, + root: URI, +): DocumentLink[] { + const links: DocumentLink[] = []; + + if (blocksNode.type === 'object' && blocksNode.children) { + blocksNode.children.forEach((propertyNode) => { + const blockValueNode = propertyNode.children?.[1]; // The value node of the property + if (!blockValueNode) return; + + // Check if the block has a 'name' key so we don't deeplink inline block types + const nameNode = findNodeAtLocation(blockValueNode, ['name']); + if (nameNode) { + return; + } + + const typeNode = findNodeAtLocation(blockValueNode, ['type']); + if (typeNode && typeNode.type === 'string' && typeof typeNode.value === 'string') { + const blockType = typeNode.value; + if (blockType.startsWith('@')) { + return; + } + + const link = createDocumentLinkForTypeNode( + typeNode, + parentNode, + textDocument, + root, + blockType, + ); + + if (link) { + links.push(link); + } + } + + // Recursively process nested blocks + const nestedBlocksNode = findNodeAtLocation(blockValueNode, ['blocks']); + if (nestedBlocksNode) { + links.push(...processPresetBlocks(nestedBlocksNode, parentNode, textDocument, root)); + } + }); + } else if (blocksNode.type === 'array' && blocksNode.children) { + blocksNode.children.forEach((blockNode) => { + // Check if the block has a 'name' key + const nameNode = findNodeAtLocation(blockNode, ['name']); + if (nameNode) { + return; // Skip creating a link if 'name' key exists + } + + const typeNode = findNodeAtLocation(blockNode, ['type']); + if (typeNode && typeNode.type === 'string' && typeof typeNode.value === 'string') { + const blockType = typeNode.value; + if (blockType.startsWith('@')) { + return; + } + + const link = createDocumentLinkForTypeNode( + typeNode, + parentNode, + textDocument, + root, + blockType, + ); + + if (link) { + links.push(link); + } + } + + // Recursively process nested blocks + const nestedBlocksNode = findNodeAtLocation(blockNode, ['blocks']); + if (nestedBlocksNode) { + links.push(...processPresetBlocks(nestedBlocksNode, parentNode, textDocument, root)); + } + }); + } + + return links; +} + +function createLinksFromBlocks( + blocksNode: JSONNode, + parentNode: LiquidRawTag, + textDocument: TextDocument, + root: URI, +): DocumentLink[] { + const links: DocumentLink[] = []; + + if (blocksNode.children) { + blocksNode.children.forEach((blockNode: JSONNode) => { + // Check if the block has a 'name' key to avoid deeplinking inline block types + const nameNode = findNodeAtLocation(blockNode, ['name']); + if (nameNode) { + return; + } + + const typeNode = findNodeAtLocation(blockNode, ['type']); + if (typeNode && typeNode.type === 'string' && typeof typeNode.value === 'string') { + const blockType = typeNode.value; + if (blockType.startsWith('@')) { + return; + } + + const link = createDocumentLinkForTypeNode( + typeNode, + parentNode, + textDocument, + root, + blockType, + ); + + if (link) { + links.push(link); + } + } + }); + } + + return links; +}