diff --git a/packages/liquid-html-parser/grammar/liquid-html.ohm b/packages/liquid-html-parser/grammar/liquid-html.ohm index a431a97be..6b1c4045a 100644 --- a/packages/liquid-html-parser/grammar/liquid-html.ohm +++ b/packages/liquid-html-parser/grammar/liquid-html.ohm @@ -37,6 +37,7 @@ Liquid <: Helpers { endOfIdentifier = endOfTagName | endOfVarName liquidNode = + | liquidDoc | liquidBlockComment | liquidRawTag | liquidDrop @@ -211,6 +212,15 @@ Liquid <: Helpers { commentBlockStart = "{%" "-"? space* ("comment" endOfIdentifier) space* tagMarkup "-"? "%}" commentBlockEnd = "{%" "-"? space* ("endcomment" endOfIdentifier) space* tagMarkup "-"? "%}" + liquidDoc = + liquidDocStart + liquidDocBody + liquidDocEnd + + liquidDocStart = "{%" "-"? space* ("doc" endOfIdentifier) space* tagMarkup "-"? "%}" + liquidDocEnd = "{%" "-"? space* ("enddoc" endOfIdentifier) space* tagMarkup "-"? "%}" + liquidDocBody = anyExceptStar<(liquidDocStart | liquidDocEnd)> + // In order for the grammar to "fallback" to the base case, this // rule must pass if and only if we support what we parse. This // implies that—since we don't support filters yet—we have a @@ -368,6 +378,10 @@ LiquidStatement <: Liquid { delimTag := liquidStatementEnd } +LiquidDoc <: Helpers { + Node := (TextNode)* +} + LiquidHTML <: Liquid { Node := yamlFrontmatter? (HtmlNode | liquidNode | TextNode)* openControl += "<" diff --git a/packages/liquid-html-parser/src/grammar.ts b/packages/liquid-html-parser/src/grammar.ts index ab6735060..18167b8e9 100644 --- a/packages/liquid-html-parser/src/grammar.ts +++ b/packages/liquid-html-parser/src/grammar.ts @@ -3,6 +3,7 @@ import ohm from 'ohm-js'; export const liquidHtmlGrammars = ohm.grammars(require('../grammar/liquid-html.ohm.js')); export const TextNodeGrammar = liquidHtmlGrammars['Helpers']; +export const LiquidDocGrammar = liquidHtmlGrammars['LiquidDoc']; export interface LiquidGrammars { Liquid: ohm.Grammar; @@ -52,4 +53,5 @@ export const TAGS_WITHOUT_MARKUP = [ 'continue', 'comment', 'raw', + 'doc', ]; 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 147916b23..71ae05a67 100644 --- a/packages/liquid-html-parser/src/stage-1-cst.spec.ts +++ b/packages/liquid-html-parser/src/stage-1-cst.spec.ts @@ -978,6 +978,34 @@ describe('Unit: Stage 1 (CST)', () => { } }); + it('should parse doc tags', () => { + for (const { toCST, expectPath } of testCases) { + const testStr = `{% doc -%} Renders loading-spinner. {%- enddoc %}`; + + cst = toCST(testStr); + expectPath(cst, '0.type').to.equal('LiquidRawTag'); + expectPath(cst, '0.name').to.equal('doc'); + expectPath(cst, '0.body').to.include('Renders loading-spinner'); + expectPath(cst, '0.whitespaceStart').to.equal(''); + expectPath(cst, '0.whitespaceEnd').to.equal('-'); + expectPath(cst, '0.delimiterWhitespaceStart').to.equal('-'); + expectPath(cst, '0.delimiterWhitespaceEnd').to.equal(''); + expectPath(cst, '0.blockStartLocStart').to.equal(0); + expectPath(cst, '0.blockStartLocEnd').to.equal(0 + '{% doc -%}'.length); + expectPath(cst, '0.blockEndLocStart').to.equal(testStr.length - '{%- enddoc %}'.length); + expectPath(cst, '0.blockEndLocEnd').to.equal(testStr.length); + expectPath(cst, '0.children').to.deep.equal([ + { + locEnd: 25, + locStart: 1, + source: '{% doc -%} Renders loading-spinner. {%- enddoc %}', + type: 'TextNode', + value: 'Renders loading-spinner.', + }, + ]); + } + }); + it('should parse tag open / close', () => { BLOCKS.forEach((block: string) => { for (const { toCST, expectPath } of testCases) { diff --git a/packages/liquid-html-parser/src/stage-1-cst.ts b/packages/liquid-html-parser/src/stage-1-cst.ts index 36c2ab853..2bb9bfa8f 100644 --- a/packages/liquid-html-parser/src/stage-1-cst.ts +++ b/packages/liquid-html-parser/src/stage-1-cst.ts @@ -34,6 +34,7 @@ import { Parser } from 'prettier'; import ohm, { Node } from 'ohm-js'; import { toAST } from 'ohm-js/extras'; import { + LiquidDocGrammar, LiquidGrammars, TextNodeGrammar, placeholderGrammars, @@ -625,6 +626,30 @@ function toCST( blockEndLocStart: (tokens: Node[]) => tokens[2].source.startIdx, blockEndLocEnd: (tokens: Node[]) => tokens[2].source.endIdx, }, + liquidDoc: { + type: ConcreteNodeTypes.LiquidRawTag, + name: 'doc', + body: (tokens: Node[]) => tokens[1].sourceString, + children: (tokens: Node[]) => { + const contentNode = tokens[1]; + return toLiquidDocAST( + source, + contentNode.sourceString, + offset + contentNode.source.startIdx, + ); + }, + whitespaceStart: (tokens: Node[]) => tokens[0].children[1].sourceString, + whitespaceEnd: (tokens: Node[]) => tokens[0].children[7].sourceString, + delimiterWhitespaceStart: (tokens: Node[]) => tokens[2].children[1].sourceString, + delimiterWhitespaceEnd: (tokens: Node[]) => tokens[2].children[7].sourceString, + locStart, + locEnd, + source, + blockStartLocStart: (tokens: Node[]) => tokens[0].source.startIdx, + blockStartLocEnd: (tokens: Node[]) => tokens[0].source.endIdx, + blockEndLocStart: (tokens: Node[]) => tokens[2].source.startIdx, + blockEndLocEnd: (tokens: Node[]) => tokens[2].source.endIdx, + }, liquidInlineComment: { type: ConcreteNodeTypes.LiquidTag, name: 3, @@ -1073,6 +1098,25 @@ function toCST( }, }; + const LiquidDocMappings: Mapping = { + Node: 0, + TextNode: textNode, + }; + + function toLiquidDocAST(source: string, matchingSource: string, offset: number) { + const res = LiquidDocGrammar.match(matchingSource, 'Node'); + if (res.failed()) { + throw new LiquidHTMLCSTParsingError(res); + } + + const LiquidDocMappings: Mapping = { + Node: 0, + TextNode: textNode, + }; + + return toAST(res, LiquidDocMappings); + } + const LiquidHTMLMappings: Mapping = { Node(frontmatter: Node, nodes: Node) { const self = this as any; 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 de6de2631..84848ccb2 100644 --- a/packages/liquid-html-parser/src/stage-2-ast.spec.ts +++ b/packages/liquid-html-parser/src/stage-2-ast.spec.ts @@ -1220,6 +1220,33 @@ describe('Unit: Stage 2 (AST)', () => { expectPath(ast, 'children.0.markup.1.children.0.children.1.markup.name').to.eql('var3'); }); + it(`should parse doc tags`, () => { + ast = toLiquidAST(`{% doc %}{% enddoc %}`); + expectPath(ast, 'children.0.type').to.eql('LiquidRawTag'); + expectPath(ast, 'children.0.name').to.eql('doc'); + expectPath(ast, 'children.0.markup').toEqual(''); + expectPath(ast, 'children.0.body.value').to.eql(''); + expectPath(ast, 'children.0.body.type').toEqual('RawMarkup'); + expectPath(ast, 'children.0.body.nodes').toEqual([]); + + ast = toLiquidAST(`{% doc -%} single line doc {%- enddoc %}`); + expectPath(ast, 'children.0.type').to.eql('LiquidRawTag'); + expectPath(ast, 'children.0.name').to.eql('doc'); + expectPath(ast, 'children.0.body.value').to.eql(' single line doc '); + expectPath(ast, 'children.0.body.nodes.0.type').toEqual('TextNode'); + + ast = toLiquidAST(`{% doc -%} + multi line doc + multi line doc + {%- enddoc %}`); + expectPath(ast, 'children.0.type').to.eql('LiquidRawTag'); + expectPath(ast, 'children.0.name').to.eql('doc'); + expectPath(ast, 'children.0.body.nodes.0.value').to.eql( + `multi line doc\n multi line doc`, + ); + expectPath(ast, 'children.0.body.nodes.0.type').toEqual('TextNode'); + }); + it('should parse unclosed tables with assignments', () => { ast = toLiquidAST(` {%- liquid diff --git a/packages/theme-check-common/src/checks/liquid-html-syntax-error/index.spec.ts b/packages/theme-check-common/src/checks/liquid-html-syntax-error/index.spec.ts index d73d8ab01..bf1abbf74 100644 --- a/packages/theme-check-common/src/checks/liquid-html-syntax-error/index.spec.ts +++ b/packages/theme-check-common/src/checks/liquid-html-syntax-error/index.spec.ts @@ -86,7 +86,7 @@ describe('Module: LiquidHTMLSyntaxError', () => { const offenses = await runLiquidCheck(LiquidHTMLSyntaxError, sourceCode); expect(offenses).to.have.length(1); expect(offenses[0].message).to.equal( - `SyntaxError: expected "#", a letter, "when", "sections", "section", "render", "liquid", "layout", "increment", "include", "elsif", "else", "echo", "decrement", "content_for", "cycle", "continue", "break", "assign", "tablerow", "unless", "if", "ifchanged", "for", "case", "capture", "paginate", "form", "end", "style", "stylesheet", "schema", "javascript", "raw", or "comment"`, + `SyntaxError: expected "#", a letter, "when", "sections", "section", "render", "liquid", "layout", "increment", "include", "elsif", "else", "echo", "decrement", "content_for", "cycle", "continue", "break", "assign", "tablerow", "unless", "if", "ifchanged", "for", "case", "capture", "paginate", "form", "end", "style", "stylesheet", "schema", "javascript", "raw", "comment", or "doc"`, ); });