Skip to content
Draft
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
6 changes: 6 additions & 0 deletions .changeset/moody-mice-learn.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'@shopify/theme-language-server-common': minor
'@shopify/theme-check-common': minor
---

Added hover support for `render` handle and named arguments for inline snippets
154 changes: 154 additions & 0 deletions packages/theme-check-common/src/liquid-doc/liquidDoc.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { expect, it, describe } from 'vitest';
import { toSourceCode } from '../to-source-code';
import { LiquidHtmlNode } from '../types';
import { extractDocDefinition } from './liquidDoc';
import { DocumentNode } from '@shopify/liquid-html-parser';

describe('Unit: extractDocDefinition', () => {
const uri = 'file:///snippets/fake.liquid';
Expand Down Expand Up @@ -293,4 +294,157 @@ describe('Unit: extractDocDefinition', () => {
},
});
});

describe('Inline snippet support', () => {
it('should extract doc from inline snippet when passed the snippet node', async () => {
const fileAST = toAST(`
{% snippet my_snippet %}
{% doc %}
@param {String} title - The title
@param {Number} count - The count
{% enddoc %}
<div>{{ title }}: {{ count }}</div>
{% endsnippet %}
`);

// Get the snippet node from the document
const snippetNode = (fileAST as DocumentNode).children.find(
(node) => node.type === 'LiquidTag' && (node as any).name === 'snippet',
)!;

const result = extractDocDefinition(uri, snippetNode);
expect(result).to.deep.equal({
uri,
liquidDoc: {
parameters: [
{
name: 'title',
description: 'The title',
type: 'String',
required: true,
nodeType: 'param',
},
{
name: 'count',
description: 'The count',
type: 'Number',
required: true,
nodeType: 'param',
},
],
},
});
});

it('should NOT include inline snippet docs when extracting from file level', async () => {
const ast = toAST(`
{% doc %}
@param {String} fileParam - File level parameter
{% enddoc %}

{% snippet inline_snippet %}
{% doc %}
@param {Number} snippetParam - Snippet level parameter
{% enddoc %}
<div>Snippet content</div>
{% endsnippet %}
`);

const result = extractDocDefinition(uri, ast);
expect(result).to.deep.equal({
uri,
liquidDoc: {
parameters: [
{
name: 'fileParam',
description: 'File level parameter',
type: 'String',
required: true,
nodeType: 'param',
},
],
},
});
});

it('should NOT include inline snippet docs when multiple inline snippets exist', async () => {
const ast = toAST(`
{% doc %}
@param {String} mainParam - Main file parameter
{% enddoc %}

{% snippet first_snippet %}
{% doc %}
@param {String} firstParam - First snippet parameter
{% enddoc %}
<div>First</div>
{% endsnippet %}

{% snippet second_snippet %}
{% doc %}
@param {Number} secondParam - Second snippet parameter
{% enddoc %}
<div>Second</div>
{% endsnippet %}
`);

const result = extractDocDefinition(uri, ast);
expect(result).to.deep.equal({
uri,
liquidDoc: {
parameters: [
{
name: 'mainParam',
description: 'Main file parameter',
type: 'String',
required: true,
nodeType: 'param',
},
],
},
});
});

it('should extract docs from specific inline snippet when passed that snippet node', async () => {
const fileAST = toAST(`
{% snippet first_snippet %}
{% doc %}
@param {String} first - First parameter
{% enddoc %}
<div>First</div>
{% endsnippet %}

{% snippet second_snippet %}
{% doc %}
@param {Number} second - Second parameter
{% enddoc %}
<div>Second</div>
{% endsnippet %}
`);

// Get the second snippet node
const secondSnippet = (fileAST as DocumentNode).children.find(
(node) =>
node.type === 'LiquidTag' &&
(node as any).name === 'snippet' &&
(node as any).markup.name === 'second_snippet',
)!;

const result = extractDocDefinition(uri, secondSnippet);
expect(result).to.deep.equal({
uri,
liquidDoc: {
parameters: [
{
name: 'second',
description: 'Second parameter',
type: 'Number',
required: true,
nodeType: 'param',
},
],
},
});
});
});
});
31 changes: 26 additions & 5 deletions packages/theme-check-common/src/liquid-doc/liquidDoc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,10 @@ import {
LiquidDocExampleNode,
LiquidDocParamNode,
LiquidDocDescriptionNode,
LiquidTagSnippet,
NamedTags,
LiquidTag,
NodeTypes,
} from '@shopify/liquid-html-parser';

