Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/fix-dynamic-html-tag-name.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@shopify/liquid-html-parser': minor
---

Support Liquid tag blocks in HTML tag names (e.g. `<{% if true %}div{% endif %}>`)
6 changes: 6 additions & 0 deletions packages/liquid-html-parser/grammar/liquid-html.ohm
Original file line number Diff line number Diff line change
Expand Up @@ -505,10 +505,16 @@ LiquidHTML <: Liquid {
// requirement
leadingTagNamePart =
| liquidDrop
| liquidTagClose
| liquidTagOpen
| liquidTag
| leadingTagNameTextNode

trailingTagNamePart =
| liquidDrop
| liquidTagClose
| liquidTagOpen
| liquidTag
| trailingTagNameTextNode

leadingTagNameTextNode = letter (alnum | "-" | ":")*
Expand Down
14 changes: 14 additions & 0 deletions packages/liquid-html-parser/src/stage-1-cst.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
'<script>\nconst a = {{ product | json }}\n</script><style>\n#id {}\n</style>',
Expand Down
6 changes: 3 additions & 3 deletions packages/liquid-html-parser/src/stage-1-cst.ts
Original file line number Diff line number Diff line change
Expand Up @@ -169,13 +169,13 @@ export interface ConcreteHtmlVoidElement extends ConcreteHtmlNodeBase<ConcreteNo
name: string;
}
export interface ConcreteHtmlSelfClosingElement extends ConcreteHtmlNodeBase<ConcreteNodeTypes.HtmlSelfClosingElement> {
name: (ConcreteTextNode | ConcreteLiquidVariableOutput)[];
name: (ConcreteTextNode | ConcreteLiquidNode)[];
}
export interface ConcreteHtmlTagOpen extends ConcreteHtmlNodeBase<ConcreteNodeTypes.HtmlTagOpen> {
name: (ConcreteTextNode | ConcreteLiquidVariableOutput)[];
name: (ConcreteTextNode | ConcreteLiquidNode)[];
}
export interface ConcreteHtmlTagClose extends ConcreteHtmlNodeBase<ConcreteNodeTypes.HtmlTagClose> {
name: (ConcreteTextNode | ConcreteLiquidVariableOutput)[];
name: (ConcreteTextNode | ConcreteLiquidNode)[];
}

export interface ConcreteAttributeNodeBase<T> extends ConcreteBasicNode<T> {
Expand Down
23 changes: 23 additions & 0 deletions packages/liquid-html-parser/src/stage-2-ast.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
27 changes: 21 additions & 6 deletions packages/liquid-html-parser/src/stage-2-ast.ts
Original file line number Diff line number Diff line change
Expand Up @@ -621,8 +621,9 @@ export interface HtmlElement extends HtmlNodeBase<NodeTypes.HtmlElement> {
/**
* 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[];
Expand All @@ -646,8 +647,9 @@ export interface HtmlDanglingMarkerClose extends ASTNode<NodeTypes.HtmlDanglingM
/**
* 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 range covered by the dangling end tag */
blockStartPosition: Position;
Expand All @@ -657,8 +659,9 @@ export interface HtmlSelfClosingElement extends HtmlNodeBase<NodeTypes.HtmlSelfC
/**
* The name of the tag can be compound
* @example `<{{ header_type }}--header />`
* @example `<{% if true %}div{% endif %} />`
*/
name: (TextNode | LiquidVariableOutput)[];
name: (TextNode | LiquidNode)[];
}

/**
Expand Down Expand Up @@ -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('');
Expand Down
Loading