when-am-i / files / scripts / gen-badges.mts
gen-badges.mts
6bcd5898 2026-06-19 Pavel Safronov | 1 | // Generate the README badge block from real project data, so the badges never |
| 2 | // drift out of sync with the repo. Run before opening a PR: | |
| 3 | // | |
| 4 | // npm run gen:badges # rewrite the badge block in README.md | |
| 5 | // npm run gen:badges -- --check # exit non-zero if it's stale (CI / pre-PR gate) | |
| 6 | // | |
| 7 | // The block lives between the <!-- badges:start --> and <!-- badges:end --> | |
| 8 | // markers in README.md. Computed values (app version, number of calendar data | |
c2d75cfd 2026-10-27 Pavel Safronov | 9 | // files) come from disk; the static badges (demo, tech stack) are |
6bcd5898 2026-06-19 Pavel Safronov | 10 | // emitted here too so the whole row has a single source of truth. |
| 11 | ||
| 12 | import { readFile, writeFile, readdir } from 'node:fs/promises'; | |
| 13 | import path from 'node:path'; | |
| 14 | import { fileURLToPath } from 'node:url'; | |
| 15 | ||
| 16 | const REPO_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); | |
| 17 | const README = path.join(REPO_ROOT, 'README.md'); | |
| 18 | ||
c2d75cfd 2026-10-27 Pavel Safronov | 19 | const PAGES_URL = 'https://apps.fuzzyworld.net/when-am-i/'; |
6bcd5898 2026-06-19 Pavel Safronov | 20 | const START = '<!-- badges:start -->'; |
| 21 | const END = '<!-- badges:end -->'; | |
| 22 | const PREAMBLE = '<!-- generated by `npm run gen:badges` — do not edit by hand -->'; | |
| 23 | ||
| 24 | /** A shields.io static badge URL: label-message-color, each part encoded. */ | |
| 25 | const shield = (label: string, message: string, color: string) => | |
| 26 | `https://img.shields.io/badge/${encodeURIComponent(label)}-${encodeURIComponent(message)}-${color}`; | |
| 27 | ||
| 28 | /** Number of calendar data files (everything under calendars/ except the schema). */ | |
| 29 | async function countCalendars(): Promise<number> { | |
| 30 | const files = await readdir(path.join(REPO_ROOT, 'src/data/calendars')); | |
| 31 | return files.filter((f) => f.endsWith('.json') && !f.startsWith('SCHEMA')).length; | |
| 32 | } | |
| 33 | ||
| 34 | async function buildBlock(): Promise<string> { | |
| 35 | const pkg = JSON.parse(await readFile(path.join(REPO_ROOT, 'package.json'), 'utf-8')) as { version: string }; | |
| 36 | const calendars = await countCalendars(); | |
| 37 | ||
| 38 | return [ | |
| 39 | PREAMBLE, | |
| 40 | `[})](${PAGES_URL})`, | |
| 41 | `})`, | |
| 42 | `, 'orange')})`, | |
| 43 | `})`, | |
| 44 | '', | |
| 45 | '', | |
| 46 | '', | |
| 47 | '', | |
| 48 | '', | |
| 49 | ].join('\n'); | |
| 50 | } | |
| 51 | ||
| 52 | async function main() { | |
| 53 | const check = process.argv.includes('--check'); | |
| 54 | const readme = await readFile(README, 'utf-8'); | |
| 55 | ||
| 56 | const startIdx = readme.indexOf(START); | |
| 57 | const endIdx = readme.indexOf(END); | |
| 58 | if (startIdx === -1 || endIdx === -1 || endIdx < startIdx) { | |
| 59 | throw new Error(`README.md is missing the ${START} … ${END} markers`); | |
| 60 | } | |
| 61 | ||
| 62 | const next = | |
| 63 | readme.slice(0, startIdx + START.length) + | |
| 64 | '\n' + await buildBlock() + '\n' + | |
| 65 | readme.slice(endIdx); | |
| 66 | ||
| 67 | if (next === readme) { | |
| 68 | console.log('✅ README badges already up to date'); | |
| 69 | return; | |
| 70 | } | |
| 71 | if (check) { | |
| 72 | console.error('❌ README badges are stale — run `npm run gen:badges` and commit the result'); | |
| 73 | process.exit(1); | |
| 74 | } | |
| 75 | await writeFile(README, next); | |
| 76 | console.log('✨ Updated README badges'); | |
| 77 | } | |
| 78 | ||
| 79 | main().catch((err) => { | |
| 80 | console.error(err); | |
| 81 | process.exit(1); | |
| 82 | }); |