export type GetDocDefinitionForURI = (
Expand Down Expand Up @@ -58,15 +62,28 @@ export function hasLiquidDoc(snippet: LiquidHtmlNode): boolean {

export function extractDocDefinition(uri: UriString, ast: LiquidHtmlNode): DocDefinition {
let hasDocTag = false;

const isSnippetTag = (node: LiquidHtmlNode): boolean => {
return node.type === NodeTypes.LiquidTag && (node as LiquidTag).name === NamedTags.snippet;
};

const isInsideInlineSnippet = (ancestors: LiquidHtmlNode[]) => {
return !isSnippetTag(ast) && ancestors.some(isSnippetTag);
};

const nodes: (LiquidDocParameter | LiquidDocExample | LiquidDocDescription)[] = visit<
SourceCodeType.LiquidHtml,
LiquidDocParameter | LiquidDocExample | LiquidDocDescription
>(ast, {
LiquidRawTag(node) {
if (node.name === 'doc') hasDocTag = true;
LiquidRawTag(node, ancestors) {
if (node.name === 'doc' && !isInsideInlineSnippet(ancestors)) {
hasDocTag = true;
}
return undefined;
},
LiquidDocParamNode(node: LiquidDocParamNode) {
LiquidDocParamNode(node: LiquidDocParamNode, ancestors) {
if (isInsideInlineSnippet(ancestors)) return undefined;

return {
name: node.paramName.value,
description: node.paramDescription?.value ?? null,
Expand All @@ -75,13 +92,17 @@ export function extractDocDefinition(uri: UriString, ast: LiquidHtmlNode): DocDe
nodeType: 'param',
};
},
LiquidDocExampleNode(node: LiquidDocExampleNode) {
LiquidDocExampleNode(node: LiquidDocExampleNode, ancestors) {
if (isInsideInlineSnippet(ancestors)) return undefined;

return {
content: handleMultilineIndentation(node.content.value.trim()),
nodeType: 'example',
};
},
LiquidDocDescriptionNode(node: LiquidDocDescriptionNode) {
LiquidDocDescriptionNode(node: LiquidDocDescriptionNode, ancestors) {
if (isInsideInlineSnippet(ancestors)) return undefined;

return {
content: handleMultilineIndentation(node.content.value.trim()),
nodeType: 'description',
Expand Down
56 changes: 55 additions & 1 deletion packages/theme-check-common/src/visitor.spec.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { LiquidHtmlNode, LiquidHtmlNodeTypes, SourceCodeType } from './types';
import { toJSONAST, toLiquidHTMLAST, toSourceCode } from './to-source-code';
import { expect, describe, it, assert } from 'vitest';
import { Visitor, findCurrentNode, findJSONNode, visit } from './visitor';
import { Visitor, findCurrentNode, findJSONNode, visit, findInlineSnippet } from './visitor';
import { NodeTypes } from '@shopify/liquid-html-parser';

describe('Module: visitor', () => {
Expand Down Expand Up @@ -161,3 +161,57 @@ describe('findCurrentNode', () => {
);
});
});

describe('findInlineSnippet', () => {
function toAST(code: string) {
return toSourceCode('/tmp/foo.liquid', code).ast as LiquidHtmlNode;
}

it('should find an inline snippet by name', () => {
const ast = toAST(`
{% snippet my_snippet %}
<div>Content</div>
{% endsnippet %}
`);

const result = findInlineSnippet(ast, 'my_snippet');

expect(result).not.to.be.null;
expect(result?.name).to.equal('snippet');
expect(result?.markup.type).to.equal(NodeTypes.VariableLookup);
expect(result?.markup.name).to.equal('my_snippet');
});

it('should return null if snippet is not found', () => {
const ast = toAST(`
{% snippet my_snippet %}
<div>Content</div>
{% endsnippet %}
`);

const result = findInlineSnippet(ast, 'nonexistent');

expect(result).to.be.null;
});

it('should find the correct snippet when multiple snippets exist', () => {
const ast = toAST(`
{% snippet first_snippet %}
<div>First</div>
{% endsnippet %}

{% snippet second_snippet %}
<div>Second</div>
{% endsnippet %}

{% snippet third_snippet %}
<div>Third</div>
{% endsnippet %}
`);

const result = findInlineSnippet(ast, 'second_snippet');

expect(result).not.to.be.null;
expect(result?.markup.name).to.equal('second_snippet');
});
});
28 changes: 27 additions & 1 deletion packages/theme-check-common/src/visitor.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,8 @@
import { NodeTypes as LiquidHtmlNodeTypes } from '@shopify/liquid-html-parser';
import {
NodeTypes as LiquidHtmlNodeTypes,
NamedTags,
LiquidTagSnippet,
} from '@shopify/liquid-html-parser';
import { AST, LiquidHtmlNode, NodeOfType, SourceCodeType, NodeTypes, JSONNode } from './types';

export type VisitorMethod<S extends SourceCodeType, T, R> = (
Expand Down Expand Up @@ -154,3 +158,25 @@ export function findJSONNode(

return [current, ancestors];
}

export function findInlineSnippet(
ast: LiquidHtmlNode,
snippetName: string,
): LiquidTagSnippet | null {
let foundSnippet: LiquidTagSnippet | null = null;

visit<SourceCodeType.LiquidHtml, void>(ast, {
LiquidTag(node) {
if (
node.name === NamedTags.snippet &&
typeof node.markup !== 'string' &&
node.markup.type === 'VariableLookup' &&
node.markup.name === snippetName
) {
foundSnippet = node as LiquidTagSnippet;
}
},
});

return foundSnippet;
}
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,38 @@ This is a description
await expect(provider).to.hover(`{% assign asdf = 'snip█pet' %}`, null);
await expect(provider).to.hover(`{{ 'snip█pet' }}`, null);
});

it('should return snippet definition with all parameters for inline snippet', async () => {
provider = createProvider(async () => undefined); // No file-based snippets

const source = `
{% snippet inline_product_card %}
{% doc %}
@param {string} title - The title of the inline product card

@example
{% render inline_product_card, title: 'My Product' %}
{% enddoc %}
<div>{{ title }}</div>
{% endsnippet %}

{% render inline_produc█t_card %}
`;

// prettier-ignore
const expectedHoverContent =
`### inline_product_card

**Parameters:**
- \`title\`: string - The title of the inline product card

**Examples:**
\`\`\`liquid
{% render inline_product_card, title: 'My Product' %}
\`\`\``;

await expect(provider).to.hover(source, expectedHoverContent);
});
});
});

Expand Down
Loading