Skip to content

Commit a42459e

Browse files
authored
[LiquidDoc] Add snippet hover support inside of {% render %} tag (#703)
## What are you adding in this PR? Shopify/developer-tools-team#496 **1) Hover support for Liquid snippets inside of a `{% render %}` tag. Users will now see:** - The snippet name as a header - A list of parameters with their types and descriptions (if documented) If there is no `{% doc %}` present in the snippet, we will just render the snippet name **2) Caching for fetching LiquidDoc definitions** ##### Example snippet documentation: ```liquid {% doc %} @param {String} title - The title of the product @param {Number} border-radius - The border radius in px {% enddoc %} ``` ##### When hovering over `product-card` in `{% render 'product-card' %}`, users will see: ```markdown ### product-card **Parameters:** - `title`: String - The title of the product - `border-radius`: Number - The border radius in px ``` Uploading Cursor - liquidDoc.ts — theme-tools.mp4… #### Hovering a snippet with docs ![image.png](https://graphite-user-uploaded-assets-prod.s3.amazonaws.com/JdHLnhebSbtZTZO01I1e/84bb1493-2812-4eba-9943-65b2d4071f9a.png) #### Hovering a snippet without liquiddoc ![image.png](https://graphite-user-uploaded-assets-prod.s3.amazonaws.com/JdHLnhebSbtZTZO01I1e/ad09bea5-5e73-454a-9269-0ce8a187900e.png) ## What's next? Any followup issues? - Consider adding validation for param types - Add support for return value documentation - Add support for example usage documentation ## What did you learn? This is my first time working with the hover API in VS code. Pretty cool to read up on! ## Before you deploy - [x] I included a minor bump `changeset` - [x] My feature is backward compatible
2 parents 9765bec + 52bf118 commit a42459e

11 files changed

+302
-0
lines changed

.changeset/dry-donkeys-behave.md

+6
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
'@shopify/theme-language-server-common': minor
3+
'@shopify/theme-check-common': minor
4+
---
5+
6+
Cache liquidDoc fetch results

.changeset/little-flowers-swim.md

+6
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
'@shopify/theme-language-server-common': minor
3+
'@shopify/theme-check-common': minor
4+
---
5+
6+
Add hover support for Liquid snippets using {% doc %} annotations

packages/theme-language-server-common/src/documents/DocumentManager.ts

+8
Original file line numberDiff line numberDiff line change
@@ -12,12 +12,14 @@ import {
1212
IsValidSchema,
1313
memo,
1414
Mode,
15+
isError,
1516
} from '@shopify/theme-check-common';
1617
import { Connection } from 'vscode-languageserver';
1718
import { TextDocument } from 'vscode-languageserver-textdocument';
1819
import { ClientCapabilities } from '../ClientCapabilities';
1920
import { percent, Progress } from '../progress';
2021
import { AugmentedSourceCode } from './types';
22+
import { getSnippetDefinition } from '../liquidDoc';
2123

2224
export class DocumentManager {
2325
/**
@@ -171,6 +173,12 @@ export class DocumentManager {
171173
const mode = await this.getModeForUri!(uri);
172174
return toSchema(mode, uri, sourceCode, this.isValidSchema);
173175
}),
176+
/** Lazy and only computed once per file version */
177+
getLiquidDoc: memo(async (snippetName: string) => {
178+
if (isError(sourceCode.ast)) return { name: snippetName };
179+
180+
return getSnippetDefinition(sourceCode.ast, snippetName);
181+
}),
174182
};
175183
default:
176184
return assertNever(sourceCode);

packages/theme-language-server-common/src/documents/types.ts

+2
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import {
66
AppBlockSchema,
77
} from '@shopify/theme-check-common';
88
import { TextDocument } from 'vscode-languageserver-textdocument';
9+
import { SnippetDefinition } from '../liquidDoc';
910

1011
/** Util type to add the common `textDocument` property to the SourceCode. */
1112
type _AugmentedSourceCode<SCT extends SourceCodeType = SourceCodeType> = SourceCode<SCT> & {
@@ -23,6 +24,7 @@ export type AugmentedJsonSourceCode = _AugmentedSourceCode<SourceCodeType.JSON>;
2324
*/
2425
export type AugmentedLiquidSourceCode = _AugmentedSourceCode<SourceCodeType.LiquidHtml> & {
2526
getSchema: () => Promise<SectionSchema | ThemeBlockSchema | AppBlockSchema | undefined>;
27+
getLiquidDoc: (snippetName: string) => Promise<SnippetDefinition>;
2628
};
2729

2830
/**

packages/theme-language-server-common/src/hover/HoverProvider.ts

+9
Original file line numberDiff line numberDiff line change
@@ -12,10 +12,12 @@ import {
1212
LiquidObjectHoverProvider,
1313
LiquidTagHoverProvider,
1414
TranslationHoverProvider,
15+
RenderSnippetHoverProvider,
1516
} from './providers';
1617
import { HtmlAttributeValueHoverProvider } from './providers/HtmlAttributeValueHoverProvider';
1718
import { findCurrentNode } from '@shopify/theme-check-common';
1819
import { GetThemeSettingsSchemaForURI } from '../settings';
20+
import { GetSnippetDefinitionForURI } from '../liquidDoc';
1921

2022
export class HoverProvider {
2123
private providers: BaseHoverProvider[] = [];
@@ -26,6 +28,12 @@ export class HoverProvider {
2628
readonly getMetafieldDefinitions: (rootUri: string) => Promise<MetafieldDefinitionMap>,
2729
readonly getTranslationsForURI: GetTranslationsForURI = async () => ({}),
2830
readonly getSettingsSchemaForURI: GetThemeSettingsSchemaForURI = async () => [],
31+
readonly getSnippetDefinitionForURI: GetSnippetDefinitionForURI = async (
32+
_uri,
33+
snippetName,
34+
) => ({
35+
name: snippetName,
36+
}),
2937
) {
3038
const typeSystem = new TypeSystem(
3139
themeDocset,
@@ -41,6 +49,7 @@ export class HoverProvider {
4149
new HtmlAttributeHoverProvider(),
4250
new HtmlAttributeValueHoverProvider(),
4351
new TranslationHoverProvider(getTranslationsForURI, documentManager),
52+
new RenderSnippetHoverProvider(getSnippetDefinitionForURI),
4453
];
4554
}
4655

Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
import { describe, beforeEach, it, expect } from 'vitest';
2+
import { DocumentManager } from '../../documents';
3+
import { HoverProvider } from '../HoverProvider';
4+
import { MetafieldDefinitionMap } from '@shopify/theme-check-common';
5+
import { GetSnippetDefinitionForURI, SnippetDefinition } from '../../liquidDoc';
6+
7+
describe('Module: RenderSnippetHoverProvider', async () => {
8+
let provider: HoverProvider;
9+
let getSnippetDefinition: GetSnippetDefinitionForURI;
10+
const mockSnippetDefinition: SnippetDefinition = {
11+
name: 'product-card',
12+
liquidDoc: {
13+
parameters: [
14+
{
15+
name: 'title',
16+
description: 'The title of the product',
17+
type: 'string',
18+
},
19+
{
20+
name: 'border-radius',
21+
description: 'The border radius in px',
22+
type: 'number',
23+
},
24+
],
25+
},
26+
};
27+
28+
const createProvider = (getSnippetDefinition: GetSnippetDefinitionForURI) => {
29+
return new HoverProvider(
30+
new DocumentManager(),
31+
{
32+
filters: async () => [],
33+
objects: async () => [],
34+
tags: async () => [],
35+
systemTranslations: async () => ({}),
36+
},
37+
async (_rootUri: string) => ({} as MetafieldDefinitionMap),
38+
async () => ({}),
39+
async () => [],
40+
getSnippetDefinition,
41+
);
42+
};
43+
44+
beforeEach(async () => {
45+
getSnippetDefinition = async () => mockSnippetDefinition;
46+
provider = createProvider(getSnippetDefinition);
47+
});
48+
49+
describe('hover', () => {
50+
it('should return snippet definition with all parameters', async () => {
51+
await expect(provider).to.hover(
52+
`{% render 'product-car█d' %}`,
53+
'### product-card\n\n**Parameters:**\n- `title`: string - The title of the product\n- `border-radius`: number - The border radius in px',
54+
);
55+
});
56+
57+
it('should return an H3 with snippet name if no LiquidDocDefinition found', async () => {
58+
getSnippetDefinition = async () => ({ name: 'unknown-snippet' });
59+
provider = createProvider(getSnippetDefinition);
60+
await expect(provider).to.hover(`{% render 'unknown-sni█ppet' %}`, '### unknown-snippet');
61+
});
62+
63+
it('should return nothing if not in render tag', async () => {
64+
await expect(provider).to.hover(`{% assign asdf = 'snip█pet' %}`, null);
65+
await expect(provider).to.hover(`{{ 'snip█pet' }}`, null);
66+
});
67+
});
68+
});
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
import { NodeTypes } from '@shopify/liquid-html-parser';
2+
import { LiquidHtmlNode } from '@shopify/theme-check-common';
3+
import { Hover, HoverParams } from 'vscode-languageserver';
4+
import { BaseHoverProvider } from '../BaseHoverProvider';
5+
import { SnippetDefinition, LiquidDocParameter } from '../../liquidDoc';
6+
7+
export class RenderSnippetHoverProvider implements BaseHoverProvider {
8+
constructor(
9+
private getSnippetDefinitionForURI: (
10+
uri: string,
11+
snippetName: string,
12+
) => Promise<SnippetDefinition>,
13+
) {}
14+
15+
async hover(
16+
currentNode: LiquidHtmlNode,
17+
ancestors: LiquidHtmlNode[],
18+
params: HoverParams,
19+
): Promise<Hover | null> {
20+
const parentNode = ancestors.at(-1);
21+
if (
22+
currentNode.type !== NodeTypes.String ||
23+
!parentNode ||
24+
parentNode.type !== NodeTypes.RenderMarkup
25+
) {
26+
return null;
27+
}
28+
29+
const snippetName = currentNode.value;
30+
const snippetDefinition = await this.getSnippetDefinitionForURI(
31+
params.textDocument.uri,
32+
snippetName,
33+
);
34+
35+
const liquidDoc = snippetDefinition.liquidDoc;
36+
37+
if (!liquidDoc) {
38+
return {
39+
contents: {
40+
kind: 'markdown',
41+
value: `### ${snippetDefinition.name}`,
42+
},
43+
};
44+
}
45+
46+
const parts = [`### ${snippetDefinition.name}`];
47+
48+
if (liquidDoc.parameters?.length) {
49+
const parameters = liquidDoc.parameters
50+
?.map(
51+
({ name, type, description }: LiquidDocParameter) =>
52+
`- \`${name}\`${type ? `: ${type}` : ''} ${description ? `- ${description}` : ''}`,
53+
)
54+
.join('\n');
55+
56+
parts.push('', '**Parameters:**', parameters);
57+
}
58+
59+
return {
60+
contents: {
61+
kind: 'markdown',
62+
value: parts.join('\n'),
63+
},
64+
};
65+
}
66+
}

packages/theme-language-server-common/src/hover/providers/index.ts

+1
Original file line numberDiff line numberDiff line change
@@ -6,3 +6,4 @@ export { HtmlTagHoverProvider } from './HtmlTagHoverProvider';
66
export { HtmlAttributeHoverProvider } from './HtmlAttributeHoverProvider';
77
export { HtmlAttributeValueHoverProvider } from './HtmlAttributeValueHoverProvider';
88
export { TranslationHoverProvider } from './TranslationHoverProvider';
9+
export { RenderSnippetHoverProvider } from './RenderSnippetHoverProvider';
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
import { expect, it } from 'vitest';
2+
import { LiquidHtmlNode } from '@shopify/theme-check-common';
3+
import { toSourceCode } from '@shopify/theme-check-common';
4+
import { describe } from 'vitest';
5+
import { getSnippetDefinition } from './liquidDoc';
6+
7+
describe('Unit: makeGetLiquidDocDefinitions', () => {
8+
function toAST(code: string) {
9+
return toSourceCode('/tmp/foo.liquid', code).ast as LiquidHtmlNode;
10+
}
11+
12+
it('should return name if no valid annotations are present in definition', async () => {
13+
const ast = toAST(`
14+
{% doc %}
15+
just a description
16+
@undefined asdf
17+
{% enddoc %}
18+
`);
19+
20+
const result = getSnippetDefinition(ast, 'product-card');
21+
expect(result).to.deep.equal({
22+
name: 'product-card',
23+
liquidDoc: {
24+
parameters: [],
25+
},
26+
});
27+
});
28+
29+
it('should extract name, description and type from param annotations', async () => {
30+
const ast = toAST(`
31+
{% doc %}
32+
@param {String} firstParam - The first param
33+
@param {Number} secondParam - The second param
34+
@param paramWithNoType - param with no type
35+
@param paramWithOnlyName
36+
{% enddoc %}
37+
`);
38+
39+
const result = getSnippetDefinition(ast, 'product-card');
40+
expect(result).to.deep.equal({
41+
name: 'product-card',
42+
liquidDoc: {
43+
parameters: [
44+
{
45+
name: 'firstParam',
46+
description: 'The first param',
47+
type: 'String',
48+
},
49+
{
50+
name: 'secondParam',
51+
description: 'The second param',
52+
type: 'Number',
53+
},
54+
{
55+
name: 'paramWithNoType',
56+
description: 'param with no type',
57+
type: null,
58+
},
59+
{
60+
name: 'paramWithOnlyName',
61+
description: '',
62+
type: null,
63+
},
64+
],
65+
},
66+
});
67+
});
68+
});
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
import { SourceCodeType, visit } from '@shopify/theme-check-common';
2+
3+
import { LiquidHtmlNode } from '@shopify/theme-check-common';
4+
5+
import { LiquidDocParamNode } from '@shopify/liquid-html-parser';
6+
7+
export type GetSnippetDefinitionForURI = (
8+
uri: string,
9+
snippetName: string,
10+
) => Promise<SnippetDefinition>;
11+
12+
export type LiquidDocParameter = {
13+
name: string;
14+
description: string | null;
15+
type: string | null;
16+
};
17+
18+
export type SnippetDefinition = {
19+
name: string;
20+
liquidDoc?: LiquidDocDefinition;
21+
};
22+
23+
type LiquidDocDefinition = {
24+
parameters?: LiquidDocParameter[];
25+
};
26+
27+
export function getSnippetDefinition(
28+
snippet: LiquidHtmlNode,
29+
snippetName: string,
30+
): SnippetDefinition {
31+
const liquidDocParameters: LiquidDocParameter[] = visit<
32+
SourceCodeType.LiquidHtml,
33+
LiquidDocParameter
34+
>(snippet, {
35+
LiquidDocParamNode(node: LiquidDocParamNode) {
36+
return {
37+
name: node.paramName.value,
38+
description: node.paramDescription?.value ?? null,
39+
type: node.paramType?.value ?? null,
40+
};
41+
},
42+
});
43+
44+
return {
45+
name: snippetName,
46+
liquidDoc: {
47+
parameters: liquidDocParameters,
48+
},
49+
};
50+
}

packages/theme-language-server-common/src/server/startServer.ts

+18
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,8 @@ import { snippetName } from '../utils/uri';
4343
import { VERSION } from '../version';
4444
import { CachedFileSystem } from './CachedFileSystem';
4545
import { Configuration } from './Configuration';
46+
import { LiquidHtmlNode } from '@shopify/liquid-html-parser';
47+
import { getSnippetDefinition, SnippetDefinition } from '../liquidDoc';
4648

4749
const defaultLogger = () => {};
4850

@@ -171,6 +173,21 @@ export function startServer(
171173
return getDefaultSchemaTranslations();
172174
};
173175

176+
const getSnippetDefinitionForURI = async (
177+
uri: string,
178+
snippetName: string,
179+
): Promise<SnippetDefinition> => {
180+
const rootUri = await findThemeRootURI(uri);
181+
const snippetURI = path.join(rootUri, 'snippets', `${snippetName}.liquid`);
182+
const snippet = documentManager.get(snippetURI);
183+
184+
if (!snippet || snippet.type !== SourceCodeType.LiquidHtml || isError(snippet.ast)) {
185+
return { name: snippetName };
186+
}
187+
188+
return snippet.getLiquidDoc(snippetName);
189+
};
190+
174191
const snippetFilter = ([uri]: FileTuple) => /\.liquid$/.test(uri) && /snippets/.test(uri);
175192
const getSnippetNamesForURI: GetSnippetNamesForURI = async (uri: string) => {
176193
const rootUri = await findThemeRootURI(uri);
@@ -252,6 +269,7 @@ export function startServer(
252269
getMetafieldDefinitions,
253270
getTranslationsForURI,
254271
getThemeSettingsSchemaForURI,
272+
getSnippetDefinitionForURI,
255273
);
256274

257275
const executeCommandProvider = new ExecuteCommandProvider(

0 commit comments

Comments
 (0)