diff --git a/README.md b/README.md index 5b9c7944..135cd20c 100644 --- a/README.md +++ b/README.md @@ -36,9 +36,12 @@ Supports: - [**Afdian**](https://afdian.com/) - [**Polar**](https://polar.sh/) - [**Liberapay**](https://liberapay.com/) + - [**Ko-fi**](https://ko-fi.com/) ## Usage +Requires Node.js 22.18+, 24.11+, or 26+. + Create `.env` file with: ```ini @@ -119,6 +122,12 @@ CONTRIBKIT_POLAR_ORGANIZATION= ; Liberapay provider. ; The name of the profile. CONTRIBKIT_LIBERAPAY_LOGIN= + +; Ko-fi provider. +; Copy the verification token from https://ko-fi.com/manage/webhooks +CONTRIBKIT_KOFI_VERIFICATION_TOKEN= +; Optional event store path populated by `contribkit kofi-webhook`. +CONTRIBKIT_KOFI_DATA_FILE=./contribkit/kofi-events.json ``` > Only one provider is required to be configured. @@ -186,6 +195,9 @@ export default defineConfig({ liberapay: { // ... }, + kofi: { + // ... + }, // For contributor providers: githubContributions: { @@ -248,6 +260,31 @@ Set credentials with environment variables such as `CONTRIBKIT_GITHUB_TOKEN`. Check the type definition or source code for more utils available. +### Ko-fi Webhooks + +Ko-fi provides payment webhooks instead of an API for listing sponsors. Start the +ContribKit receiver: + +```bash +npx contribkit kofi-webhook +``` + +Expose `http://127.0.0.1:3456/kofi` through an HTTPS tunnel, then set that public +URL on the [Ko-fi webhooks page](https://ko-fi.com/manage/webhooks). ContribKit +verifies the webhook token, removes the token and email before persistence, +deduplicates retries by `message_id`, and stores events in +`./contribkit/kofi-events.json`. + +After Ko-fi sends a payment or test payment, generate the sponsor output normally: + +```bash +npx contribkit --force +``` + +Ko-fi does not send an event when a membership ends. ContribKit therefore treats +subscription payments as active for 35 days by default. Configure +`kofi.subscriptionEffectivity` to change this window. + ### Renderers We provide two renderers built-in: diff --git a/package-lock.json b/package-lock.json index a9c70ccb..d7b80f60 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,12 +10,15 @@ "license": "MIT", "dependencies": { "@crowdin/crowdin-api-client": "^1.41.2", - "ansis": "^4.3.0", + "ansis": "^4.3.1", "cac": "^7.0.0", "consola": "^3.4.2", + "d3-hierarchy": "^3.1.2", "dotenv": "^18.0.0", + "normalize-url": "^9.0.1", "ofetch": "^1.5.1", - "sharp": "^0.35.0", + "p-limit": "^7.3.1", + "sharp": "^0.35.3", "unconfig": "^7.5.0" }, "bin": { @@ -32,19 +35,20 @@ "@types/d3-hierarchy": "^3.1.7", "@types/node": "^25.9.1", "bumpp": "^12.0.0", - "d3-hierarchy": "^3.1.2", "eslint": "^10.4.0", "globals": "17.13.0", "jest": "30.5.2", "jest-environment-jsdom": "30.5.2", "jest-junit": "17.0.0", "jiti": "^2.7.0", - "normalize-url": "^9.0.0", "npm-run-all2": "9.0.3", - "p-limit": "^7.3.0", "tsx": "^4.22.3", "typescript": "^7.0.0", - "vite": "8.3.2" + "vite": "8.3.2", + "vitest": "^4.1.10" + }, + "engines": { + "node": "^22.18.0 || ^24.11.0 || >=26.0.0" }, "funding": { "url": "https://app.lizardbyte.dev" @@ -4839,6 +4843,13 @@ "@sinonjs/commons": "^3.0.1" } }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true, + "license": "MIT" + }, "node_modules/@tybys/wasm-util": { "version": "0.10.4", "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.4.tgz", @@ -5063,6 +5074,17 @@ "node": ">=6.9.0" } }, + "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/d3-hierarchy": { "version": "3.1.7", "resolved": "https://registry.npmjs.org/@types/d3-hierarchy/-/d3-hierarchy-3.1.7.tgz", @@ -5070,6 +5092,13 @@ "dev": true, "license": "MIT" }, + "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/esrecurse": { "version": "4.3.1", "resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz", @@ -5886,6 +5915,119 @@ "win32" ] }, + "node_modules/@vitest/expect": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.11.tgz", + "integrity": "sha512-VX2x5vNJXET47KAFzwERI+KRMtTTCSWTfSMKsW7JsUsXV4psq++e3DvZpuTDOpHcxytiDs6p2nhVb2tVDiiUYw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.1.11", + "@vitest/utils": "4.1.11", + "chai": "^6.2.2", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.11.tgz", + "integrity": "sha512-2XJVD55d1o5AZous5CCGKS74g/riOj9odEt2bQpCVZeblHyHdnMeFl4jl0XjU21stf4mbjUkew2eXQZt65g5CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.1.11", + "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 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.11.tgz", + "integrity": "sha512-yiZzPbGTS9Sr/JpFl8zHrcIkAofNbFV6k21vIgQN/cY/oxZeXhJv5sc/MBJ5jFKWmWs+oJHw0UXLZjmf931+Vw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.11.tgz", + "integrity": "sha512-LztvUgdwMNJMIkj3hQnnxiC2Xy1zNxq928W/xhjCLaNCzqTZOudjwbQf6v9IntZGPw132i2Lq2rgTRZHD3JHNw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.1.11", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.11.tgz", + "integrity": "sha512-pN7ikn1ON7h8ee4gIAp4AzyK+zBtJPzVbqOgu5LCEh4VaJVbPQcgYQYJIMGQPXVeJJq1fnfazis7a5pFNPahog==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.11", + "@vitest/utils": "4.1.11", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.11.tgz", + "integrity": "sha512-apNa/prQy2qCeywhnixOHPRCgGNhvg7T4Dapfl1GahLp/R+uhBm5cPyFoNVyqsNd2h1nJxL6BqqdIjiABL60YA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.11.tgz", + "integrity": "sha512-zTCVGpyFsGWBhllOyKlTw/vnr6D9qxsfSDyfbyZmTyjHw5N/VuvzHpHoQjm2ZJzn4RJgx5w4r7V0er69CmLgPQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.11", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, "node_modules/acorn": { "version": "8.18.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.18.0.tgz", @@ -6033,6 +6175,16 @@ "dev": true, "license": "MIT" }, + "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/asynckit": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", @@ -6595,6 +6747,16 @@ ], "license": "CC-BY-4.0" }, + "node_modules/chai": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.3.0.tgz", + "integrity": "sha512-XWAtwJ6OHO+tj0EKCs0Y2UamnyOxseZWltU4x2U2wh8g4AigdjwvtUjvLP2tqkA/avxHEtzxNaqGq/YGNwckKg==", + "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", @@ -6790,7 +6952,6 @@ "version": "3.1.2", "resolved": "https://registry.npmjs.org/d3-hierarchy/-/d3-hierarchy-3.1.2.tgz", "integrity": "sha512-FX/9frcub54beBdugHjDCdikxThEqjnR93Qt7PvQTOHxyiNCAlvMrHhclk3cD5VeAaq9fxmfRp+CnWw9rEMBuA==", - "dev": true, "license": "ISC", "engines": { "node": ">=12" @@ -7278,6 +7439,16 @@ "node": ">=4.0" } }, + "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", @@ -7340,6 +7511,16 @@ "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, + "node_modules/expect-type": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -10186,6 +10367,16 @@ "dev": true, "license": "ISC" }, + "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/make-dir": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", @@ -10389,7 +10580,6 @@ "version": "9.0.1", "resolved": "https://registry.npmjs.org/normalize-url/-/normalize-url-9.0.1.tgz", "integrity": "sha512-ARftfC5HdUNu9jJeL8pHj8debUIHA2b91FizCoMzY4lG6dDX13jdvTK0TBe24IBDRf2HvJSzzwEPvmbkQWHRSg==", - "dev": true, "license": "MIT", "engines": { "node": ">=20" @@ -10557,7 +10747,6 @@ "version": "7.3.3", "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-7.3.3.tgz", "integrity": "sha512-SKgVvDU5TNIxQ4r30U75BNLC6a+6GOEjngrk/ysM5+Zu3oJuk1SC5ApzIzY/7PjCEjoVPMWJ0zho/4QUiEB7TQ==", - "dev": true, "license": "MIT", "dependencies": { "yocto-queue": "^1.2.1" @@ -10749,6 +10938,13 @@ "node": "20 || >=22" } }, + "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/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -11274,6 +11470,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "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": "3.0.7", "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", @@ -11331,6 +11534,20 @@ "node": ">=8" } }, + "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": "4.3.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.3.0.tgz", + "integrity": "sha512-OtU/EgQ1kIm5KwqQpBC6ZEMXrZRui11w8zgfTWp8cdO9B8OaPsbA8bTHO2P+HNo1VlUTGMVBwPhydu6poeXiag==", + "dev": true, + "license": "MIT" + }, "node_modules/string-length": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/string-length/-/string-length-4.0.2.tgz", @@ -11559,6 +11776,13 @@ "url": "https://github.com/sponsors/isaacs" } }, + "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.3.1", "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.3.1.tgz", @@ -11586,6 +11810,16 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, + "node_modules/tinyrainbow": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.2.0.tgz", + "integrity": "sha512-LgO3D9yZJjApUiuUfl9iFAwrtaX4+lok3wJIqttGoKCHlWUqHqbQpnxCf82L8FjgKsh4iGo78hqJwgL8F6To2A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/tldts": { "version": "6.1.86", "resolved": "https://registry.npmjs.org/tldts/-/tldts-6.1.86.tgz", @@ -12055,6 +12289,96 @@ } } }, + "node_modules/vitest": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.11.tgz", + "integrity": "sha512-fhACrNXUidIbGSBr5FlbuBkO7VWC1ZyLl0DO4CU2DrQoAPxX84Ysxs+HeGQpii5lZWV1Q4gBZTTu49mF+A6Edw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.1.11", + "@vitest/mocker": "4.1.11", + "@vitest/pretty-format": "4.1.11", + "@vitest/runner": "4.1.11", + "@vitest/snapshot": "4.1.11", + "@vitest/spy": "4.1.11", + "@vitest/utils": "4.1.11", + "es-module-lexer": "^2.0.0", + "expect-type": "^1.3.0", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^4.0.0-rc.1", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.1.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.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.1.11", + "@vitest/browser-preview": "4.1.11", + "@vitest/browser-webdriverio": "4.1.11", + "@vitest/coverage-istanbul": "4.1.11", + "@vitest/coverage-v8": "4.1.11", + "@vitest/ui": "4.1.11", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "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/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, "node_modules/w3c-xmlserializer": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", @@ -12139,6 +12463,23 @@ "node": ">= 8" } }, + "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", @@ -12325,7 +12666,6 @@ "version": "1.2.2", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-1.2.2.tgz", "integrity": "sha512-4LCcse/U2MHZ63HAJVE+v71o7yOdIe4cZ70Wpf8D/IyjDKYQLV5GD46B+hSTjJsvV5PztjvHoU580EftxjDZFQ==", - "dev": true, "license": "MIT", "engines": { "node": ">=12.20" diff --git a/package.json b/package.json index a098ac42..795a4d67 100644 --- a/package.json +++ b/package.json @@ -24,7 +24,8 @@ "types": "./dist/index.d.ts", "default": "./dist/index.mjs" }, - "./package.json": "./package.json" + "./package.json": "./package.json", + "./cli": "./dist/cli.mjs" }, "main": "./dist/index.mjs", "module": "./dist/index.mjs", @@ -36,28 +37,35 @@ "bin", "dist" ], + "engines": { + "node": "^22.18.0 || ^24.11.0 || >=26.0.0" + }, "scripts": { "build": "npm-run-all build:js build:types", "build:js": "vite build", "build:types": "tsc --project tsconfig.build.json", "dev": "tsx src/cli.ts", - "test": "npm-run-all test:unit test:report test:lint test:typecheck", + "test": "npm-run-all test:unit test:report test:upstream test:lint test:typecheck", "test:unit": "jest --coverage", "test:report": "jest --reporters=jest-junit", "test:lint": "eslint .", "test:typecheck": "tsc --noEmit", "prepublishOnly": "nr build", - "release": "bumpp && pnpm publish" + "release": "bumpp && pnpm publish", + "test:upstream": "vitest run" }, "dependencies": { "@crowdin/crowdin-api-client": "^1.41.2", - "ansis": "^4.3.0", + "ansis": "^4.3.1", "cac": "^7.0.0", "consola": "^3.4.2", "dotenv": "^18.0.0", "ofetch": "^1.5.1", - "sharp": "^0.35.0", - "unconfig": "^7.5.0" + "sharp": "^0.35.3", + "unconfig": "^7.5.0", + "d3-hierarchy": "^3.1.2", + "normalize-url": "^9.0.1", + "p-limit": "^7.3.1" }, "devDependencies": { "@antfu/ni": "^30.1.0", @@ -70,25 +78,26 @@ "@types/d3-hierarchy": "^3.1.7", "@types/node": "^25.9.1", "bumpp": "^12.0.0", - "d3-hierarchy": "^3.1.2", "eslint": "^10.4.0", "globals": "17.13.0", "jest": "30.5.2", "jest-environment-jsdom": "30.5.2", "jest-junit": "17.0.0", "jiti": "^2.7.0", - "normalize-url": "^9.0.0", "npm-run-all2": "9.0.3", - "p-limit": "^7.3.0", "tsx": "^4.22.3", "typescript": "^7.0.0", - "vite": "8.3.2" + "vite": "8.3.2", + "vitest": "^4.1.10" }, "jest": { "collectCoverageFrom": [ "src/**/*.{js,jsx}" ], - "testEnvironment": "jsdom" + "testEnvironment": "jsdom", + "roots": [ + "/tests" + ] }, "overrides": { "@codecov/vite-plugin": { diff --git a/src/cli.ts b/src/cli.ts index a1d63e8f..7c645de7 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -1,6 +1,9 @@ import type { ContribkitConfig } from './types.js' import cac from 'cac' import { version } from '../package.json' +import { getCredentials } from './configs/credentials.js' +import { loadConfig } from './configs/index.js' +import { DEFAULT_KOFI_DATA_FILE, startKofiWebhookServer } from './providers/kofi.js' import { run } from './run.js' const RE_FILTER = /^(<=?|>=?)(\d+)$/ @@ -8,6 +11,31 @@ const cli = cac('contributors-svg') .version(version) .help() +cli + .command('kofi-webhook', 'Receive and store Ko-fi payment webhooks') + .option('--host ', 'Host to listen on', { default: '127.0.0.1' }) + .option('--port ', 'Port to listen on', { default: 3456 }) + .option('--path ', 'Webhook path', { default: '/kofi' }) + .option('--data-file ', 'Ko-fi event store') + .action(async (options) => { + const config = await loadConfig() + const verificationToken = getCredentials(config).kofi?.verificationToken + if (!verificationToken) { + throw new Error('Ko-fi verification token is required') + } + const dataFile = options.dataFile || config.kofi?.dataFile || DEFAULT_KOFI_DATA_FILE + const port = Number.parseInt(options.port) + await startKofiWebhookServer({ + verificationToken, + dataFile, + host: options.host, + port, + path: options.path, + }) + console.log(`[contribkit] Ko-fi webhook listening on http://${options.host}:${port}${options.path}`) + console.log(`[contribkit] Storing sanitized events in ${resolveDisplayPath(dataFile)}`) + }) + cli .command('[outputDir]', 'Generate contributors SVG') .option('--width, -w ', 'SVG width', { default: 800 }) @@ -53,3 +81,7 @@ function createFilterFromString(template: string): ContribkitConfig['filter'] { return s => s.monthlyDollars >= num throw new Error(`Unable to parse filter template ${template}`) } + +function resolveDisplayPath(path: string) { + return path.replaceAll('\\', '/') +} diff --git a/src/configs/credentials.ts b/src/configs/credentials.ts index 2cc7799e..a6e680a6 100644 --- a/src/configs/credentials.ts +++ b/src/configs/credentials.ts @@ -18,6 +18,9 @@ export interface EnvCredentials { polar?: { token?: string } + kofi?: { + verificationToken?: string + } githubContributors?: { token?: string } @@ -66,6 +69,9 @@ export function loadEnvCredentials(): EnvCredentials { polar: { token: process.env.CONTRIBKIT_POLAR_TOKEN || process.env.POLAR_TOKEN, }, + kofi: { + verificationToken: process.env.CONTRIBKIT_KOFI_VERIFICATION_TOKEN || process.env.KOFI_VERIFICATION_TOKEN, + }, githubContributors: { token: process.env.CONTRIBKIT_GITHUB_CONTRIBUTORS_TOKEN, }, diff --git a/src/configs/env.ts b/src/configs/env.ts index ae97924c..03bcb57d 100644 --- a/src/configs/env.ts +++ b/src/configs/env.ts @@ -53,6 +53,9 @@ export function loadEnv(): EnvConfig { maxContributions: Number(process.env.CONTRIBKIT_GITHUB_CONTRIBUTIONS_MAX) || undefined, logarithmicScaling: process.env.CONTRIBKIT_GITHUB_CONTRIBUTIONS_LOGARITHMIC === 'true', }, + kofi: { + dataFile: process.env.CONTRIBKIT_KOFI_DATA_FILE, + }, } // remove undefined keys diff --git a/src/configs/index.test.ts b/src/configs/index.test.ts new file mode 100644 index 00000000..c78cba08 --- /dev/null +++ b/src/configs/index.test.ts @@ -0,0 +1,73 @@ +import type { Sponsorship, Tier } from '../types.js' +import { describe, expect, it } from 'vitest' +import { partitionTiers } from './index.js' + +function sponsor(login: string, monthlyDollars: number, createdAt: string): Sponsorship { + return { + sponsor: { + type: 'User', + login, + name: login, + avatarUrl: '', + }, + monthlyDollars, + createdAt, + } +} + +const tiers: Tier[] = [ + { title: 'Backers' }, // the required zero-dollar tier + { title: 'Sponsors', monthlyDollars: 10 }, + { title: 'Gold', monthlyDollars: 100 }, +] + +describe('partitionTiers', () => { + it('requires exactly one tier without monthlyDollars', () => { + expect(() => partitionTiers([], [{ title: 'a', monthlyDollars: 10 }])) + .toThrow('There should be exactly one tier with no `monthlyDollars`, but got 0') + + expect(() => partitionTiers([], [{ title: 'a' }, { title: 'b' }])) + .toThrow('There should be exactly one tier with no `monthlyDollars`, but got 2') + }) + + it('buckets sponsors into the highest tier they qualify for', () => { + const sponsors = [ + sponsor('gold', 100, '2024-01-01'), + sponsor('mid', 10, '2024-01-02'), + sponsor('small', 5, '2024-01-03'), + ] + + const result = partitionTiers(sponsors, tiers) + + // sorted by monthlyDollars descending + expect(result.map(t => t.monthlyDollars)).toEqual([100, 10, 0]) + expect(result[0].sponsors.map(s => s.sponsor.login)).toEqual(['gold']) + expect(result[1].sponsors.map(s => s.sponsor.login)).toEqual(['mid']) + expect(result[2].sponsors.map(s => s.sponsor.login)).toEqual(['small']) + }) + + it('excludes past sponsors unless includePastSponsors is set', () => { + const sponsors = [ + sponsor('active', 10, '2024-01-01'), + sponsor('past', -1, '2024-01-02'), + ] + + const without = partitionTiers(structuredClone(sponsors), tiers) + expect(without.flatMap(t => t.sponsors.map(s => s.sponsor.login))).toEqual(['active']) + + const withPast = partitionTiers(structuredClone(sponsors), tiers, true) + expect(withPast.flatMap(t => t.sponsors.map(s => s.sponsor.login)).sort()) + .toEqual(['active', 'past']) + }) + + it('orders sponsors within a tier by createdAt ascending', () => { + const sponsors = [ + sponsor('later', 10, '2024-03-01'), + sponsor('earlier', 10, '2024-01-01'), + ] + + const result = partitionTiers(sponsors, tiers) + const bucket = result.find(t => t.monthlyDollars === 10)! + expect(bucket.sponsors.map(s => s.sponsor.login)).toEqual(['earlier', 'later']) + }) +}) diff --git a/src/configs/index.ts b/src/configs/index.ts index da1f714c..863f6416 100644 --- a/src/configs/index.ts +++ b/src/configs/index.ts @@ -63,6 +63,11 @@ export async function loadConfig(inlineConfig: ContribkitConfig = {}): Promise diff --git a/src/processing/svg.test.ts b/src/processing/svg.test.ts new file mode 100644 index 00000000..47b42596 --- /dev/null +++ b/src/processing/svg.test.ts @@ -0,0 +1,102 @@ +import type { BadgePreset, ContribkitRenderOptions, Sponsorship } from '../types.js' +import { Buffer } from 'node:buffer' +import { describe, expect, it } from 'vitest' +import { genSvgImage, SvgComposer } from './svg.js' + +function createComposer(width = 800) { + return new SvgComposer({ + width, + svgInlineCSS: '.text { fill: red }', + imageFormat: 'webp', + } as unknown as Required) +} + +describe('svgComposer', () => { + it('starts empty', () => { + const composer = createComposer() + expect(composer.height).toBe(0) + expect(composer.body).toBe('') + }) + + it('addText appends centered text and advances the height', () => { + const composer = createComposer(800) + composer.addText('Hello') + expect(composer.height).toBe(20) + expect(composer.body).toContain('x="400"') + expect(composer.body).toContain('>Hello') + expect(composer.body).toContain('class="text"') + }) + + it('addTitle uses the tier-title class', () => { + const composer = createComposer() + composer.addTitle('Backers') + expect(composer.body).toContain('class="contribkit-tier-title"') + expect(composer.body).toContain('>Backers') + }) + + it('addSpan only advances the height', () => { + const composer = createComposer() + composer.addSpan(42) + expect(composer.height).toBe(42) + expect(composer.body).toBe('') + }) + + it('addRaw appends without touching the height', () => { + const composer = createComposer() + composer.addRaw('') + expect(composer.body).toBe('') + expect(composer.height).toBe(0) + }) + + it('is chainable', () => { + const composer = createComposer() + expect(composer.addSpan(10).addText('x')).toBe(composer) + }) + + it('generateSvg wraps the body with dimensions and inline css', () => { + const composer = createComposer(600) + composer.addText('Hi') + const svg = composer.generateSvg() + expect(svg).toContain('viewBox="0 0 600 20"') + expect(svg).toContain('width="600"') + expect(svg).toContain('height="20"') + expect(svg).toContain('') + expect(svg).toContain('>Hi') + }) +}) + +describe('render-local crop ids', () => { + it('keeps identical avatars unique across repeated rows and resets for each render', async () => { + const sponsors: Sponsorship[] = Array.from({ length: 2 }, (_, index) => ({ + sponsor: { type: 'User', login: `user-${index}`, name: `User ${index}`, avatarUrl: '', avatarBuffer: Buffer.from('same-avatar') }, + monthlyDollars: 5, + })) + const preset: BadgePreset = { boxWidth: 120, boxHeight: 0, avatar: { size: 120 }, name: false } + const composer = createComposer() + await composer.addSponsorLine(sponsors, preset) + await composer.addSponsorLine(sponsors, preset) + const ids = [...composer.generateSvg().matchAll(/ match[1]) + expect(ids).toEqual(['c0', 'c1', 'c2', 'c3']) + expect(new Set(ids).size).toBe(4) + const next = createComposer() + await next.addSponsorLine(sponsors, preset) + expect(next.body).toContain('') + }) +}) + +describe('genSvgImage', () => { + it('embeds the image as a base64 data uri', () => { + const svg = genSvgImage(1, 2, 40, 0.5, 'QUJD', 'png', 'crop-1') + expect(svg).toContain('href="data:image/png;base64,QUJD"') + expect(svg).toContain('x="1"') + expect(svg).toContain('y="2"') + expect(svg).toContain('width="40"') + expect(svg).toContain('rx="20"') // size * radius + }) + + it('uses the provided crop id for both the clip path and its reference', () => { + const svg = genSvgImage(0, 0, 40, 0.5, 'SAME', 'webp', 'render-crop-42') + expect(svg).toContain('') + expect(svg).toContain('clip-path="url(#render-crop-42)"') + }) +}) diff --git a/src/processing/svg.ts b/src/processing/svg.ts index a23eb46b..40a793c4 100644 --- a/src/processing/svg.ts +++ b/src/processing/svg.ts @@ -1,5 +1,4 @@ import type { BadgePreset, ContribkitRenderOptions, ImageFormat, Sponsor, Sponsorship } from '../types.js' -import crypto from 'node:crypto' import { resizeImage } from './image.js' export function genSvgImage( @@ -9,10 +8,8 @@ export function genSvgImage( radius: number, base64Image: string, imageFormat: ImageFormat, + cropId: string, ) { - // Unique clipPath id per element, ensuring duplicated images are properly rendered. - const hashInput = `${x}:${y}:${size}:${radius}:${base64Image}` - const cropId = `c${crypto.createHash('sha256').update(hashInput).digest('hex').slice(0, 6)}` return ` @@ -27,6 +24,7 @@ export async function generateBadge( preset: BadgePreset, radius: number, imageFormat: ImageFormat, + cropId: string, ) { const { login } = sponsor let name = (sponsor.name || sponsor.login).trim() @@ -59,15 +57,24 @@ export async function generateBadge( : '' return ` - ${nameSvg}${genSvgImage(x, y, size, radius, avatarBase64, imageFormat)} + ${nameSvg}${genSvgImage(x, y, size, radius, avatarBase64, imageFormat, cropId)} `.trim() } export class SvgComposer { height = 0 body = '' + private cropId = 0 - constructor(public readonly config: Required) {} + readonly config: Required + + constructor(config: Required) { + this.config = config + } + + getNextCropId() { + return `c${this.cropId++}` + } addSpan(height = 0) { this.height += height @@ -96,7 +103,8 @@ export class SvgComposer { const x = offsetX + preset.boxWidth * i const y = this.height const radius = s.sponsor.type === 'Organization' ? 0.1 : 0.5 - return await generateBadge(x, y, s.sponsor, preset, radius, this.config.imageFormat) + const cropId = this.getNextCropId() + return await generateBadge(x, y, s.sponsor, preset, radius, this.config.imageFormat, cropId) })) this.body += sponsorLine.join('\n') diff --git a/src/providers/github.test.ts b/src/providers/github.test.ts new file mode 100644 index 00000000..9362f468 --- /dev/null +++ b/src/providers/github.test.ts @@ -0,0 +1,158 @@ +import type { Mock } from 'vitest' +import type { ContribkitConfig } from '../types.js' +import { $fetch } from 'ofetch' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { + fetchGitHubSponsors, + makeQuery, + makeSponsoringQuery, + makeSponsoringTotalAmountQuery, +} from './github.js' + +vi.mock('ofetch', () => ({ $fetch: vi.fn() })) + +const fetchMock = $fetch as unknown as Mock + +beforeEach(() => { + fetchMock.mockReset() +}) + +describe('makeQuery', () => { + it('embeds the login and account type', () => { + expect(makeQuery('antfu', 'user')).toContain('user(login: "antfu")') + expect(makeQuery('antfu', 'organization')).toContain('organization(login: "antfu")') + }) + + it('queries sponsorships as a maintainer', () => { + expect(makeQuery('antfu', 'user')).toContain('sponsorshipsAsMaintainer(') + }) + + it('interpolates the activeOnly flag', () => { + expect(makeQuery('antfu', 'user', true)).toContain('activeOnly: true') + expect(makeQuery('antfu', 'user', false)).toContain('activeOnly: false') + }) + + it('adds an after cursor only when provided', () => { + expect(makeQuery('antfu', 'user', true)).not.toContain('after:') + expect(makeQuery('antfu', 'user', true, 'CURSOR')).toContain('after: "CURSOR"') + }) +}) + +describe('makeSponsoringQuery', () => { + it('queries sponsorships as a sponsor', () => { + const query = makeSponsoringQuery('antfu', 'user', false, 'CURSOR') + expect(query).toContain('sponsorshipsAsSponsor(') + expect(query).toContain('activeOnly: false') + expect(query).toContain('after: "CURSOR"') + }) +}) + +describe('makeSponsoringTotalAmountQuery', () => { + it('omits parameters when no options are given', () => { + const query = makeSponsoringTotalAmountQuery('antfu', 'user') + expect(query).toContain('totalSponsorshipAmountAsSponsorInCents\n') + expect(query).not.toContain('totalSponsorshipAmountAsSponsorInCents(') + }) + + it('serializes since, until and sponsorableLogins', () => { + const query = makeSponsoringTotalAmountQuery('antfu', 'user', { + since: '2024-01-01', + until: '2024-12-31', + sponsorableLogins: ['vuejs', 'vitejs'], + }) + expect(query).toContain('since: "2024-01-01"') + expect(query).toContain('until: "2024-12-31"') + expect(query).toContain('sponsorableLogins: ["vuejs", "vitejs"]') + }) +}) + +describe('fetchGitHubSponsors', () => { + it('validates its required arguments', async () => { + await expect(fetchGitHubSponsors('', 'antfu', 'user', {})) + .rejects + .toThrow('GitHub token is required') + await expect(fetchGitHubSponsors('token', '', 'user', {})) + .rejects + .toThrow('GitHub login is required') + await expect(fetchGitHubSponsors('token', 'antfu', 'invalid' as any, {})) + .rejects + .toThrow('GitHub type must be either `user` or `organization`') + }) + + it('paginates and maps the response into sponsorships', async () => { + const page = (login: string, hasNextPage: boolean, endCursor: string | null) => ({ + data: { + user: { + sponsorshipsAsMaintainer: { + totalCount: 2, + pageInfo: { hasNextPage, endCursor }, + nodes: [ + { + createdAt: '2024-01-01T00:00:00Z', + privacyLevel: 'PUBLIC', + isActive: true, + tier: { name: 'Gold', isOneTime: false, monthlyPriceInCents: 1000, monthlyPriceInDollars: 10 }, + sponsorEntity: { __typename: 'User', login, name: login, avatarUrl: 'a', websiteUrl: 'example.com' }, + }, + ], + }, + }, + }, + }) + + fetchMock + .mockResolvedValueOnce(page('alice', true, 'CURSOR')) + .mockResolvedValueOnce(page('bob', false, null)) + + const sponsors = await fetchGitHubSponsors('token', 'antfu', 'user', {}) + + expect(fetchMock).toHaveBeenCalledTimes(2) + // second request carries the cursor from the first page + expect(fetchMock.mock.calls[1][1].body.query).toContain('after: "CURSOR"') + + expect(sponsors.map(s => s.sponsor.login)).toEqual(['alice', 'bob']) + expect(sponsors[0]).toMatchObject({ + monthlyDollars: 10, + tierName: 'Gold', + isOneTime: false, + sponsor: { + login: 'alice', + type: 'User', + websiteUrl: 'https://example.com', + linkUrl: 'https://github.com/alice', + }, + }) + }) + + it('marks inactive non-prorated sponsors as past sponsors', async () => { + fetchMock.mockResolvedValueOnce({ + data: { + user: { + sponsorshipsAsMaintainer: { + totalCount: 1, + pageInfo: { hasNextPage: false, endCursor: null }, + nodes: [ + { + createdAt: '2024-01-01T00:00:00Z', + privacyLevel: 'PUBLIC', + isActive: false, + tier: { name: 'Gold', isOneTime: false, monthlyPriceInCents: 1000, monthlyPriceInDollars: 10 }, + sponsorEntity: { __typename: 'User', login: 'past', name: 'past', avatarUrl: 'a' }, + }, + ], + }, + }, + }, + }) + + const sponsors = await fetchGitHubSponsors('token', 'antfu', 'user', {}) + expect(sponsors[0].monthlyDollars).toBe(-1) + }) + + it('throws when the API returns errors', async () => { + fetchMock.mockResolvedValueOnce({ errors: [{ type: 'INSUFFICIENT_SCOPES' }] }) + await expect(fetchGitHubSponsors('token', 'antfu', 'user', {} as ContribkitConfig)) + .rejects + .toThrow('read:user') + }) +}) diff --git a/src/providers/index.test.ts b/src/providers/index.test.ts new file mode 100644 index 00000000..2a9cadad --- /dev/null +++ b/src/providers/index.test.ts @@ -0,0 +1,85 @@ +import type { Provider } from '../types.js' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { getCredentials } from '../configs/credentials.js' +import { loadEnv } from '../configs/env.js' +import { GitHubProvider } from './github.js' +import { guessProviders, ProvidersMap, resolveProviders } from './index.js' + +afterEach(() => vi.unstubAllEnvs()) + +describe('guessProviders', () => { + it('falls back to github when nothing is configured', () => { + expect(guessProviders({})).toEqual(['github']) + }) + + it('detects each provider from its required config', () => { + expect(guessProviders({ github: { login: 'antfu' } })).toEqual(['github']) + expect(guessProviders({ credentials: { patreon: { token: 't' } } } as Parameters[0])).toEqual(['patreon']) + expect(guessProviders({ opencollective: { slug: 'antfu' } })).toEqual(['opencollective']) + expect(guessProviders({ afdian: { userId: 'u' }, credentials: { afdian: { token: 't' } } } as Parameters[0])).toEqual(['afdian']) + expect(guessProviders({ credentials: { polar: { token: 't' } } } as Parameters[0])).toEqual(['polar']) + expect(guessProviders({ liberapay: { login: 'antfu' } })).toEqual(['liberapay']) + expect(guessProviders({ credentials: { kofi: { verificationToken: 't' } } } as Parameters[0])).toEqual(['kofi']) + expect(guessProviders({ kofi: { dataFile: './events.json' } })).toEqual(['kofi']) + }) + + it('reads Ko-fi credentials separately from the public config', () => { + vi.stubEnv('CONTRIBKIT_KOFI_VERIFICATION_TOKEN', 'webhook-token') + vi.stubEnv('CONTRIBKIT_KOFI_DATA_FILE', './events.json') + const env = loadEnv() + expect(env.config.kofi).toEqual({ dataFile: './events.json' }) + expect(getCredentials(env.config).kofi).toEqual({ verificationToken: 'webhook-token' }) + expect(guessProviders(env.config)).toEqual(['kofi']) + }) + + it('ignores providers with incomplete config', () => { + // afdian needs both userId and token + expect(guessProviders({ afdian: { userId: 'u' } })).toEqual(['github']) + // github needs a login + expect(guessProviders({ github: {} })).toEqual(['github']) + }) + + it('collects multiple providers in declaration order', () => { + expect(guessProviders({ + github: { login: 'antfu' }, + credentials: { polar: { token: 't' } }, + kofi: { dataFile: './events.json' }, + } as Parameters[0])).toEqual(['github', 'polar', 'kofi']) + }) +}) + +describe('resolveProviders', () => { + it('maps provider names to their implementations', () => { + expect(resolveProviders(['github'])).toEqual([GitHubProvider]) + }) + + it('deduplicates repeated names', () => { + expect(resolveProviders(['github', 'github'])).toEqual([GitHubProvider]) + }) + + it('passes custom provider objects through untouched', () => { + const custom: Provider = { name: 'custom', fetchSponsors: async () => [] } + expect(resolveProviders([custom])).toEqual([custom]) + }) + + it('throws on an unknown provider name', () => { + // @ts-expect-error deliberately invalid provider name + expect(() => resolveProviders(['nope'])).toThrow('Unknown provider: nope') + }) + + it('exposes every known provider in ProvidersMap', () => { + expect(Object.keys(ProvidersMap)).toEqual([ + 'github', + 'patreon', + 'opencollective', + 'afdian', + 'polar', + 'liberapay', + 'githubContributors', + 'githubContributions', + 'gitlabContributors', + 'crowdinContributors', + 'kofi', + ]) + }) +}) diff --git a/src/providers/index.ts b/src/providers/index.ts index a8099603..572057c9 100644 --- a/src/providers/index.ts +++ b/src/providers/index.ts @@ -6,12 +6,14 @@ import { GitHubProvider } from './github.js' import { GitHubContributorsProvider } from './githubContributors.js' import { GitHubContributionsProvider } from './githubContributions.js' import { GitlabContributorsProvider } from './gitlabContributors.js' +import { KofiProvider } from './kofi.js' import { LiberapayProvider } from './liberapay.js' import { OpenCollectiveProvider } from './opencollective.js' import { PatreonProvider } from './patreon.js' import { PolarProvider } from './polar.js' export * from './github.js' +export * from './kofi.js' export const ProvidersMap = { github: GitHubProvider, @@ -24,6 +26,7 @@ export const ProvidersMap = { githubContributions: GitHubContributionsProvider, gitlabContributors: GitlabContributorsProvider, crowdinContributors: CrowdinContributorsProvider, + kofi: KofiProvider, } export function guessProviders(config: ContribkitConfig) { @@ -36,6 +39,7 @@ export function guessProviders(config: ContribkitConfig) { ['afdian', config.afdian?.userId && credentials.afdian?.token], ['polar', credentials.polar?.token], ['liberapay', config.liberapay?.login], + ['kofi', credentials.kofi?.verificationToken || config.kofi?.dataFile], ['githubContributors', config.githubContributors?.login && credentials.githubContributors?.token], ['githubContributions', config.githubContributions?.login && credentials.githubContributions?.token], ['gitlabContributors', credentials.gitlabContributors?.token && config.gitlabContributors?.repoId], diff --git a/src/providers/kofi.test.ts b/src/providers/kofi.test.ts new file mode 100644 index 00000000..45dd3aa8 --- /dev/null +++ b/src/providers/kofi.test.ts @@ -0,0 +1,222 @@ +import type { AddressInfo } from 'node:net' +import { mkdtemp, readFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { + fetchKofiSponsors, + parseKofiWebhookBody, + startKofiWebhookServer, + storeKofiEvent, +} from './kofi.js' + +const servers: Awaited>[] = [] + +afterEach(async () => { + await Promise.all(servers.splice(0).map(server => new Promise((resolve, reject) => { + server.close(error => error ? reject(error) : resolve()) + }))) +}) + +describe('ko-fi provider', () => { + it('verifies and sanitizes webhook payloads', () => { + const event = parseKofiWebhookBody(createBody(), 'correct-token') + + expect(event).toMatchObject({ + messageId: 'message-1', + timestamp: '2026-07-27T05:00:00.000Z', + type: 'Subscription', + isPublic: true, + fromName: 'Ada', + amount: 5, + currency: 'USD', + isSubscriptionPayment: true, + tierName: 'Gold', + }) + expect(event).not.toHaveProperty('verification_token') + expect(event).not.toHaveProperty('email') + }) + + it('rejects a webhook with the wrong verification token', () => { + expect(() => parseKofiWebhookBody(createBody(), 'wrong-token')) + .toThrow('Invalid Ko-fi verification token') + }) + + it('scrubs the identity of private payments', () => { + const event = parseKofiWebhookBody(createBody({ + is_public: false, + from_name: 'Private Name', + email: 'private@example.com', + }), 'correct-token') + + expect(event.fromName).toBe('Private Sponsor') + expect(event.isPublic).toBe(false) + }) + + it('groups private subscription renewals without exposing their identity', async () => { + const directory = await mkdtemp(join(tmpdir(), 'contribkit-kofi-private-')) + const dataFile = join(directory, 'events.json') + const firstPayment = parseKofiWebhookBody(createBody({ + message_id: 'private-payment-1', + timestamp: '2026-07-01T00:00:00Z', + is_public: false, + from_name: 'Private Name', + email: 'Private@Example.com', + }), 'correct-token') + const renewal = parseKofiWebhookBody(createBody({ + message_id: 'private-payment-2', + timestamp: '2026-07-20T00:00:00Z', + is_public: false, + from_name: 'Private Name', + email: 'private@example.com', + is_first_subscription_payment: false, + }), 'correct-token') + + expect(firstPayment.sponsorKey).toBe(renewal.sponsorKey) + await storeKofiEvent(firstPayment, dataFile) + await storeKofiEvent(renewal, dataFile) + + const sponsors = await fetchKofiSponsors( + { dataFile }, + Date.parse('2026-07-27T00:00:00Z'), + ) + expect(sponsors).toHaveLength(1) + expect(sponsors[0]).toMatchObject({ + monthlyDollars: 5, + privacyLevel: 'PRIVATE', + sponsor: { name: 'Private Sponsor' }, + }) + }) + + it('does not merge unrelated anonymous payments', () => { + const firstPayment = parseKofiWebhookBody(createBody({ + message_id: 'anonymous-payment-1', + type: 'Tip', + from_name: '', + email: '', + is_subscription_payment: false, + }), 'correct-token') + const secondPayment = parseKofiWebhookBody(createBody({ + message_id: 'anonymous-payment-2', + type: 'Tip', + from_name: '', + email: '', + is_subscription_payment: false, + }), 'correct-token') + + expect(firstPayment.fromName).toBe('Anonymous') + expect(firstPayment.sponsorKey).not.toBe(secondPayment.sponsorKey) + }) + + it('keeps public and private payments from the same identity separate', () => { + const publicPayment = parseKofiWebhookBody(createBody(), 'correct-token') + const privatePayment = parseKofiWebhookBody(createBody({ + message_id: 'private-payment', + is_public: false, + }), 'correct-token') + + expect(publicPayment.sponsorKey).not.toBe(privatePayment.sponsorKey) + }) + + it('deduplicates retries and aggregates recent payments', async () => { + const directory = await mkdtemp(join(tmpdir(), 'contribkit-kofi-')) + const dataFile = join(directory, 'events.json') + const subscription = parseKofiWebhookBody(createBody({ + timestamp: '2026-07-01T00:00:00Z', + }), 'correct-token') + const tip = parseKofiWebhookBody(createBody({ + message_id: 'message-2', + timestamp: '2026-07-05T00:00:00Z', + type: 'Tip', + amount: '2', + is_subscription_payment: false, + is_first_subscription_payment: false, + tier_name: undefined, + }), 'correct-token') + + expect(await storeKofiEvent(subscription, dataFile)).toBe(true) + expect(await storeKofiEvent(subscription, dataFile)).toBe(false) + expect(await storeKofiEvent(tip, dataFile)).toBe(true) + + const sponsors = await fetchKofiSponsors( + { dataFile }, + Date.parse('2026-07-20T00:00:00Z'), + ) + expect(sponsors).toHaveLength(1) + expect(sponsors[0]).toMatchObject({ + monthlyDollars: 7, + expireAt: '2026-08-05T00:00:00.000Z', + privacyLevel: 'PUBLIC', + isOneTime: false, + sponsor: { + name: 'Ada', + avatarUrl: '', + }, + }) + }) + + it('receives a live-shaped HTTP form post and persists it', async () => { + const directory = await mkdtemp(join(tmpdir(), 'contribkit-kofi-http-')) + const dataFile = join(directory, 'events.json') + const server = await startKofiWebhookServer({ + verificationToken: 'correct-token', + dataFile, + host: '127.0.0.1', + port: 0, + }) + servers.push(server) + const { port } = server.address() as AddressInfo + + const response = await fetch(`http://127.0.0.1:${port}/kofi`, { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: createBody(), + }) + + expect(response.status).toBe(200) + expect(await response.json()).toEqual({ ok: true, stored: true }) + const stored = JSON.parse(await readFile(dataFile, 'utf8')) + expect(stored.events).toHaveLength(1) + expect(stored.events[0]).not.toHaveProperty('verification_token') + }) + + it('provides a browser-friendly health check', async () => { + const server = await startKofiWebhookServer({ + verificationToken: 'correct-token', + host: '127.0.0.1', + port: 0, + }) + servers.push(server) + const { port } = server.address() as AddressInfo + + const response = await fetch(`http://127.0.0.1:${port}/kofi`) + + expect(response.status).toBe(200) + await expect(response.json()).resolves.toMatchObject({ + ok: true, + message: expect.stringContaining('receiver is ready'), + }) + }) +}) + +function createBody(overrides: Record = {}) { + const payload = { + verification_token: 'correct-token', + message_id: 'message-1', + timestamp: '2026-07-27T05:00:00Z', + type: 'Subscription', + is_public: true, + from_name: 'Ada', + message: 'Thank you', + amount: '5', + url: 'https://ko-fi.com/example', + email: 'ada@example.com', + currency: 'USD', + is_subscription_payment: true, + is_first_subscription_payment: true, + tier_name: 'Gold', + kofi_transaction_id: 'transaction-1', + ...overrides, + } + return new URLSearchParams({ data: JSON.stringify(payload) }).toString() +} diff --git a/src/providers/kofi.ts b/src/providers/kofi.ts new file mode 100644 index 00000000..f35aa587 --- /dev/null +++ b/src/providers/kofi.ts @@ -0,0 +1,323 @@ +import type { IncomingMessage, Server, ServerResponse } from 'node:http' +import type { Provider, ContribkitConfig, Sponsorship } from '../types.js' +import { Buffer } from 'node:buffer' +import { createHash, timingSafeEqual } from 'node:crypto' +import { promises as fsp } from 'node:fs' +import { createServer } from 'node:http' +import { dirname, resolve } from 'node:path' +import process from 'node:process' +import { $fetch } from 'ofetch' + +export const DEFAULT_KOFI_DATA_FILE = './contribkit/kofi-events.json' + +export interface KofiWebhookPayload { + verification_token?: string + message_id?: string + timestamp?: string + type?: string + is_public?: boolean | string + from_name?: string + message?: string + amount?: string + url?: string + email?: string + currency?: string + is_subscription_payment?: boolean | string + is_first_subscription_payment?: boolean | string + tier_name?: string + kofi_transaction_id?: string + [key: string]: unknown +} + +export interface StoredKofiEvent { + messageId: string + sponsorKey: string + timestamp: string + type: string + isPublic: boolean + fromName: string + amount: number + currency: string + isSubscriptionPayment: boolean + isFirstSubscriptionPayment: boolean + tierName?: string + transactionId?: string +} + +interface KofiEventStore { + version: 1 + events: StoredKofiEvent[] +} + +interface ExchangeRate { + inverseRate: number +} + +type ExchangeRates = Record + +export interface KofiWebhookServerOptions { + verificationToken: string + dataFile?: string + host?: string + port?: number + path?: string +} + +export const KofiProvider: Provider = { + name: 'kofi', + fetchSponsors(config) { + if (config.mode === 'sponsees') { + console.warn('[contribkit] Ko-fi provider does not support `mode: "sponsees"` yet') + return Promise.resolve([]) + } + + return fetchKofiSponsors(config.kofi) + }, +} + +export function parseKofiWebhookBody(body: string, verificationToken: string): StoredKofiEvent { + if (!verificationToken) + throw new Error('Ko-fi verification token is required') + + const data = new URLSearchParams(body).get('data') + if (!data) + throw new Error('Ko-fi webhook body must contain a `data` form field') + + let payload: KofiWebhookPayload + try { + payload = JSON.parse(data) + } + catch { + throw new Error('Ko-fi webhook `data` field must contain valid JSON') + } + + if (!payload.verification_token || !tokensEqual(payload.verification_token, verificationToken)) + throw new Error('Invalid Ko-fi verification token') + if (!payload.message_id) + throw new Error('Ko-fi webhook payload is missing `message_id`') + if (!payload.timestamp || !Number.isFinite(Date.parse(payload.timestamp))) + throw new Error('Ko-fi webhook payload has an invalid `timestamp`') + + const isPublic = toBoolean(payload.is_public) + const fromName = isPublic ? payload.from_name?.trim() || 'Anonymous' : 'Private Sponsor' + const email = payload.email?.trim().toLowerCase() + const identity = email ? `email:${email}` : `event:${payload.message_id}` + + return { + messageId: payload.message_id, + // Keep private activity separate so it can never contribute to public output. + sponsorKey: sha256(`${isPublic ? 'public' : 'private'}:${identity}`), + timestamp: new Date(payload.timestamp).toISOString(), + type: payload.type?.trim() || 'Tip', + isPublic, + fromName, + amount: Number.parseFloat(payload.amount || '0') || 0, + currency: payload.currency?.trim().toUpperCase() || 'USD', + isSubscriptionPayment: toBoolean(payload.is_subscription_payment) || payload.type === 'Subscription', + isFirstSubscriptionPayment: toBoolean(payload.is_first_subscription_payment), + tierName: payload.tier_name?.trim() || undefined, + transactionId: payload.kofi_transaction_id?.trim() || undefined, + } +} + +export async function storeKofiEvent(event: StoredKofiEvent, dataFile = DEFAULT_KOFI_DATA_FILE): Promise { + const path = resolve(dataFile) + const store = await readKofiStore(path) + if (store.events.some(item => item.messageId === event.messageId)) + return false + + store.events.push(event) + await fsp.mkdir(dirname(path), { recursive: true }) + const tempPath = `${path}.${process.pid}.tmp` + await fsp.writeFile(tempPath, JSON.stringify(store, null, 2)) + await fsp.rename(tempPath, path) + return true +} + +export async function fetchKofiSponsors( + options: ContribkitConfig['kofi'] = {}, + now = Date.now(), +): Promise { + const { + dataFile = DEFAULT_KOFI_DATA_FILE, + tipEffectivity = 30, + subscriptionEffectivity = 35, + } = options + const store = await readKofiStore(resolve(dataFile)) + const supportedEvents = store.events.filter(event => ['tip', 'donation', 'subscription'].includes(event.type.toLowerCase())) + const currencies = new Set(supportedEvents.map(event => event.currency)) + const exchangeRates = currencies.size > 1 || !currencies.has('USD') + ? await $fetch('https://www.floatrates.com/daily/usd.json', { timeout: 5000 }) + : {} + + const groups = new Map() + for (const event of supportedEvents) { + const events = groups.get(event.sponsorKey) + if (events) + events.push(event) + else + groups.set(event.sponsorKey, [event]) + } + + return [...groups.values()].map(events => + createKofiSponsorship(events, exchangeRates, now, tipEffectivity, subscriptionEffectivity)) +} + +export function createKofiWebhookHandler(options: KofiWebhookServerOptions) { + const webhookPath = normalizePath(options.path || '/kofi') + const dataFile = options.dataFile || DEFAULT_KOFI_DATA_FILE + let writeQueue: Promise = Promise.resolve() + + return async (request: IncomingMessage, response: ServerResponse) => { + const pathname = new URL(request.url || '/', 'http://localhost').pathname + if (pathname !== webhookPath) + return sendJson(response, 404, { error: 'Not found' }) + if (request.method === 'GET') { + return sendJson(response, 200, { + ok: true, + message: 'ContribKit Ko-fi webhook receiver is ready. Ko-fi payment events must use POST.', + }) + } + if (request.method !== 'POST') + return sendJson(response, 405, { error: 'Method not allowed' }) + + try { + const body = await readBody(request) + const event = parseKofiWebhookBody(body, options.verificationToken) + const task = writeQueue.then(() => storeKofiEvent(event, dataFile)) + writeQueue = task.catch(() => undefined) + const stored = await task + return sendJson(response, 200, { ok: true, stored }) + } + catch (error) { + const message = error instanceof Error ? error.message : 'Invalid Ko-fi webhook' + const status = message === 'Invalid Ko-fi verification token' ? 401 : 400 + return sendJson(response, status, { error: message }) + } + } +} + +export async function startKofiWebhookServer(options: KofiWebhookServerOptions): Promise { + if (!options.verificationToken) + throw new Error('Ko-fi verification token is required') + + const server = createServer(createKofiWebhookHandler(options)) + await new Promise((resolveListen, reject) => { + server.once('error', reject) + server.listen(options.port ?? 3456, options.host || '127.0.0.1', () => { + server.off('error', reject) + resolveListen() + }) + }) + return server +} + +function createKofiSponsorship( + events: StoredKofiEvent[], + exchangeRates: ExchangeRates, + now: number, + tipEffectivity: number, + subscriptionEffectivity: number, +): Sponsorship { + const sorted = [...events].sort((a, b) => Date.parse(b.timestamp) - Date.parse(a.timestamp)) + const latest = sorted[0] + const recurring = sorted.filter(event => event.isSubscriptionPayment) + const tips = sorted.filter(event => !event.isSubscriptionPayment) + const latestRecurring = recurring[0] + const activeRecurring = latestRecurring && isEffective(latestRecurring.timestamp, subscriptionEffectivity, now) + ? toDollars(latestRecurring.amount, latestRecurring.currency, exchangeRates) + : 0 + const activeTips = tips + .filter(event => isEffective(event.timestamp, tipEffectivity, now)) + .reduce((sum, event) => sum + toDollars(event.amount, event.currency, exchangeRates), 0) + const monthlyDollars = activeRecurring + activeTips || -1 + const activeExpirations = [ + latestRecurring && activeRecurring + ? effectivityDate(latestRecurring.timestamp, subscriptionEffectivity) + : undefined, + ...tips + .filter(event => isEffective(event.timestamp, tipEffectivity, now)) + .map(event => effectivityDate(event.timestamp, tipEffectivity)), + ].filter((value): value is string => !!value) + + return { + sponsor: { + type: 'User', + login: `kofi-${latest.sponsorKey.slice(0, 16)}`, + name: latest.fromName, + avatarUrl: '', + }, + monthlyDollars, + privacyLevel: latest.isPublic ? 'PUBLIC' : 'PRIVATE', + tierName: latestRecurring?.tierName || 'Ko-fi', + createdAt: sorted.at(-1)!.timestamp, + expireAt: activeExpirations.toSorted((a, b) => a.localeCompare(b)).at(-1), + isOneTime: recurring.length === 0, + provider: 'kofi', + raw: sorted, + } +} + +async function readKofiStore(path: string): Promise { + try { + const parsed = JSON.parse(await fsp.readFile(path, 'utf8')) as Partial + if (parsed.version !== 1 || !Array.isArray(parsed.events)) + throw new Error(`Invalid Ko-fi event store: ${path}`) + return parsed as KofiEventStore + } + catch (error: any) { + if (error?.code === 'ENOENT') + return { version: 1, events: [] } + throw error + } +} + +async function readBody(request: IncomingMessage): Promise { + const chunks: Buffer[] = [] + let size = 0 + for await (const chunk of request) { + const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk) + size += buffer.length + if (size > 1024 * 1024) + throw new Error('Ko-fi webhook body exceeds 1 MB') + chunks.push(buffer) + } + return Buffer.concat(chunks).toString('utf8') +} + +function tokensEqual(actual: string, expected: string) { + return timingSafeEqual(Buffer.from(sha256(actual)), Buffer.from(sha256(expected))) +} + +function sha256(value: string) { + return createHash('sha256').update(value).digest('hex') +} + +function toBoolean(value: boolean | string | undefined) { + return value === true || value === 'true' +} + +function toDollars(amount: number, currency: string, exchangeRates: ExchangeRates) { + return currency === 'USD' + ? amount + : amount * (exchangeRates[currency.toLowerCase()]?.inverseRate ?? 1) +} + +function isEffective(timestamp: string, days: number, now: number) { + return days <= 0 || Date.parse(timestamp) + days * 24 * 60 * 60 * 1000 >= now +} + +function effectivityDate(timestamp: string, days: number) { + return days > 0 + ? new Date(Date.parse(timestamp) + days * 24 * 60 * 60 * 1000).toISOString() + : undefined +} + +function normalizePath(path: string) { + return path.startsWith('/') ? path : `/${path}` +} + +function sendJson(response: ServerResponse, status: number, body: unknown) { + response.writeHead(status, { 'Content-Type': 'application/json' }) + response.end(JSON.stringify(body)) +} diff --git a/src/providers/opencollective.test.ts b/src/providers/opencollective.test.ts new file mode 100644 index 00000000..02f0ffcf --- /dev/null +++ b/src/providers/opencollective.test.ts @@ -0,0 +1,35 @@ +import { $fetch } from 'ofetch' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { fetchOpenCollectiveSponsors } from './opencollective.js' + +vi.mock('ofetch', () => ({ $fetch: vi.fn() })) +const fetchMock = vi.mocked($fetch) + +beforeEach(() => fetchMock.mockReset()) + +const emptyOrders = { data: { account: { orders: { nodes: [], totalCount: 0 } } } } +const emptyTransactions = { data: { account: { transactions: { nodes: null, totalCount: 0 } } } } + +describe('OpenCollective GraphQL responses', () => { + it.each(['subscriptions', 'transactions'])('reports %s errors before accessing missing data', async (stage) => { + if (stage === 'transactions') + fetchMock.mockResolvedValueOnce(emptyOrders) + fetchMock.mockResolvedValueOnce({ data: null, errors: [{ message: 'Rate limit exceeded' }, { message: 'Try again later' }] }) + await expect(fetchOpenCollectiveSponsors('key', undefined, 'collective')) + .rejects.toThrow('OpenCollective query errors: Rate limit exceeded; Try again later') + expect(fetchMock).toHaveBeenCalledTimes(stage === 'transactions' ? 2 : 1) + }) + + it('accepts empty and null node lists', async () => { + fetchMock.mockResolvedValueOnce(emptyOrders).mockResolvedValueOnce(emptyTransactions) + await expect(fetchOpenCollectiveSponsors('key', undefined, 'collective')).resolves.toEqual([]) + expect(fetchMock).toHaveBeenCalledTimes(2) + }) + + it('preserves sponsees mode by fetching transactions directly', async () => { + fetchMock.mockResolvedValueOnce(emptyTransactions) + await expect(fetchOpenCollectiveSponsors('key', undefined, 'collective', undefined, true, true)).resolves.toEqual([]) + expect(fetchMock).toHaveBeenCalledTimes(1) + expect(fetchMock.mock.calls[0][1]?.body).toMatchObject({ query: expect.stringContaining('type: DEBIT') }) + }) +}) diff --git a/src/providers/opencollective.ts b/src/providers/opencollective.ts index d52146fb..97159450 100644 --- a/src/providers/opencollective.ts +++ b/src/providers/opencollective.ts @@ -126,7 +126,7 @@ async function fetchOpenCollectivePages( } async function fetchOpenCollectivePage(key: string, query: string) { - return await $fetch(API, { + const result = await $fetch(API, { method: 'POST', body: { query }, headers: { @@ -134,6 +134,10 @@ async function fetchOpenCollectivePage(key: string, query: string) { 'Content-Type': 'application/json', }, }) as any + if (result.errors?.length) { + throw new Error(`OpenCollective query errors: ${result.errors.map((error: Error) => error.message).join('; ')}`) + } + return result } function getNextOffset(offset: number, nodeCount: number, totalCount: number) { diff --git a/src/renders/circles.ts b/src/renders/circles.ts index 7013aef8..13fc6cda 100644 --- a/src/renders/circles.ts +++ b/src/renders/circles.ts @@ -55,6 +55,7 @@ export const circlesRenderer: ContribkitRenderer = { }, 0.5, config.imageFormat, + composer.getNextCropId(), )))) composer.addRaw(badges.join('')) diff --git a/src/types.ts b/src/types.ts index 0fa666f1..9657651e 100644 --- a/src/types.ts +++ b/src/types.ts @@ -75,7 +75,7 @@ export const outputFormats = ['svg', 'png', 'webp', 'json'] as const export type OutputFormat = typeof outputFormats[number] -export type ProviderName = 'github' | 'patreon' | 'opencollective' | 'afdian' | 'polar' | 'liberapay' | 'githubContributors' | 'gitlabContributors' | 'crowdinContributors' | 'githubContributions' +export type ProviderName = 'github' | 'patreon' | 'opencollective' | 'afdian' | 'polar' | 'liberapay' | 'kofi' | 'githubContributors' | 'gitlabContributors' | 'crowdinContributors' | 'githubContributions' export type HookResult = PromiseLike | T | void | undefined | null export type GitHubAccountType = 'user' | 'organization' @@ -248,6 +248,30 @@ export interface ProvidersConfig { */ logarithmicScaling?: boolean } + + kofi?: { + /** + * Local event store populated by the `kofi-webhook` command. + * + * @default './contribkit/kofi-events.json' + */ + dataFile?: string + /** + * Number of days one-time tips affect the current sponsorship tier. + * Set to 0 to keep them active indefinitely. + * + * @default 30 + */ + tipEffectivity?: number + /** + * Number of days after a subscription payment the membership is considered active. + * Ko-fi does not send an event when a membership ends. + * Set to 0 to keep subscriptions active indefinitely. + * + * @default 35 + */ + subscriptionEffectivity?: number + } } export interface ContribkitRenderOptions { diff --git a/src/utils.test.ts b/src/utils.test.ts new file mode 100644 index 00000000..7a0c47a8 --- /dev/null +++ b/src/utils.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, it } from 'vitest' +import { normalizeUrl } from './utils.js' + +describe('normalizeUrl', () => { + it('returns undefined for empty input', () => { + expect(normalizeUrl(undefined)).toBeUndefined() + expect(normalizeUrl('')).toBeUndefined() + }) + + it('adds the default https protocol when missing', () => { + expect(normalizeUrl('example.com')).toBe('https://example.com') + }) + + it('preserves an explicit protocol', () => { + expect(normalizeUrl('http://example.com')).toBe('http://example.com') + }) + + it('normalizes the url (strips trailing slash)', () => { + expect(normalizeUrl('https://example.com/')).toBe('https://example.com') + }) +}) diff --git a/tests/fixtures/async-work.mjs b/tests/fixtures/async-work.mjs index 5ec554a2..3e54fa24 100644 --- a/tests/fixtures/async-work.mjs +++ b/tests/fixtures/async-work.mjs @@ -255,7 +255,7 @@ async function circles(failImage) { const badges = await Promise.all(packed.map(circle => generateBadge(circle.x - circle.r, circle.y - circle.r, circle.data.sponsor, { name: false, boxHeight: circle.r * 2, boxWidth: circle.r * 2, avatar: { size: circle.r * 2 }, - }, 0.5, config.imageFormat))) + }, 0.5, config.imageFormat, composer.getNextCropId()))) badges.forEach(badge => composer.addRaw(badge)) composer.height = config.width assert.equal(svg, composer.generateSvg()) diff --git a/tsconfig.build.json b/tsconfig.build.json index 3f3576f4..14307cf3 100644 --- a/tsconfig.build.json +++ b/tsconfig.build.json @@ -6,5 +6,10 @@ "outDir": "dist", "rootDir": "src" }, - "include": ["src/**/*.ts"] + "include": [ + "src/**/*.ts" + ], + "exclude": [ + "src/**/*.test.ts" + ] } diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 00000000..f1d66461 --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,7 @@ +import { configDefaults, defineConfig } from 'vitest/config' + +export default defineConfig({ + test: { + exclude: [...configDefaults.exclude, 'example/**', 'tests/**'], + }, +})