Skip to content

fix(parser): support Liquid tag blocks in HTML tag names#1190

Open
SinhSinhAn wants to merge 1 commit intoShopify:mainfrom
SinhSinhAn:fix/dynamic-html-tag-name
Open

fix(parser): support Liquid tag blocks in HTML tag names#1190
SinhSinhAn wants to merge 1 commit intoShopify:mainfrom
SinhSinhAn:fix/dynamic-html-tag-name

Conversation

@SinhSinhAn
Copy link
Copy Markdown
Contributor

What is this PR?

Fixes a LiquidHTMLSyntaxError false positive where the parser rejected HTML tags whose names contain {% ... %} blocks:

<{% if true %}div{% endif %}></{% if true %}div{% endif %}>

What was the issue?

#1155 reported that @shopify/liquid-html-parser throws on this syntax, but the theme editor accepts it and older versions of the Dawn theme used this pattern (example).

The parser grammar only allowed {{ ... }} (liquidDrop / LiquidVariableOutput) in tag name positions. {% ... %} blocks were not listed as alternatives in leadingTagNamePart / trailingTagNamePart, so parsing failed at the { character with the familiar expected a letter, "{{", "wbr" (case-insensitive)... error.

How this PR fixes it

Grammar (liquid-html.ohm)

Added liquidTagClose, liquidTagOpen, and liquidTag as alternatives in both leadingTagNamePart and trailingTagNamePart. Order matters: the close/open variants are listed before the generic liquidTag to prevent the broader base-case rule from greedily absorbing specific block openers.

Types (stage-1-cst.ts, stage-2-ast.ts)

Widened the name field on ConcreteHtmlTagOpen, ConcreteHtmlTagClose, ConcreteHtmlSelfClosingElement to accept ConcreteLiquidNode alongside ConcreteTextNode. Mirrored the change on the AST side for HtmlElement, HtmlDanglingMarkerClose, and HtmlSelfClosingElement (now (TextNode | LiquidNode)[]).

Name matching (getName() in stage-2-ast.ts)

The existing implementation only handled TextNode and LiquidVariableOutput. Added a branch for any other liquid node (liquid tags, raw tags, etc.) that returns the raw source slice between the part's start and end positions. This means the comparison between <{% if %}div{% endif %}> and its closing tag happens byte-for-byte, and mismatches still produce the existing "Attempting to close HtmlElement ... before ... was closed" error.

AST builder behavior

No change needed. The AST builder's cstToAst already recursively converts each name-array part individually. When the grammar emits a LiquidTagOpen + TextNode + LiquidTagClose sequence, the builder assembles them into a single LiquidTag node with the text wrapped in a LiquidBranch child, which is the cleanest possible representation.

Tests

Added 3 test cases across the CST and AST spec files:

  • stage-1-cst.spec.ts: verifies the CST correctly emits LiquidTagOpen + TextNode + LiquidTagClose for both open and close HTML tags
  • stage-2-ast.spec.ts: verifies the AST correctly assembles into a single LiquidTag with branch children
  • stage-2-ast.spec.ts: verifies mixed drop + liquid-tag tag names (<{{ prefix }}-{% if cond %}suffix{% endif %}>)

Regression coverage

All existing tests still pass:

  • 108 CST tests
  • 54 AST tests
  • 3 grammar tests
  • 856 theme-check-common tests
  • 141 prettier-plugin-liquid tests

1,162 total tests pass, zero regressions.

Manual spot check:

toLiquidHtmlAST('<{% if true %}div{% endif %}></{% if true %}div{% endif %}>'); // parses
toLiquidHtmlAST('<{% if true %}div{% endif %}></{% if true %}span{% endif %}>');
// throws: Attempting to close HtmlElement '{% if true %}span{% endif %}' before HtmlElement '{% if true %}div{% endif %}' was closed

Closes #1155

Previously the parser threw LiquidHTMLSyntaxError for tag names that
contained {% ... %} blocks:

  <{% if true %}div{% endif %}></{% 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 Shopify#1155
@SinhSinhAn SinhSinhAn requested a review from a team as a code owner April 21, 2026 19:30
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

LiquidHTMLSyntaxError false positive with dynamic html tag

1 participant