From d265a2f57c89c097e080ca883e56de49db659a5f Mon Sep 17 00:00:00 2001 From: Josh Faigan Date: Mon, 20 Jan 2025 16:35:53 -0500 Subject: [PATCH 1/4] Implement Liquid grammar for examplenode --- .../liquid-html-parser/grammar/liquid-html.ohm | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/packages/liquid-html-parser/grammar/liquid-html.ohm b/packages/liquid-html-parser/grammar/liquid-html.ohm index 4d95edbc5..42a2b8d4b 100644 --- a/packages/liquid-html-parser/grammar/liquid-html.ohm +++ b/packages/liquid-html-parser/grammar/liquid-html.ohm @@ -128,10 +128,10 @@ Liquid <: Helpers { liquidTagLiquidMarkup = tagMarkup liquidTagContentFor = liquidTagRule<"content_for", liquidTagContentForMarkup> - + liquidTagContentForMarkup = contentForType (argumentSeparatorOptionalComma contentForTagArgument) (space* ",")? space* - + contentForTagArgument = listOf, argumentSeparatorOptionalComma> contentForNamedArgument = (variableSegment ("." variableSegment)*) space* ":" space* (liquidExpression) @@ -217,7 +217,7 @@ Liquid <: Helpers { commentBlockStart = "{%" "-"? space* ("comment" endOfIdentifier) space* tagMarkup "-"? "%}" commentBlockEnd = "{%" "-"? space* ("endcomment" endOfIdentifier) space* tagMarkup "-"? "%}" - liquidDoc = + liquidDoc = liquidDocStart liquidDocBody liquidDocEnd @@ -392,20 +392,26 @@ LiquidDoc <: Helpers { Node := (LiquidDocNode | TextNode)* LiquidDocNode = | paramNode + | exampleNode | fallbackNode // By default, space matches new lines as well. We override it here to make writing rules easier. strictSpace = " " | "\t" - // We use this as an escape hatch to stop matching TextNode and try again when one of these characters is encountered + // We use this as an escape hatch to stop matching TextNode and try again when one of these characters is encountered openControl:= "@" | end - fallbackNode = "@" anyExceptStar paramNode = "@param" strictSpace* paramType? strictSpace* paramName (strictSpace* "-")? strictSpace* paramDescription paramType = "{" strictSpace* paramTypeContent strictSpace* "}" paramTypeContent = anyExceptStar<("}"| strictSpace)> paramName = identifierCharacter+ paramDescription = anyExceptStar endOfParam = strictSpace* (newline | end) + + exampleNode = "@example" strictSpace* exampleContent + exampleContent = anyExceptStar + endOfExample = strictSpace* ("@" | end) + + fallbackNode = "@" anyExceptStar } LiquidHTML <: Liquid { From c72cb9905ceb2ec0771a9de69aead2abbf351b80 Mon Sep 17 00:00:00 2001 From: Josh Faigan Date: Mon, 20 Jan 2025 16:36:45 -0500 Subject: [PATCH 2/4] add examplenode in stage1 and test coverage --- .../src/stage-1-cst.spec.ts | 81 ++++++++++++++++++- .../liquid-html-parser/src/stage-1-cst.ts | 18 ++++- 2 files changed, 96 insertions(+), 3 deletions(-) diff --git a/packages/liquid-html-parser/src/stage-1-cst.spec.ts b/packages/liquid-html-parser/src/stage-1-cst.spec.ts index 08303b8e0..ced4bc547 100644 --- a/packages/liquid-html-parser/src/stage-1-cst.spec.ts +++ b/packages/liquid-html-parser/src/stage-1-cst.spec.ts @@ -1108,6 +1108,83 @@ describe('Unit: Stage 1 (CST)', () => { expectPath(cst, '0.children.2.type').to.equal('TextNode'); expectPath(cst, '0.children.2.value').to.equal('@unsupported'); }); + + it('should parse a basic example tag', () => { + const testStr = `{% doc -%} @example {%- enddoc %}`; + cst = toCST(testStr); + expectPath(cst, '0.type').to.equal('LiquidRawTag'); + expectPath(cst, '0.name').to.equal('doc'); + expectPath(cst, '0.children.0.type').to.equal('LiquidDocExampleNode'); + expectPath(cst, '0.children.0.exampleContent.value').to.equal(''); + }); + + it('should parse example tag with content that has leading whitespace', () => { + const testStr = `{% doc %} @example hello there {%- enddoc %}`; + cst = toCST(testStr); + expectPath(cst, '0.type').to.equal('LiquidRawTag'); + expectPath(cst, '0.name').to.equal('doc'); + expectPath(cst, '0.children.0.type').to.equal('LiquidDocExampleNode'); + expectPath(cst, '0.children.0.name').to.equal('example'); + expectPath(cst, '0.children.0.exampleContent.value').to.equal('hello there'); + }); + + it('should parse an example tag with a value', () => { + const testStr = `{% doc %} + @example + This is an example + It supports multiple lines + {% enddoc %}`; + + cst = toCST(testStr); + expectPath(cst, '0.type').to.equal('LiquidRawTag'); + expectPath(cst, '0.name').to.equal('doc'); + expectPath(cst, '0.children.0.type').to.equal('LiquidDocExampleNode'); + expectPath(cst, '0.children.0.name').to.equal('example'); + expectPath(cst, '0.children.0.exampleContent.value').toEqual( + expect.stringContaining('This is an example'), + ); + expectPath(cst, '0.children.0.exampleContent.value').toEqual( + expect.stringContaining('It supports multiple lines'), + ); + }); + + it('should parse example node and stop at the next @', () => { + const testStr = `{% doc %} + @example + This is an example + @param param1 + {% enddoc %}`; + cst = toCST(testStr); + expectPath(cst, '0.children.0.type').to.equal('LiquidDocExampleNode'); + expectPath(cst, '0.children.0.name').to.equal('example'); + expectPath(cst, '0.children.0.exampleContent.value').toEqual( + expect.stringContaining('This is an example'), + ); + expectPath(cst, '0.children.1.type').to.equal('LiquidDocParamNode'); + expectPath(cst, '0.children.1.paramName.value').to.equal('param1'); + }); + + it('should parse example node with whitespace and new lines', () => { + const testStr = `{% doc %} + @example hello there my friend + This is an example + It supports multiple lines + {% enddoc %}`; + cst = toCST(testStr); + expectPath(cst, '0.type').to.equal('LiquidRawTag'); + expectPath(cst, '0.name').to.equal('doc'); + expectPath(cst, '0.children.0.type').to.equal('LiquidDocExampleNode'); + expectPath(cst, '0.children.0.name').to.equal('example'); + expectPath(cst, '0.children.0.exampleContent.value').toEqual( + expect.stringContaining('hello there my friend'), + ); + expectPath(cst, '0.children.0.exampleContent.value').toEqual( + expect.stringContaining('This is an example'), + ); + expectPath(cst, '0.children.0.exampleContent.value').toEqual( + expect.stringContaining('It supports multiple lines'), + ); + }); } }); }); @@ -1304,8 +1381,8 @@ describe('Unit: Stage 1 (CST)', () => { { type: 'AttrSingleQuoted', name: 'single', quote: '‘' }, { type: 'AttrSingleQuoted', name: 'single', quote: '’' }, { type: 'AttrDoubleQuoted', name: 'double', quote: '"' }, - { type: 'AttrDoubleQuoted', name: 'double', quote: '“' }, - { type: 'AttrDoubleQuoted', name: 'double', quote: '”' }, + { type: 'AttrDoubleQuoted', name: 'double', quote: '"' }, + { type: 'AttrDoubleQuoted', name: 'double', quote: '"' }, { type: 'AttrUnquoted', name: 'unquoted', quote: '' }, ].forEach((testConfig) => { [ diff --git a/packages/liquid-html-parser/src/stage-1-cst.ts b/packages/liquid-html-parser/src/stage-1-cst.ts index 905c52b3e..9cdbe9068 100644 --- a/packages/liquid-html-parser/src/stage-1-cst.ts +++ b/packages/liquid-html-parser/src/stage-1-cst.ts @@ -85,6 +85,7 @@ export enum ConcreteNodeTypes { ContentForNamedArgument = 'ContentForNamedArgument', LiquidDocParamNode = 'LiquidDocParamNode', + LiquidDocExampleNode = 'LiquidDocExampleNode', } export const LiquidLiteralValues = { @@ -115,6 +116,12 @@ export interface ConcreteLiquidDocParamNode paramType: ConcreteTextNode | null; } +export interface ConcreteLiquidDocExampleNode + extends ConcreteBasicNode { + name: 'example'; + exampleContent: ConcreteTextNode; +} + export interface ConcreteHtmlNodeBase extends ConcreteBasicNode { attrList?: ConcreteAttributeNode[]; } @@ -454,7 +461,7 @@ export type LiquidHtmlCST = LiquidHtmlConcreteNode[]; export type LiquidCST = LiquidConcreteNode[]; -export type LiquidDocConcreteNode = ConcreteLiquidDocParamNode; +export type LiquidDocConcreteNode = ConcreteLiquidDocParamNode | ConcreteLiquidDocExampleNode; interface Mapping { [k: string]: number | TemplateMapping | TopLevelFunctionMapping; @@ -1346,6 +1353,15 @@ function toLiquidDocAST(source: string, matchingSource: string, offset: number) paramTypeContent: textNode, paramName: textNode, paramDescription: textNode, + exampleNode: { + type: ConcreteNodeTypes.LiquidDocExampleNode, + name: 'example', + locStart, + locEnd, + source, + exampleContent: 2, + }, + exampleContent: textNode, fallbackNode: textNode, }; From e44887cd45b9d96048acc48900c94f1990ee3e3b Mon Sep 17 00:00:00 2001 From: Josh Faigan Date: Mon, 20 Jan 2025 16:37:28 -0500 Subject: [PATCH 3/4] add examplenode to stage 2 and test coverage --- .../src/stage-2-ast.spec.ts | 49 +++++++++++++++++++ .../liquid-html-parser/src/stage-2-ast.ts | 31 +++++++++++- packages/liquid-html-parser/src/types.ts | 1 + 3 files changed, 80 insertions(+), 1 deletion(-) diff --git a/packages/liquid-html-parser/src/stage-2-ast.spec.ts b/packages/liquid-html-parser/src/stage-2-ast.spec.ts index 0e382fe7f..11b4a7fc6 100644 --- a/packages/liquid-html-parser/src/stage-2-ast.spec.ts +++ b/packages/liquid-html-parser/src/stage-2-ast.spec.ts @@ -1258,6 +1258,55 @@ describe('Unit: Stage 2 (AST)', () => { expectPath(ast, 'children.0.body.nodes.2.value').to.eql( '@unsupported this node falls back to a text node', ); + + ast = toLiquidAST(` + {% doc -%} + @example simple inline example + {%- enddoc %} + `); + expectPath(ast, 'children.0.type').to.eql('LiquidRawTag'); + expectPath(ast, 'children.0.name').to.eql('doc'); + expectPath(ast, 'children.0.body.nodes.0.name').to.eql('example'); + expectPath(ast, 'children.0.body.nodes.0.type').to.eql('LiquidDocExampleNode'); + expectPath(ast, 'children.0.body.nodes.0.exampleContent.type').to.eql('TextNode'); + expectPath(ast, 'children.0.body.nodes.0.exampleContent.value').to.eql( + 'simple inline example', + ); + + ast = toLiquidAST(` + {% doc -%} + @example including inline code + This is a valid example + It can have multiple lines + {% enddoc %} + `); + expectPath(ast, 'children.0.body.nodes.0.type').to.eql('LiquidDocExampleNode'); + expectPath(ast, 'children.0.body.nodes.0.name').to.eql('example'); + expectPath(ast, 'children.0.body.nodes.0.exampleContent.value').to.eql( + 'including inline code\nThis is a valid example\nIt can have multiple lines', + ); + + ast = toLiquidAST(` + {% doc -%} + @example + This is a valid example + It can have multiple lines + @param {String} paramWithDescription - param with description + {% enddoc %} + `); + expectPath(ast, 'children.0.type').to.eql('LiquidRawTag'); + expectPath(ast, 'children.0.name').to.eql('doc'); + expectPath(ast, 'children.0.body.nodes.0.type').to.eql('LiquidDocExampleNode'); + expectPath(ast, 'children.0.body.nodes.0.name').to.eql('example'); + expectPath(ast, 'children.0.body.nodes.0.exampleContent.value').to.eql( + 'This is a valid example\nIt can have multiple lines', + ); + expectPath(ast, 'children.0.body.nodes.1.type').to.eql('LiquidDocParamNode'); + expectPath(ast, 'children.0.body.nodes.1.name').to.eql('param'); + expectPath(ast, 'children.0.body.nodes.1.paramName.value').to.eql('paramWithDescription'); + expectPath(ast, 'children.0.body.nodes.1.paramDescription.value').to.eql( + 'param with description', + ); }); it('should parse unclosed tables with assignments', () => { diff --git a/packages/liquid-html-parser/src/stage-2-ast.ts b/packages/liquid-html-parser/src/stage-2-ast.ts index 7dac9780d..89d1de98d 100644 --- a/packages/liquid-html-parser/src/stage-2-ast.ts +++ b/packages/liquid-html-parser/src/stage-2-ast.ts @@ -108,7 +108,8 @@ export type LiquidHtmlNode = | LiquidLogicalExpression | LiquidComparison | TextNode - | LiquidDocParamNode; + | LiquidDocParamNode + | LiquidDocExampleNode; /** The root node of all LiquidHTML ASTs. */ export interface DocumentNode extends ASTNode { @@ -765,6 +766,14 @@ export interface LiquidDocParamNode extends ASTNode { + name: 'example'; + /** The contents of the example (e.g. "{{ product }}"). Can be multiline. */ + exampleContent: TextNode; +} + export interface ASTNode { /** * The type of the node, as a string. @@ -1297,6 +1306,26 @@ function buildAst( break; } + case ConcreteNodeTypes.LiquidDocExampleNode: { + builder.push({ + type: NodeTypes.LiquidDocExampleNode, + name: node.name, + position: position(node), + source: node.source, + exampleContent: { + type: NodeTypes.TextNode, + value: node.exampleContent.value + .split('\n') + .map((line) => line.trim()) + .filter(Boolean) + .join('\n'), + position: position(node.exampleContent), + source: node.exampleContent.source, + }, + }); + break; + } + default: { assertNever(node); } diff --git a/packages/liquid-html-parser/src/types.ts b/packages/liquid-html-parser/src/types.ts index 49efa1bf3..c31d097e2 100644 --- a/packages/liquid-html-parser/src/types.ts +++ b/packages/liquid-html-parser/src/types.ts @@ -45,6 +45,7 @@ export enum NodeTypes { RenderMarkup = 'RenderMarkup', RenderVariableExpression = 'RenderVariableExpression', LiquidDocParamNode = 'LiquidDocParamNode', + LiquidDocExampleNode = 'LiquidDocExampleNode', } // These are officially supported with special node types From fcc693ef032267ef3ee576730cbe61d9ce33102b Mon Sep 17 00:00:00 2001 From: Josh Faigan Date: Mon, 20 Jan 2025 16:38:04 -0500 Subject: [PATCH 4/4] add prettier support and test coverage for example in liquid doc --- .../preprocess/augment-with-css-properties.ts | 2 ++ .../src/printer/print/liquid.ts | 25 +++++++++++++++++++ .../src/printer/printer-liquid-html.ts | 6 +++++ .../src/test/liquid-doc/fixed.liquid | 6 +++++ .../src/test/liquid-doc/index.liquid | 5 ++++ .../params/LiquidCompletionParams.ts | 3 +++ 6 files changed, 47 insertions(+) diff --git a/packages/prettier-plugin-liquid/src/printer/preprocess/augment-with-css-properties.ts b/packages/prettier-plugin-liquid/src/printer/preprocess/augment-with-css-properties.ts index 4ad565c6d..f3cb31691 100644 --- a/packages/prettier-plugin-liquid/src/printer/preprocess/augment-with-css-properties.ts +++ b/packages/prettier-plugin-liquid/src/printer/preprocess/augment-with-css-properties.ts @@ -129,6 +129,7 @@ function getCssDisplay(node: AugmentedNode, options: LiquidParserO case NodeTypes.LogicalExpression: case NodeTypes.Comparison: case NodeTypes.LiquidDocParamNode: + case NodeTypes.LiquidDocExampleNode: return 'should not be relevant'; default: @@ -235,6 +236,7 @@ function getNodeCssStyleWhiteSpace( case NodeTypes.LogicalExpression: case NodeTypes.Comparison: case NodeTypes.LiquidDocParamNode: + case NodeTypes.LiquidDocExampleNode: return 'should not be relevant'; default: diff --git a/packages/prettier-plugin-liquid/src/printer/print/liquid.ts b/packages/prettier-plugin-liquid/src/printer/print/liquid.ts index 47242ae0c..6efcca730 100644 --- a/packages/prettier-plugin-liquid/src/printer/print/liquid.ts +++ b/packages/prettier-plugin-liquid/src/printer/print/liquid.ts @@ -4,6 +4,7 @@ import { isBranchedTag, RawMarkup, LiquidDocParamNode, + LiquidDocExampleNode, } from '@shopify/liquid-html-parser'; import { Doc, doc } from 'prettier'; @@ -536,6 +537,30 @@ export function printLiquidDocParam( return parts; } +export function printLiquidDocExample( + path: AstPath, + options: LiquidParserOptions, + _print: LiquidPrinter, + _args: LiquidPrinterArgs, +): Doc { + const node = path.getValue(); + const parts: Doc[] = ['@example']; + + if (node.exampleContent?.value) { + const content = node.exampleContent.value.trim(); + if (content) { + parts.push(hardline); + const lines = content + .split('\n') + .map((line) => line.trim()) + .filter(Boolean); + parts.push(join(hardline, lines)); + } + } + + return parts; +} + function innerLeadingWhitespace(node: LiquidTag | LiquidBranch) { if (!node.firstChild) { if (node.isDanglingWhitespaceSensitive && node.hasDanglingWhitespace) { diff --git a/packages/prettier-plugin-liquid/src/printer/printer-liquid-html.ts b/packages/prettier-plugin-liquid/src/printer/printer-liquid-html.ts index 781db0453..ab1b97138 100644 --- a/packages/prettier-plugin-liquid/src/printer/printer-liquid-html.ts +++ b/packages/prettier-plugin-liquid/src/printer/printer-liquid-html.ts @@ -1,5 +1,6 @@ import { getConditionalComment, + LiquidDocExampleNode, LiquidDocParamNode, NodeTypes, Position, @@ -47,6 +48,7 @@ import { printLiquidTag, printLiquidVariableOutput, printLiquidDocParam, + printLiquidDocExample, } from './print/liquid'; import { printClosingTagSuffix, printOpeningTagPrefix } from './print/tag'; import { bodyLines, hasLineBreakInRange, isEmpty, isTextLikeNode, reindent } from './utils'; @@ -559,6 +561,10 @@ function printNode( return printLiquidDocParam(path as AstPath, options, print, args); } + case NodeTypes.LiquidDocExampleNode: { + return printLiquidDocExample(path as AstPath, options, print, args); + } + default: { return assertNever(node); } diff --git a/packages/prettier-plugin-liquid/src/test/liquid-doc/fixed.liquid b/packages/prettier-plugin-liquid/src/test/liquid-doc/fixed.liquid index 3ec30f8c3..58c7c41f8 100644 --- a/packages/prettier-plugin-liquid/src/test/liquid-doc/fixed.liquid +++ b/packages/prettier-plugin-liquid/src/test/liquid-doc/fixed.liquid @@ -27,3 +27,9 @@ It should normalize the param description {% doc %} @param paramName - param with description {% enddoc %} + +It should push example content to the next line +{% doc %} + @example + This is a valid example +{% enddoc %} diff --git a/packages/prettier-plugin-liquid/src/test/liquid-doc/index.liquid b/packages/prettier-plugin-liquid/src/test/liquid-doc/index.liquid index e57fd339d..f3d86be5e 100644 --- a/packages/prettier-plugin-liquid/src/test/liquid-doc/index.liquid +++ b/packages/prettier-plugin-liquid/src/test/liquid-doc/index.liquid @@ -27,3 +27,8 @@ It should normalize the param description {% doc %} @param paramName - param with description {% enddoc %} + +It should push example content to the next line +{% doc %} + @example This is a valid example +{% enddoc %} diff --git a/packages/theme-language-server-common/src/completions/params/LiquidCompletionParams.ts b/packages/theme-language-server-common/src/completions/params/LiquidCompletionParams.ts index 9ed9bcaf0..96e80e53f 100644 --- a/packages/theme-language-server-common/src/completions/params/LiquidCompletionParams.ts +++ b/packages/theme-language-server-common/src/completions/params/LiquidCompletionParams.ts @@ -406,6 +406,9 @@ function findCurrentNode( case NodeTypes.LiquidDocParamNode: { break; } + case NodeTypes.LiquidDocExampleNode: { + break; + } default: { return assertNever(current);