my pkgs monorepo

feat(supporters): implement @ewanc26/supporters library

ewancroft.uk 31e54047 93e4d799

verified
+1014 -45
+85 -36
packages/supporters/README.md
··· 1 - # Svelte library 1 + # @ewanc26/supporters 2 2 3 - Everything you need to build a Svelte library, powered by [`sv`](https://npmjs.com/package/sv). 3 + SvelteKit component library for displaying Ko-fi supporters, backed by an ATProto PDS. 4 4 5 - Read more about creating a library [in the docs](https://svelte.dev/docs/kit/packaging). 5 + Ko-fi's webhook pushes payment events to your endpoint. Each event is stored as a record under the `uk.ewancroft.kofi.supporter` lexicon on your PDS, with a TID rkey derived from the transaction timestamp. The component reads those records and renders them. 6 6 7 - ## Creating a project 7 + --- 8 + 9 + ## How it works 10 + 11 + 1. Ko-fi POSTs a webhook event to `/webhook` on each transaction 12 + 2. The handler verifies the `verification_token`, respects `is_public`, and calls `appendEvent` 13 + 3. `appendEvent` writes a record to your PDS under `uk.ewancroft.kofi.supporter` 14 + 4. `readStore` fetches all records and aggregates them into `KofiSupporter` objects 15 + 5. Pass the result to `<KofiSupporters>` or `<LunarContributors>` 8 16 9 - If you're seeing this, you've probably already done this step. Congrats! 17 + --- 10 18 11 - ```sh 12 - # create a new project in the current directory 13 - npx sv create 19 + ## Setup 14 20 15 - # create a new project in my-app 16 - npx sv create my-app 17 - ``` 21 + ### 1. Environment variables 18 22 19 - To recreate this project with the same configuration: 23 + ```env 24 + # Required — copy from ko-fi.com/manage/webhooks → Advanced → Verification Token 25 + KOFI_VERIFICATION_TOKEN=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx 20 26 21 - ```sh 22 - # recreate this project 23 - pnpm dlx sv@0.12.5 create --template library --types ts --add prettier tailwindcss="plugins:typography" sveltekit-adapter="adapter:auto" --install pnpm ./supporters 27 + # Required — your ATProto identity and a dedicated app password 28 + ATPROTO_DID=did:plc:yourdidhex 29 + ATPROTO_PDS_URL=https://your-pds.example.com 30 + ATPROTO_APP_PASSWORD=xxxx-xxxx-xxxx-xxxx 24 31 ``` 25 32 26 - ## Developing 33 + Generate an app password at your PDS under **Settings → App Passwords**. 27 34 28 - Once you've created a project and installed dependencies with `npm install` (or `pnpm install` or `yarn`), start a development server: 35 + ### 2. Register the webhook 29 36 30 - ```sh 31 - npm run dev 37 + Go to **ko-fi.com/manage/webhooks** and set your webhook URL to: 32 38 33 - # or start the server and open the app in a new browser tab 34 - npm run dev -- --open 39 + ``` 40 + https://your-domain.com/webhook 35 41 ``` 36 42 37 - Everything inside `src/lib` is part of your library, everything inside `src/routes` can be used as a showcase or preview app. 43 + ### 3. Add the route 38 44 39 - ## Building 45 + Copy `src/routes/webhook/+server.ts` into your SvelteKit app's routes directory. 46 + 47 + ### 4. Use the component 40 48 41 - To build your library: 49 + ```ts 50 + // +page.server.ts 51 + import { readStore } from '@ewanc26/supporters'; 42 52 43 - ```sh 44 - npm pack 53 + export const load = async () => ({ 54 + supporters: await readStore() 55 + }); 45 56 ``` 46 57 47 - To create a production version of your showcase app: 58 + ```svelte 59 + <!-- +page.svelte --> 60 + <script lang="ts"> 61 + import { KofiSupporters } from '@ewanc26/supporters'; 62 + let { data } = $props(); 63 + </script> 48 64 49 - ```sh 50 - npm run build 65 + <KofiSupporters supporters={data.supporters} /> 51 66 ``` 52 67 53 - You can preview the production build with `npm run preview`. 68 + --- 54 69 55 - > To deploy your app, you may need to install an [adapter](https://svelte.dev/docs/kit/adapters) for your target environment. 70 + ## Components 56 71 57 - ## Publishing 72 + ### `<KofiSupporters>` 58 73 59 - Go into the `package.json` and give your package the desired name through the `"name"` option. Also consider adding a `"license"` field and point it to a `LICENSE` file which you can create from a template (one popular option is the [MIT license](https://opensource.org/license/mit/)). 74 + Displays all supporters with emoji type badges (☕ donation, ⭐ subscription, 🎨 commission, 🛍️ shop order). 60 75 61 - To publish your library to [npm](https://www.npmjs.com): 76 + | Prop | Type | Default | 77 + |---|---|---| 78 + | `supporters` | `KofiSupporter[]` | `[]` | 79 + | `heading` | `string` | `'Supporters'` | 80 + | `description` | `string` | `'People who support my work on Ko-fi.'` | 81 + | `filter` | `KofiEventType[]` | `undefined` (show all) | 82 + | `loading` | `boolean` | `false` | 83 + | `error` | `string \| null` | `null` | 62 84 63 - ```sh 64 - npm publish 85 + ### `<LunarContributors>` 86 + 87 + Convenience wrapper around `<KofiSupporters>` pre-filtered to `Subscription` events. 88 + 89 + --- 90 + 91 + ## Importing historical data 92 + 93 + Export your transaction history from **ko-fi.com/manage/transactions → Export CSV**, then: 94 + 95 + ```bash 96 + ATPROTO_DID=... ATPROTO_PDS_URL=... ATPROTO_APP_PASSWORD=... \ 97 + node node_modules/@ewanc26/supporters/scripts/import-history.mjs transactions.csv --dry-run 65 98 ``` 99 + 100 + --- 101 + 102 + ## Lexicon 103 + 104 + Records are stored under `uk.ewancroft.kofi.supporter` (see `lexicons/`). Each record contains: 105 + 106 + ```ts 107 + { 108 + name: string // display name from Ko-fi 109 + type: string // "Donation" | "Subscription" | "Commission" | "Shop Order" 110 + tier?: string // subscription tier name, if applicable 111 + } 112 + ``` 113 + 114 + rkeys are TIDs derived from the transaction timestamp via [`@ewanc26/tid`](https://npmjs.com/package/@ewanc26/tid).
+29
packages/supporters/lexicons/uk/ewancroft/kofi/supporter.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "uk.ewancroft.kofi.supporter", 4 + "defs": { 5 + "main": { 6 + "type": "record", 7 + "description": "A single Ko-fi payment event. One record per event, rkey is a TID.", 8 + "key": "tid", 9 + "record": { 10 + "type": "object", 11 + "required": ["name", "type"], 12 + "properties": { 13 + "name": { 14 + "type": "string", 15 + "description": "Display name from Ko-fi." 16 + }, 17 + "type": { 18 + "type": "string", 19 + "description": "Ko-fi event type: Donation, Subscription, Commission, or Shop Order." 20 + }, 21 + "tier": { 22 + "type": "string", 23 + "description": "Subscription tier name, if applicable." 24 + } 25 + } 26 + } 27 + } 28 + } 29 + }
+23 -5
packages/supporters/package.json
··· 1 1 { 2 - "name": "supporters", 3 - "version": "0.0.1", 2 + "name": "@ewanc26/supporters", 3 + "version": "0.1.0", 4 + "description": "SvelteKit component library for displaying Ko-fi supporters, backed by an ATProto PDS.", 5 + "author": "Ewan Croft", 6 + "license": "AGPL-3.0-only", 4 7 "scripts": { 5 8 "dev": "vite dev", 6 9 "build": "vite build && npm run prepack", ··· 14 17 }, 15 18 "files": [ 16 19 "dist", 20 + "lexicons", 21 + "scripts", 17 22 "!dist/**/*.test.*", 18 23 "!dist/**/*.spec.*" 19 24 ], ··· 26 31 "exports": { 27 32 ".": { 28 33 "types": "./dist/index.d.ts", 29 - "svelte": "./dist/index.js" 34 + "svelte": "./dist/index.js", 35 + "default": "./dist/index.js" 30 36 } 31 37 }, 38 + "dependencies": { 39 + "@ewanc26/tid": "^1.1.1" 40 + }, 32 41 "peerDependencies": { 42 + "@atproto/api": ">=0.13.0", 33 43 "svelte": "^5.0.0" 34 44 }, 35 45 "devDependencies": { 46 + "@atproto/api": "^0.18.1", 36 47 "@sveltejs/adapter-auto": "^7.0.0", 37 48 "@sveltejs/kit": "^2.50.2", 38 49 "@sveltejs/package": "^2.5.7", ··· 50 61 "vite": "^7.3.1" 51 62 }, 52 63 "keywords": [ 53 - "svelte" 54 - ] 64 + "svelte", 65 + "atproto", 66 + "ko-fi", 67 + "supporters", 68 + "webhook" 69 + ], 70 + "publishConfig": { 71 + "access": "public" 72 + } 55 73 }
+189
packages/supporters/scripts/import-history.mjs
··· 1 + #!/usr/bin/env node 2 + /** 3 + * Import historical Ko-fi transaction data into your PDS. 4 + * 5 + * Export your transactions from: ko-fi.com/manage/transactions → Export CSV 6 + * 7 + * Usage: 8 + * ATPROTO_DID=... ATPROTO_PDS_URL=... ATPROTO_APP_PASSWORD=... \ 9 + * node scripts/import-history.mjs /path/to/kofi-transactions.csv 10 + * 11 + * Flags: 12 + * --dry-run Print what would be upserted without writing to the PDS. 13 + * --skip N Skip the first N data rows (resume after a partial import). 14 + * 15 + * The script is idempotent — re-running it will merge new event types and 16 + * tiers into existing records rather than creating duplicates. 17 + */ 18 + 19 + import { createReadStream } from 'node:fs'; 20 + import { createInterface } from 'node:readline'; 21 + import { resolve } from 'node:path'; 22 + 23 + // ── Args ───────────────────────────────────────────────────────────────────── 24 + 25 + const args = process.argv.slice(2); 26 + const csvPath = args.find((a) => !a.startsWith('--')); 27 + const dryRun = args.includes('--dry-run'); 28 + const skipArg = args.find((a) => a.startsWith('--skip=')); 29 + const skip = skipArg ? parseInt(skipArg.split('=')[1], 10) : 0; 30 + 31 + if (!csvPath) { 32 + console.error('Usage: node scripts/import-history.mjs <path-to-csv> [--dry-run] [--skip=N]'); 33 + process.exit(1); 34 + } 35 + 36 + // ── Ko-fi CSV column mapping ────────────────────────────────────────────────── 37 + // Actual export headers (from ko-fi.com/manage/transactions): 38 + // DateTime (UTC), From, Message, Item, Received, Given, Currency, 39 + // TransactionType, TransactionId, Reference, SalesTax, ... 40 + // 41 + // TransactionType values seen in exports → our KofiEventType: 42 + // "Tip" → "Donation" 43 + // "Monthly Tip" → "Subscription" 44 + // "Commission" → "Commission" 45 + // "Shop Order" → "Shop Order" 46 + 47 + const TYPE_MAP = { 48 + 'tip': 'Donation', 49 + 'monthly tip': 'Subscription', 50 + 'commission': 'Commission', 51 + 'shop order': 'Shop Order', 52 + }; 53 + 54 + function normaliseType(raw) { 55 + const key = raw.trim().toLowerCase(); 56 + return TYPE_MAP[key] ?? raw.trim(); 57 + } 58 + 59 + // Ko-fi's CSV header names, lowercased + spaces collapsed to underscores. 60 + // Used to map row fields by name rather than position. 61 + const COL_TIMESTAMP = 'datetime_(utc)'; 62 + const COL_NAME = 'from'; 63 + const COL_TYPE = 'transactiontype'; 64 + const COL_TIER = 'item'; // only meaningful for subscriptions 65 + 66 + // ── ATProto write ──────────────────────────────────────────────────────────── 67 + 68 + const COLLECTION = 'uk.ewancroft.kofi.supporter'; 69 + 70 + function requireEnv(key) { 71 + const val = process.env[key]; 72 + if (!val) { console.error(`Missing env var: ${key}`); process.exit(1); } 73 + return val; 74 + } 75 + 76 + const { generateTID } = await import('@ewanc26/tid'); 77 + 78 + async function appendEvent(agent, did, name, type, tier, timestamp) { 79 + const rkey = generateTID(timestamp); 80 + const record = { name, type, ...(tier ? { tier } : {}) }; 81 + await agent.com.atproto.repo.putRecord({ repo: did, collection: COLLECTION, rkey, record }); 82 + return rkey; 83 + } 84 + 85 + // ── CSV parser (no dependencies) ───────────────────────────────────────────── 86 + 87 + function parseCSVLine(line) { 88 + const fields = []; 89 + let current = ''; 90 + let inQuotes = false; 91 + 92 + for (let i = 0; i < line.length; i++) { 93 + const ch = line[i]; 94 + if (ch === '"') { 95 + if (inQuotes && line[i + 1] === '"') { current += '"'; i++; } 96 + else { inQuotes = !inQuotes; } 97 + } else if (ch === ',' && !inQuotes) { 98 + fields.push(current.trim()); 99 + current = ''; 100 + } else { 101 + current += ch; 102 + } 103 + } 104 + fields.push(current.trim()); 105 + return fields; 106 + } 107 + 108 + // ── Main ───────────────────────────────────────────────────────────────────── 109 + 110 + const { AtpAgent } = await import('@atproto/api'); 111 + 112 + let agent, did; 113 + 114 + if (!dryRun) { 115 + did = requireEnv('ATPROTO_DID'); 116 + const pdsUrl = requireEnv('ATPROTO_PDS_URL'); 117 + const password = requireEnv('ATPROTO_APP_PASSWORD'); 118 + 119 + agent = new AtpAgent({ service: pdsUrl }); 120 + await agent.login({ identifier: did, password }); 121 + console.log(`✓ Logged in as ${did}\n`); 122 + } else { 123 + console.log('DRY RUN — nothing will be written to the PDS.\n'); 124 + } 125 + 126 + const rl = createInterface({ input: createReadStream(resolve(csvPath)), crlfDelay: Infinity }); 127 + 128 + let headers = null; 129 + let rowIndex = 0; 130 + let processed = 0; 131 + let skipped = 0; 132 + let errors = 0; 133 + 134 + for await (const line of rl) { 135 + if (!line.trim()) continue; 136 + 137 + if (!headers) { 138 + headers = parseCSVLine(line).map((h) => h.toLowerCase().replace(/\s+/g, '_')); 139 + console.log(`Headers: ${headers.join(', ')}\n`); 140 + continue; 141 + } 142 + 143 + rowIndex++; 144 + 145 + if (rowIndex <= skip) { 146 + skipped++; 147 + continue; 148 + } 149 + 150 + const fields = parseCSVLine(line); 151 + const row = Object.fromEntries(headers.map((h, i) => [h, fields[i] ?? ''])); 152 + 153 + const name = row[COL_NAME]?.trim(); 154 + const type = normaliseType(row[COL_TYPE] ?? ''); 155 + const rawTier = row[COL_TIER]?.trim(); 156 + // Only treat "Item" as a tier if it's a Subscription AND not the generic "Ko-fi Support" label. 157 + const tier = type === 'Subscription' && rawTier && rawTier.toLowerCase() !== 'ko-fi support' ? rawTier : null; 158 + // Ko-fi exports timestamps as "MM/DD/YYYY HH:MM" UTC — convert to ISO 8601. 159 + const rawTs = row[COL_TIMESTAMP]?.trim(); 160 + const timestamp = rawTs ? new Date(rawTs + ' UTC').toISOString() : new Date().toISOString(); 161 + 162 + if (!name) { 163 + console.warn(`Row ${rowIndex}: empty name, skipping.`); 164 + errors++; 165 + continue; 166 + } 167 + 168 + if (dryRun) { 169 + const rkey = generateTID(timestamp); 170 + console.log(`[${rowIndex}] ${rkey} ${name} · ${type}${tier ? ` · ${tier}` : ''}`); 171 + processed++; 172 + continue; 173 + } 174 + 175 + try { 176 + const rkey = await appendEvent(agent, did, name, type, tier, timestamp); 177 + console.log(`[${rowIndex}] ${rkey} ${name} · ${type}${tier ? ` · ${tier}` : ''}`); 178 + processed++; 179 + } catch (err) { 180 + console.error(`[${rowIndex}] ERROR for "${name}": ${err.message}`); 181 + errors++; 182 + } 183 + } 184 + 185 + console.log(`\n─────────────────────────────`); 186 + console.log(`Processed : ${processed}`); 187 + if (skipped) console.log(`Skipped : ${skipped} (--skip=${skip})`); 188 + if (errors) console.log(`Errors : ${errors}`); 189 + console.log(`─────────────────────────────`);
+37
packages/supporters/scripts/probe-api.mjs
··· 1 + #!/usr/bin/env node 2 + /** 3 + * Probe the ko-fi.tools API for a given Ko-fi page ID. 4 + * 5 + * Usage: 6 + * node scripts/probe-api.mjs YOUR_KOFI_PAGE_ID 7 + * 8 + * If the endpoint path is wrong, update API_BASE / path in src/lib/api.ts 9 + * once the ko-fi.tools V2 docs are published. 10 + */ 11 + 12 + const pageId = process.argv[2]; 13 + 14 + if (!pageId) { 15 + console.error('Usage: node scripts/probe-api.mjs YOUR_KOFI_PAGE_ID'); 16 + process.exit(1); 17 + } 18 + 19 + const API_BASE = 'https://api.ko-fi.tools/v2'; 20 + const url = `${API_BASE}/${encodeURIComponent(pageId)}/supporters`; 21 + 22 + console.log(`→ GET ${url}\n`); 23 + 24 + try { 25 + const res = await fetch(url, { headers: { Accept: 'application/json' } }); 26 + console.log(`Status: ${res.status} ${res.statusText}`); 27 + console.log('Headers:', Object.fromEntries(res.headers.entries())); 28 + 29 + const body = await res.text(); 30 + try { 31 + console.log('\nBody (parsed):', JSON.stringify(JSON.parse(body), null, 2)); 32 + } catch { 33 + console.log('\nBody (raw):', body); 34 + } 35 + } catch (err) { 36 + console.error('Network error:', err.message); 37 + }
+33
packages/supporters/scripts/probe-endpoints.mjs
··· 1 + #!/usr/bin/env node 2 + /** 3 + * Probe multiple candidate ko-fi.tools API endpoint patterns. 4 + * Usage: node scripts/probe-endpoints.mjs YOUR_KOFI_PAGE_ID 5 + */ 6 + 7 + const pageId = process.argv[2] ?? 'ewancroft'; 8 + 9 + const candidates = [ 10 + `https://api.ko-fi.tools/v2/${pageId}/supporters`, 11 + `https://api.ko-fi.tools/v2/${pageId}/top-supporters`, 12 + `https://api.ko-fi.tools/v2/supporters/${pageId}`, 13 + `https://api.ko-fi.tools/${pageId}/supporters`, 14 + `https://api.ko-fi.tools/${pageId}`, 15 + `https://api.ko-fi.tools/v2/${pageId}`, 16 + `https://api.ko-fi.tools/v1/${pageId}/supporters`, 17 + `https://api.ko-fi.tools/v1/${pageId}/top`, 18 + `https://ko-fi.tools/api/${pageId}/supporters`, 19 + `https://ko-fi.tools/api/v2/${pageId}/supporters`, 20 + ]; 21 + 22 + for (const url of candidates) { 23 + try { 24 + const res = await fetch(url, { headers: { Accept: 'application/json' } }); 25 + const body = await res.text(); 26 + let parsed; 27 + try { parsed = JSON.parse(body); } catch { parsed = body.slice(0, 120); } 28 + console.log(`${res.status} ${url}`); 29 + if (res.status !== 404) console.log(' →', JSON.stringify(parsed).slice(0, 200)); 30 + } catch (e) { 31 + console.log(`ERR ${url} — ${e.message}`); 32 + } 33 + }
+62
packages/supporters/scripts/simulate-webhook.mjs
··· 1 + #!/usr/bin/env node 2 + /** 3 + * Simulate Ko-fi webhook POSTs against your local dev server. 4 + * 5 + * Usage: 6 + * KOFI_VERIFICATION_TOKEN=your-token node scripts/simulate-webhook.mjs [type] [name] [tier] 7 + * 8 + * Types: Donation | Subscription | Commission | "Shop Order" 9 + * 10 + * Examples: 11 + * node scripts/simulate-webhook.mjs Donation "Jo Example" 12 + * node scripts/simulate-webhook.mjs Subscription "Alice" "Lunar Contributors" 13 + * node scripts/simulate-webhook.mjs Commission "Bob" 14 + * node scripts/simulate-webhook.mjs "Shop Order" "Carol" 15 + */ 16 + 17 + const token = process.env.KOFI_VERIFICATION_TOKEN ?? 'test-token'; 18 + const type = process.argv[2] ?? 'Donation'; 19 + const name = process.argv[3] ?? 'Jo Example'; 20 + const tier = process.argv[4] ?? (type === 'Subscription' ? 'Lunar Contributors' : null); 21 + const url = process.argv[5] ?? 'http://localhost:5173/webhook'; 22 + 23 + const isSubscription = type === 'Subscription'; 24 + 25 + const payload = { 26 + verification_token: token, 27 + message_id: crypto.randomUUID(), 28 + timestamp: new Date().toISOString(), 29 + type, 30 + is_public: true, 31 + from_name: name, 32 + message: 'Simulated webhook event', 33 + amount: '3.00', 34 + url: 'https://ko-fi.com/Home/CoffeeShop?txid=test', 35 + email: 'test@example.com', 36 + currency: 'GBP', 37 + is_subscription_payment: isSubscription, 38 + is_first_subscription_payment: isSubscription, 39 + kofi_transaction_id: crypto.randomUUID(), 40 + shop_items: type === 'Shop Order' ? [{ id: 'item-1', name: 'Test Item' }] : null, 41 + tier_name: tier ?? null, 42 + shipping: null 43 + }; 44 + 45 + const body = new URLSearchParams({ data: JSON.stringify(payload) }); 46 + 47 + console.log(`→ POST ${url}`); 48 + console.log(` type: ${type}`); 49 + console.log(` from_name: ${name}`); 50 + if (tier) console.log(` tier_name: ${tier}`); 51 + console.log(); 52 + 53 + const res = await fetch(url, { 54 + method: 'POST', 55 + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, 56 + body 57 + }); 58 + 59 + console.log(`Status: ${res.status} ${res.statusText}`); 60 + if (res.status !== 200) { 61 + console.log('Body:', await res.text()); 62 + }
+198
packages/supporters/src/lib/KofiSupporters.svelte
··· 1 + <script lang="ts"> 2 + import type { KofiSupportersProps, KofiEventType } from './types.js'; 3 + 4 + let { 5 + supporters = [], 6 + heading = 'Supporters', 7 + description = 'People who support my work on Ko-fi.', 8 + filter = undefined, 9 + loading = false, 10 + error = null 11 + }: KofiSupportersProps = $props(); 12 + 13 + const TYPE_LABELS: Record<KofiEventType, string> = { 14 + Donation: '☕', 15 + Subscription: '⭐', 16 + Commission: '🎨', 17 + 'Shop Order': '🛍️' 18 + }; 19 + 20 + let visible = $derived( 21 + filter 22 + ? supporters.filter((s) => s.types.some((t) => filter!.includes(t))) 23 + : supporters 24 + ); 25 + 26 + /** Deterministic pastel colour from a name string. */ 27 + function nameToHsl(name: string): string { 28 + let hash = 0; 29 + for (let i = 0; i < name.length; i++) hash = name.charCodeAt(i) + ((hash << 5) - hash); 30 + const h = Math.abs(hash) % 360; 31 + return `hsl(${h} 55% 70%)`; 32 + } 33 + 34 + /** Initials from a display name. */ 35 + function initials(name: string): string { 36 + return name 37 + .split(/\s+/) 38 + .slice(0, 2) 39 + .map((w) => w[0]?.toUpperCase() ?? '') 40 + .join(''); 41 + } 42 + </script> 43 + 44 + <section class="kofi-supporters" aria-label={heading}> 45 + <header class="kofi-supporters__header"> 46 + <h2 class="kofi-supporters__heading">{heading}</h2> 47 + {#if description} 48 + <p class="kofi-supporters__description">{description}</p> 49 + {/if} 50 + </header> 51 + 52 + {#if loading} 53 + <ul class="kofi-supporters__grid" aria-busy="true" aria-label="Loading supporters"> 54 + {#each { length: 6 } as _} 55 + <li class="kofi-supporters__item kofi-supporters__item--skeleton" aria-hidden="true"> 56 + <span class="kofi-supporters__avatar kofi-supporters__avatar--skeleton"></span> 57 + <span class="kofi-supporters__name kofi-supporters__name--skeleton"></span> 58 + </li> 59 + {/each} 60 + </ul> 61 + {:else if error} 62 + <p class="kofi-supporters__error" role="alert">{error}</p> 63 + {:else if visible.length === 0} 64 + <p class="kofi-supporters__empty">No supporters yet — be the first!</p> 65 + {:else} 66 + <ul class="kofi-supporters__grid"> 67 + {#each visible as supporter (supporter.name)} 68 + {@const typeIcons = supporter.types.map((t) => TYPE_LABELS[t]).join('')} 69 + <li class="kofi-supporters__item"> 70 + <span 71 + class="kofi-supporters__card" 72 + title="{supporter.name} · {supporter.types.join(', ')}{supporter.tiers.length ? ` · ${supporter.tiers.join(', ')}` : ''}" 73 + > 74 + <span 75 + class="kofi-supporters__avatar" 76 + style="background-color: {nameToHsl(supporter.name)}" 77 + aria-hidden="true" 78 + > 79 + {initials(supporter.name)} 80 + </span> 81 + <span class="kofi-supporters__name">{supporter.name}</span> 82 + <span class="kofi-supporters__icons" aria-label={supporter.types.join(', ')}>{typeIcons}</span> 83 + </span> 84 + </li> 85 + {/each} 86 + </ul> 87 + {/if} 88 + </section> 89 + 90 + <style> 91 + .kofi-supporters { 92 + container-type: inline-size; 93 + } 94 + 95 + .kofi-supporters__header { 96 + margin-block-end: 1rem; 97 + } 98 + 99 + .kofi-supporters__heading { 100 + font-size: 1.25rem; 101 + font-weight: 700; 102 + margin: 0; 103 + } 104 + 105 + .kofi-supporters__description { 106 + margin-block-start: 0.25rem; 107 + font-size: 0.875rem; 108 + opacity: 0.75; 109 + } 110 + 111 + .kofi-supporters__grid { 112 + display: flex; 113 + flex-wrap: wrap; 114 + gap: 0.75rem; 115 + list-style: none; 116 + margin: 0; 117 + padding: 0; 118 + } 119 + 120 + .kofi-supporters__card { 121 + display: flex; 122 + flex-direction: column; 123 + align-items: center; 124 + gap: 0.25rem; 125 + padding: 0.5rem; 126 + border-radius: 0.5rem; 127 + cursor: default; 128 + } 129 + 130 + .kofi-supporters__avatar { 131 + width: 3rem; 132 + height: 3rem; 133 + border-radius: 50%; 134 + display: flex; 135 + align-items: center; 136 + justify-content: center; 137 + font-size: 1rem; 138 + font-weight: 700; 139 + color: #fff; 140 + flex-shrink: 0; 141 + } 142 + 143 + .kofi-supporters__name { 144 + font-size: 0.75rem; 145 + text-align: center; 146 + max-width: 5rem; 147 + overflow: hidden; 148 + text-overflow: ellipsis; 149 + white-space: nowrap; 150 + } 151 + 152 + .kofi-supporters__icons { 153 + font-size: 0.65rem; 154 + line-height: 1; 155 + } 156 + 157 + /* Skeletons */ 158 + .kofi-supporters__item--skeleton { 159 + display: flex; 160 + flex-direction: column; 161 + align-items: center; 162 + gap: 0.375rem; 163 + padding: 0.5rem; 164 + } 165 + 166 + .kofi-supporters__avatar--skeleton { 167 + display: block; 168 + width: 3rem; 169 + height: 3rem; 170 + border-radius: 50%; 171 + background-color: color-mix(in srgb, currentColor 15%, transparent); 172 + animation: ks-pulse 1.4s ease-in-out infinite; 173 + } 174 + 175 + .kofi-supporters__name--skeleton { 176 + display: block; 177 + width: 4rem; 178 + height: 0.75rem; 179 + border-radius: 0.25rem; 180 + background-color: color-mix(in srgb, currentColor 15%, transparent); 181 + animation: ks-pulse 1.4s ease-in-out 200ms infinite; 182 + } 183 + 184 + @keyframes ks-pulse { 185 + 0%, 100% { opacity: 1; } 186 + 50% { opacity: 0.4; } 187 + } 188 + 189 + .kofi-supporters__error, 190 + .kofi-supporters__empty { 191 + font-size: 0.875rem; 192 + opacity: 0.75; 193 + } 194 + 195 + .kofi-supporters__error { 196 + color: #c0392b; 197 + } 198 + </style>
+28
packages/supporters/src/lib/LunarContributors.svelte
··· 1 + <script lang="ts"> 2 + /** 3 + * Convenience wrapper around KofiSupporters pre-filtered to Subscription 4 + * events, with the "Lunar Contributors" heading. 5 + * 6 + * Equivalent to: 7 + * <KofiSupporters filter={['Subscription']} heading="Lunar Contributors" /> 8 + */ 9 + import KofiSupporters from './KofiSupporters.svelte'; 10 + import type { KofiSupportersProps } from './types.js'; 11 + 12 + let { 13 + supporters = [], 14 + heading = 'Lunar Contributors', 15 + description = 'People who support my work on Ko-fi.', 16 + loading = false, 17 + error = null 18 + }: Omit<KofiSupportersProps, 'filter'> = $props(); 19 + </script> 20 + 21 + <KofiSupporters 22 + {supporters} 23 + {heading} 24 + {description} 25 + filter={['Subscription']} 26 + {loading} 27 + {error} 28 + />
+6 -1
packages/supporters/src/lib/index.ts
··· 1 - // Reexport your entry components here 1 + export { default as KofiSupporters } from './KofiSupporters.svelte'; 2 + export { default as LunarContributors } from './LunarContributors.svelte'; 3 + export { readStore, appendEvent } from './store.js'; 4 + export type { KofiEventRecord } from './store.js'; 5 + export { parseWebhook, WebhookError } from './webhook.js'; 6 + export type { KofiSupporter, KofiWebhookPayload, KofiSupportersProps, KofiEventType } from './types.js';
+125
packages/supporters/src/lib/store.ts
··· 1 + /** 2 + * ATProto PDS store for Ko-fi supporter data. 3 + * 4 + * Each Ko-fi event is stored as a separate record under: 5 + * uk.ewancroft.kofi.supporter 6 + * 7 + * rkeys are TIDs generated by @ewanc26/tid — one record per event. 8 + * The aggregated KofiSupporter view (deduped by name) is built at read time. 9 + * 10 + * Reads are public (no auth). Writes use an app password. 11 + * 12 + * Required environment variables: 13 + * ATPROTO_DID — your DID, e.g. did:plc:abc123 14 + * ATPROTO_PDS_URL — your PDS URL, e.g. https://pds.ewancroft.uk 15 + * ATPROTO_APP_PASSWORD — an app password from your PDS settings 16 + */ 17 + 18 + import { AtpAgent } from '@atproto/api'; 19 + import { generateTID } from '@ewanc26/tid'; 20 + import type { KofiSupporter, KofiEventType } from './types.js'; 21 + 22 + const COLLECTION = 'uk.ewancroft.kofi.supporter'; 23 + 24 + /** The shape of a raw record stored in the PDS. */ 25 + export interface KofiEventRecord { 26 + name: string; 27 + type: KofiEventType; 28 + tier?: string; 29 + } 30 + 31 + function requireEnv(key: string): string { 32 + const val = process.env[key]; 33 + if (!val) throw new Error(`Missing required environment variable: ${key}`); 34 + return val; 35 + } 36 + 37 + function dedupe<T>(arr: T[], extra: T): T[] { 38 + return Array.from(new Set([...arr, extra])); 39 + } 40 + 41 + /** Authenticated agent for write operations. */ 42 + async function authedAgent(): Promise<{ agent: AtpAgent; did: string }> { 43 + const did = requireEnv('ATPROTO_DID'); 44 + const pdsUrl = requireEnv('ATPROTO_PDS_URL'); 45 + const password = requireEnv('ATPROTO_APP_PASSWORD'); 46 + 47 + const agent = new AtpAgent({ service: pdsUrl }); 48 + await agent.login({ identifier: did, password }); 49 + return { agent, did }; 50 + } 51 + 52 + /** 53 + * Read all event records from the PDS and aggregate into KofiSupporter objects. 54 + * No auth required — collection is publicly readable. 55 + */ 56 + export async function readStore(): Promise<KofiSupporter[]> { 57 + const did = requireEnv('ATPROTO_DID'); 58 + const pdsUrl = requireEnv('ATPROTO_PDS_URL'); 59 + 60 + const agent = new AtpAgent({ service: pdsUrl }); 61 + 62 + const events: KofiEventRecord[] = []; 63 + let cursor: string | undefined; 64 + 65 + do { 66 + const res = await agent.com.atproto.repo.listRecords({ 67 + repo: did, 68 + collection: COLLECTION, 69 + limit: 100, 70 + cursor 71 + }); 72 + 73 + for (const record of res.data.records) { 74 + events.push(record.value as unknown as KofiEventRecord); 75 + } 76 + 77 + cursor = res.data.cursor; 78 + } while (cursor); 79 + 80 + return aggregateEvents(events); 81 + } 82 + 83 + /** Aggregate raw event records into deduplicated KofiSupporter objects. */ 84 + function aggregateEvents(events: KofiEventRecord[]): KofiSupporter[] { 85 + const map = new Map<string, KofiSupporter>(); 86 + 87 + for (const event of events) { 88 + const existing = map.get(event.name); 89 + map.set(event.name, { 90 + name: event.name, 91 + types: dedupe(existing?.types ?? [], event.type), 92 + tiers: event.tier 93 + ? dedupe(existing?.tiers ?? [], event.tier) 94 + : (existing?.tiers ?? []) 95 + }); 96 + } 97 + 98 + return Array.from(map.values()); 99 + } 100 + 101 + /** 102 + * Write a single Ko-fi event as a new record. 103 + * rkey is a TID generated at call time. 104 + */ 105 + export async function appendEvent( 106 + name: string, 107 + type: KofiEventType, 108 + tier: string | null, 109 + timestamp: string 110 + ): Promise<void> { 111 + const { agent, did } = await authedAgent(); 112 + 113 + const record: KofiEventRecord = { 114 + name, 115 + type, 116 + ...(tier ? { tier } : {}) 117 + }; 118 + 119 + await agent.com.atproto.repo.putRecord({ 120 + repo: did, 121 + collection: COLLECTION, 122 + rkey: generateTID(timestamp), 123 + record: record as unknown as { [x: string]: unknown } 124 + }); 125 + }
+47
packages/supporters/src/lib/types.ts
··· 1 + export type KofiEventType = 'Donation' | 'Subscription' | 'Commission' | 'Shop Order'; 2 + 3 + /** 4 + * Ko-fi webhook payload — sent as application/x-www-form-urlencoded. 5 + * The `data` field is a JSON string containing this structure. 6 + * 7 + * @see https://ko-fi.com/manage/webhooks 8 + */ 9 + export interface KofiWebhookPayload { 10 + verification_token: string; 11 + message_id: string; 12 + timestamp: string; 13 + type: KofiEventType; 14 + is_public: boolean; 15 + from_name: string; 16 + message: string | null; 17 + amount: string; 18 + url: string; 19 + email: string; 20 + currency: string; 21 + is_subscription_payment: boolean; 22 + is_first_subscription_payment: boolean; 23 + kofi_transaction_id: string; 24 + shop_items: unknown | null; 25 + tier_name: string | null; 26 + shipping: unknown | null; 27 + } 28 + 29 + /** A persisted supporter record, derived from one or more webhook events. */ 30 + export interface KofiSupporter { 31 + /** Display name from the Ko-fi payment */ 32 + name: string; 33 + /** All event types seen from this person (deduplicated) */ 34 + types: KofiEventType[]; 35 + /** All tier names seen from this person (deduplicated, non-null) */ 36 + tiers: string[]; 37 + } 38 + 39 + export interface KofiSupportersProps { 40 + supporters: KofiSupporter[]; 41 + heading?: string; 42 + description?: string; 43 + /** If set, only show supporters who have at least one event of these types */ 44 + filter?: KofiEventType[]; 45 + loading?: boolean; 46 + error?: string | null; 47 + }
+47
packages/supporters/src/lib/webhook.ts
··· 1 + /** 2 + * Validates and parses an incoming Ko-fi webhook request. 3 + * 4 + * Ko-fi sends application/x-www-form-urlencoded with a single `data` field 5 + * containing the payment JSON. We verify the embedded verification_token 6 + * matches the secret set in KOFI_VERIFICATION_TOKEN. 7 + * 8 + * @see https://ko-fi.com/manage/webhooks (Advanced → Verification Token) 9 + */ 10 + 11 + import type { KofiWebhookPayload } from './types.js'; 12 + 13 + export class WebhookError extends Error { 14 + constructor( 15 + message: string, 16 + public readonly status: number 17 + ) { 18 + super(message); 19 + } 20 + } 21 + 22 + export async function parseWebhook(request: Request): Promise<KofiWebhookPayload> { 23 + const secret = process.env.KOFI_VERIFICATION_TOKEN; 24 + if (!secret) throw new WebhookError('KOFI_VERIFICATION_TOKEN is not set', 500); 25 + 26 + const contentType = request.headers.get('content-type') ?? ''; 27 + if (!contentType.includes('application/x-www-form-urlencoded')) { 28 + throw new WebhookError('Unexpected content-type', 400); 29 + } 30 + 31 + const body = await request.formData(); 32 + const raw = body.get('data'); 33 + if (!raw || typeof raw !== 'string') throw new WebhookError('Missing data field', 400); 34 + 35 + let payload: KofiWebhookPayload; 36 + try { 37 + payload = JSON.parse(raw); 38 + } catch { 39 + throw new WebhookError('Invalid JSON in data field', 400); 40 + } 41 + 42 + if (payload.verification_token !== secret) { 43 + throw new WebhookError('Verification token mismatch', 401); 44 + } 45 + 46 + return payload; 47 + }
+6
packages/supporters/src/routes/+page.server.ts
··· 1 + import { readStore } from '$lib/store.js'; 2 + import type { PageServerLoad } from './$types.js'; 3 + 4 + export const load: PageServerLoad = async () => { 5 + return { supporters: await readStore() }; 6 + };
+8 -3
packages/supporters/src/routes/+page.svelte
··· 1 - <h1>Welcome to your library project</h1> 2 - <p>Create your package using @sveltejs/package and preview/showcase your work with SvelteKit</p> 3 - <p>Visit <a href="https://svelte.dev/docs/kit">svelte.dev/docs/kit</a> to read the documentation</p> 1 + <script lang="ts"> 2 + import KofiSupporters from '$lib/KofiSupporters.svelte'; 3 + import type { PageData } from './$types.js'; 4 + 5 + let { data }: { data: PageData } = $props(); 6 + </script> 7 + 8 + <KofiSupporters supporters={data.supporters} heading="All Supporters" />
+31
packages/supporters/src/routes/webhook/+server.ts
··· 1 + import { json } from '@sveltejs/kit'; 2 + import { parseWebhook, WebhookError } from '$lib/webhook.js'; 3 + import { appendEvent } from '$lib/store.js'; 4 + import type { RequestHandler } from './$types.js'; 5 + 6 + export const POST: RequestHandler = async ({ request }) => { 7 + let payload; 8 + try { 9 + payload = await parseWebhook(request); 10 + } catch (err) { 11 + if (err instanceof WebhookError) { 12 + return json({ error: err.message }, { status: err.status }); 13 + } 14 + throw err; 15 + } 16 + 17 + // Respect the supporter's privacy preference. 18 + if (!payload.is_public) { 19 + return new Response(null, { status: 200 }); 20 + } 21 + 22 + await appendEvent( 23 + payload.from_name, 24 + payload.type, 25 + payload.tier_name, 26 + payload.timestamp 27 + ); 28 + 29 + // Ko-fi retries if it doesn't receive 200. 30 + return new Response(null, { status: 200 }); 31 + };
+60
pnpm-lock.yaml
··· 69 69 specifier: ^7.3.1 70 70 version: 7.3.1(@types/node@22.19.15)(jiti@2.6.1)(lightningcss@1.31.1) 71 71 72 + packages/supporters: 73 + dependencies: 74 + '@ewanc26/tid': 75 + specifier: ^1.1.1 76 + version: 1.1.1 77 + devDependencies: 78 + '@atproto/api': 79 + specifier: ^0.18.1 80 + version: 0.18.21 81 + '@sveltejs/adapter-auto': 82 + specifier: ^7.0.0 83 + version: 7.0.1(@sveltejs/kit@2.53.4(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.53.7)(vite@7.3.1(@types/node@22.19.15)(jiti@2.6.1)(lightningcss@1.31.1)))(svelte@5.53.7)(typescript@5.9.3)(vite@7.3.1(@types/node@22.19.15)(jiti@2.6.1)(lightningcss@1.31.1))) 84 + '@sveltejs/kit': 85 + specifier: ^2.50.2 86 + version: 2.53.4(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.53.7)(vite@7.3.1(@types/node@22.19.15)(jiti@2.6.1)(lightningcss@1.31.1)))(svelte@5.53.7)(typescript@5.9.3)(vite@7.3.1(@types/node@22.19.15)(jiti@2.6.1)(lightningcss@1.31.1)) 87 + '@sveltejs/package': 88 + specifier: ^2.5.7 89 + version: 2.5.7(svelte@5.53.7)(typescript@5.9.3) 90 + '@sveltejs/vite-plugin-svelte': 91 + specifier: ^6.2.4 92 + version: 6.2.4(svelte@5.53.7)(vite@7.3.1(@types/node@22.19.15)(jiti@2.6.1)(lightningcss@1.31.1)) 93 + '@tailwindcss/typography': 94 + specifier: ^0.5.19 95 + version: 0.5.19(tailwindcss@4.2.1) 96 + '@tailwindcss/vite': 97 + specifier: ^4.1.18 98 + version: 4.2.1(vite@7.3.1(@types/node@22.19.15)(jiti@2.6.1)(lightningcss@1.31.1)) 99 + prettier: 100 + specifier: ^3.8.1 101 + version: 3.8.1 102 + prettier-plugin-svelte: 103 + specifier: ^3.4.1 104 + version: 3.5.1(prettier@3.8.1)(svelte@5.53.7) 105 + prettier-plugin-tailwindcss: 106 + specifier: ^0.7.2 107 + version: 0.7.2(prettier-plugin-svelte@3.5.1(prettier@3.8.1)(svelte@5.53.7))(prettier@3.8.1) 108 + publint: 109 + specifier: ^0.3.17 110 + version: 0.3.18 111 + svelte: 112 + specifier: ^5.51.0 113 + version: 5.53.7 114 + svelte-check: 115 + specifier: ^4.4.2 116 + version: 4.4.4(picomatch@4.0.3)(svelte@5.53.7)(typescript@5.9.3) 117 + tailwindcss: 118 + specifier: ^4.1.18 119 + version: 4.2.1 120 + typescript: 121 + specifier: ^5.9.3 122 + version: 5.9.3 123 + vite: 124 + specifier: ^7.3.1 125 + version: 7.3.1(@types/node@22.19.15)(jiti@2.6.1)(lightningcss@1.31.1) 126 + 72 127 packages/svelte-standard-site: 73 128 dependencies: 74 129 '@atproto/api': ··· 426 481 engines: {node: '>=18'} 427 482 cpu: [x64] 428 483 os: [win32] 484 + 485 + '@ewanc26/tid@1.1.1': 486 + resolution: {integrity: sha512-u/Ks251B+5Dy1lx1PC814mWpMg2TNly4b+bHMdLCz4TwiArD/se3iApL+L6pl16eKQQlqWS2yrVKhhSwThC3WA==} 429 487 430 488 '@jridgewell/gen-mapping@0.3.13': 431 489 resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} ··· 1953 2011 1954 2012 '@esbuild/win32-x64@0.27.3': 1955 2013 optional: true 2014 + 2015 + '@ewanc26/tid@1.1.1': {} 1956 2016 1957 2017 '@jridgewell/gen-mapping@0.3.13': 1958 2018 dependencies: