From 5404ed6a0769cfb5465f80a260fc1423e203c7f2 Mon Sep 17 00:00:00 2001 From: Agnik47 <140933190+Agnik47@users.noreply.github.com> Date: Fri, 2 Oct 2026 09:44:43 +0530 Subject: [PATCH] fix(pipeline): respect quotes when parsing template expressions A template expression that starts and ends with a string literal, such as ${{ 'https://x.test/' + item.id + '/' }}, matched the quoted-literal fast path and rendered its own source text instead of being evaluated. A | inside a quoted string, as in join(' | ') or 'a|b', was treated as a filter separator, so the filter was silently skipped or the literal became undefined. Limit the fast path to literals whose body has no delimiter or backslash, and split filters with a quote-aware scanner that falls back to the plain split when a quote is unterminated. --- src/pipeline/template.test.ts | 26 ++++++++++++++++++++++++ src/pipeline/template.ts | 38 ++++++++++++++++++++++++++++++++--- 2 files changed, 61 insertions(+), 3 deletions(-) diff --git a/src/pipeline/template.test.ts b/src/pipeline/template.test.ts index fe7172229..6ad1f640c 100644 --- a/src/pipeline/template.test.ts +++ b/src/pipeline/template.test.ts @@ -100,6 +100,29 @@ describe('evalExpr', () => { it('applies join filter', () => { expect(evalExpr('item.tags | join(,)', { item: { tags: ['a', 'b', 'c'] } })).toBe('a,b,c'); }); + it('keeps a | inside a quoted filter argument', () => { + expect(evalExpr("item.tags | join(' | ')", { item: { tags: ['a', 'b', 'c'] } })).toBe('a | b | c'); + expect(evalExpr("item.name | default('a|b') | upper", { item: {} })).toBe('A|B'); + }); + it('keeps a | inside a quoted string literal', () => { + expect(evalExpr("'a|b'", {})).toBe('a|b'); + expect(evalExpr("item.name || 'a|b'", { item: {} })).toBe('a|b'); + }); + it('still splits filters after an unterminated quote', () => { + expect(evalExpr("item.name | default(it's) | upper", { item: {} })).toBe("IT'S"); + }); + it('returns a plain quoted string literal', () => { + expect(evalExpr("'plain'", {})).toBe('plain'); + expect(evalExpr('"it\'s"', {})).toBe("it's"); + }); + it('evaluates concatenation that starts and ends with a string literal', () => { + expect(evalExpr("'https://x.test/' + item.id + '/'", { item: { id: 7 } })).toBe('https://x.test/7/'); + expect(evalExpr('"a" + "b"', {})).toBe('ab'); + expect(evalExpr("'x' === 'x' ? 'yes' : 'no'", {})).toBe('yes'); + }); + it('evaluates escapes in a string literal', () => { + expect(evalExpr("'it\\'s'", {})).toBe("it's"); + }); it('applies upper filter', () => { expect(evalExpr('item.name | upper', { item: { name: 'hello' } })).toBe('HELLO'); }); @@ -147,6 +170,9 @@ describe('render', () => { it('renders inline helper expressions', () => { expect(render('https://example.com/search?q=${{ encodeURIComponent(args.keyword) }}', { args: { keyword: 'hello world' } })).toBe('https://example.com/search?q=hello%20world'); }); + it('renders a concatenation wrapped in string literals', () => { + expect(render("${{ 'https://x.test/' + item.id + '/' }}", { item: { id: 7 } })).toBe('https://x.test/7/'); + }); it('renders full multiline expressions', () => { expect(render("${{\n args.topic ? `https://medium.com/tag/${args.topic}` : 'https://medium.com/tag/technology'\n}}", { args: { topic: 'ai' } })).toBe('https://medium.com/tag/ai'); }); diff --git a/src/pipeline/template.ts b/src/pipeline/template.ts index 7e1ebfc5d..181cf2977 100644 --- a/src/pipeline/template.ts +++ b/src/pipeline/template.ts @@ -41,7 +41,7 @@ export function evalExpr(expr: string, ctx: RenderContext): unknown { // ── Pipe filters: expr | filter1(arg) | filter2 ── // Split on single | (not ||) so "item.a || item.b | upper" works correctly. - const pipeSegments = expr.split(/(? s.trim()); + const pipeSegments = splitPipes(expr); if (pipeSegments.length > 1) { let result = evalExpr(pipeSegments[0], ctx); for (let i = 1; i < pipeSegments.length; i++) { @@ -50,8 +50,11 @@ export function evalExpr(expr: string, ctx: RenderContext): unknown { return result; } - // Fast path: quoted string literal — skip VM overhead - const strLit = expr.match(/^(['"])(.*)\1$/); + // Fast path: quoted string literal — skip VM overhead. + // The body must not contain the delimiter or a backslash: "'a' + item.id + 'b'" + // also starts and ends with a quote but is a concatenation, and escapes need + // real JS parsing. Both fall through to the VM below. + const strLit = expr.match(/^(['"])((?:(?!\1)[^\\])*)\1$/); if (strLit) return strLit[2]; // Fast path: numeric literal @@ -66,6 +69,35 @@ export function evalExpr(expr: string, ctx: RenderContext): unknown { return evalJsExpr(expr, { args, item, data, root, index }); } +const PIPE_SEPARATOR = /(? s.trim()); + segments.push(expr.slice(start)); + return segments.map(s => s.trim()); +} + /** * Apply a named filter to a value. * Supported filters: