From c18e8fc2eab4137a53c1f406be4db85ecc36d3a5 Mon Sep 17 00:00:00 2001 From: SinhSinh An Date: Tue, 21 Apr 2026 14:29:54 -0500 Subject: [PATCH] fix(parser): support Liquid tag blocks in HTML tag names Previously the parser threw LiquidHTMLSyntaxError for tag names that contained {% ... %} blocks: <{% if true %}div{% endif %}> Only {{ ... }} (LiquidVariableOutput) was allowed in tag name positions. This pattern existed in older Dawn themes and is still usable in the theme editor, so the parser should accept it. Grammar change: add liquidTagClose, liquidTagOpen, and liquidTag as alternatives in leadingTagNamePart and trailingTagNamePart. The CST and AST builders already iterate over the name array and convert each part individually, so the existing pipeline handles the new node types once the grammar allows them. Name matching for open/close tag pairing in getName() now uses the raw source slice for any non-text non-drop part, so {% if true %}div{% endif %} compares byte-for-byte across open and close tags. Closes #1155 --- .changeset/fix-dynamic-html-tag-name.md | 5 ++++ .../grammar/liquid-html.ohm | 6 +++++ .../src/stage-1-cst.spec.ts | 14 ++++++++++ .../liquid-html-parser/src/stage-1-cst.ts | 6 ++--- .../src/stage-2-ast.spec.ts | 23 ++++++++++++++++ .../liquid-html-parser/src/stage-2-ast.ts | 27 ++++++++++++++----- 6 files changed, 72 insertions(+), 9 deletions(-) create mode 100644 .changeset/fix-dynamic-html-tag-name.md diff --git a/.changeset/fix-dynamic-html-tag-name.md b/.changeset/fix-dynamic-html-tag-name.md new file mode 100644 index 000000000..9a59ff7b0 --- /dev/null +++ b/.changeset/fix-dynamic-html-tag-name.md @@ -0,0 +1,5 @@ +--- +'@shopify/liquid-html-parser': minor +--- + +Support Liquid tag blocks in HTML tag names (e.g. `<{% if true %}div{% endif %}>`) diff --git a/packages/liquid-html-parser/grammar/liquid-html.ohm b/packages/liquid-html-parser/grammar/liquid-html.ohm index c60759c29..1a450ca27 100644 --- a/packages/liquid-html-parser/grammar/liquid-html.ohm +++ b/packages/liquid-html-parser/grammar/liquid-html.ohm @@ -505,10 +505,16 @@ LiquidHTML <: Liquid { // requirement leadingTagNamePart = | liquidDrop + | liquidTagClose + | liquidTagOpen + | liquidTag | leadingTagNameTextNode trailingTagNamePart = | liquidDrop + | liquidTagClose + | liquidTagOpen + | liquidTag | trailingTagNameTextNode leadingTagNameTextNode = letter (alnum | "-" | ":")* 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 c5b20f1e8..5c3733eeb 100644 --- a/packages/liquid-html-parser/src/stage-1-cst.spec.ts +++ b/packages/liquid-html-parser/src/stage-1-cst.spec.ts @@ -1618,6 +1618,20 @@ describe('Unit: Stage 1 (CST)', () => { expectPath(cst, '1.name.0.markup.expression.name').to.equal('node_type'); }); + it('should parse liquid tag block names', () => { + cst = toLiquidHtmlCST('<{% if true %}div{% endif %}>'); + expectPath(cst, '0.type').to.equal('HtmlTagOpen'); + expectPath(cst, '0.name.0.type').to.equal('LiquidTagOpen'); + expectPath(cst, '0.name.0.name').to.equal('if'); + expectPath(cst, '0.name.1.type').to.equal('TextNode'); + expectPath(cst, '0.name.1.value').to.equal('div'); + expectPath(cst, '0.name.2.type').to.equal('LiquidTagClose'); + expectPath(cst, '0.name.2.name').to.equal('if'); + expectPath(cst, '1.type').to.equal('HtmlTagClose'); + expectPath(cst, '1.name.0.type').to.equal('LiquidTagOpen'); + expectPath(cst, '1.name.0.name').to.equal('if'); + }); + it('should parse script and style tags as a dump', () => { cst = toLiquidHtmlCST( '', diff --git a/packages/liquid-html-parser/src/stage-1-cst.ts b/packages/liquid-html-parser/src/stage-1-cst.ts index 35f3e9a9b..4b84dd27d 100644 --- a/packages/liquid-html-parser/src/stage-1-cst.ts +++ b/packages/liquid-html-parser/src/stage-1-cst.ts @@ -169,13 +169,13 @@ export interface ConcreteHtmlVoidElement extends ConcreteHtmlNodeBase { - name: (ConcreteTextNode | ConcreteLiquidVariableOutput)[]; + name: (ConcreteTextNode | ConcreteLiquidNode)[]; } export interface ConcreteHtmlTagOpen extends ConcreteHtmlNodeBase { - name: (ConcreteTextNode | ConcreteLiquidVariableOutput)[]; + name: (ConcreteTextNode | ConcreteLiquidNode)[]; } export interface ConcreteHtmlTagClose extends ConcreteHtmlNodeBase { - name: (ConcreteTextNode | ConcreteLiquidVariableOutput)[]; + name: (ConcreteTextNode | ConcreteLiquidNode)[]; } export interface ConcreteAttributeNodeBase extends ConcreteBasicNode { 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 91b2188a9..92d88276e 100644 --- a/packages/liquid-html-parser/src/stage-2-ast.spec.ts +++ b/packages/liquid-html-parser/src/stage-2-ast.spec.ts @@ -958,6 +958,29 @@ describe('Unit: Stage 2 (AST)', () => { expectPath(ast, 'children.0.name.1.value').to.eql('--header'); }); + it('should parse HTML tags with dynamic liquid tag names', () => { + ast = toLiquidHtmlAST(`<{% if true %}div{% endif %}>`); + expectPath(ast, 'children.0').to.exist; + expectPath(ast, 'children.0.type').to.eql('HtmlElement'); + expectPath(ast, 'children.0.name.0.type').to.eql('LiquidTag'); + expectPath(ast, 'children.0.name.0.name').to.eql('if'); + // The `div` text is inside the LiquidTag's branch children + expectPath(ast, 'children.0.name.0.children.0.type').to.eql('LiquidBranch'); + expectPath(ast, 'children.0.name.0.children.0.children.0.type').to.eql('TextNode'); + expectPath(ast, 'children.0.name.0.children.0.children.0.value').to.eql('div'); + }); + + it('should parse HTML tags with mixed liquid drop and liquid tag names', () => { + ast = toLiquidHtmlAST(`<{{ prefix }}-{% if cond %}suffix{% endif %}>`); + expectPath(ast, 'children.0').to.exist; + expectPath(ast, 'children.0.type').to.eql('HtmlElement'); + expectPath(ast, 'children.0.name.0.type').to.eql('LiquidVariableOutput'); + expectPath(ast, 'children.0.name.1.type').to.eql('TextNode'); + expectPath(ast, 'children.0.name.1.value').to.eql('-'); + expectPath(ast, 'children.0.name.2.type').to.eql('LiquidTag'); + expectPath(ast, 'children.0.name.2.name').to.eql('if'); + }); + it('should allow unclosed nodes inside conditional and case branches', () => { let testCases = [ // one unclosed diff --git a/packages/liquid-html-parser/src/stage-2-ast.ts b/packages/liquid-html-parser/src/stage-2-ast.ts index 0212bceca..9e2a93c04 100644 --- a/packages/liquid-html-parser/src/stage-2-ast.ts +++ b/packages/liquid-html-parser/src/stage-2-ast.ts @@ -621,8 +621,9 @@ export interface HtmlElement extends HtmlNodeBase { /** * The name of the tag can be compound * e.g. `<{{ header_type }}--header />` + * e.g. `<{% if true %}div{% endif %}>` */ - name: (TextNode | LiquidVariableOutput)[]; + name: (TextNode | LiquidNode)[]; /** The child nodes delimited by the start and end tags */ children: LiquidHtmlNode[]; @@ -646,8 +647,9 @@ export interface HtmlDanglingMarkerClose extends ASTNode` + * e.g. `` */ - name: (TextNode | LiquidVariableOutput)[]; + name: (TextNode | LiquidNode)[]; /** The range covered by the dangling end tag */ blockStartPosition: Position; @@ -657,8 +659,9 @@ export interface HtmlSelfClosingElement extends HtmlNodeBase` + * @example `<{% if true %}div{% endif %} />` */ - name: (TextNode | LiquidVariableOutput)[]; + name: (TextNode | LiquidNode)[]; } /** @@ -1134,10 +1137,22 @@ export function getName( .map((part) => { if (part.type === NodeTypes.TextNode || part.type == ConcreteNodeTypes.TextNode) { return part.value; - } else if (typeof part.markup === 'string') { - return `{{${part.markup.trim()}}}`; - } else { + } else if ( + part.type === NodeTypes.LiquidVariableOutput || + part.type === ConcreteNodeTypes.LiquidVariableOutput + ) { + if (typeof part.markup === 'string') { + return `{{${part.markup.trim()}}}`; + } return `{{${part.markup.rawSource}}}`; + } else { + // LiquidTag, LiquidTagOpen, LiquidTagClose, etc. inside tag names + // (e.g. `<{% if true %}div{% endif %}>`). Use the raw source span + // so open/close matching compares byte-for-byte. CST concrete + // nodes expose locStart/locEnd; AST nodes expose position. + const start = 'position' in part ? part.position.start : part.locStart; + const end = 'position' in part ? part.position.end : part.locEnd; + return part.source.slice(start, end); } }) .join('');