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

Commit

Permalink
Merge pull request #187 from Shopify/fix/strict-mode
Browse files Browse the repository at this point in the history
Add support for strict Liquid parsing
  • Loading branch information
charlespwd authored May 26, 2023
2 parents 1f8d07a + 0dfdc15 commit 11889c6
Show file tree
Hide file tree
Showing 15 changed files with 344 additions and 117 deletions.
33 changes: 31 additions & 2 deletions grammar/liquid-html.ohm
Original file line number Diff line number Diff line change
Expand Up @@ -45,11 +45,14 @@ Liquid <: Helpers {
| liquidTag
| liquidInlineComment

liquidTag =
liquidTagStrict =
| liquidTagAssign
| liquidTagBreak
| liquidTagContinue
| liquidTagCycle
| liquidTagDecrement
| liquidTagEcho
| liquidTagElse
| liquidTagElsif
| liquidTagInclude
| liquidTagIncrement
Expand All @@ -59,9 +62,12 @@ Liquid <: Helpers {
| liquidTagSection
| liquidTagSections
| liquidTagWhen

liquidTag =
| liquidTagStrict
| liquidTagBaseCase

liquidTagOpen =
liquidTagOpenStrict =
| liquidTagOpenCase
| liquidTagOpenCapture
| liquidTagOpenForm
Expand All @@ -70,7 +76,11 @@ Liquid <: Helpers {
| liquidTagOpenIf
| liquidTagOpenPaginate
| liquidTagOpenUnless

liquidTagOpen =
| liquidTagOpenStrict
| liquidTagOpenBaseCase

liquidTagClose = "{%" "-"? space* "end" blockName space* tagMarkup "-"? "%}"

// These two are the same but transformed differently
Expand Down Expand Up @@ -148,6 +158,10 @@ Liquid <: Helpers {
liquidTagOpenUnless = liquidTagOpenRule<"unless", liquidTagOpenConditionalMarkup>
liquidTagElsif = liquidTagRule<"elsif", liquidTagOpenConditionalMarkup>

liquidTagBreak = liquidTagRule<"break", empty>
liquidTagContinue = liquidTagRule<"continue", empty>
liquidTagElse = liquidTagRule<"else", empty>

liquidTagOpenConditionalMarkup = nonemptyListOf<condition, conditionSeparator> space*
conditionSeparator = &logicalOperator
condition = logicalOperator? space* (comparison | liquidExpression) space*
Expand Down Expand Up @@ -455,3 +469,18 @@ LiquidHTML <: Liquid {
| caseInsensitive<"wbr">
) ~identifierCharacter
}

StrictLiquid <: Liquid {
liquidTag := liquidTagStrict
liquidTagOpen := liquidTagOpenStrict
}

StrictLiquidStatement <: LiquidStatement {
liquidTag := liquidTagStrict
liquidTagOpen := liquidTagOpenStrict
}

StrictLiquidHTML <: LiquidHTML {
liquidTag := liquidTagStrict
liquidTagOpen := liquidTagOpenStrict
}
192 changes: 114 additions & 78 deletions src/parser/grammar.spec.ts
Original file line number Diff line number Diff line change
@@ -1,89 +1,125 @@
import { expect } from 'chai';
import { liquidHtmlGrammar, liquidStatementsGrammar } from '~/parser/grammar';
import { strictGrammars, tolerantGrammars } from '~/parser/grammar';

describe('Unit: liquidHtmlGrammar', () => {
it('should parse or not parse HTML+Liquid', () => {
expectMatchSucceeded('<h6 data-src="hello world">').to.be.true;
expectMatchSucceeded('<a src="https://product"></a>').to.be.true;
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;
expectMatchSucceeded(`{%-if A-%}`).to.be.true;
expectMatchSucceeded(`{%- else-%}`).to.be.true;
expectMatchSucceeded(`{%- liquid-%}`).to.be.true;
expectMatchSucceeded(`{%- schema-%}`).to.be.true;
expectMatchSucceeded(`{%- form-%}`).to.be.true;
expectMatchSucceeded(`{{ true-}}`).to.be.true;
expectMatchSucceeded(`
<html>
<head>
{{ 'foo' | script_tag }}
</head>
<body>
{% if true %}
<div>
hello world
</div>
{% else %}
nope
{% endif %}
</body>
</html>
`).to.be.true;
expectMatchSucceeded(`
<input
class="[[ cssClasses.checkbox ]] form-checkbox sm:text-[8px]"
type="checkbox"
const grammars = [
{ mode: 'strict', grammar: strictGrammars },
{ mode: 'tolerant', grammar: tolerantGrammars },
];

[[# isRefined ]]
checked
[[/ isRefined ]]
/>
`).to.be.true;
expectMatchSucceeded(`
<svg>
<svg a=1><svg b=2>
<path d="M12"></path>
</svg></svg>
</svg>
`).to.be.true;
expectMatchSucceeded(`<div data-popup-{{ section.id }}="size-{{ section.id }}">`).to.be.true;
expectMatchSucceeded('<img {% if aboveFold %} loading="lazy"{% endif %} />').to.be.true;
expectMatchSucceeded('<svg><use></svg>').to.be.true;
expectMatchSucceeded('<6h>').to.be.false;
grammars.forEach(({ mode, grammar }) => {
describe(`Case: ${mode}`, () => {
it('should parse or not parse HTML+Liquid', () => {
expectMatchSucceeded('<h6 data-src="hello world">').to.be.true;
expectMatchSucceeded('<a src="https://product"></a>').to.be.true;
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;
expectMatchSucceeded(`{%-if A-%}`).to.be.true;
expectMatchSucceeded(`{%- else-%}`).to.be.true;
expectMatchSucceeded(`{%- break-%}`).to.be.true;
expectMatchSucceeded(`{%- continue -%}`).to.be.true;
expectMatchSucceeded(`{%- liquid-%}`).to.be.true;
expectMatchSucceeded(`{%- schema-%}{% endschema %}`).to.be.true;
expectMatchSucceeded(`{%- form 'form-type'-%}`).to.be.true;
expectMatchSucceeded(`{%- # a comment -%}`).to.be.true;
expectMatchSucceeded(`{%- javascript -%}{% endjavascript %}`).to.be.true;
expectMatchSucceeded(`{%- include 'layout' -%}`).to.be.true;
expectMatchSucceeded(`{%- layout 'full-width' -%}`).to.be.true;
expectMatchSucceeded(`{%- layout none -%}`).to.be.true;
expectMatchSucceeded(`{% render 'filename' for array as item %}`).to.be.true;
expectMatchSucceeded(`{% section 'name' %}`).to.be.true;
expectMatchSucceeded(`{% sections 'name' %}`).to.be.true;
expectMatchSucceeded(`{% style %}{% endstyle %}`).to.be.true;
expectMatchSucceeded(`{% stylesheet %}{% endstylesheet %}`).to.be.true;
expectMatchSucceeded(`{% assign variable_name = value %}`).to.be.true;
expectMatchSucceeded(`
{% capture variable %}
value
{% endcapture %}
`).to.be.true;
expectMatchSucceeded(`
{% for variable in array limit: number %}
expression
{% endfor %}
`).to.be.true;

function expectMatchSucceeded(text: string) {
const match = liquidHtmlGrammar.match(text, 'Node');
return expect(match.succeeded());
}
});
expectMatchSucceeded(`{% decrement variable_name %}`).to.be.true;
expectMatchSucceeded(`{% increment variable_name %}`).to.be.true;
expectMatchSucceeded(`{{ true-}}`).to.be.true;
expectMatchSucceeded(`
<html>
<head>
{{ 'foo' | script_tag }}
</head>
<body>
{% if true %}
<div>
hello world
</div>
{% else %}
nope
{% endif %}
</body>
</html>
`).to.be.true;
expectMatchSucceeded(`
<input
class="[[ cssClasses.checkbox ]] form-checkbox sm:text-[8px]"
type="checkbox"
[[# isRefined ]]
checked
[[/ isRefined ]]
/>
`).to.be.true;
expectMatchSucceeded(`
<svg>
<svg a=1><svg b=2>
<path d="M12"></path>
</svg></svg>
</svg>
`).to.be.true;
expectMatchSucceeded(`<div data-popup-{{ section.id }}="size-{{ section.id }}">`).to.be
.true;
expectMatchSucceeded('<img {% if aboveFold %} loading="lazy"{% endif %} />').to.be.true;
expectMatchSucceeded('<svg><use></svg>').to.be.true;
expectMatchSucceeded('<6h>').to.be.false;

function expectMatchSucceeded(text: string) {
const match = grammar.LiquidHTML.match(text, 'Node');
return expect(match.succeeded(), text);
}
});

it('should parse or not parse {% liquid %} lines', () => {
expectMatchSucceeded(`
layout none
it('should parse or not parse {% liquid %} lines', () => {
expectMatchSucceeded(`
layout none
paginate search.results by 28
for item in search.results
if item.object_type != 'product'
continue
endif
paginate search.results by 28
for item in search.results
if item.object_type != 'product'
continue
endif
render 'product-item', product: item
endfor
endpaginate
`).to.be.true;
render 'product-item', product: item
endfor
endpaginate
`).to.be.true;

function expectMatchSucceeded(text: string) {
const match = liquidStatementsGrammar.match(text.trimStart(), 'Node');
return expect(match.succeeded());
}
function expectMatchSucceeded(text: string) {
const match = grammar.LiquidStatement.match(text.trimStart(), 'Node');
return expect(match.succeeded(), text);
}
});
});
});
});
18 changes: 13 additions & 5 deletions src/parser/grammar.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,18 +4,26 @@ export const liquidHtmlGrammars = ohm.grammars(
require('../../grammar/liquid-html.ohm.js'),
);

export const liquidGrammar = liquidHtmlGrammars['Liquid'];
export const liquidHtmlGrammar = liquidHtmlGrammars['LiquidHTML'];
export const liquidStatementsGrammar = liquidHtmlGrammars['LiquidStatement'];
export const strictGrammars = {
Liquid: liquidHtmlGrammars['StrictLiquid'],
LiquidHTML: liquidHtmlGrammars['StrictLiquidHTML'],
LiquidStatement: liquidHtmlGrammars['StrictLiquidStatement'],
};

export const tolerantGrammars = {
Liquid: liquidHtmlGrammars['Liquid'],
LiquidHTML: liquidHtmlGrammars['LiquidHTML'],
LiquidStatement: liquidHtmlGrammars['LiquidStatement'],
};

// see ../../grammar/liquid-html.ohm for full list
export const BLOCKS = (
liquidHtmlGrammar.rules as any
strictGrammars.LiquidHTML.rules as any
).blockName.body.factors[0].terms.map((x: any) => x.obj) as string[];

// see ../../grammar/liquid-html.ohm for full list
export const VOID_ELEMENTS = (
liquidHtmlGrammar.rules as any
strictGrammars.LiquidHTML.rules as any
).voidElementName.body.factors[0].terms.map(
(x: any) => x.args[0].obj,
) as string[];
Expand Down
Loading

0 comments on commit 11889c6

Please sign in to comment.