From 54ee43bdaf28ca3e0f1c74bee475261eba5126fe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alejandro=20G=C3=B3mez?= Date: Thu, 11 Dec 2025 16:57:40 +0100 Subject: [PATCH] feat: comma-separated flags to REQ --- CLAUDE.md | 41 ++++ package-lock.json | 456 ++++++++++++++++++++++++++++++++++- package.json | 9 +- src/components/ReqViewer.tsx | 4 +- src/lib/req-parser.test.ts | 326 +++++++++++++++++++++++++ src/lib/req-parser.ts | 169 +++++++++---- src/types/man.ts | 16 +- vitest.config.ts | 14 ++ 8 files changed, 973 insertions(+), 62 deletions(-) create mode 100644 src/lib/req-parser.test.ts create mode 100644 vitest.config.ts diff --git a/CLAUDE.md b/CLAUDE.md index d19d828..caf4789 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -78,9 +78,50 @@ Use hooks like `useProfile()`, `useNostrEvent()`, `useTimeline()` - they handle - Don't traverse or modify layout tree manually - Adding/removing windows handled by `logic.ts` functions +## Testing + +**Test Framework**: Vitest with node environment + +**Running Tests**: +```bash +npm test # Watch mode (auto-runs on file changes) +npm run test:ui # Visual UI for test exploration +npm run test:run # Single run (CI mode) +``` + +**Test Conventions**: +- Test files: `*.test.ts` or `*.test.tsx` colocated with source files +- Focus on testing pure functions and parsing logic +- Use descriptive test names that explain behavior +- Group related tests with `describe` blocks + +**What to Test**: +- **Parsers** (`src/lib/*-parser.ts`): All argument parsing logic, edge cases, validation +- **Pure functions** (`src/core/logic.ts`): State mutations, business logic +- **Utilities** (`src/lib/*.ts`): Helper functions, data transformations +- **Not UI components**: React components tested manually (for now) + +**Example Test Structure**: +```typescript +describe("parseReqCommand", () => { + describe("kind flag (-k, --kind)", () => { + it("should parse single kind", () => { + const result = parseReqCommand(["-k", "1"]); + expect(result.filter.kinds).toEqual([1]); + }); + + it("should deduplicate kinds", () => { + const result = parseReqCommand(["-k", "1,3,1"]); + expect(result.filter.kinds).toEqual([1, 3]); + }); + }); +}); +``` + ## Critical Notes - React 19 features in use (ensure compatibility) - LocalStorage persistence has quota handling built-in - Dark mode is default (controlled via HTML class) - EventStore handles event deduplication and replaceability automatically +- Run tests before committing changes to parsers or core logic diff --git a/package-lock.json b/package-lock.json index 4d5b3db..1d1631d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -45,6 +45,7 @@ "@types/react-dom": "^19.2.3", "@types/uuid": "^10.0.0", "@vitejs/plugin-react": "^4.3.4", + "@vitest/ui": "^4.0.15", "autoprefixer": "^10.4.20", "eslint": "^9.17.0", "eslint-config-prettier": "^10.1.8", @@ -57,7 +58,8 @@ "tailwindcss": "^3.4.17", "typescript": "~5.6.2", "typescript-eslint": "^8.18.2", - "vite": "^6.0.5" + "vite": "^6.0.5", + "vitest": "^4.0.15" } }, "node_modules/@alloc/quick-lru": { @@ -1565,6 +1567,13 @@ "url": "https://opencollective.com/pkgr" } }, + "node_modules/@polka/url": { + "version": "1.0.0-next.29", + "resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.29.tgz", + "integrity": "sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==", + "dev": true, + "license": "MIT" + }, "node_modules/@radix-ui/number": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/@radix-ui/number/-/number-1.1.1.tgz", @@ -3283,6 +3292,13 @@ "url": "https://paulmillr.com/funding/" } }, + "node_modules/@standard-schema/spec": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.0.0.tgz", + "integrity": "sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/babel__core": { "version": "7.20.5", "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", @@ -3328,6 +3344,17 @@ "@babel/types": "^7.28.2" } }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, "node_modules/@types/debug": { "version": "4.1.12", "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.12.tgz", @@ -3337,6 +3364,13 @@ "@types/ms": "*" } }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/estree": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", @@ -3719,6 +3753,160 @@ "node": ">=0.10.0" } }, + "node_modules/@vitest/expect": { + "version": "4.0.15", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.0.15.tgz", + "integrity": "sha512-Gfyva9/GxPAWXIWjyGDli9O+waHDC0Q0jaLdFP1qPAUUfo1FEXPXUfUkp3eZA0sSq340vPycSyOlYUeM15Ft1w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.0.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.0.15", + "@vitest/utils": "4.0.15", + "chai": "^6.2.1", + "tinyrainbow": "^3.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "4.0.15", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.0.15.tgz", + "integrity": "sha512-CZ28GLfOEIFkvCFngN8Sfx5h+Se0zN+h4B7yOsPVCcgtiO7t5jt9xQh2E1UkFep+eb9fjyMfuC5gBypwb07fvQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.0.15", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0-0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "4.0.15", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.0.15.tgz", + "integrity": "sha512-SWdqR8vEv83WtZcrfLNqlqeQXlQLh2iilO1Wk1gv4eiHKjEzvgHb2OVc3mIPyhZE6F+CtfYjNlDJwP5MN6Km7A==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.0.15", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.0.15.tgz", + "integrity": "sha512-+A+yMY8dGixUhHmNdPUxOh0la6uVzun86vAbuMT3hIDxMrAOmn5ILBHm8ajrqHE0t8R9T1dGnde1A5DTnmi3qw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.0.15", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner/node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@vitest/snapshot": { + "version": "4.0.15", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.0.15.tgz", + "integrity": "sha512-A7Ob8EdFZJIBjLjeO0DZF4lqR6U7Ydi5/5LIZ0xcI+23lYlsYJAfGn8PrIWTYdZQRNnSRlzhg0zyGu37mVdy5g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.0.15", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot/node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@vitest/spy": { + "version": "4.0.15", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.0.15.tgz", + "integrity": "sha512-+EIjOJmnY6mIfdXtE/bnozKEvTC4Uczg19yeZ2vtCz5Yyb0QQ31QWVQ8hswJ3Ysx/K2EqaNsVanjr//2+P3FHw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/ui": { + "version": "4.0.15", + "resolved": "https://registry.npmjs.org/@vitest/ui/-/ui-4.0.15.tgz", + "integrity": "sha512-sxSyJMaKp45zI0u+lHrPuZM1ZJQ8FaVD35k+UxVrha1yyvQ+TZuUYllUixwvQXlB7ixoDc7skf3lQPopZIvaQw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.0.15", + "fflate": "^0.8.2", + "flatted": "^3.3.3", + "pathe": "^2.0.3", + "sirv": "^3.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "vitest": "4.0.15" + } + }, + "node_modules/@vitest/ui/node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@vitest/utils": { + "version": "4.0.15", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.0.15.tgz", + "integrity": "sha512-HXjPW2w5dxhTD0dLwtYHDnelK3j8sR8cWIaLxr22evTyY6q8pRCjZSmhRWVjBaOVXChQd6AwMzi9pucorXCPZA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.0.15", + "tinyrainbow": "^3.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, "node_modules/acorn": { "version": "8.15.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", @@ -4016,6 +4204,16 @@ "node": ">=10" } }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, "node_modules/autoprefixer": { "version": "10.4.22", "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.22.tgz", @@ -4226,6 +4424,16 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/chai": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.1.tgz", + "integrity": "sha512-p4Z49OGG5W/WBCPSS/dH3jQ73kD6tiMmUM+bckNK6Jr5JHMG3k9bg/BvKR8lKmtVBKmOiuVaV2ws8s9oSbwysg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/chalk": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", @@ -4914,6 +5122,16 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, "node_modules/esutils": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", @@ -4937,6 +5155,16 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/expect-type": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", + "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/extend": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", @@ -5010,6 +5238,13 @@ "reusify": "^1.0.4" } }, + "node_modules/fflate": { + "version": "0.8.2", + "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.2.tgz", + "integrity": "sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==", + "dev": true, + "license": "MIT" + }, "node_modules/file-entry-cache": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", @@ -5800,6 +6035,16 @@ "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, "node_modules/markdown-table": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/markdown-table/-/markdown-table-3.0.4.tgz", @@ -6702,6 +6947,16 @@ "node": ">=16 || 14 >=14.17" } }, + "node_modules/mrmime": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.1.tgz", + "integrity": "sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -6926,6 +7181,17 @@ "rxjs": ">=6.0.0" } }, + "node_modules/obug": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz", + "integrity": "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT" + }, "node_modules/optionator": { "version": "0.9.4", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", @@ -8043,6 +8309,13 @@ "node": ">=8" } }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, "node_modules/signal-exit": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", @@ -8056,6 +8329,21 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/sirv": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/sirv/-/sirv-3.0.2.tgz", + "integrity": "sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@polka/url": "^1.0.0-next.24", + "mrmime": "^2.0.0", + "totalist": "^3.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", @@ -8112,6 +8400,20 @@ "dev": true, "license": "CC0-1.0" }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "dev": true, + "license": "MIT" + }, "node_modules/string-width": { "version": "5.1.2", "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", @@ -8448,6 +8750,23 @@ "node": ">=0.8" } }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.2.tgz", + "integrity": "sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/tinyglobby": { "version": "0.2.15", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", @@ -8496,6 +8815,16 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/tinyrainbow": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.0.3.tgz", + "integrity": "sha512-PSkbLUoxOFRzJYjjxHJt9xro7D+iilgMX/C9lawzVuYiIdcihh9DXmVibBe8lmcFrRi/VzlPjBxbN7rH24q8/Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/to-regex-range": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", @@ -8509,6 +8838,16 @@ "node": ">=8.0" } }, + "node_modules/totalist": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/totalist/-/totalist-3.0.1.tgz", + "integrity": "sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/trim-lines": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/trim-lines/-/trim-lines-3.0.1.tgz", @@ -9013,6 +9352,104 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/vitest": { + "version": "4.0.15", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.0.15.tgz", + "integrity": "sha512-n1RxDp8UJm6N0IbJLQo+yzLZ2sQCDyl1o0LeugbPWf8+8Fttp29GghsQBjYJVmWq3gBFfe9Hs1spR44vovn2wA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.0.15", + "@vitest/mocker": "4.0.15", + "@vitest/pretty-format": "4.0.15", + "@vitest/runner": "4.0.15", + "@vitest/snapshot": "4.0.15", + "@vitest/spy": "4.0.15", + "@vitest/utils": "4.0.15", + "es-module-lexer": "^1.7.0", + "expect-type": "^1.2.2", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^3.10.0", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.0.3", + "vite": "^6.0.0 || ^7.0.0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.0.15", + "@vitest/browser-preview": "4.0.15", + "@vitest/browser-webdriverio": "4.0.15", + "@vitest/ui": "4.0.15", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, + "node_modules/vitest/node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/vitest/node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, "node_modules/which": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/which/-/which-3.0.1.tgz", @@ -9029,6 +9466,23 @@ "node": "^14.17.0 || ^16.13.0 || >=18.0.0" } }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/word-wrap": { "version": "1.2.5", "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", diff --git a/package.json b/package.json index ab120e7..2a7ad44 100644 --- a/package.json +++ b/package.json @@ -10,7 +10,10 @@ "lint:fix": "eslint . --fix", "format": "prettier --write \"src/**/*.{ts,tsx,js,jsx,json,css}\"", "format:check": "prettier --check \"src/**/*.{ts,tsx,js,jsx,json,css}\"", - "preview": "vite preview" + "preview": "vite preview", + "test": "vitest", + "test:ui": "vitest --ui", + "test:run": "vitest run" }, "dependencies": { "@radix-ui/react-avatar": "^1.1.11", @@ -50,6 +53,7 @@ "@types/react-dom": "^19.2.3", "@types/uuid": "^10.0.0", "@vitejs/plugin-react": "^4.3.4", + "@vitest/ui": "^4.0.15", "autoprefixer": "^10.4.20", "eslint": "^9.17.0", "eslint-config-prettier": "^10.1.8", @@ -62,6 +66,7 @@ "tailwindcss": "^3.4.17", "typescript": "~5.6.2", "typescript-eslint": "^8.18.2", - "vite": "^6.0.5" + "vite": "^6.0.5", + "vitest": "^4.0.15" } } diff --git a/src/components/ReqViewer.tsx b/src/components/ReqViewer.tsx index 9ebf6c4..093dee0 100644 --- a/src/components/ReqViewer.tsx +++ b/src/components/ReqViewer.tsx @@ -163,7 +163,7 @@ export default function ReqViewer({ Authors: {filter.authors.length} {nip05Authors && nip05Authors.length > 0 && ( -
+
{nip05Authors.map((nip05) => (
→ {nip05}
))} @@ -178,7 +178,7 @@ export default function ReqViewer({ #p Tags: {filter["#p"].length} {nip05PTags && nip05PTags.length > 0 && ( -
+
{nip05PTags.map((nip05) => (
→ {nip05}
))} diff --git a/src/lib/req-parser.test.ts b/src/lib/req-parser.test.ts new file mode 100644 index 0000000..52c85f6 --- /dev/null +++ b/src/lib/req-parser.test.ts @@ -0,0 +1,326 @@ +import { describe, it, expect, vi } from "vitest"; +import { parseReqCommand } from "./req-parser"; + +describe("parseReqCommand", () => { + describe("kind flag (-k, --kind)", () => { + it("should parse single kind", () => { + const result = parseReqCommand(["-k", "1"]); + expect(result.filter.kinds).toEqual([1]); + }); + + it("should parse comma-separated kinds", () => { + const result = parseReqCommand(["-k", "1,3,7"]); + expect(result.filter.kinds).toEqual([1, 3, 7]); + }); + + it("should parse comma-separated kinds with spaces", () => { + const result = parseReqCommand(["-k", "1, 3, 7"]); + expect(result.filter.kinds).toEqual([1, 3, 7]); + }); + + it("should deduplicate kinds", () => { + const result = parseReqCommand(["-k", "1,3,1,3"]); + expect(result.filter.kinds).toEqual([1, 3]); + }); + + it("should deduplicate across multiple -k flags", () => { + const result = parseReqCommand(["-k", "1", "-k", "3", "-k", "1"]); + expect(result.filter.kinds).toEqual([1, 3]); + }); + + it("should handle --kind long form", () => { + const result = parseReqCommand(["--kind", "1,3,7"]); + expect(result.filter.kinds).toEqual([1, 3, 7]); + }); + + it("should ignore invalid kinds", () => { + const result = parseReqCommand(["-k", "1,invalid,3"]); + expect(result.filter.kinds).toEqual([1, 3]); + }); + }); + + describe("author flag (-a, --author)", () => { + it("should parse hex pubkey", () => { + const hex = "a".repeat(64); + const result = parseReqCommand(["-a", hex]); + expect(result.filter.authors).toEqual([hex]); + }); + + it("should parse comma-separated hex pubkeys", () => { + const hex1 = "a".repeat(64); + const hex2 = "b".repeat(64); + const result = parseReqCommand(["-a", `${hex1},${hex2}`]); + expect(result.filter.authors).toEqual([hex1, hex2]); + }); + + it("should deduplicate authors", () => { + const hex = "a".repeat(64); + const result = parseReqCommand(["-a", `${hex},${hex}`]); + expect(result.filter.authors).toEqual([hex]); + }); + + it("should accumulate NIP-05 identifiers for async resolution", () => { + const result = parseReqCommand([ + "-a", + "user@domain.com,alice@example.com", + ]); + expect(result.nip05Authors).toEqual(["user@domain.com", "alice@example.com"]); + expect(result.filter.authors).toBeUndefined(); + }); + + it("should handle mixed hex and NIP-05", () => { + const hex = "a".repeat(64); + const result = parseReqCommand(["-a", `${hex},user@domain.com`]); + expect(result.filter.authors).toEqual([hex]); + expect(result.nip05Authors).toEqual(["user@domain.com"]); + }); + + it("should deduplicate NIP-05 identifiers", () => { + const result = parseReqCommand([ + "-a", + "user@domain.com,user@domain.com", + ]); + expect(result.nip05Authors).toEqual(["user@domain.com"]); + }); + }); + + describe("event ID flag (-e)", () => { + it("should parse hex event ID", () => { + const hex = "a".repeat(64); + const result = parseReqCommand(["-e", hex]); + expect(result.filter["#e"]).toEqual([hex]); + }); + + it("should parse comma-separated event IDs", () => { + const hex1 = "a".repeat(64); + const hex2 = "b".repeat(64); + const result = parseReqCommand(["-e", `${hex1},${hex2}`]); + expect(result.filter["#e"]).toEqual([hex1, hex2]); + }); + + it("should deduplicate event IDs", () => { + const hex = "a".repeat(64); + const result = parseReqCommand(["-e", `${hex},${hex}`]); + expect(result.filter["#e"]).toEqual([hex]); + }); + }); + + describe("pubkey tag flag (-p)", () => { + it("should parse hex pubkey for #p tag", () => { + const hex = "a".repeat(64); + const result = parseReqCommand(["-p", hex]); + expect(result.filter["#p"]).toEqual([hex]); + }); + + it("should parse comma-separated pubkeys", () => { + const hex1 = "a".repeat(64); + const hex2 = "b".repeat(64); + const result = parseReqCommand(["-p", `${hex1},${hex2}`]); + expect(result.filter["#p"]).toEqual([hex1, hex2]); + }); + + it("should accumulate NIP-05 identifiers for #p tags", () => { + const result = parseReqCommand(["-p", "user@domain.com,alice@example.com"]); + expect(result.nip05PTags).toEqual(["user@domain.com", "alice@example.com"]); + expect(result.filter["#p"]).toBeUndefined(); + }); + + it("should deduplicate #p tags", () => { + const hex = "a".repeat(64); + const result = parseReqCommand(["-p", `${hex},${hex}`]); + expect(result.filter["#p"]).toEqual([hex]); + }); + }); + + describe("hashtag flag (-t)", () => { + it("should parse single hashtag", () => { + const result = parseReqCommand(["-t", "nostr"]); + expect(result.filter["#t"]).toEqual(["nostr"]); + }); + + it("should parse comma-separated hashtags", () => { + const result = parseReqCommand(["-t", "nostr,bitcoin,lightning"]); + expect(result.filter["#t"]).toEqual(["nostr", "bitcoin", "lightning"]); + }); + + it("should parse comma-separated hashtags with spaces", () => { + const result = parseReqCommand(["-t", "nostr, bitcoin, lightning"]); + expect(result.filter["#t"]).toEqual(["nostr", "bitcoin", "lightning"]); + }); + + it("should deduplicate hashtags", () => { + const result = parseReqCommand(["-t", "nostr,bitcoin,nostr"]); + expect(result.filter["#t"]).toEqual(["nostr", "bitcoin"]); + }); + }); + + describe("d-tag flag (-d)", () => { + it("should parse single d-tag", () => { + const result = parseReqCommand(["-d", "article1"]); + expect(result.filter["#d"]).toEqual(["article1"]); + }); + + it("should parse comma-separated d-tags", () => { + const result = parseReqCommand(["-d", "article1,article2,article3"]); + expect(result.filter["#d"]).toEqual(["article1", "article2", "article3"]); + }); + + it("should deduplicate d-tags", () => { + const result = parseReqCommand(["-d", "article1,article2,article1"]); + expect(result.filter["#d"]).toEqual(["article1", "article2"]); + }); + }); + + describe("limit flag (-l, --limit)", () => { + it("should parse limit", () => { + const result = parseReqCommand(["-l", "100"]); + expect(result.filter.limit).toBe(100); + }); + + it("should handle --limit long form", () => { + const result = parseReqCommand(["--limit", "50"]); + expect(result.filter.limit).toBe(50); + }); + }); + + describe("time flags (--since, --until)", () => { + it("should parse unix timestamp for --since", () => { + const result = parseReqCommand(["--since", "1234567890"]); + expect(result.filter.since).toBe(1234567890); + }); + + it("should parse relative time for --since (hours)", () => { + const result = parseReqCommand(["--since", "2h"]); + expect(result.filter.since).toBeDefined(); + expect(result.filter.since).toBeGreaterThan(0); + }); + + it("should parse relative time for --since (days)", () => { + const result = parseReqCommand(["--since", "7d"]); + expect(result.filter.since).toBeDefined(); + expect(result.filter.since).toBeGreaterThan(0); + }); + + it("should parse unix timestamp for --until", () => { + const result = parseReqCommand(["--until", "1234567890"]); + expect(result.filter.until).toBe(1234567890); + }); + }); + + describe("search flag (--search)", () => { + it("should parse search query", () => { + const result = parseReqCommand(["--search", "bitcoin"]); + expect(result.filter.search).toBe("bitcoin"); + }); + }); + + describe("relay parsing", () => { + it("should parse relay with wss:// protocol", () => { + const result = parseReqCommand(["wss://relay.example.com"]); + expect(result.relays).toEqual(["wss://relay.example.com"]); + }); + + it("should parse relay domain and add wss://", () => { + const result = parseReqCommand(["relay.example.com"]); + expect(result.relays).toEqual(["wss://relay.example.com"]); + }); + + it("should parse multiple relays", () => { + const result = parseReqCommand([ + "wss://relay1.com", + "relay2.com", + "wss://relay3.com", + ]); + expect(result.relays).toEqual([ + "wss://relay1.com", + "wss://relay2.com", + "wss://relay3.com", + ]); + }); + }); + + describe("close-on-eose flag", () => { + it("should parse --close-on-eose", () => { + const result = parseReqCommand(["--close-on-eose"]); + expect(result.closeOnEose).toBe(true); + }); + + it("should default to false when not provided", () => { + const result = parseReqCommand(["-k", "1"]); + expect(result.closeOnEose).toBe(false); + }); + }); + + describe("complex scenarios", () => { + it("should handle multiple flags together", () => { + const hex = "a".repeat(64); + const result = parseReqCommand([ + "-k", + "1,3", + "-a", + hex, + "-t", + "nostr,bitcoin", + "-l", + "100", + "--since", + "1h", + "relay.example.com", + ]); + + expect(result.filter.kinds).toEqual([1, 3]); + expect(result.filter.authors).toEqual([hex]); + expect(result.filter["#t"]).toEqual(["nostr", "bitcoin"]); + expect(result.filter.limit).toBe(100); + expect(result.filter.since).toBeDefined(); + expect(result.relays).toEqual(["wss://relay.example.com"]); + }); + + it("should handle deduplication across multiple flags and commas", () => { + const result = parseReqCommand([ + "-k", + "1,3", + "-k", + "3,7", + "-k", + "1", + "-t", + "nostr", + "-t", + "bitcoin,nostr", + ]); + + expect(result.filter.kinds).toEqual([1, 3, 7]); + expect(result.filter["#t"]).toEqual(["nostr", "bitcoin"]); + }); + + it("should handle empty comma-separated values", () => { + const result = parseReqCommand(["-k", "1,,3,,"]); + expect(result.filter.kinds).toEqual([1, 3]); + }); + + it("should handle whitespace in comma-separated values", () => { + const result = parseReqCommand(["-t", " nostr , bitcoin , lightning "]); + expect(result.filter["#t"]).toEqual(["nostr", "bitcoin", "lightning"]); + }); + }); + + describe("edge cases", () => { + it("should handle empty args", () => { + const result = parseReqCommand([]); + expect(result.filter).toEqual({}); + expect(result.relays).toBeUndefined(); + expect(result.closeOnEose).toBe(false); + }); + + it("should handle flag without value", () => { + const result = parseReqCommand(["-k"]); + expect(result.filter.kinds).toBeUndefined(); + }); + + it("should handle unknown flags gracefully", () => { + const result = parseReqCommand(["-x", "value", "-k", "1"]); + expect(result.filter.kinds).toEqual([1]); + }); + }); +}); diff --git a/src/lib/req-parser.ts b/src/lib/req-parser.ts index 92c6622..c1dc9d6 100644 --- a/src/lib/req-parser.ts +++ b/src/lib/req-parser.ts @@ -15,6 +15,30 @@ export interface ParsedReqCommand { nip05PTags?: string[]; // NIP-05 identifiers for #p tags that need async resolution } +/** + * Parse comma-separated values and apply a parser function to each + * Returns true if at least one value was successfully parsed + */ +function parseCommaSeparated( + value: string, + parser: (v: string) => T | null, + target: Set +): boolean { + const values = value.split(',').map(v => v.trim()); + let addedAny = false; + + for (const val of values) { + if (!val) continue; + const parsed = parser(val); + if (parsed !== null) { + target.add(parsed); + addedAny = true; + } + } + + return addedAny; +} + /** * Parse REQ command arguments into a Nostr filter * Supports: @@ -27,8 +51,17 @@ export interface ParsedReqCommand { export function parseReqCommand(args: string[]): ParsedReqCommand { const filter: NostrFilter = {}; const relays: string[] = []; - const nip05Authors: string[] = []; - const nip05PTags: string[] = []; + const nip05Authors = new Set(); + const nip05PTags = new Set(); + + // Use sets for deduplication during accumulation + const kinds = new Set(); + const authors = new Set(); + const eventIds = new Set(); + const pTags = new Set(); + const tTags = new Set(); + const dTags = new Set(); + let closeOnEose = false; let i = 0; @@ -58,33 +91,47 @@ export function parseReqCommand(args: string[]): ParsedReqCommand { switch (flag) { case "-k": case "--kind": { - const kind = parseInt(nextArg, 10); - if (!isNaN(kind)) { - if (!filter.kinds) filter.kinds = []; - filter.kinds.push(kind); - i += 2; - } else { + // Support comma-separated kinds: -k 1,3,7 + if (!nextArg) { i++; + break; } + const addedAny = parseCommaSeparated( + nextArg, + (v) => { + const kind = parseInt(v, 10); + return isNaN(kind) ? null : kind; + }, + kinds + ); + i += addedAny ? 2 : 1; break; } case "-a": case "--author": { - // Check if it's a NIP-05 identifier - if (isNip05(nextArg)) { - nip05Authors.push(nextArg); - i += 2; - } else { - const pubkey = parseNpubOrHex(nextArg); - if (pubkey) { - if (!filter.authors) filter.authors = []; - filter.authors.push(pubkey); - i += 2; + // Support comma-separated authors: -a npub1...,npub2...,user@domain.com + if (!nextArg) { + i++; + break; + } + let addedAny = false; + const values = nextArg.split(',').map(a => a.trim()); + for (const authorStr of values) { + if (!authorStr) continue; + // Check if it's a NIP-05 identifier + if (isNip05(authorStr)) { + nip05Authors.add(authorStr); + addedAny = true; } else { - i++; + const pubkey = parseNpubOrHex(authorStr); + if (pubkey) { + authors.add(pubkey); + addedAny = true; + } } } + i += addedAny ? 2 : 1; break; } @@ -101,41 +148,55 @@ export function parseReqCommand(args: string[]): ParsedReqCommand { } case "-e": { - const eventId = parseNoteOrHex(nextArg); - if (eventId) { - if (!filter["#e"]) filter["#e"] = []; - filter["#e"].push(eventId); - i += 2; - } else { + // Support comma-separated event IDs: -e id1,id2,id3 + if (!nextArg) { i++; + break; } + const addedAny = parseCommaSeparated( + nextArg, + parseNoteOrHex, + eventIds + ); + i += addedAny ? 2 : 1; break; } case "-p": { - // Check if it's a NIP-05 identifier - if (isNip05(nextArg)) { - nip05PTags.push(nextArg); - i += 2; - } else { - const pubkey = parseNpubOrHex(nextArg); - if (pubkey) { - if (!filter["#p"]) filter["#p"] = []; - filter["#p"].push(pubkey); - i += 2; + // Support comma-separated pubkeys: -p npub1...,npub2...,user@domain.com + if (!nextArg) { + i++; + break; + } + let addedAny = false; + const values = nextArg.split(',').map(p => p.trim()); + for (const pubkeyStr of values) { + if (!pubkeyStr) continue; + // Check if it's a NIP-05 identifier + if (isNip05(pubkeyStr)) { + nip05PTags.add(pubkeyStr); + addedAny = true; } else { - i++; + const pubkey = parseNpubOrHex(pubkeyStr); + if (pubkey) { + pTags.add(pubkey); + addedAny = true; + } } } + i += addedAny ? 2 : 1; break; } case "-t": { - // Hashtag filter + // Support comma-separated hashtags: -t nostr,bitcoin,lightning if (nextArg) { - if (!filter["#t"]) filter["#t"] = []; - filter["#t"].push(nextArg); - i += 2; + const addedAny = parseCommaSeparated( + nextArg, + (v) => v, // hashtags are already strings + tTags + ); + i += addedAny ? 2 : 1; } else { i++; } @@ -143,11 +204,14 @@ export function parseReqCommand(args: string[]): ParsedReqCommand { } case "-d": { - // D-tag filter (for replaceable events) + // Support comma-separated d-tags: -d article1,article2,article3 if (nextArg) { - if (!filter["#d"]) filter["#d"] = []; - filter["#d"].push(nextArg); - i += 2; + const addedAny = parseCommaSeparated( + nextArg, + (v) => v, // d-tags are already strings + dTags + ); + i += addedAny ? 2 : 1; } else { i++; } @@ -201,16 +265,21 @@ export function parseReqCommand(args: string[]): ParsedReqCommand { } } - const result = { + // Convert accumulated sets to filter arrays (with deduplication) + if (kinds.size > 0) filter.kinds = Array.from(kinds); + if (authors.size > 0) filter.authors = Array.from(authors); + if (eventIds.size > 0) filter["#e"] = Array.from(eventIds); + if (pTags.size > 0) filter["#p"] = Array.from(pTags); + if (tTags.size > 0) filter["#t"] = Array.from(tTags); + if (dTags.size > 0) filter["#d"] = Array.from(dTags); + + return { filter, relays: relays.length > 0 ? relays : undefined, closeOnEose, - nip05Authors: nip05Authors.length > 0 ? nip05Authors : undefined, - nip05PTags: nip05PTags.length > 0 ? nip05PTags : undefined, + nip05Authors: nip05Authors.size > 0 ? Array.from(nip05Authors) : undefined, + nip05PTags: nip05PTags.size > 0 ? Array.from(nip05PTags) : undefined, }; - - console.log("parseReqCommand result:", result); - return result; } /** diff --git a/src/types/man.ts b/src/types/man.ts index df76d2a..4a6a63c 100644 --- a/src/types/man.ts +++ b/src/types/man.ts @@ -144,12 +144,12 @@ export const manPages: Record = { { flag: "-k, --kind ", description: - "Filter by event kind (e.g., 0=metadata, 1=note, 7=reaction)", + "Filter by event kind (e.g., 0=metadata, 1=note, 7=reaction). Supports comma-separated values: -k 1,3,7", }, { flag: "-a, --author ", description: - "Filter by author pubkey (supports npub, hex, NIP-05 identifier, or bare domain)", + "Filter by author pubkey (supports npub, hex, NIP-05 identifier, or bare domain). Supports comma-separated values: -a npub1...,user@domain.com", }, { flag: "-l, --limit ", @@ -157,20 +157,20 @@ export const manPages: Record = { }, { flag: "-e ", - description: "Filter by referenced event ID (#e tag)", + description: "Filter by referenced event ID (#e tag). Supports comma-separated values: -e id1,id2,id3", }, { flag: "-p ", description: - "Filter by mentioned pubkey (#p tag, supports npub, hex, NIP-05, or bare domain)", + "Filter by mentioned pubkey (#p tag, supports npub, hex, NIP-05, or bare domain). Supports comma-separated values: -p npub1...,npub2...", }, { flag: "-t ", - description: "Filter by hashtag (#t tag)", + description: "Filter by hashtag (#t tag). Supports comma-separated values: -t nostr,bitcoin,lightning", }, { flag: "-d ", - description: "Filter by d-tag identifier (replaceable events)", + description: "Filter by d-tag identifier (replaceable events). Supports comma-separated values: -d article1,article2", }, { flag: "--since