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('');