Skip to content
Open
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
26 changes: 26 additions & 0 deletions src/pipeline/template.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
});
Expand Down Expand Up @@ -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');
});
Expand Down
38 changes: 35 additions & 3 deletions src/pipeline/template.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(/(?<!\|)\|(?!\|)/).map(s => s.trim());
const pipeSegments = splitPipes(expr);
if (pipeSegments.length > 1) {
let result = evalExpr(pipeSegments[0], ctx);
for (let i = 1; i < pipeSegments.length; i++) {
Expand All @@ -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
Expand All @@ -66,6 +69,35 @@ export function evalExpr(expr: string, ctx: RenderContext): unknown {
return evalJsExpr(expr, { args, item, data, root, index });
}

const PIPE_SEPARATOR = /(?<!\|)\|(?!\|)/;

/**
* Split an expression into its filter segments on single `|` (not `||`),
* skipping any `|` inside a quoted string so `join(' | ')` and `'a|b'` stay
* in one segment. An unterminated quote (e.g. `default(it's)`) means the
* quotes are not string delimiters, so fall back to the plain split.
*/
function splitPipes(expr: string): string[] {
const segments: string[] = [];
let quote = '';
let start = 0;
for (let i = 0; i < expr.length; i++) {
const ch = expr[i];
if (quote) {
if (ch === '\\') i++;
else if (ch === quote) quote = '';
} else if (ch === "'" || ch === '"' || ch === '`') {
quote = ch;
} else if (ch === '|' && expr[i - 1] !== '|' && expr[i + 1] !== '|') {
segments.push(expr.slice(start, i));
start = i + 1;
}
}
if (quote) return expr.split(PIPE_SEPARATOR).map(s => s.trim());
segments.push(expr.slice(start));
return segments.map(s => s.trim());
}

/**
* Apply a named filter to a value.
* Supported filters:
Expand Down
Loading