fuzzyworld

when-am-i / commit

docs: generated README badges + MIT license

6bcd5898c234be7b7de096c519f9c6fa35c5a2df · Pavel Safronov · 2026-06-18 23:38:17 -0700 · parents 1c947a5e88ee

Claude Code usage: this commit ~42.1K out / ~4.3M cached · cost unknown (no per-model trailer) · session 1 tally 83.1K in / 1.0M out / 161.9M cached · usage by commit and session

docs: generated README badges + MIT license

- Add scripts/gen-badges.mts (npm run gen:badges): a single TS script that
  computes the badge values from disk — app version and the number of
  calendar data files — and rewrites the badge block between markers in
  README.md. --check mode writes nothing and fails if the block is stale.
- Gate it in CI (test stage) so a stale badge block blocks the deploy.
- Add the badge row to the README (pipeline, demo, version, calendar data,
  license, tech stack) and document the generator + contributor step.
- Formalize the MIT license that the README already declared: add a LICENSE
  file and a license field in package.json.

Usage: 83120 in / 1012219 out / 161854881 cached (session tally)
rtk:   1.7K saved, 73.3% over 14 cmds (global/lifetime)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
 .gitlab-ci.yml         |  3 ++
 LICENSE                | 21 +++++++++++++
 README.md              | 29 ++++++++++++++++-
 package.json           |  2 ++
 scripts/gen-badges.mts | 84 ++++++++++++++++++++++++++++++++++++++++++++++++++
 5 files changed, 138 insertions(+), 1 deletion(-)

diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml
index 838ec31..5767c1f 100644
--- a/.gitlab-ci.yml
+++ b/.gitlab-ci.yml
@@ -14,6 +14,9 @@ test:
     - npm run lint
     - npm run typecheck
     - npm test
+    # Fail if the README badge block is out of sync with the repo (run
+    # `npm run gen:badges` and commit the result to fix).
+    - npm run gen:badges -- --check
 
 create-pages:
   stage: deploy
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000..88e06c2
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2026 Pavel Safronov
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/README.md b/README.md
index e92fb0d..ad9dad3 100644
--- a/README.md
+++ b/README.md
@@ -1,5 +1,19 @@
 # 🚀 [Live site](https://pavelsafronov.gitlab.io/what-year-is-it)
 
