feat: release v0.2.0 with all new adapters and fixes

This commit is contained in:
jackwener committed 2026-03-15 08:54:35 +08:00
1 parent f19842c6b7
commit 1361fe6c19
4 files changed
+114 -21

No files matched your search

+16 -1
View File
@@ -67,7 +67,22 @@ export class Page implements IPage {
}
async evaluate(js: string): Promise<any> {
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<any> {
+10 -6
View File
@@ -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);
}
+18
View File
@@ -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', () => {
+70 -14
View File
@@ -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 ?? {};