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 42b82c2dc..6477de22d 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,45 @@ describe('DocumentLinksProvider', () => { expect(result[i].target).toBe(expectedUrls[i]); } }); + + it('should return a list of document links for customizable theme block types', async () => { + // @theme and @app are not included as these are general types used for block targeting + // inline blocks are not included as there are no related block files to link to + uriString = 'file:///path/to/liquid-raw-tag-document.liquid'; + rootUri = 'file:///path/to/project'; + + const liquidRawTagContent = ` + {% schema %} + { "blocks": [{ "type": "@theme" }, {"type": "top_level" }, {"type": "_private_block" }], + "name": "Test section", + "presets": [ + { + "blocks": { + "block-1": { + "name": "inline block", + "type": "inline_block" + }, + "block-2": { + "type": "nested_block" + } + } + } + ] + } + {% endschema %} + `; + + documentManager.open(uriString, liquidRawTagContent, 1); + + const result = await documentLinksProvider.documentLinks(uriString); + const expectedUrls = [ + 'file:///path/to/project/blocks/top_level.liquid', + 'file:///path/to/project/blocks/_private_block.liquid', + 'file:///path/to/project/blocks/nested_block.liquid', + ]; + expect(result.length).toBe(expectedUrls.length); + for (let i = 0; i < expectedUrls.length; i++) { + expect(result[i].target).toBe(expectedUrls[i]); + } + }); }); diff --git a/packages/theme-language-server-common/src/documentLinks/DocumentLinksProvider.ts b/packages/theme-language-server-common/src/documentLinks/DocumentLinksProvider.ts index 3541370b3..515c72ec7 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,36 @@ function documentLinksVisitor( Utils.resolvePath(root, 'assets', expression.value).toString(), ); }, + + LiquidRawTag(node) { + if (node.name === 'schema') { + const errors: ParseError[] = []; + const jsonNode = parseTree(node.body.value, errors); + if (!jsonNode || errors.length > 0) { + return []; + } + + const links: DocumentLink[] = []; + + const blocksNode = findNodeAtLocation(jsonNode, ['blocks']); + if (blocksNode && blocksNode.type === 'array' && blocksNode.children) { + links.push(...createLinksFromBlocks(blocksNode, node, textDocument, root)); + } + + const presetsNode = findNodeAtLocation(jsonNode, ['presets']); + if (presetsNode && presetsNode.type === 'array' && presetsNode.children) { + presetsNode.children.forEach((presetNode) => { + const presetBlocksNode = findNodeAtLocation(presetNode, ['blocks']); + if (presetBlocksNode) { + links.push(...processPresetBlocks(presetBlocksNode, node, textDocument, root)); + } + }); + } + + return links; + } + return []; + }, }; } @@ -91,3 +123,144 @@ 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]; + if (!blockValueNode) return; + + 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); + } + } + + 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) => { + 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); + } + } + + 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) => { + 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; +}