diff --git a/src/pipeline/template.test.ts b/src/pipeline/template.test.ts index 266b4e99..532cd309 100644 --- a/src/pipeline/template.test.ts +++ b/src/pipeline/template.test.ts @@ -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'); }); diff --git a/src/pipeline/template.ts b/src/pipeline/template.ts index 786a7554..0a2a03e1 100644 --- a/src/pipeline/template.ts +++ b/src/pipeline/template.ts @@ -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; @@ -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 }); @@ -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; @@ -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; @@ -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': {