Skip to content
Merged
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/empty-filter-arguments.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@shopify/liquid-html-parser': patch
---

Parse empty filter argument lists such as `{{ 'key' | t: }}`.
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,50 @@ describe('Unit: liquid-variable-output', () => {
expectPath(ast, 'children.0.markup.filters.0.name').to.eql('upcase');
});

it.each(["{{ 'key' | t:}}", "{{ 'key' | t: }}", "{{- 'key' | t:\n-}}"])(
'should parse an empty filter argument list in strict mode: %s',
(source) => {
for (const parse of [toLiquidHtmlAST, toLiquidAST]) {
const ast = parse(source, { mode: 'strict', allowUnclosedDocumentNode: false });
expectPath(ast, 'children.0.markup.type').to.eql('LiquidVariable');
expectPath(ast, 'children.0.markup.filters').to.have.lengthOf(1);
expectPath(ast, 'children.0.markup.filters.0.name').to.eql('t');
expectPath(ast, 'children.0.markup.filters.0.args').to.eql([]);
expectPath(ast, 'children.0.markup.rawSource').to.eql("'key' | t:");
expectPosition(ast, 'children.0.markup.filters.0').to.eql(' | t:');
}
},
);

it('should parse another filter after an empty argument list', () => {
const ast = toLiquidHtmlAST("{{ 'key' | t: | escape }}", {
mode: 'strict',
allowUnclosedDocumentNode: false,
});
expectPath(ast, 'children.0.markup.filters').to.have.lengthOf(2);
expectPath(ast, 'children.0.markup.filters.0.name').to.eql('t');
expectPath(ast, 'children.0.markup.filters.0.args').to.eql([]);
expectPosition(ast, 'children.0.markup.filters.0').to.eql(' | t:');
expectPath(ast, 'children.0.markup.filters.1.name').to.eql('escape');
expectPosition(ast, 'children.0.markup.filters.1').to.eql(' | escape');
});

it.each([
"'key' | t: title:",
"'key' | t: title: | escape",
"'key' | t: ,",
"'key' | t: title: product.title,",
"'key' | t: @",
])('should keep malformed filter arguments as string markup: %s', (markup) => {
for (const parse of [toLiquidHtmlAST, toLiquidAST]) {
const ast = parse(`{{ ${markup} }}`, {
mode: 'strict',
allowUnclosedDocumentNode: false,
});
expectPath(ast, 'children.0.markup').to.eql(markup);
}
});

it('should parse multiple filters', () => {
const ast = toLiquidHtmlAST('{{ product | upcase | strip }}');
expectPath(ast, 'children.0.markup.filters').to.have.lengthOf(2);
Expand Down
13 changes: 13 additions & 0 deletions packages/liquid-html-parser/src/markup/parser.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -902,6 +902,19 @@ describe('Unit: MarkupParser structured primitives', () => {
});

describe('filters()', () => {
it.each(['x | t:', 'x | upcase: '])(
'includes the empty argument colon in the filter position: %s',
(source) => {
const p = parser(source);
p.expression();
const result = p.filters(1);
expect(result).toHaveLength(1);
expect(result[0].args).toEqual([]);
expect(result[0].position).toEqual({ start: 1, end: source.trimEnd().length });
expect(p.isAtEnd()).toBe(true);
},
);

it('parses filter with no args', () => {
const p = parser('x | upcase');
p.expression(); // consume the expression first
Expand Down
17 changes: 12 additions & 5 deletions packages/liquid-html-parser/src/markup/parser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1215,15 +1215,22 @@ export class MarkupParser {
return result;
}

// filter := id (":" arguments)?
// filter := id (":" arguments?)?
filter(previousEnd: number): LiquidFilter {
const nameToken = this.consume(MarkupTokenType.Id);
let args: LiquidArgument[] = [];
let end = nameToken.end;
if (this.consumeOptional(MarkupTokenType.Colon)) {
// Lax: a colon with no following argument (`upcase:`) is tolerated; only
// parse arguments when something argument-like actually follows.
if (!(this.lax || this.tolerant) || this.atArgumentStart()) {
const colon = this.consumeOptional(MarkupTokenType.Colon);
if (colon) {
end = colon.end;
const atFilterBoundary = this.isAtEnd() || this.look(MarkupTokenType.Pipe);
const hasOnlyWhitespaceBeforeBoundary = !/\S/.test(
this.source.slice(colon.end, this.peek().start),
);
const emptyArguments = atFilterBoundary && hasOnlyWhitespaceBeforeBoundary;
// Strict mode accepts an empty list at a clean filter boundary. Recovery
// modes also skip malformed fragments that cannot start an argument.
if (!emptyArguments && (!(this.lax || this.tolerant) || this.atArgumentStart())) {
args = this.arguments();
}
if (args.length > 0) {
Expand Down
17 changes: 17 additions & 0 deletions packages/liquid-html-parser/src/tags/echo.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,23 @@ describe('echoTag', () => {
});
});

it("parses 'key' | t: with no arguments", () => {
const markup = "'key' | t: ";
const result = echoTag.parse('echo', parser(markup), stubParser);
expect(result).toMatchObject({
type: NodeTypes.LiquidVariable,
rawSource: "'key' | t:",
filters: [
{
type: NodeTypes.LiquidFilter,
name: 't',
args: [],
position: { start: OFFSET + 5, end: OFFSET + 10 },
},
],
});
});

it("parses 'hello' | append: ' world'", () => {
const result = echoTag.parse('echo', parser("'hello' | append: ' world'"), stubParser);
expect(result).toMatchObject({
Expand Down
2 changes: 2 additions & 0 deletions packages/liquid-html-parser/src/tolerant.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,8 @@ describe('tolerant mode is inert on clean input', () => {
'',
'plain text only',
'{{ product.title }}',
"{{ 'key' | t: }}",
"{{ 'key' | t: | escape }}",
'{% assign x = 1 %}',
'{% if x %}a{% else %}b{% endif %}',
'{% for item in collection %}{{ item.title }}{% endfor %}',
Expand Down
Loading