contribs.sh 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Chris Hartman
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,229 @@
1
- # Temporary Holding Version
1
+ # contribs.sh
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ A browser dashboard for your contribution history, merged across **multiple sources** —
4
+ github.com accounts, GitHub Enterprise, and Forgejo/Gitea instances (e.g. Codeberg) — rendered
5
+ with [`@json-render/react`](https://json-render.dev). It distinguishes genuine zero-activity days
6
+ from days where no source has data (lost / pre-migration history), which render as a distinct
7
+ hatched "no data" cell, and lets you annotate periods your work lived on another platform.
8
+
9
+ ## Make it your own
10
+
11
+ Everything personal (which accounts to fetch, your lost/incomplete periods, tokens, generated data)
12
+ is gitignored — the repo ships `.example` templates, so a fresh clone builds with neutral defaults.
13
+ To run your own version:
14
+
15
+ ```bash
16
+ git clone <your-fork-url> && cd contribs.sh
17
+ npm install
18
+
19
+ cp sources.example.json sources.json # edit: your accounts (see "Configuring sources")
20
+ cp lost-ranges.example.json lost-ranges.json # optional: periods on other platforms / lost history
21
+
22
+ npm run fetch # pulls your data → public/data/contributions.json
23
+ npm run dev # open the printed localhost URL
24
+ ```
25
+
26
+ **Prerequisites:** Node 18+ and either the [`gh` CLI](https://cli.github.com/) authenticated with your
27
+ github.com account(s), or Personal Access Tokens for any source not in `gh` (see Tokens below).
28
+
29
+ ## Configuring sources
30
+
31
+ `sources.json` (gitignored) lists the accounts to merge. Each entry:
32
+
33
+ | field | meaning |
34
+ | ---------- | ----------------------------------------------------------------------- |
35
+ | `id` | unique slug (used for the per-source file name) |
36
+ | `label` | display name in the UI |
37
+ | `type` | `github` (github.com or Enterprise) or `forgejo` (Codeberg/Gitea) |
38
+ | `host` | `github.com`, your Enterprise host, or `codeberg.org` |
39
+ | `username` | the account's login |
40
+ | `tokenEnv` | name of the env var holding the token — **omit** for a github.com account managed by the `gh` CLI |
41
+ | `enabled` | set `false` to keep an entry without fetching it |
42
+
43
+ **Tokens.** github.com accounts in your `gh` keyring need no token — the fetch script pulls one via
44
+ `gh auth token --user <username>`. Every other source reads its token from an env var named by
45
+ `tokenEnv`, loaded from a gitignored `.env` (see `.env.example`):
46
+
47
+ - **GitHub Enterprise** — a PAT with `repo` + `read:org` on the Enterprise host.
48
+ - **Codeberg / Forgejo** — an access token with `read:user`.
49
+ - **A second github.com account** not in `gh` — a PAT with `repo` + `read:org`.
50
+
51
+ Sources whose token can't be resolved are skipped with a warning, so the dashboard always builds
52
+ with whatever credentials are available.
53
+
54
+ ## How it works
55
+
56
+ - **`src/fetch/`** — `npm run fetch` reads `sources.json`, runs one adapter per source
57
+ (`adapters/github.ts` = GraphQL `contributionsCollection`; `adapters/forgejo.ts` = the
58
+ `/api/v1/users/{user}/heatmap` REST endpoint), then `merge.ts` sums per-day counts and unions
59
+ coverage windows into `public/data/contributions.json` (per-source files land in
60
+ `public/data/sources/`).
61
+ - **`src/`** — the React app loads that JSON, exposes it via `contributionsContext`, and renders a
62
+ layout described by `dashboard.json` against the json-render catalog/registry in `catalog.tsx`.
63
+ Components (`YearHeatmap`, `StatsPanel`, `YearSelector`, `SourceLegend`) read the data from context.
64
+
65
+ A day is **known** when at least one source's coverage window includes it; unknown days carry count 0
66
+ and are excluded from streak/average stats so lost history never distorts the numbers.
67
+ Coverage ends no later than the UTC fetch date. The full current-year grid remains visible:
68
+ dates after today use a subdued outline and a "Future date" tooltip, contribute to no stats,
69
+ and cannot receive lost-history annotations. Elapsed dates after the last fetch are "No data"
70
+ until a refresh covers them. Future-date styling advances with the UTC date while the app is open.
71
+
72
+ ## Marking history lost to a migration
73
+
74
+ Some real history (e.g. a Bitbucket → GitHub migration) no longer exists on any source. You can mark
75
+ those days so they render in a distinct **gray "lost"** style with a hover note, instead of looking
76
+ like zero-activity days. Edit the committed **`lost-ranges.json`**:
77
+
78
+ ```json
79
+ {
80
+ "enabled": true,
81
+ "label": "Contribs lost due to Bitbucket migration",
82
+ "ranges": [
83
+ {
84
+ "id": "bitbucket-era",
85
+ "from": "2016-01-01",
86
+ "to": "2020-12-31",
87
+ "days": "weekdays",
88
+ "weekdays": [1, 2, 3, 4, 5],
89
+ "note": "Contribs lost due to Bitbucket migration",
90
+ "enabled": true
91
+ }
92
+ ]
93
+ }
94
+ ```
95
+
96
+ - `from` / `to` — inclusive ISO date range to mark.
97
+ - `days` — `"weekdays"` (Mon–Fri), `"all"` (include weekends), or `"selective"` (use the `weekdays`
98
+ list; `0`=Sun … `6`=Sat).
99
+ - `note` — optional hover text for this range; falls back to the top-level `label`.
100
+ - `label` — optional short name shown in the legend; falls back to `id`.
101
+ - `color` — optional cell color for this range's lost days (any CSS color); falls back to a neutral
102
+ gray. Use distinct colors to tell ranges apart at a glance.
103
+ - Only **0-contribution** days in the range are colored — days that already have contributions stay
104
+ green. Each active range gets its own swatch in the legend.
105
+
106
+ **Toggle:** the legend has a **"Lost / incomplete"** checkbox that shows/hides the overlay live (your
107
+ choice is remembered in the browser). While it's on, each lost day counts as **1** toward **Total
108
+ contributions**, **Active days**, and **Avg / active day** — but **never** toward **Longest** or
109
+ **Current streak**, which always reflect real GitHub activity only. Turning it off reverts both the
110
+ heatmap and the stats to GitHub-only numbers.
111
+
112
+ **To remove a range entirely:** set its `"enabled": false` (or delete the entry), or flip the
113
+ top-level `"enabled": false`. Config changes apply on reload — no re-fetch.
114
+
115
+ `lost-ranges.example.json` ships a single **disabled** example range; copy it to `lost-ranges.json`,
116
+ set your real window(s), and `"enabled": true`. If `lost-ranges.json` is absent the app falls back to
117
+ the example, so the build never breaks.
118
+
119
+ ## Display settings
120
+
121
+ `config.json` (committed — not personal) holds display preferences:
122
+
123
+ | field | meaning |
124
+ | ------------- | ---------------------------------------------------------------------------- |
125
+ | `title` | Site name — shown as the page heading and the browser tab title. |
126
+ | `recentYears` | In the "All years" view, how many recent years to show before the **"Show earlier years"** toggle (default `10`). Older years fade in when you expand them. |
127
+ | `startYear` | Earliest year to display (currently `2007`). Takes effect on the next fetch; years without source coverage show "No data" and do not change contribution totals. Earlier source history is still retained. |
128
+
129
+ The year filter at the top is a dropdown — jump straight to any year (including older, collapsed ones)
130
+ or back to the all-years overview.
131
+
132
+ The sticky legend's service buttons filter the heatmaps, tooltips, year totals, and stats.
133
+ GitHub controls fetched activity from every GitHub source plus any lost range labelled `GitHub`;
134
+ each other lost range (in `lost-ranges.json` order) gets its own button that toggles its
135
+ annotations. The gray Lost / Incomplete checkbox controls all annotations without hiding real
136
+ activity. Selections persist in the browser; No Data and Less–More are informational.
137
+
138
+ ## Scripts
139
+
140
+ | script | does |
141
+ | --------------- | ----------------------------------------------- |
142
+ | `npm run fetch` | fetch + merge all sources → `public/data/` |
143
+ | `npm run dev` | Vite dev server |
144
+ | `npm run build` | type-check (app + node) and production build |
145
+ | `npm run preview` | preview the production build |
146
+ | `npm run banner -- ...` | render banner / card / Wrapped / poster images |
147
+ | `npm run cli -- ...` | the `contribs-sh` CLI (`init`, `fetch`, `banner`, `scan`) |
148
+
149
+ ## Recovering history from local repos
150
+
151
+ Commits that never reached a hosted forge (an old employer's GitLab, an SVN repo you converted with
152
+ `git svn`, a Bitbucket repo you still have cloned) can be counted straight from disk:
153
+
154
+ ```bash
155
+ npm run cli -- scan ~/code/old-work --email you@old-employer.example
156
+ ```
157
+
158
+ `scan` lists commits per year and prints a `"type": "local"` source to paste into `sources.json`.
159
+ Commits are de-duplicated by hash across repos. Anything you also pushed to GitHub is already in your
160
+ GitHub graph, so set `from`/`to` to the period GitHub is missing. Recovered days are real data, so
161
+ they replace the hand-drawn `lost-ranges.json` annotation for that period.
162
+
163
+ ## Exporting images
164
+
165
+ The dashboard has an **Export image** panel. It renders the same SVG the CLI uses and downloads it
166
+ as PNG or SVG:
167
+
168
+ | preset | size | layout |
169
+ | ---------- | ------------ | ---------------------------------------------- |
170
+ | `x` | 1500×500 | banner; content kept right of the profile photo |
171
+ | `linkedin` | 1584×396 | banner |
172
+ | `og` | 1200×630 | card |
173
+ | `github` | 1280×640 | card (repo social preview) |
174
+ | `wrapped` | 1080×1350 | year-in-review story with stats |
175
+ | `poster` | 5400×7200 | every year, 18×24 in at 300 dpi |
176
+
177
+ From the command line (renders with `resvg`, no browser):
178
+
179
+ ```bash
180
+ npm run banner -- --preset x # contribs-x.png
181
+ npm run banner -- --preset wrapped --range 2026 --title "2026 in code"
182
+ npm run banner -- --preset poster --theme light --out poster.png
183
+ npm run banner -- --preset all --out banners/ # every preset, PNG + SVG
184
+ ```
185
+
186
+ Options: `--range rolling|all|YEAR`, `--theme dark|light`, `--title`, `--scale`, `--no-stats`,
187
+ `--no-mark`, `--transparent`. Lost ranges from `lost-ranges.json` are drawn in their colors.
188
+
189
+ ## CLI
190
+
191
+ Install with `npx contribs.sh <command>` (or `npm i -g contribs.sh`; the binary is `contribs.sh`, with `contribs-sh` as an alias). Inside a clone, `npm run cli --` runs the same thing:
192
+
193
+ ```bash
194
+ contribs.sh init [--ci] # sources.json for your gh login; --ci adds the Pages workflow
195
+ contribs.sh fetch # same as npm run fetch
196
+ contribs.sh banner [...] # see above
197
+ contribs.sh scan DIR... # see "Recovering history from local repos"
198
+ ```
199
+
200
+ ## Self-hosting with GitHub Actions
201
+
202
+ `.github/workflows/contribs.yml` refreshes the data daily, builds the dashboard, renders every banner
203
+ preset, and publishes to GitHub Pages. It does nothing until configured, so forks are safe:
204
+
205
+ 1. Repo → Settings → Secrets → Actions: add `CONTRIBS_SOURCES` (your `sources.json`, with a
206
+ `tokenEnv` on every source), one secret per `tokenEnv` (e.g. `GH_MAIN_TOKEN`, a classic PAT with
207
+ `read:user`, `repo`, `read:org`), and optionally `CONTRIBS_LOST_RANGES`.
208
+ 2. Settings → Pages → Source: **GitHub Actions**.
209
+ 3. Run the workflow once from the Actions tab.
210
+
211
+ Your banners are then at `https://<you>.github.io/<repo>/banners/x.png` (and `.svg`), ready to embed in
212
+ a README or site. Add extra token secrets to the `env:` block of the "Fetch, build, render banners" step.
213
+
214
+ ## Deploying
215
+
216
+ The app is a static site — the only runtime input is `public/data/contributions.json`, produced by
217
+ `npm run fetch`. To publish:
218
+
219
+ ```bash
220
+ npm run fetch # refresh your data (needs gh/tokens; do this wherever you build)
221
+ npm run build # outputs dist/
222
+ # deploy dist/ to GitHub Pages, Vercel, Netlify, Cloudflare Pages, etc.
223
+ ```
224
+
225
+ Because `sources.json`, `lost-ranges.json`, and `public/data/` are gitignored, the simplest path is to
226
+ **fetch + build + deploy from your own machine**. To deploy from CI instead, provide those files to the
227
+ CI job (e.g. commit a token-free `lost-ranges.json`, recreate `sources.json` from a secret, and run
228
+ `gh auth login` / set token env vars) before `npm run fetch`. Re-run `fetch` on a schedule to keep the
229
+ graph current.
@@ -0,0 +1,5 @@
1
+ #!/usr/bin/env node
2
+ import { register } from 'tsx/esm/api';
3
+
4
+ register();
5
+ await import('../src/cli/index.ts');
package/config.json ADDED
@@ -0,0 +1,5 @@
1
+ {
2
+ "title": "contribs.sh",
3
+ "recentYears": 10,
4
+ "startYear": 2007
5
+ }
package/package.json CHANGED
@@ -1,6 +1,58 @@
1
1
  {
2
2
  "name": "contribs.sh",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.0",
4
+ "type": "module",
5
+ "scripts": {
6
+ "dev": "vite",
7
+ "build": "tsc -p tsconfig.json && tsc -p tsconfig.node.json && vite build",
8
+ "preview": "vite preview",
9
+ "fetch": "tsx src/fetch/fetch.ts",
10
+ "cli": "tsx src/cli/index.ts",
11
+ "banner": "tsx src/cli/index.ts banner"
12
+ },
13
+ "dependencies": {
14
+ "@resvg/resvg-js": "^2.6.2",
15
+ "tsx": "^4.19.2"
16
+ },
17
+ "devDependencies": {
18
+ "@json-render/core": "^0.19.0",
19
+ "@json-render/react": "^0.19.0",
20
+ "@types/node": "^22.10.1",
21
+ "@types/react": "^19.0.1",
22
+ "@types/react-dom": "^19.0.1",
23
+ "@vitejs/plugin-react": "^4.3.4",
24
+ "react": "^19.2.3",
25
+ "react-dom": "^19.2.3",
26
+ "typescript": "^5.6.3",
27
+ "vite": "^6.0.3",
28
+ "zod": "^4.4.3"
29
+ },
30
+ "bin": {
31
+ "contribs.sh": "bin/contribs-sh.mjs",
32
+ "contribs-sh": "bin/contribs-sh.mjs"
33
+ },
34
+ "description": "Every contribution, every forge, one heatmap — merge GitHub, GitHub Enterprise, Forgejo and local git history, export banners for X, LinkedIn and more.",
35
+ "license": "MIT",
36
+ "homepage": "https://contribs.sh",
37
+ "engines": {
38
+ "node": ">=20"
39
+ },
40
+ "files": [
41
+ "bin",
42
+ "src/cli",
43
+ "src/render",
44
+ "src/data",
45
+ "src/fetch",
46
+ "src/config.ts",
47
+ "config.json"
48
+ ],
49
+ "keywords": [
50
+ "github",
51
+ "contributions",
52
+ "heatmap",
53
+ "banner",
54
+ "cli",
55
+ "forgejo",
56
+ "gitea"
57
+ ]
58
+ }
@@ -0,0 +1,237 @@
1
+ import { execFileSync } from 'node:child_process';
2
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
3
+ import { dirname, extname, join, resolve } from 'node:path';
4
+ import { parseArgs } from 'node:util';
5
+ import type { MergedData } from '../data/types';
6
+ import { compileLost, EMPTY_LOST_CONFIG, type LostConfig } from '../data/lost';
7
+ import { PRESETS, PRESET_IDS, renderSvg, type PresetId, type Range, type ThemeId } from '../render/svg';
8
+ import { findRepos, localAdapter } from '../fetch/adapters/local';
9
+ import { runFetch } from '../fetch/run';
10
+ import { workflowYaml } from './workflow';
11
+
12
+ const HELP = `contribs.sh — every contribution, every forge, one heatmap.
13
+
14
+ Usage:
15
+ contribs.sh init [--ci] create sources.json (and a GitHub Actions workflow with --ci)
16
+ contribs.sh fetch [--out DIR] fetch + merge all sources (default DIR: public/data)
17
+ contribs.sh banner [options] render an image from the merged data
18
+ contribs.sh scan DIR... [options] recover history from local git repos on disk
19
+
20
+ banner options:
21
+ --preset ID ${PRESET_IDS.join(' | ')} | all (default: x)
22
+ --range R rolling | all | YEAR (default depends on preset)
23
+ --theme T dark | light (default: dark)
24
+ --title TEXT heading for card / wrapped / poster layouts
25
+ --out PATH .png or .svg file; a directory when --preset all (default: contribs-<preset>.png)
26
+ --data PATH merged data (default: public/data/contributions.json; fetched if missing)
27
+ --scale N PNG pixel ratio (default: 1)
28
+ --no-stats hide totals and stats text
29
+ --no-mark hide the contribs.sh mark
30
+ --transparent no background fill
31
+
32
+ scan options:
33
+ --email ADDR author email to count (repeatable; default: git config user.email)
34
+ --depth N directory levels to search (default: 4)
35
+ --from DATE ignore commits before YYYY-MM-DD
36
+ --to DATE ignore commits after YYYY-MM-DD
37
+ `;
38
+
39
+ const root = process.cwd();
40
+
41
+ function fail(message: string): never {
42
+ console.error(message);
43
+ process.exit(1);
44
+ }
45
+
46
+ function readJson<T>(path: string): T {
47
+ return JSON.parse(readFileSync(path, 'utf8')) as T;
48
+ }
49
+
50
+ function loadLost(): LostConfig {
51
+ const path = resolve(root, 'lost-ranges.json');
52
+ return existsSync(path) ? readJson<LostConfig>(path) : EMPTY_LOST_CONFIG;
53
+ }
54
+
55
+ function gitEmail(): string | null {
56
+ try {
57
+ return execFileSync('git', ['config', 'user.email'], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim() || null;
58
+ } catch {
59
+ return null;
60
+ }
61
+ }
62
+
63
+ function ghLogin(): string | null {
64
+ try {
65
+ return execFileSync('gh', ['api', 'user', '-q', '.login'], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim() || null;
66
+ } catch {
67
+ return null;
68
+ }
69
+ }
70
+
71
+ async function init(args: string[]) {
72
+ const { values } = parseArgs({ args, options: { ci: { type: 'boolean' } } });
73
+ const sourcesPath = resolve(root, 'sources.json');
74
+ if (existsSync(sourcesPath)) {
75
+ console.log('sources.json already exists — leaving it alone.');
76
+ } else {
77
+ const login = ghLogin();
78
+ const sources = {
79
+ sources: [
80
+ {
81
+ id: 'gh-main',
82
+ label: 'GitHub',
83
+ type: 'github',
84
+ host: 'github.com',
85
+ username: login ?? 'your-github-username',
86
+ ...(values.ci ? { tokenEnv: 'GH_MAIN_TOKEN' } : {}),
87
+ enabled: true,
88
+ },
89
+ ],
90
+ };
91
+ writeFileSync(sourcesPath, `${JSON.stringify(sources, null, 2)}\n`);
92
+ console.log(`Wrote sources.json${login ? ` for ${login}` : ' — edit "username"'}.`);
93
+ }
94
+ if (values.ci) {
95
+ const wf = resolve(root, '.github/workflows/contribs.yml');
96
+ if (existsSync(wf)) {
97
+ console.log('.github/workflows/contribs.yml already exists — leaving it alone.');
98
+ } else {
99
+ mkdirSync(dirname(wf), { recursive: true });
100
+ writeFileSync(wf, workflowYaml());
101
+ console.log('Wrote .github/workflows/contribs.yml. Add a GH_MAIN_TOKEN repo secret (classic PAT: read:user, repo, read:org)');
102
+ console.log('and a CONTRIBS_SOURCES secret holding your sources.json, then enable GitHub Pages → "GitHub Actions".');
103
+ }
104
+ }
105
+ console.log('Next: contribs.sh fetch && contribs.sh banner');
106
+ }
107
+
108
+ async function fetchCmd(args: string[]) {
109
+ const { values } = parseArgs({ args, options: { out: { type: 'string' } } });
110
+ await runFetch(root, resolve(root, values.out ?? 'public/data'));
111
+ }
112
+
113
+ async function toPng(svg: string, scale: number): Promise<Buffer> {
114
+ const { Resvg } = await import('@resvg/resvg-js');
115
+ const resvg = new Resvg(svg, {
116
+ fitTo: scale === 1 ? { mode: 'original' } : { mode: 'zoom', value: scale },
117
+ font: { loadSystemFonts: true, defaultFontFamily: 'Helvetica' },
118
+ });
119
+ return resvg.render().asPng();
120
+ }
121
+
122
+ async function banner(args: string[]) {
123
+ const { values } = parseArgs({
124
+ args,
125
+ options: {
126
+ preset: { type: 'string', default: 'x' },
127
+ range: { type: 'string' },
128
+ theme: { type: 'string', default: 'dark' },
129
+ title: { type: 'string' },
130
+ out: { type: 'string' },
131
+ data: { type: 'string' },
132
+ scale: { type: 'string', default: '1' },
133
+ 'no-stats': { type: 'boolean' },
134
+ 'no-mark': { type: 'boolean' },
135
+ transparent: { type: 'boolean' },
136
+ },
137
+ });
138
+
139
+ const presets: PresetId[] = values.preset === 'all' ? PRESET_IDS : [values.preset as PresetId];
140
+ for (const p of presets) if (!PRESETS[p]) fail(`Unknown preset "${p}". Use one of: ${PRESET_IDS.join(', ')}, all`);
141
+ if (values.theme !== 'dark' && values.theme !== 'light') fail('--theme must be dark or light');
142
+ const range: Range | undefined = values.range === undefined ? undefined
143
+ : values.range === 'rolling' || values.range === 'all' ? values.range
144
+ : /^\d{4}$/.test(values.range) ? Number(values.range) : fail('--range must be rolling, all, or a year');
145
+ const scale = Number(values.scale);
146
+ if (!(scale > 0 && scale <= 4)) fail('--scale must be between 0 and 4');
147
+
148
+ const dataPath = resolve(root, values.data ?? 'public/data/contributions.json');
149
+ const data: MergedData = existsSync(dataPath)
150
+ ? readJson<MergedData>(dataPath)
151
+ : values.data ? fail(`No data at ${dataPath}`) : await runFetch(root, dirname(dataPath));
152
+
153
+ const lostFor = compileLost(loadLost());
154
+ for (const preset of presets) {
155
+ let out = values.out ?? `contribs-${preset}.png`;
156
+ if (presets.length > 1) {
157
+ const dir = resolve(root, values.out ?? 'banners');
158
+ mkdirSync(dir, { recursive: true });
159
+ out = join(dir, `${preset}.png`);
160
+ }
161
+ const svg = renderSvg(data, {
162
+ preset,
163
+ range,
164
+ theme: values.theme as ThemeId,
165
+ title: values.title,
166
+ stats: !values['no-stats'],
167
+ mark: !values['no-mark'],
168
+ transparent: values.transparent,
169
+ lostFor,
170
+ });
171
+ const target = resolve(root, out);
172
+ mkdirSync(dirname(target), { recursive: true });
173
+ if (extname(target).toLowerCase() === '.svg') {
174
+ writeFileSync(target, svg);
175
+ } else {
176
+ writeFileSync(target, await toPng(svg, scale));
177
+ if (presets.length > 1) writeFileSync(target.replace(/\.png$/, '.svg'), svg);
178
+ }
179
+ console.log(`Wrote ${target} (${PRESETS[preset].label})`);
180
+ }
181
+ }
182
+
183
+ async function scan(args: string[]) {
184
+ const { values, positionals } = parseArgs({
185
+ args,
186
+ allowPositionals: true,
187
+ options: {
188
+ email: { type: 'string', multiple: true },
189
+ depth: { type: 'string', default: '4' },
190
+ from: { type: 'string' },
191
+ to: { type: 'string' },
192
+ },
193
+ });
194
+ if (positionals.length === 0) fail('Usage: contribs.sh scan DIR... [--email ADDR]');
195
+ const emails = values.email?.length ? values.email : [gitEmail()].filter((e): e is string => !!e);
196
+ if (emails.length === 0) fail('No author email: pass --email or set git config user.email');
197
+ const depth = Number(values.depth);
198
+
199
+ const repos = [...new Set(positionals.flatMap((p) => findRepos(p, depth)))];
200
+ console.log(`Found ${repos.length} repo(s) under ${positionals.join(', ')}; counting commits by ${emails.join(', ')}…`);
201
+ const result = await localAdapter({
202
+ id: 'local-scan', label: 'Local repos', type: 'local', host: '', username: '', token: '',
203
+ paths: positionals, emails, depth, from: values.from, to: values.to,
204
+ });
205
+
206
+ const byYear = new Map<string, number>();
207
+ for (const [date, count] of Object.entries(result.days)) byYear.set(date.slice(0, 4), (byYear.get(date.slice(0, 4)) ?? 0) + count);
208
+ const total = [...byYear.values()].reduce((a, b) => a + b, 0);
209
+ if (total === 0) {
210
+ console.log('No matching commits. Check the email(s) your old commits used: git log --format=%ae | sort -u');
211
+ return;
212
+ }
213
+ for (const [year, count] of [...byYear].sort()) console.log(` ${year} ${String(count).padStart(6)} commits`);
214
+ console.log(` total ${String(total).padStart(6)} commits (${result.coverage.from} → ${result.coverage.to})\n`);
215
+ console.log('Add this to sources.json to merge it (set from/to to the years GitHub is missing):');
216
+ console.log(JSON.stringify({
217
+ id: 'local-history',
218
+ label: 'Recovered (local git)',
219
+ type: 'local',
220
+ paths: positionals,
221
+ emails,
222
+ from: values.from ?? result.coverage.from,
223
+ to: values.to ?? result.coverage.to,
224
+ enabled: true,
225
+ }, null, 2));
226
+ }
227
+
228
+ const commands: Record<string, (args: string[]) => Promise<void>> = { init, fetch: fetchCmd, banner, scan };
229
+
230
+ const [command, ...rest] = process.argv.slice(2);
231
+ if (!command || command === '-h' || command === '--help' || command === 'help') {
232
+ process.stdout.write(HELP);
233
+ } else if (!commands[command]) {
234
+ fail(`Unknown command "${command}".\n\n${HELP}`);
235
+ } else {
236
+ commands[command](rest).catch((err: unknown) => fail(err instanceof Error ? err.message : String(err)));
237
+ }
@@ -0,0 +1,85 @@
1
+ /** GitHub Actions workflow: daily fetch → build → banners → GitHub Pages. */
2
+ export function workflowYaml(): string {
3
+ return `# contribs.sh — refresh your heatmap daily and publish it (plus banner images) to GitHub Pages.
4
+ #
5
+ # Setup (repo → Settings):
6
+ # 1. Secrets and variables → Actions → New repository secret:
7
+ # CONTRIBS_SOURCES contents of your sources.json (every source needs "tokenEnv")
8
+ # GH_MAIN_TOKEN classic PAT with read:user, repo, read:org (one secret per tokenEnv)
9
+ # CONTRIBS_LOST_RANGES optional: contents of your lost-ranges.json
10
+ # 2. Pages → Build and deployment → Source: GitHub Actions
11
+ #
12
+ # Published files: index.html (dashboard), banners/<preset>.png|svg, data/contributions.json
13
+ name: contribs
14
+
15
+ on:
16
+ schedule:
17
+ - cron: '17 6 * * *'
18
+ workflow_dispatch:
19
+
20
+ permissions:
21
+ contents: read
22
+ pages: write
23
+ id-token: write
24
+
25
+ concurrency:
26
+ group: pages
27
+ cancel-in-progress: true
28
+
29
+ jobs:
30
+ build:
31
+ runs-on: ubuntu-latest
32
+ outputs:
33
+ configured: \${{ steps.check.outputs.configured }}
34
+ steps:
35
+ - id: check
36
+ env:
37
+ CONTRIBS_SOURCES: \${{ secrets.CONTRIBS_SOURCES }}
38
+ run: |
39
+ if [ -n "$CONTRIBS_SOURCES" ]; then echo configured=true >> "$GITHUB_OUTPUT"; else
40
+ echo "::notice::CONTRIBS_SOURCES secret not set — skipping. See the setup notes at the top of this workflow."
41
+ echo configured=false >> "$GITHUB_OUTPUT"; fi
42
+ - if: steps.check.outputs.configured == 'true'
43
+ uses: actions/checkout@v4
44
+ - if: steps.check.outputs.configured == 'true'
45
+ uses: actions/setup-node@v4
46
+ with:
47
+ node-version: 22
48
+ cache: npm
49
+ - if: steps.check.outputs.configured == 'true'
50
+ name: Write config from secrets
51
+ env:
52
+ CONTRIBS_SOURCES: \${{ secrets.CONTRIBS_SOURCES }}
53
+ CONTRIBS_LOST_RANGES: \${{ secrets.CONTRIBS_LOST_RANGES }}
54
+ run: |
55
+ printf '%s' "$CONTRIBS_SOURCES" > sources.json
56
+ if [ -n "$CONTRIBS_LOST_RANGES" ]; then printf '%s' "$CONTRIBS_LOST_RANGES" > lost-ranges.json; fi
57
+ - if: steps.check.outputs.configured == 'true'
58
+ name: Fetch, build, render banners
59
+ env:
60
+ GH_MAIN_TOKEN: \${{ secrets.GH_MAIN_TOKEN }}
61
+ # Add one line per extra "tokenEnv" in your sources.json, e.g.:
62
+ # GHE_TOKEN: \${{ secrets.GHE_TOKEN }}
63
+ # CODEBERG_TOKEN: \${{ secrets.CODEBERG_TOKEN }}
64
+ run: |
65
+ npm ci
66
+ npm run fetch
67
+ npm run build -- --base ./
68
+ npm run banner -- --preset all --out dist/banners
69
+ - if: steps.check.outputs.configured == 'true'
70
+ uses: actions/upload-pages-artifact@v3
71
+ with:
72
+ path: dist
73
+
74
+ deploy:
75
+ needs: build
76
+ if: needs.build.outputs.configured == 'true'
77
+ runs-on: ubuntu-latest
78
+ environment:
79
+ name: github-pages
80
+ url: \${{ steps.deployment.outputs.page_url }}
81
+ steps:
82
+ - id: deployment
83
+ uses: actions/deploy-pages@v4
84
+ `;
85
+ }
package/src/config.ts ADDED
@@ -0,0 +1,20 @@
1
+ import raw from '../config.json';
2
+
3
+ /**
4
+ * Site/display settings. Committed (not personal data) — fork and edit freely.
5
+ * Personal config lives in the gitignored `sources.json` / `lost-ranges.json`.
6
+ */
7
+ interface AppConfig {
8
+ /** Site name — shown as the page heading and the browser tab title. */
9
+ title: string;
10
+ /** How many of the most recent years to show before the "show earlier" toggle. */
11
+ recentYears: number;
12
+ /** Earliest year to include, even when sources have no coverage. */
13
+ startYear: number;
14
+ }
15
+
16
+ const config = raw as Partial<AppConfig>;
17
+
18
+ export const title = config.title ?? 'contribs.sh';
19
+ export const recentYears = config.recentYears ?? 10;
20
+ export const startYear = config.startYear;