From 983f25e5ca9880636451d6c878890f77d6a3fb37 Mon Sep 17 00:00:00 2001 From: "Charles-P. Clermont" Date: Thu, 10 Sep 2026 15:54:29 -0400 Subject: [PATCH] Parse empty filter argument lists --- .changeset/empty-filter-arguments.md | 5 +++ .../document/liquid-variable-output.test.ts | 44 +++++++++++++++++++ .../src/markup/parser.test.ts | 13 ++++++ .../liquid-html-parser/src/markup/parser.ts | 17 ++++--- .../liquid-html-parser/src/tags/echo.test.ts | 17 +++++++ .../liquid-html-parser/src/tolerant.test.ts | 2 + 6 files changed, 93 insertions(+), 5 deletions(-) create mode 100644 .changeset/empty-filter-arguments.md diff --git a/.changeset/empty-filter-arguments.md b/.changeset/empty-filter-arguments.md new file mode 100644 index 000000000..bc1b745a4 --- /dev/null +++ b/.changeset/empty-filter-arguments.md @@ -0,0 +1,5 @@ +--- +'@shopify/liquid-html-parser': patch +--- + +Parse empty filter argument lists such as `{{ 'key' | t: }}`. diff --git a/packages/liquid-html-parser/src/document/liquid-variable-output.test.ts b/packages/liquid-html-parser/src/document/liquid-variable-output.test.ts index 688065511..0e9f524ca 100644 --- a/packages/liquid-html-parser/src/document/liquid-variable-output.test.ts +++ b/packages/liquid-html-parser/src/document/liquid-variable-output.test.ts @@ -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); diff --git a/packages/liquid-html-parser/src/markup/parser.test.ts b/packages/liquid-html-parser/src/markup/parser.test.ts index 3e601edda..c8c9b6e9a 100644 --- a/packages/liquid-html-parser/src/markup/parser.test.ts +++ b/packages/liquid-html-parser/src/markup/parser.test.ts @@ -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 diff --git a/packages/liquid-html-parser/src/markup/parser.ts b/packages/liquid-html-parser/src/markup/parser.ts index de78f751b..b5fa0b6e5 100644 --- a/packages/liquid-html-parser/src/markup/parser.ts +++ b/packages/liquid-html-parser/src/markup/parser.ts @@ -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) { diff --git a/packages/liquid-html-parser/src/tags/echo.test.ts b/packages/liquid-html-parser/src/tags/echo.test.ts index 901a5b7cf..3fa66b172 100644 --- a/packages/liquid-html-parser/src/tags/echo.test.ts +++ b/packages/liquid-html-parser/src/tags/echo.test.ts @@ -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({ diff --git a/packages/liquid-html-parser/src/tolerant.test.ts b/packages/liquid-html-parser/src/tolerant.test.ts index b4da977b3..9a0f42750 100644 --- a/packages/liquid-html-parser/src/tolerant.test.ts +++ b/packages/liquid-html-parser/src/tolerant.test.ts @@ -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 %}',