From 1361fe6c1995983a01b4700117da63dac57de8a2 Mon Sep 17 00:00:00 2001 From: jackwener Date: Sun, 15 Mar 2026 08:54:35 +0800 Subject: [PATCH] feat: release v0.2.0 with all new adapters and fixes --- src/browser.ts | 17 ++++++- src/output.ts | 16 ++++--- src/pipeline/template.test.ts | 18 ++++++++ src/pipeline/template.ts | 84 +++++++++++++++++++++++++++++------ 4 files changed, 114 insertions(+), 21 deletions(-) diff --git a/src/browser.ts b/src/browser.ts index 68b06ff..3969a9d 100644 --- a/src/browser.ts +++ b/src/browser.ts @@ -67,7 +67,22 @@ export class Page implements IPage { } async evaluate(js: string): Promise { - return this.call('tools/call', { name: 'browser_evaluate', arguments: { function: js } }); + // Normalize IIFE format to function format expected by MCP browser_evaluate + const normalized = this.normalizeEval(js); + return this.call('tools/call', { name: 'browser_evaluate', arguments: { function: normalized } }); + } + + private normalizeEval(source: string): string { + const s = source.trim(); + if (!s) return '() => undefined'; + // IIFE: (async () => {...})() → wrap as () => (...) + if (s.startsWith('(') && s.endsWith(')()')) return `() => (${s})`; + // Already a function/arrow + if (/^(async\s+)?\([^)]*\)\s*=>/.test(s)) return s; + if (/^(async\s+)?[A-Za-z_][A-Za-z0-9_]*\s*=>/.test(s)) return s; + if (s.startsWith('function ') || s.startsWith('async function ')) return s; + // Raw expression → wrap + return `() => (${s})`; } async snapshot(opts: { interactive?: boolean; compact?: boolean; maxDepth?: number; raw?: boolean } = {}): Promise { diff --git a/src/output.ts b/src/output.ts index 8aaa216..c8ee159 100644 --- a/src/output.ts +++ b/src/output.ts @@ -1,9 +1,10 @@ /** - * Output formatting: table, JSON, Markdown, CSV. + * Output formatting: table, JSON, Markdown, CSV, YAML. */ import chalk from 'chalk'; import Table from 'cli-table3'; +import yaml from 'js-yaml'; export interface RenderOptions { fmt?: string; @@ -23,6 +24,7 @@ export function render(data: any, opts: RenderOptions = {}): void { case 'json': renderJson(data); break; case 'md': case 'markdown': renderMarkdown(data, opts); break; case 'csv': renderCsv(data, opts); break; + case 'yaml': case 'yml': renderYaml(data); break; default: renderTable(data, opts); break; } } @@ -32,16 +34,14 @@ function renderTable(data: any, opts: RenderOptions): void { if (!rows.length) { console.log(chalk.dim('(no data)')); return; } const columns = opts.columns ?? Object.keys(rows[0]); - const header = columns.map((c, i) => i === 0 ? '#' : capitalize(c)); + const header = columns.map(c => capitalize(c)); const table = new Table({ head: header.map(h => chalk.bold(h)), style: { head: [], border: [] }, wordWrap: true, wrapOnWordBoundary: true, - colWidths: columns.map((c, i) => { - if (i === 0) return 4; - if (c === 'url' || c === 'description') return null as any; - if (c === 'title' || c === 'name' || c === 'repo') return null as any; + colWidths: columns.map((_c, i) => { + if (i === 0) return 6; return null as any; }).filter(() => true), }); @@ -91,6 +91,10 @@ function renderCsv(data: any, opts: RenderOptions): void { } } +function renderYaml(data: any): void { + console.log(yaml.dump(data, { sortKeys: false, lineWidth: 120, noRefs: true })); +} + function capitalize(s: string): string { return s.charAt(0).toUpperCase() + s.slice(1); } diff --git a/src/pipeline/template.test.ts b/src/pipeline/template.test.ts index aade4a5..b74c30d 100644 --- a/src/pipeline/template.test.ts +++ b/src/pipeline/template.test.ts @@ -57,6 +57,24 @@ describe('evalExpr', () => { it('resolves simple path', () => { expect(evalExpr('item.title', { item: { title: 'Test' } })).toBe('Test'); }); + it('applies join filter', () => { + expect(evalExpr('item.tags | join(,)', { item: { tags: ['a', 'b', 'c'] } })).toBe('a,b,c'); + }); + it('applies upper filter', () => { + expect(evalExpr('item.name | upper', { item: { name: 'hello' } })).toBe('HELLO'); + }); + it('applies lower filter', () => { + expect(evalExpr('item.name | lower', { item: { name: 'HELLO' } })).toBe('hello'); + }); + it('applies truncate filter', () => { + expect(evalExpr('item.text | truncate(5)', { item: { text: 'Hello World!' } })).toBe('Hello...'); + }); + it('chains filters', () => { + expect(evalExpr('item.name | upper | truncate(3)', { item: { name: 'hello' } })).toBe('HEL...'); + }); + it('applies length filter', () => { + expect(evalExpr('item.items | length', { item: { items: [1, 2, 3] } })).toBe(3); + }); }); describe('render', () => { diff --git a/src/pipeline/template.ts b/src/pipeline/template.ts index 9d21395..7cf0279 100644 --- a/src/pipeline/template.ts +++ b/src/pipeline/template.ts @@ -11,8 +11,17 @@ export interface RenderContext { export function render(template: any, ctx: RenderContext): any { if (typeof template !== 'string') return template; - const fullMatch = template.match(/^\$\{\{\s*(.*?)\s*\}\}$/); - if (fullMatch) return evalExpr(fullMatch[1].trim(), ctx); + // Full expression: entire string is a single ${{ ... }} + // Use [^}] to prevent matching across }} boundaries (e.g. "${{ a }}-${{ b }}") + const fullMatch = template.match(/^\$\{\{\s*([^}]*(?:\}[^}][^}]*)*)\s*\}\}$/); + if (fullMatch && !template.includes('}}-') && !template.includes('}}${{')) return evalExpr(fullMatch[1].trim(), ctx); + // Check if the entire string is a single expression (no other text around it) + const singleExpr = template.match(/^\$\{\{\s*([\s\S]*?)\s*\}\}$/); + if (singleExpr) { + // Verify it's truly a single expression (no other ${{ inside) + const inner = singleExpr[1]; + if (!inner.includes('${{')) return evalExpr(inner.trim(), ctx); + } return template.replace(/\$\{\{\s*(.*?)\s*\}\}/g, (_m, expr) => String(evalExpr(expr.trim(), ctx))); } @@ -22,18 +31,14 @@ export function evalExpr(expr: string, ctx: RenderContext): any { const data = ctx.data; const index = ctx.index ?? 0; - // Default filter: args.limit | default(20) - if (expr.includes('|') && expr.includes('default(')) { - const [mainExpr, rest] = expr.split('|', 2); - const defaultMatch = rest.match(/default\((.+?)\)/); - const defaultVal = defaultMatch ? defaultMatch[1] : null; - const result = resolvePath(mainExpr.trim(), { args, item, data, index }); - if (result === null || result === undefined) { - if (defaultVal !== null) { - const intVal = parseInt(defaultVal!, 10); - if (!isNaN(intVal) && String(intVal) === defaultVal!.trim()) return intVal; - return defaultVal!.replace(/^['"]|['"]$/g, ''); - } + // ── Pipe filters: expr | filter1(arg) | filter2 ── + // Supports: default(val), join(sep), upper, lower, truncate(n), trim, replace(old,new) + if (expr.includes('|') && !expr.includes('||')) { + const segments = expr.split('|').map(s => s.trim()); + const mainExpr = segments[0]; + let result = resolvePath(mainExpr, { args, item, data, index }); + for (let i = 1; i < segments.length; i++) { + result = applyFilter(segments[i], result); } return result; } @@ -66,6 +71,57 @@ export function evalExpr(expr: string, ctx: RenderContext): any { return resolvePath(expr, { args, item, data, index }); } +/** + * Apply a named filter to a value. + * Supported filters: + * default(val), join(sep), upper, lower, truncate(n), trim, + * replace(old,new), keys, length, first, last + */ +function applyFilter(filterExpr: string, value: any): any { + const match = filterExpr.match(/^(\w+)(?:\((.+)\))?$/); + if (!match) return value; + const [, name, rawArgs] = match; + const filterArg = rawArgs?.replace(/^['"]|['"]$/g, '') ?? ''; + + switch (name) { + case 'default': { + if (value === null || value === undefined || value === '') { + const intVal = parseInt(filterArg, 10); + if (!isNaN(intVal) && String(intVal) === filterArg.trim()) return intVal; + return filterArg; + } + return value; + } + case 'join': + return Array.isArray(value) ? value.join(filterArg || ', ') : value; + case 'upper': + return typeof value === 'string' ? value.toUpperCase() : value; + case 'lower': + return typeof value === 'string' ? value.toLowerCase() : value; + case 'trim': + 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; + } + case 'replace': { + if (typeof value !== 'string') return value; + const parts = rawArgs?.split(',').map(s => s.trim().replace(/^['"]|['"]$/g, '')) ?? []; + return parts.length >= 2 ? value.replaceAll(parts[0], parts[1]) : value; + } + case 'keys': + return value && typeof value === 'object' ? Object.keys(value) : value; + case 'length': + return Array.isArray(value) ? value.length : typeof value === 'string' ? value.length : value; + case 'first': + return Array.isArray(value) ? value[0] : value; + case 'last': + return Array.isArray(value) ? value[value.length - 1] : value; + default: + return value; + } +} + export function resolvePath(pathStr: string, ctx: RenderContext): any { const args = ctx.args ?? {}; const item = ctx.item ?? {};