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 %}>{% 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 %}>{% 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 %}>{{ 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. `{% if true %}div{% endif %}>`
*/
- 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('');