Skip to content
This repository has been archived by the owner on Jun 20, 2024. It is now read-only.

Commit

Permalink
Add support for compound Liquid + Html element names
Browse files Browse the repository at this point in the history
e.g. `<h{{ header_number }}>` or `<{{ type }}-header>`

And, at the same time, fixes the error in compound attribute names where
the previous node was a string and not a node.

Fixes #128
Fixes #134
  • Loading branch information
charlespwd committed Dec 6, 2022
1 parent 52e9eb3 commit e49c842
Show file tree
Hide file tree
Showing 15 changed files with 303 additions and 156 deletions.
26 changes: 18 additions & 8 deletions grammar/liquid-html.ohm
Original file line number Diff line number Diff line change
Expand Up @@ -379,20 +379,29 @@ LiquidHTML <: Liquid {
#("<" voidElementName &(space | "/" | ">")) AttrList "/"? ">"

HtmlSelfClosingElement =
#("<" tagNameOrLiquidDrop) AttrList "/>"
#("<" tagName) AttrList "/>"

HtmlTagOpen =
#("<" tagNameOrLiquidDrop) AttrList ">"
#("<" tagName) AttrList ">"

HtmlTagClose =
#("</" tagNameOrLiquidDrop) ">"
#("</" tagName) ">"

tagNameOrLiquidDrop =
| tagName
tagName = leadingTagNamePart trailingTagNamePart*

// The difference here is that the first text part must start
// with a letter, but trailing text parts don't have that
// requirement
leadingTagNamePart =
| liquidDrop
| leadingTagNameTextNode

trailingTagNamePart =
| liquidDrop
| trailingTagNameTextNode

tagName =
letter (alnum | "-" | ":")*
leadingTagNameTextNode = letter (alnum | "-" | ":")*
trailingTagNameTextNode = (alnum | "-" | ":")+

AttrList = Attr*

Expand All @@ -405,8 +414,9 @@ LiquidHTML <: Liquid {
AttrSingleQuoted = attrName "=" "'" #(attrSingleQuotedValue "'")
AttrDoubleQuoted = attrName "=" "\"" #(attrDoubleQuotedValue "\"")

// https://html.spec.whatwg.org/#attributes-2
attrName = (liquidDrop | attrNameTextNode)+

// https://html.spec.whatwg.org/#attributes-2
attrNameTextNode = anyExceptPlus<(space | quotes | "=" | ">" | "/>" | "{{" | "{%" | controls | noncharacters)>
attrUnquotedValue = (liquidDrop | attrUnquotedTextNode)*
attrSingleQuotedValue = (liquidNode | attrSingleQuotedTextNode)*
Expand Down
5 changes: 5 additions & 0 deletions src/parser/grammar.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,11 @@ describe('Unit: liquidHtmlGrammar', () => {
expectMatchSucceeded('<a src="https://google.com"></b>').to.be.true;
expectMatchSucceeded(`<img src="hello" loading='lazy' enabled=true disabled>`).to.be.true;
expectMatchSucceeded(`<img src="hello" loading='lazy' enabled=true disabled />`).to.be.true;
expectMatchSucceeded(`<{{header_type}}-header>`).to.be.true;
expectMatchSucceeded(`<header--{{header_type}}>`).to.be.true;
expectMatchSucceeded(`<-nope>`).to.be.false;
expectMatchSucceeded(`<:nope>`).to.be.false;
expectMatchSucceeded(`<1nope>`).to.be.false;
expectMatchSucceeded(`{{ product.feature }}`).to.be.true;
expectMatchSucceeded(`{{product.feature}}`).to.be.true;
expectMatchSucceeded(`{%- if A -%}`).to.be.true;
Expand Down
59 changes: 44 additions & 15 deletions src/parser/stage-1-cst.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,25 +40,53 @@ describe('Unit: toLiquidHtmlCST(text)', () => {
it('should basically parse open and close tags', () => {
['<div></div>', '<div ></div >'].forEach((text) => {
cst = toLiquidHtmlCST(text);
expectPath(cst, '0.type').to.equal('HtmlTagOpen');
expectPath(cst, '0.name').to.equal('div');
expectPath(cst, '1.type').to.equal('HtmlTagClose');
expectPath(cst, '1.name').to.equal('div');
expectPath(cst, '0.type').to.eql('HtmlTagOpen');
expectPath(cst, '0.name.0.value').to.eql('div');
expectPath(cst, '1.type').to.eql('HtmlTagClose');
expectPath(cst, '1.name.0.value').to.eql('div');
});
});

it('should parse compound tag names', () => {
cst = toLiquidHtmlCST('<{{header_type}}--header></{{header_type}}--header>');
expectPath(cst, '0.type').to.eql('HtmlTagOpen');
expectPath(cst, '0.name.0.type').to.eql('LiquidDrop');
expectPath(cst, '0.name.0.markup.type').to.eql('LiquidVariable');
expectPath(cst, '0.name.0.markup.rawSource').to.eql('header_type');
expectPath(cst, '0.name.1.value').to.eql('--header');
expectPath(cst, '1.type').to.eql('HtmlTagClose');
expectPath(cst, '1.name.0.type').to.eql('LiquidDrop');
expectPath(cst, '1.name.0.markup.type').to.eql('LiquidVariable');
expectPath(cst, '0.name.0.markup.rawSource').to.eql('header_type');
expectPath(cst, '1.name.1.value').to.eql('--header');

cst = toLiquidHtmlCST('<header--{{header_type}} ></header--{{header_type}} >');
expectPath(cst, '0.type').to.eql('HtmlTagOpen');
expectPath(cst, '0.name.0.type').to.eql('TextNode');
expectPath(cst, '0.name.0.value').to.eql('header--');
expectPath(cst, '0.name.1.type').to.eql('LiquidDrop');
expectPath(cst, '0.name.1.markup.type').to.eql('LiquidVariable');
expectPath(cst, '0.name.1.markup.rawSource').to.eql('header_type');
expectPath(cst, '1.type').to.eql('HtmlTagClose');
expectPath(cst, '1.name.0.type').to.eql('TextNode');
expectPath(cst, '1.name.0.value').to.eql('header--');
expectPath(cst, '1.name.1.type').to.eql('LiquidDrop');
expectPath(cst, '1.name.1.markup.type').to.eql('LiquidVariable');
expectPath(cst, '0.name.1.markup.rawSource').to.eql('header_type');
});

it('should parse liquid drop tag names', () => {
cst = toLiquidHtmlCST('<{{ node_type }}></{{ node_type }}>');
expectPath(cst, '0.type').to.equal('HtmlTagOpen');
expectPath(cst, '0.name.type').to.equal('LiquidDrop');
expectPath(cst, '0.name.markup.type').to.equal('LiquidVariable');
expectPath(cst, '0.name.markup.expression.type').to.equal('VariableLookup');
expectPath(cst, '0.name.markup.expression.name').to.equal('node_type');
expectPath(cst, '0.name.0.type').to.equal('LiquidDrop');
expectPath(cst, '0.name.0.markup.type').to.equal('LiquidVariable');
expectPath(cst, '0.name.0.markup.expression.type').to.equal('VariableLookup');
expectPath(cst, '0.name.0.markup.expression.name').to.equal('node_type');
expectPath(cst, '1.type').to.equal('HtmlTagClose');
expectPath(cst, '1.name.type').to.equal('LiquidDrop');
expectPath(cst, '1.name.markup.type').to.equal('LiquidVariable');
expectPath(cst, '1.name.markup.expression.type').to.equal('VariableLookup');
expectPath(cst, '1.name.markup.expression.name').to.equal('node_type');
expectPath(cst, '1.name.0.type').to.equal('LiquidDrop');
expectPath(cst, '1.name.0.markup.type').to.equal('LiquidVariable');
expectPath(cst, '1.name.0.markup.expression.type').to.equal('VariableLookup');
expectPath(cst, '1.name.0.markup.expression.name').to.equal('node_type');
});

it('should parse script and style tags as a dump', () => {
Expand Down Expand Up @@ -88,15 +116,16 @@ describe('Unit: toLiquidHtmlCST(text)', () => {
cst = toLiquidHtmlCST(`<${voidElementName} disabled>`);
expectPath(cst, '0.type').to.equal('HtmlVoidElement');
expectPath(cst, '0.name').to.equal(voidElementName);
expectPath(cst, '0.attrList.0.name').to.eql(['disabled']);
expectPath(cst, '0.attrList.0.name.0.type').to.eql('TextNode');
expectPath(cst, '0.attrList.0.name.0.value').to.eql('disabled');
});
});

it('should parse empty attributes', () => {
['<div empty>', '<div empty >', '<div\nempty\n>'].forEach((text) => {
cst = toLiquidHtmlCST(text);
expectPath(cst, '0.attrList.0.type').to.equal('AttrEmpty');
expectPath(cst, '0.attrList.0.name').to.eql(['empty']);
expectPath(cst, '0.attrList.0.name.0.value').to.eql('empty');
expectPath(cst, '0.name.attrList.0.value').to.be.undefined;
});
});
Expand All @@ -114,7 +143,7 @@ describe('Unit: toLiquidHtmlCST(text)', () => {
].forEach((text) => {
cst = toLiquidHtmlCST(text);
expectPath(cst, '0.attrList.0.type').to.equal(testConfig.type);
expectPath(cst, '0.attrList.0.name').to.eql([testConfig.name]);
expectPath(cst, '0.attrList.0.name.0.value').to.eql(testConfig.name);
expectPath(cst, '0.attrList.0.value.0.type').to.eql('TextNode');
expectPath(cst, '0.attrList.0.value.0.value').to.eql(testConfig.name);
});
Expand Down
34 changes: 24 additions & 10 deletions src/parser/stage-1-cst.ts
Original file line number Diff line number Diff line change
Expand Up @@ -97,17 +97,16 @@ export interface ConcreteBasicNode<T> {
}

export interface ConcreteHtmlNodeBase<T> extends ConcreteBasicNode<T> {
name: string | ConcreteLiquidDrop;
attrList?: ConcreteAttributeNode[];
}

export interface ConcreteHtmlDoctype
extends ConcreteHtmlNodeBase<ConcreteNodeTypes.HtmlDoctype> {
extends ConcreteBasicNode<ConcreteNodeTypes.HtmlDoctype> {
legacyDoctypeString: string | null;
}

export interface ConcreteHtmlComment
extends ConcreteHtmlNodeBase<ConcreteNodeTypes.HtmlComment> {
extends ConcreteBasicNode<ConcreteNodeTypes.HtmlComment> {
body: string;
}

Expand All @@ -125,14 +124,20 @@ export interface ConcreteHtmlVoidElement
name: string;
}
export interface ConcreteHtmlSelfClosingElement
extends ConcreteHtmlNodeBase<ConcreteNodeTypes.HtmlSelfClosingElement> {}
extends ConcreteHtmlNodeBase<ConcreteNodeTypes.HtmlSelfClosingElement> {
name: (ConcreteTextNode | ConcreteLiquidDrop)[];
}
export interface ConcreteHtmlTagOpen
extends ConcreteHtmlNodeBase<ConcreteNodeTypes.HtmlTagOpen> {}
extends ConcreteHtmlNodeBase<ConcreteNodeTypes.HtmlTagOpen> {
name: (ConcreteTextNode | ConcreteLiquidDrop)[];
}
export interface ConcreteHtmlTagClose
extends ConcreteHtmlNodeBase<ConcreteNodeTypes.HtmlTagClose> {}
extends ConcreteHtmlNodeBase<ConcreteNodeTypes.HtmlTagClose> {
name: (ConcreteTextNode | ConcreteLiquidDrop)[];
}

export interface ConcreteAttributeNodeBase<T> extends ConcreteBasicNode<T> {
name: (ConcreteLiquidDrop | string)[];
name: (ConcreteLiquidDrop | ConcreteTextNode)[];
value: (ConcreteLiquidNode | ConcreteTextNode)[];
}

Expand All @@ -151,7 +156,7 @@ export interface ConcreteAttrUnquoted
extends ConcreteAttributeNodeBase<ConcreteNodeTypes.AttrUnquoted> {}
export interface ConcreteAttrEmpty
extends ConcreteBasicNode<ConcreteNodeTypes.AttrEmpty> {
name: (ConcreteLiquidDrop | string)[];
name: (ConcreteLiquidDrop | ConcreteTextNode)[];
}

export type ConcreteLiquidNode =
Expand Down Expand Up @@ -1093,7 +1098,16 @@ export function toLiquidHtmlCST(source: string): LiquidHtmlCST {
source,
},

tagNameOrLiquidDrop: 0,
leadingTagNamePart: 0,
leadingTagNameTextNode: textNode,
trailingTagNamePart: 0,
trailingTagNameTextNode: textNode,
tagName(leadingPart: Node, trailingParts: Node) {
const mappings = (this as any).args.mapping;
return [leadingPart.toAST(mappings)].concat(
trailingParts.toAST(mappings),
);
},

AttrUnquoted: {
type: ConcreteNodeTypes.AttrUnquoted,
Expand Down Expand Up @@ -1131,7 +1145,7 @@ export function toLiquidHtmlCST(source: string): LiquidHtmlCST {
},

attrName: 0,
attrNameTextNode: 0,
attrNameTextNode: textNode,
attrDoubleQuotedValue: 0,
attrSingleQuotedValue: 0,
attrUnquotedValue: 0,
Expand Down
40 changes: 30 additions & 10 deletions src/parser/stage-2-ast.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -531,14 +531,14 @@ describe('Unit: toLiquidHtmlAST', () => {
expectPath(ast, 'children.0').to.exist;
expectPath(ast, 'children.0.type').to.eql('HtmlVoidElement');
expectPath(ast, 'children.0.name').to.eql('img');
expectPath(ast, 'children.0.attributes.0.name').to.eql(['src']);
expectPath(ast, 'children.0.attributes.0.name.0.value').to.eql('src');
expectPath(ast, 'children.0.attributes.0.value.0.type').to.eql('TextNode');
expectPath(ast, 'children.0.attributes.0.value.0.value').to.eql('https://1234');
expectPath(ast, 'children.0.attributes.1.name').to.eql(['loading']);
expectPath(ast, 'children.0.attributes.1.name.0.value').to.eql('loading');
expectPath(ast, 'children.0.attributes.1.value.0.type').to.eql('TextNode');
expectPath(ast, 'children.0.attributes.1.value.0.value').to.eql('lazy');
expectPath(ast, 'children.0.attributes.2.name').to.eql(['disabled']);
expectPath(ast, 'children.0.attributes.3.name').to.eql(['checked']);
expectPath(ast, 'children.0.attributes.2.name.0.value').to.eql('disabled');
expectPath(ast, 'children.0.attributes.3.name.0.value').to.eql('checked');
expectPath(ast, 'children.0.attributes.3.value.0').to.be.undefined;

expectPosition(ast, 'children.0');
Expand Down Expand Up @@ -579,19 +579,39 @@ describe('Unit: toLiquidHtmlAST', () => {
ast = toLiquidHtmlAST(testCase);
expectPath(ast, 'children.0').to.exist;
expectPath(ast, 'children.0.type').to.eql('HtmlElement');
expectPath(ast, 'children.0.name.type').to.eql('LiquidDrop');
expectPath(ast, 'children.0.name.markup.type').to.eql('LiquidVariable');
expectPath(ast, 'children.0.name.markup.rawSource').to.eql('node_type');
expectPath(ast, 'children.0.attributes.0.name').to.eql(['src']);
expectPath(ast, 'children.0.name.0.type').to.eql('LiquidDrop');
expectPath(ast, 'children.0.name.0.markup.type').to.eql('LiquidVariable');
expectPath(ast, 'children.0.name.0.markup.rawSource').to.eql('node_type');
expectPath(ast, 'children.0.attributes.0.name.0.value').to.eql('src');
expectPath(ast, 'children.0.attributes.0.value.0.type').to.eql('TextNode');
expectPath(ast, 'children.0.attributes.0.value.0.value').to.eql('https://1234');
expectPath(ast, 'children.0.attributes.1.name').to.eql(['loading']);
expectPath(ast, 'children.0.attributes.1.name.0.value').to.eql('loading');
expectPath(ast, 'children.0.attributes.1.value.0.type').to.eql('TextNode');
expectPath(ast, 'children.0.attributes.1.value.0.value').to.eql('lazy');
expectPath(ast, 'children.0.attributes.2.name').to.eql(['disabled']);
expectPath(ast, 'children.0.attributes.2.name.0.value').to.eql('disabled');
});
});

it('should parse HTML tags with compound Liquid Drop names', () => {
ast = toLiquidHtmlAST(`<{{ node_type }}--header ></{{node_type}}--header>`);
expectPath(ast, 'children.0').to.exist;
expectPath(ast, 'children.0.type').to.eql('HtmlElement');
expectPath(ast, 'children.0.name.0.type').to.eql('LiquidDrop');
expectPath(ast, 'children.0.name.0.markup.type').to.eql('LiquidVariable');
expectPath(ast, 'children.0.name.0.markup.rawSource').to.eql('node_type');
expectPath(ast, 'children.0.name.1.value').to.eql('--header');
});

it('should parse HTML self-closing elements with compound Liquid Drop names', () => {
ast = toLiquidHtmlAST(`<{{ node_type }}--header />`);
expectPath(ast, 'children.0').to.exist;
expectPath(ast, 'children.0.type').to.eql('HtmlSelfClosingElement');
expectPath(ast, 'children.0.name.0.type').to.eql('LiquidDrop');
expectPath(ast, 'children.0.name.0.markup.type').to.eql('LiquidVariable');
expectPath(ast, 'children.0.name.0.markup.rawSource').to.eql('node_type');
expectPath(ast, 'children.0.name.1.value').to.eql('--header');
});

it('should throw when trying to close the wrong node', () => {
const testCases = [
'<a><div></a>',
Expand Down
Loading

0 comments on commit e49c842

Please sign in to comment.