+<!-- badges:start -->
+<!-- generated by `npm run gen:badges` — do not edit by hand -->
+[![pipeline status](https://gitlab.com/PavelSafronov/what-year-is-it/badges/main/pipeline.svg)](https://gitlab.com/PavelSafronov/what-year-is-it/-/pipelines)
+[![live demo](https://img.shields.io/badge/demo-live-success)](https://pavelsafronov.gitlab.io/what-year-is-it)
+![version](https://img.shields.io/badge/version-v0.1.0-blue)
+![calendar data](https://img.shields.io/badge/calendar%20data-1-orange)
+![license](https://img.shields.io/badge/license-MIT-green)
+
+![React](https://img.shields.io/badge/React-20232A?logo=react&logoColor=61DAFB)
+![TypeScript](https://img.shields.io/badge/TypeScript-3178C6?logo=typescript&logoColor=fff)
+![Vite](https://img.shields.io/badge/Vite-646CFF?logo=vite&logoColor=fff)
+![Leaflet](https://img.shields.io/badge/Leaflet-199900?logo=leaflet&logoColor=fff)
+<!-- badges:end -->
+
 ## What Year Is It?
 
 Imagine you're a time traveler who ends up in a random place and time in history. What does a local say when you ask, "What year is it?" This project is a fun and educational React app that lets you explore different calendar systems around the world by selecting a location on an interactive map and choosing any year in history. It then shows you the local calendar year and system for that place and time.
@@ -49,6 +63,17 @@ npm test            # Vitest unit tests
 npm run test:watch  # Vitest in watch mode
 ```
 
+### Badges
+
+The README badge block (between the `badges:start`/`badges:end` markers above) is
+generated, not hand-edited. It pulls the app version from `package.json` and counts
+the calendar data files, so the badges never drift. Run it before opening a PR:
+
+```bash
+npm run gen:badges            # rewrite the badge block in README.md
+npm run gen:badges -- --check # fail if the block is stale (no write) — for CI
+```
+
 ### Screenshots
 
 Screenshots are generated with Playwright, which forces deterministic app state
@@ -144,11 +169,13 @@ See `src/data/calendars/SCHEMA.json` for the schema and field documentation.
 - Run `npm run screenshot:calendar -- src/data/calendars/<your-file>.json` and
   attach the images to your PR, so the region, eras, and conversions can be
   reviewed visually.
+- Run `npm run gen:badges` so the README's calendar-data count reflects your
+  addition, and commit the updated README.
 - The build process will validate and compile all data for use in the app.
 
 ## License
 
-MIT
+[MIT](LICENSE) © Pavel Safronov
 
 ## Attributions
 
diff --git a/package.json b/package.json
index a7c8918..72bfccb 100644
--- a/package.json
+++ b/package.json
@@ -4,6 +4,7 @@
     "private": true,
     "type": "module",
     "description": "A React SPA to explore what year it was in different places and times.",
+    "license": "MIT",
     "scripts": {
         "dev": "vite --open",
         "build": "vite build",
@@ -14,6 +15,7 @@
         "test:watch": "vitest",
         "screenshot": "tsx scripts/generate-screenshots.mts",
         "screenshot:calendar": "tsx scripts/screenshot-calendar.mts",
+        "gen:badges": "tsx scripts/gen-badges.mts",
         "gen:favicon": "sips -z 64 64 calendar-source.png --out src/assets/favicon.png",
         "copy:leaflet-icons": "mkdir -p public && cp node_modules/leaflet/dist/images/marker-icon.png public/ && cp node_modules/leaflet/dist/images/marker-icon-2x.png public/ && cp node_modules/leaflet/dist/images/marker-shadow.png public/",
         "postinstall": "npm run copy:leaflet-icons"
diff --git a/scripts/gen-badges.mts b/scripts/gen-badges.mts
new file mode 100644
index 0000000..49dfe87
--- /dev/null
+++ b/scripts/gen-badges.mts
@@ -0,0 +1,84 @@
+// Generate the README badge block from real project data, so the badges never
+// drift out of sync with the repo. Run before opening a PR:
+//
+//   npm run gen:badges            # rewrite the badge block in README.md
+//   npm run gen:badges -- --check # exit non-zero if it's stale (CI / pre-PR gate)
+//
+// The block lives between the <!-- badges:start --> and <!-- badges:end -->
+// markers in README.md. Computed values (app version, number of calendar data
+// files) come from disk; the static badges (pipeline, demo, tech stack) are
+// emitted here too so the whole row has a single source of truth.
+
+import { readFile, writeFile, readdir } from 'node:fs/promises';
+import path from 'node:path';
+import { fileURLToPath } from 'node:url';
+
+const REPO_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
+const README = path.join(REPO_ROOT, 'README.md');
+
+const GITLAB_PROJECT = 'PavelSafronov/what-year-is-it';
+const PAGES_URL = 'https://pavelsafronov.gitlab.io/what-year-is-it';
+const START = '<!-- badges:start -->';
+const END = '<!-- badges:end -->';
+const PREAMBLE = '<!-- generated by `npm run gen:badges` — do not edit by hand -->';
+
+/** A shields.io static badge URL: label-message-color, each part encoded. */
+const shield = (label: string, message: string, color: string) =>
+    `https://img.shields.io/badge/${encodeURIComponent(label)}-${encodeURIComponent(message)}-${color}`;
+
+/** Number of calendar data files (everything under calendars/ except the schema). */
+async function countCalendars(): Promise<number> {
+    const files = await readdir(path.join(REPO_ROOT, 'src/data/calendars'));
+    return files.filter((f) => f.endsWith('.json') && !f.startsWith('SCHEMA')).length;
+}
+
+async function buildBlock(): Promise<string> {
+    const pkg = JSON.parse(await readFile(path.join(REPO_ROOT, 'package.json'), 'utf-8')) as { version: string };
+    const calendars = await countCalendars();
+
+    return [
+        PREAMBLE,
+        `[![pipeline status](https://gitlab.com/${GITLAB_PROJECT}/badges/main/pipeline.svg)](https://gitlab.com/${GITLAB_PROJECT}/-/pipelines)`,
+        `[![live demo](${shield('demo', 'live', 'success')})](${PAGES_URL})`,
+        `![version](${shield('version', `v${pkg.version}`, 'blue')})`,
+        `![calendar data](${shield('calendar data', String(calendars), 'orange')})`,
+        `![license](${shield('license', 'MIT', 'green')})`,
+        '',
+        '![React](https://img.shields.io/badge/React-20232A?logo=react&logoColor=61DAFB)',
+        '![TypeScript](https://img.shields.io/badge/TypeScript-3178C6?logo=typescript&logoColor=fff)',
+        '![Vite](https://img.shields.io/badge/Vite-646CFF?logo=vite&logoColor=fff)',
+        '![Leaflet](https://img.shields.io/badge/Leaflet-199900?logo=leaflet&logoColor=fff)',
+    ].join('\n');
+}
+
+async function main() {
+    const check = process.argv.includes('--check');
+    const readme = await readFile(README, 'utf-8');
+
+    const startIdx = readme.indexOf(START);
+    const endIdx = readme.indexOf(END);
+    if (startIdx === -1 || endIdx === -1 || endIdx < startIdx) {
+        throw new Error(`README.md is missing the ${START} … ${END} markers`);
+    }
+
+    const next =
+        readme.slice(0, startIdx + START.length) +
+        '\n' + await buildBlock() + '\n' +
+        readme.slice(endIdx);
+
+    if (next === readme) {
+        console.log('✅  README badges already up to date');
+        return;
+    }
+    if (check) {
+        console.error('❌  README badges are stale — run `npm run gen:badges` and commit the result');
+        process.exit(1);
+    }
+    await writeFile(README, next);
+    console.log('✨  Updated README badges');
+}
+
+main().catch((err) => {
+    console.error(err);
+    process.exit(1);
+});