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
22 changes: 22 additions & 0 deletions src/pipeline/template.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,28 @@ describe('evalExpr', () => {
it('evaluates || with truthy left', () => {
expect(evalExpr("item.name || 'N/A'", { item: { name: 'Alice' } })).toBe('Alice');
});
it('evaluates chained || fallback (issue #303)', () => {
// When first two are falsy, should evaluate through to the string literal
expect(evalExpr("item.a || item.b || 'default'", { item: {} })).toBe('default');
});
it('evaluates chained || with middle value truthy', () => {
expect(evalExpr("item.a || item.b || 'default'", { item: { b: 'middle' } })).toBe('middle');
});
it('evaluates chained || with first value truthy', () => {
expect(evalExpr("item.a || item.b || 'default'", { item: { a: 'first', b: 'middle' } })).toBe('first');
});
it('evaluates || with 0 as falsy left (JS semantics)', () => {
expect(evalExpr("item.count || 'N/A'", { item: { count: 0 } })).toBe('N/A');
});
it('evaluates || with empty string as falsy left', () => {
expect(evalExpr("item.name || 'unknown'", { item: { name: '' } })).toBe('unknown');
});
it('evaluates || with numeric fallback returning number type', () => {
expect(evalExpr('item.a || 42', { item: {} })).toBe(42);
});
it('evaluates 4-way chained ||', () => {
expect(evalExpr("item.a || item.b || item.c || 'last'", { item: { c: 'third' } })).toBe('third');
});
it('resolves simple path', () => {
expect(evalExpr('item.title', { item: { title: 'Test' } })).toBe('Test');
});
Expand Down
12 changes: 7 additions & 5 deletions src/pipeline/template.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ export function evalExpr(expr: string, ctx: RenderContext): unknown {
const val = resolvePath(varName, { args, item, data, index });
if (val !== null && val !== undefined) {
const numVal = Number(val); const num = Number(numStr);
if (!isNaN(numVal)) {
if (!Number.isNaN(numVal)) {
switch (op) {
case '+': return numVal + num; case '-': return numVal - num;
case '*': return numVal * num; case '/': return num !== 0 ? numVal / num : 0;
Expand All @@ -65,12 +65,13 @@ export function evalExpr(expr: string, ctx: RenderContext): unknown {
}

// JS-like fallback expression: item.tweetCount || 'N/A'
// Recursively evaluate the right side so chained || works:
// item.a || item.b || 'default' → eval(item.a) || eval(item.b || 'default')
const orMatch = expr.match(/^(.+?)\s*\|\|\s*(.+)$/);
if (orMatch) {
const left = evalExpr(orMatch[1].trim(), ctx);
if (left) return left;
const right = orMatch[2].trim();
return right.replace(/^['"]|['"]$/g, '');
return evalExpr(orMatch[2].trim(), ctx);
}

const resolved = resolvePath(expr, { args, item, data, index });
Expand All @@ -95,7 +96,7 @@ function applyFilter(filterExpr: string, value: unknown): unknown {
case 'default': {
if (value === null || value === undefined || value === '') {
const intVal = parseInt(filterArg, 10);
if (!isNaN(intVal) && String(intVal) === filterArg.trim()) return intVal;
if (!Number.isNaN(intVal) && String(intVal) === filterArg.trim()) return intVal;
return filterArg;
}
return value;
Expand All @@ -110,7 +111,7 @@ function applyFilter(filterExpr: string, value: unknown): unknown {
return typeof value === 'string' ? value.trim() : value;
case 'truncate': {
const n = parseInt(filterArg, 10) || 50;
return typeof value === 'string' && value.length > n ? value.slice(0, n) + '...' : value;
return typeof value === 'string' && value.length > n ? `${value.slice(0, n)}...` : value;
}
case 'replace': {
if (typeof value !== 'string') return value;
Expand Down Expand Up @@ -138,6 +139,7 @@ function applyFilter(filterExpr: string, value: unknown): unknown {
case 'sanitize':
// Remove invalid filename characters
return typeof value === 'string'
// biome-ignore lint/suspicious/noControlCharactersInRegex: intentional - strips C0 control chars from filenames
? value.replace(/[<>:"/\\|?*\x00-\x1f]/g, '_')
: value;
case 'ext': {
Expand Down
Loading