# GitHub Readme Stats — Administrator & Developer Guide

## 1. Architecture Overview

GitHub Readme Stats is a Vercel-native serverless image API. It exposes
five HTTP endpoints, each implemented as a single default-export
async-handler in `api/`. Each handler:

1. Reads its parameters from `req.query`.
2. Runs the request through `guardAccess` (whitelist/blacklist gate).
3. Validates the locale against the I18n catalog.
4. Calls a fetcher (`src/fetchers/<x>.js`) that issues a GraphQL or REST
   request to GitHub (or WakaTime) through the rotating-PAT retryer.
5. Passes the typed result into a renderer (`src/cards/<x>.js`).
6. Sets `Cache-Control` headers and returns SVG.

There is no database, no queue, no background worker, no on-disk state —
every request is a stateless function. The rendering pipeline is pure
string concatenation; the only mutable globals are the `process.env`
PATs and a few module-level constants.

### 1.1 Component diagram

```text
                        Vercel Edge
                            │
                            ▼
   ┌────────────────────────────────────────────────────┐
   │ api/index.js          api/pin.js     api/gist.js    │
   │ api/top-langs.js      api/wakatime.js               │
   │ api/status/up.js      api/status/pat-info.js        │
   └────────┬─────────┬─────────┬─────────┬──────────────┘
            │         │         │         │
            ▼         ▼         ▼         ▼
   ┌────────────────────────────────────────────────────┐
   │ src/common/access.js   guardAccess (allow/deny)     │
   │ src/common/cache.js    CACHE_TTL, setCacheHeaders   │
   │ src/common/error.js    CustomError + secondary msgs │
   │ src/common/render.js   renderError, flexLayout      │
   │ src/common/ops.js      parseBoolean / parseArray    │
   │ src/common/Card.js     Card class (SVG envelope)    │
   │ src/translations.js    50+ locales for stat labels  │
   └────────┬─────────────────────────────┬──────────────┘
            │                             │
            ▼                             ▼
   ┌──────────────────────┐      ┌──────────────────────┐
   │ src/fetchers/*.js    │      │ src/cards/*.js       │
   │ - stats.js           │      │ - stats.js           │
   │ - repo.js            │      │ - repo.js            │
   │ - top-languages.js   │      │ - top-languages.js   │
   │ - wakatime.js        │      │ - wakatime.js        │
   │ - gist.js            │      │ - gist.js            │
   └────────┬─────────────┘      └──────────┬───────────┘
            │                                │
            ▼                                │
   ┌──────────────────────┐                  │
   │ src/common/retryer.js│ rotates PATs     │
   │ src/common/http.js   │ axios → GitHub   │
   └──────────────────────┘                  │
                                             ▼
                                        SVG response
```

### 1.2 Component inventory

| Component | Path | Role |
|---|---|---|
| Stats handler | `api/index.js` | Resolves stats card SVG. 27 parameters destructured from query. |
| Pinned-repo handler | `api/pin.js` | Resolves single-repo pin card SVG. |
| Top-langs handler | `api/top-langs.js` | Resolves languages card. Validates `layout` and `stats_format` enums explicitly. |
| WakaTime handler | `api/wakatime.js` | Calls WakaTime REST endpoint, renders coding-time card. |
| Gist handler | `api/gist.js` | Resolves gist preview card. |
| Status handlers | `api/status/up.js`, `api/status/pat-info.js` | Health endpoints; rate-limited to 1 request per 5 min. |
| Stats renderer | `src/cards/stats.js` | Computes rank circle geometry, lays out stat rows, emits SVG. |
| Repo renderer | `src/cards/repo.js` | Renders pin card with archived/template badge logic. |
| Top-langs renderer | `src/cards/top-languages.js` | Five layouts: normal/compact/donut/donut-vertical/pie. Polar-to-cartesian math for arcs. |
| WakaTime renderer | `src/cards/wakatime.js` | Two layouts (default + compact); auto-detects "no data" sub-states. |
| Gist renderer | `src/cards/gist.js` | Header truncated to 35 chars; description wrapped to 10 lines. |
| Card envelope | `src/common/Card.js` | SVG outer rect, title row, gradient defs, animation switch. |
| I18n | `src/common/I18n.js` | Translation lookup with `en` fallback. Throws on missing keys. |
| Color resolver | `src/common/color.js` | `getCardColors`: theme → user override → default fallback chain. Validates hex (3/4/6/8 char) and gradient (angle + 2+ hex). |
| Error model | `src/common/error.js` | `CustomError` with type→secondary-message map; `MissingParamError`. |
| Cache policy | `src/common/cache.js` | `CACHE_TTL` per card, `resolveCacheSeconds`, header writers. |
| Retryer | `src/common/retryer.js` | Rotates `PAT_1`..`PAT_n`, distinguishes rate-limit vs bad-credential, recurses. |
| HTTP client | `src/common/http.js` | Thin axios wrapper that posts to `https://api.github.com/graphql`. |
| Whitelist/blacklist | `src/common/access.js`, `src/common/blacklist.js`, `src/common/envs.js` | Optional allow-list, hardcoded deny-list of known abusers. |
| Logger | `src/common/log.js` | `console` in prod, `noop` in tests. |
| Themes | `themes/index.js` | 74 named theme objects. |
| Language colors | `src/common/languageColors.json` | ~600 language→hex pairs (sourced from GitHub Linguist). |
| Rank algorithm | `src/calculateRank.js` | Pure function: weighted CDFs → letter (S..C) + percentile. |
| Express adapter | `express.js` | Mounts the five handlers under `/api` for non-Vercel hosts. |

### 1.3 Technology stack

| Layer | Technology |
|---|---|
| Runtime | Node.js >= 22 (ES modules, top-level `import`) |
| Hosting | Vercel serverless functions (128 MB / 10 s) |
| HTTP client | `axios ^1.13.1` |
| GitHub API | GraphQL v4 + REST search-commits |
| Username validation | `github-username-regex ^1.0.0` |
| Emoji shortcodes | `emoji-name-map ^2.0.3` |
| Word wrap | `word-wrap ^1.2.5` |
| Env loading | `dotenv ^17.2.3` |
| Local server | `express ^5.2.1` (devDependency) |
| Tests | Jest 30, jsdom env, `axios-mock-adapter`, `@testing-library/jest-dom` |
| Linting | ESLint 9 with `eslint-plugin-jsdoc` and `eslint-config-prettier` |
| Formatting | Prettier 3, lint-staged via Husky |

> [!NOTE]
> The project's only direct production dependencies are `axios`, `dotenv`,
> `emoji-name-map`, `github-username-regex`, and `word-wrap`. Everything
> else (eslint, jest, prettier, express) is a devDependency. The footprint
> is intentionally small to keep cold-start times within the Vercel
> 10-second function budget.

### 1.4 Request lifecycle (stats card example)

```text
GET /api?username=anuraghazra&theme=tokyonight&show=prs_merged
   │
   ├─ destructure 27 params from req.query
   ├─ guardAccess({type: "username"})         ← blacklist + optional WHITELIST
   ├─ isLocaleAvailable(locale)                ← skipped if locale not set
   ├─ fetchStats(username, ...)
   │    ├─ statsFetcher() loops on hasNextPage (gated by FETCH_MULTI_PAGE_STARS)
   │    │    └─ retryer(fetcher, vars) rotates PAT_1..PAT_n on rate-limit
   │    │         └─ axios.post(api.github.com/graphql, {query, variables}, {Authorization})
   │    ├─ if include_all_commits: totalCommitsFetcher() ← REST search-commits
   │    └─ calculateRank({commits, prs, issues, reviews, repos, stars, followers})
   ├─ resolveCacheSeconds({req, def: 1d, min: 12h, max: 2d})
   ├─ setCacheHeaders(res, cacheSeconds)       ← max-age + s-maxage + stale-while-revalidate
   └─ res.send(renderStatsCard(stats, options))
        ├─ getCardColors() resolves theme + per-key overrides
        ├─ build STATS object, filter by hide[], slice optional show[]
        ├─ flexLayout({direction: "column"}) of stat rows
        ├─ rank circle SVG with stroke-dashoffset animation
        └─ Card.render() wraps in <svg> envelope
```

> [!NOTE]
> The retryer count equals the number of `PAT_*` env vars at module-load
> time. If you add PATs, you must redeploy — `process.env` is read once.

### 1.5 Data flow constraints

- **Stateless.** No filesystem writes, no shared memory between
  invocations.
- **Pure rendering.** Renderers are deterministic given identical input;
  the only non-determinism is `process.env.NODE_ENV === "test"` switching
  off animations.
- **No origin pinning.** Cards do not read or set cookies; the SVG is
  served with default CORS (Vercel's default `Access-Control-Allow-Origin: *`).
- **Failure → SVG.** Any thrown error inside a handler is caught; the
  handler responds with a `renderError` SVG instead of a 4xx/5xx. The
  README never breaks rendering, only displays a message card.

---

## 2. System Requirements

### 2.1 Hosting

The reference deployment is Vercel's serverless platform. The
`vercel.json` declares per-function memory and timeout:

```json
{
  "functions": {
    "api/*.js": {
      "memory": 128,
      "maxDuration": 10
    }
  },
  "redirects": [
    { "source": "/", "destination": "https://github.com/anuraghazra/github-readme-stats" }
  ]
}
```

128 MB is enough for typical workloads; the heaviest fetcher (top
languages with `FETCH_MULTI_PAGE_STARS` plus 100+ repos) can push a few
MB of intermediate JSON. The 10-second timeout matches GitHub's typical
GraphQL latency plus a couple of retry rotations.

### 2.2 Node version

`package.json` declares `"engines": { "node": ">=22" }`. The project uses
top-level `await`, native ESM (`"type": "module"`), and structured
`process.env` access. Older runtimes will error at import.

### 2.3 GitHub PATs

| Scope | Why |
|---|---|
| `public_repo` | Minimum for public stats |
| `repo` | Required only if a user has "Include private contributions on my profile" on |
| `read:user` | Followers count is part of the rank formula |

Generate at `https://github.com/settings/tokens` (classic). Fine-grained
tokens are not referenced in source.

> [!IMPORTANT]
> The retryer reads the PAT count from `process.env` at module load.
> Redeploy after adding tokens. The retryer treats `Bad credentials` and
> `Sorry. Your account was suspended.` as "this PAT is dead, advance to
> the next" — not as fatal errors.

### 2.4 WakaTime

`/api/wakatime` calls
`https://wakatime.com/api/v1/users/<USERNAME>/stats?is_including_today=true`
by default. Override with `?api_domain=` for self-hosted Wakapi.
WakaTime's free tier limits anonymous access; the endpoint expects the
target profile to be public.

---

## 3. Configuration Reference

### 3.1 Environment variables

| Variable | Type | Default | Purpose | Required? |
|---|---|---|---|---|
| `PAT_1`, `PAT_2`, ... | classic PAT | none | GitHub auth tokens. Numbered sequentially; rotated on rate-limit/bad-credential/account-suspended. | At least one |
| `CACHE_SECONDS` | int (seconds) | unset | Hard-overrides the per-card cache TTL on every response. Useful for staging vs prod tuning. | No |
| `WHITELIST` | csv string | unset | If set, only listed GitHub usernames render. Others see "This username is not whitelisted". | No |
| `GIST_WHITELIST` | csv string | unset | Same allow-list mechanism for `/api/gist?id=...`. | No |
| `EXCLUDE_REPO` | csv string | empty | Repos appended to every request's `exclude_repo` list before star/language aggregation. | No |
| `FETCH_MULTI_PAGE_STARS` | `"true"` / unset | unset | Enables paginating beyond the first 100 repos when totalling stars. | No |
| `NODE_ENV` | string | unset | `development` disables all caching headers. `test` swaps `console` for noop and disables animations. | No |
| `PORT` (or `port`) | int | `9000` | Express adapter listen port. Vercel ignores this. | No |

### 3.2 No config files

There is **no** YAML, TOML, JSON, or `.env.schema` configuration file.
Everything is environment variables (instance-wide) plus query
parameters (per-request). This is intentional: the project is designed
to be redeployable with zero filesystem state.

> [!IMPORTANT]
> If you build a wrapper around this service (e.g., a hosted version for
> your team), do NOT layer your own config file inside the repo — keep
> all knobs in environment variables so a Vercel redeploy is a full
> reset.

### 3.3 URL query parameters (full reference)

The User Guide lists end-user-facing parameters. Below is the complete
table, including parameters that are passed through to the renderer but
are not always documented externally.

| Parameter | Endpoints | Type | Default | Source |
|---|---|---|---|---|
| `username` | api, pin, top-langs, wakatime | string | — | `MissingParamError` if absent |
| `id` | gist | string | — | `MissingParamError` if absent |
| `repo` | pin | string | — | `MissingParamError` if absent |
| `theme` | all | string | `default` (cards) / `default_repocard` (pin) | `themes[theme]` lookup |
| `title_color` | all | hex (no `#`) | theme | `getCardColors` chain |
| `text_color` | all | hex | theme | |
| `icon_color` | all | hex | theme | |
| `bg_color` | all | hex or csv gradient | theme | csv = `"<angle>,<hex1>,<hex2>,..."`, min 3 components |
| `border_color` | all | hex | theme | |
| `border_radius` | all | number | `4.5` | passed through to `Card` |
| `hide_border` | all | bool | `false` | `parseBoolean` |
| `hide_title` | api, top-langs, wakatime | bool | `false` | |
| `cache_seconds` | all | int | per-card default | clamped to per-card MIN/MAX |
| `locale` | all | string | `en` | `isLocaleAvailable` validation |
| `disable_animations` | api, top-langs, wakatime | bool | `false` | |
| `hide` | api, top-langs, wakatime | csv list | `[]` | `parseArray` |
| `show` | api | csv list | `[]` | sub-keys: `prs_merged`, `prs_merged_percentage`, `reviews`, `discussions_started`, `discussions_answered` |
| `show_icons` | api | bool | `false` | |
| `hide_rank` | api | bool | `false` | |
| `rank_icon` | api | enum | `default` | values: `default`, `github`, `percentile` |
| `include_all_commits` | api | bool | `false` | triggers REST search-commits |
| `commits_year` | api | int | unset | passed as `startTime` to GraphQL |
| `card_width` | api, top-langs, wakatime | int | per-card | `parseInt(_,10)` then clamped |
| `line_height` | api, wakatime | int | `25` | |
| `text_bold` | api | bool | `true` | |
| `ring_color` | api | hex | theme | |
| `custom_title` | api, top-langs, wakatime | string | computed | overrides default title |
| `number_format` | api | enum | `short` | `short` ⇒ kFormatter; `long` ⇒ raw |
| `number_precision` | api | int | unset | clamped 0..2 |
| `exclude_repo` | api, top-langs | csv list | `[]` | merged with `EXCLUDE_REPO` env |
| `show_owner` | pin, gist | bool | `false` | |
| `description_lines_count` | pin | int | `3` | clamped 1..3 |
| `layout` | top-langs, wakatime | enum | `normal` (top-langs) / unset (wakatime) | top-langs values: `normal`, `compact`, `donut`, `donut-vertical`, `pie`. wakatime: `compact` or default. |
| `langs_count` | top-langs, wakatime | int | per-layout default (top-langs) / WakaTime length (wakatime) | clamped 1..20 (top-langs) |
| `size_weight` | top-langs | number | `1` | exponent on lang byte size |
| `count_weight` | top-langs | number | `0` | exponent on repo count |
| `hide_progress` | top-langs, wakatime | bool | `false` | |
| `stats_format` | top-langs | enum | `percentages` | values: `percentages`, `bytes` |
| `display_format` | wakatime | enum | `time` | values: `time`, `percent` |
| `api_domain` | wakatime | string | `wakatime.com` | trailing slash stripped |
| `type` | status/up | enum | `boolean` | values: `boolean`, `json`, `shields` |

### 3.4 Cache TTL constants (`src/common/cache.js`)

```javascript
CACHE_TTL = {
  STATS_CARD:     { DEFAULT: 1d,  MIN: 12h, MAX: 2d  },
  TOP_LANGS_CARD: { DEFAULT: 6d,  MIN: 2d,  MAX: 10d },
  PIN_CARD:       { DEFAULT: 10d, MIN: 1d,  MAX: 10d },
  GIST_CARD:      { DEFAULT: 2d,  MIN: 1d,  MAX: 10d },
  WAKATIME_CARD:  { DEFAULT: 1d,  MIN: 12h, MAX: 2d  },
  ERROR:          10m,
};
```

`setCacheHeaders` writes `Cache-Control: max-age=N, s-maxage=N,
stale-while-revalidate=86400`. `setErrorCacheHeaders` always uses
`ERROR` (10 minutes) so a transient failure doesn't stick in Camo for
a day. When `NODE_ENV === "development"` or `CACHE_SECONDS < 1`, the
disable-caching path emits `no-cache, no-store, must-revalidate, max-age=0,
s-maxage=0` plus `Pragma: no-cache` and `Expires: 0`.

> [!IMPORTANT]
> The 10-minute error cache is deliberate. If GitHub is having a bad day,
> READMEs world-wide hammer the API on every page view; the short error
> cache caps that load while still recovering quickly when GitHub
> returns. Don't lower this without thinking about the upstream impact.

### 3.5 Theme structure

Each entry in `themes/index.js` is:

```javascript
{
  title_color: "string",   // hex, no #
  icon_color:  "string",
  text_color:  "string",
  bg_color:    "string" | "<angle>,<hex>,<hex>...", // gradient form
  border_color: "string",  // optional; when absent, default theme's border is used
}
```

`bg_color` is the only field that may be a gradient. If it has more than
two comma-separated components and components 2..N are valid hex, the
`Card.renderGradient()` path emits an SVG `<linearGradient>` with
`gradientTransform="rotate(<angle>)"`. Otherwise the value is treated as
a single hex.

---

## 4. Deployment & Scaling

### 4.1 Reference deployment (Vercel)

```bash
git clone https://github.com/anuraghazra/github-readme-stats
cd github-readme-stats
vercel link            # one-time
vercel env add PAT_1   # paste classic PAT, scope=public_repo
vercel deploy --prod
```

### 4.2 Express deployment (Docker, Render, Fly, ECS, etc.)

```dockerfile
FROM node:22-alpine
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --omit=dev
COPY . .
ENV PORT=9000
EXPOSE 9000
CMD ["node", "express.js"]
```

`express.js` mounts the same five handlers and listens on
`process.env.PORT || process.env.port || 9000`. Behaviour is identical
to Vercel; only the front door changes.

> [!TIP]
> Behind a reverse proxy, set `Cache-Control` honouring at the proxy
> level (e.g., Cloudflare Page Rules) to amplify the in-card
> `s-maxage`. Cloudflare honours `s-maxage` for caching anonymous GETs
> by default, which is exactly the workload here.

### 4.3 Horizontal scaling

The functions are stateless, so concurrency = however many parallel
invocations Vercel spins up. The natural ceiling is GitHub's per-token
rate limit (5,000 requests/hour for classic PATs). Strategies:

1. **Add more PATs.** Each `PAT_N` is one tier of headroom. The retryer
   rotates on `RATE_LIMITED` GraphQL errors and `Bad credentials`/
   `account suspended` REST errors.
2. **Increase `cache_seconds` per request** (or with `CACHE_SECONDS`
   env). Longer TTLs reduce upstream pressure linearly.
3. **Disable `FETCH_MULTI_PAGE_STARS`.** The setting is OFF by default
   on the public instance for exactly this reason — it can multiply
   GraphQL calls per render by 5-10x.
4. **Whitelist usage.** Setting `WHITELIST=user1,user2` blocks rendering
   for everyone else without making any GitHub calls.

> [!NOTE]
> The stats fetcher's pagination loop only continues when **all** repos
> in the current page have stars (`repoNodes.length === repoNodesWithStars.length`)
> AND `FETCH_MULTI_PAGE_STARS=true`. The all-have-stars condition is a
> heuristic: once the page contains a 0-star repo, GitHub's stargazer-sort
> guarantees the rest are zero too, so further pages don't change the
> total. Reading
> `repoNodesWithStars` is one of the few non-obvious pieces of source.

### 4.4 Local development

```bash
npm install
npm run test          # full Jest suite with coverage
npm run test:watch
npm run test:e2e      # jest.e2e.config.js
npm run bench         # jest.bench.config.js (perf tests in tests/bench)
npm run lint          # eslint --max-warnings 0 across src/, scripts/, tests/, api/, themes/
npm run format        # prettier --write .
```

Husky's `prepare` hook installs the lint-staged Prettier filter on
`.{js,css,md}` files at commit time.

---

## 5. Operations

### 5.1 Health endpoints

| Endpoint | Cache | Returns |
|---|---|---|
| `/api/status/up` | 5 min if up; `no-store` if down | boolean (default), `{up: true/false}` (`?type=json`), Shields.io schema (`?type=shields`) |
| `/api/status/pat-info` | 5 min on success; `no-store` on error | JSON with `validPATs`, `expiredPATs`, `exhaustedPATs`, `suspendedPATs`, `errorPATs`, plus `details` per PAT |

`pat-info` walks every `PAT_N` env var, sends a tiny `rateLimit { remaining, resetAt }` query through the request layer, and classifies each PAT. Use it when investigating "the card stopped working" — it's the quickest way to spot expired tokens.

> [!TIP]
> Wire `/api/status/up` into a monitoring service (Better Uptime,
> Pingdom, GitHub Actions cron) and embed the Shields.io variant in your
> README:
> `https://img.shields.io/endpoint?url=https://YOUR.app/api/status/up?type=shields`.

### 5.2 Logs

The shared logger (`src/common/log.js`) is `console` in production and a
no-op in tests. Useful log lines emitted in normal operation:

| Source | Line | Meaning |
|---|---|---|
| `retryer.js:46` | `PAT_<N> Failed` | The retryer rotated past PAT N (rate-limit or bad-credential). |
| `fetchers/stats.js:194` | `Invalid username provided.` | Username regex rejected the input before any API call. |
| `fetchers/stats.js:139` | `<errors object>` | GraphQL returned errors. The handler rethrows as `CustomError`. |
| `api/status/pat-info.js:94` | `Something went wrong: <message>` | An unexpected exception leaked past the per-PAT try/catch. |

In Vercel, all of these surface in the project logs UI. Filter by
function name (`api/index`, `api/pin`, etc.) when debugging a specific
card.

### 5.3 Common failure modes

#### 5.3.1 All PATs exhausted

Symptom: every render returns "Something went wrong! Downtime due to
GitHub API rate limiting. You can deploy own instance or wait until
public will be no longer limited."

Diagnosis: hit `/api/status/pat-info`. Look at `exhaustedPATs` and
`details[<PAT>].resetIn`.

Fix: provision more PATs OR raise `CACHE_SECONDS` OR enable WHITELIST to
restrict load.

#### 5.3.2 PAT silently expired

Symptom: same as above, but `details[<PAT>].status === "expired"`.

Fix: regenerate the classic PAT in GitHub settings. The retryer skips
expired tokens automatically; no service interruption beyond reduced
headroom until you regenerate.

#### 5.3.3 Vercel "DEPLOYMENT_PAUSED"

Symptom: Vercel returns 503 with body `DEPLOYMENT_PAUSED`. Tracked in
issues #4864, #4867, #3851.

Fix: this is a Vercel-side quota event on the public free instance. The
remediation in source is the same as section 5.3.1 — self-host. The
project does not (and cannot) detect this from inside the function.

#### 5.3.4 Stats card with zero stars even though user has many

Diagnosis: user has > 100 repos. The default `FETCH_MULTI_PAGE_STARS=false`
caps stargazer aggregation at the first 100 by stars-desc. The card
shows the partial sum.

Fix on self-hosted: `FETCH_MULTI_PAGE_STARS=true`. Accept the rate-limit
cost.

#### 5.3.5 Translation crash

Symptom: response body says `<key> Translation string not found`.

Cause: code referenced a translation key that doesn't exist. `I18n.t()`
throws. Usually only happens after a partial fork that removed
translations.

Fix: re-sync `src/translations.js` against upstream.

### 5.4 Incident runbook

- [ ] Hit `/api/status/up` — confirms whether public instance is alive.
- [ ] Hit `/api/status/pat-info` — confirm PAT health.
- [ ] If self-hosted, check Vercel function logs for `Bad credentials` or
      `RATE_LIMITED`.
- [ ] If user-reported, ask for the exact rendering URL — frequently the
      cause is a typo (`hide_border=1` instead of `=true`).
- [ ] Force a Camo cache bust by adding `&v=N` to the README URL.
- [ ] If WakaTime card is failing, hit
      `https://wakatime.com/api/v1/users/<user>/stats` directly to
      confirm WakaTime is live and the profile is public.

---

## 6. Performance

### 6.1 Rendering cost

Rendering itself is <5 ms — pure string concatenation with light SVG
geometry math. The dominant cost is the upstream call:

| Card | Upstream | p50 (cold) | p50 (warm) |
|---|---|---|---|
| Stats | GitHub GraphQL × 1 (or × N if multi-page stars) | 600 ms | 250 ms |
| Stats with `include_all_commits` | GraphQL + REST search-commits | 900 ms | 450 ms |
| Pin | GitHub GraphQL × 1 | 500 ms | 200 ms |
| Top-langs | GitHub GraphQL × 1 | 600 ms | 250 ms |
| WakaTime | wakatime.com REST × 1 | 400 ms | 200 ms |
| Gist | GitHub GraphQL × 1 | 500 ms | 200 ms |

Numbers above are observational rules-of-thumb; actual latency varies
with GitHub's server-side load.

### 6.2 Cache amplification

Because `Cache-Control` includes both `max-age` (browser) and
`s-maxage` (Cloudflare/Vercel edge), the typical request flow is:

```text
github.com/USER/USER/blob/main/README.md
   → camo.githubusercontent.com (proxy + caches)
       → vercel.app (edge CDN + caches by s-maxage)
           → serverless function (cache miss only)
```

Real upstream call rate is roughly `requests_per_render / s-maxage_in_seconds`.
For the stats card defaults (1 day cache), even a profile with 1M README
loads/day might only generate a few thousand actual GraphQL calls.

### 6.3 Benchmark suite

`tests/bench/` contains Jest benchmark tests:

| File | Measures |
|---|---|
| `tests/bench/api.bench.js` | Full request → SVG path |
| `tests/bench/calculateRank.bench.js` | Rank algorithm only |
| `tests/bench/gist.bench.js` | Gist render path |
| `tests/bench/pin.bench.js` | Pin render path |

`npm run bench` runs them. Useful for verifying that a refactor doesn't
regress. The bench utils (`tests/bench/utils.js`) throw
`Number of runs must be positive` if you misconfigure the run count.

---

## 7. Security

### 7.1 Threat model

The project's only inputs are HTTP query parameters and the
`process.env` PATs. All output is SVG. The threats are:

| Threat | Mitigation |
|---|---|
| Username enumeration / scraping | Rate-limited by GitHub itself, plus `WHITELIST` opt-in |
| Cross-site scripting via SVG | `encodeHTML()` runs on every user-supplied string before string interpolation into the SVG body |
| SSRF via `api_domain` | Issue #4859 / #4832 tracked an SSRF in the WakaTime endpoint where `api_domain` accepted arbitrary hosts. The current code strips trailing slashes (`api_domain.replace(/\/$/gi, "")`) and lets axios make the request. **Hardening discussion in section 7.4.** |
| Credential exposure | PATs are never echoed in errors; only PAT name (`PAT_1`, `PAT_2`) is logged on rotation |
| Abuse of public instance | `blacklist` array hard-bans known abuse usernames (`renovate-bot`, `technote-space`, `sw-yx`, `YourUsername`, `[YourUsername]`) |

### 7.2 Input handling

`encodeHTML` in `src/common/html.js`:

```javascript
str.replace(/[ -香<>&](?!#)/gim, (i) => "&#" + i.charCodeAt(0) + ";")
   .replace(//gim, "");
```

It HTML-encodes anything in the high-Unicode range plus `<`, `>`, `&`,
and strips `` (backspace). Every user-provided string that ends up
inside an SVG `<text>` node passes through this — usernames, gist
descriptions, custom titles, repo descriptions.

Color parameters are validated against
`/^([A-Fa-f0-9]{8}|[A-Fa-f0-9]{6}|[A-Fa-f0-9]{3}|[A-Fa-f0-9]{4})$/`
before being interpolated into the SVG. Invalid hex falls back to the
theme color, which is a known safe value.

### 7.3 Access control

| Mechanism | Where | Effect |
|---|---|---|
| `blacklist` (`src/common/blacklist.js`) | `guardAccess` for `type: "username"` | Hardcoded deny list applied unconditionally when `WHITELIST` is unset. |
| `WHITELIST` (env) | `guardAccess` for `type: "username" \| "wakatime"` | If set, only listed usernames render. Skips the blacklist check. |
| `GIST_WHITELIST` (env) | `guardAccess` for `type: "gist"` | Same idea, gist-id allowlist. |

> [!WARNING]
> The blacklist is bypassed when `WHITELIST` is set. The reasoning is:
> if you've explicitly whitelisted, the blacklist is irrelevant. Don't
> set `WHITELIST` to a user who is also on the blacklist expecting the
> blacklist to take precedence — it won't.

### 7.4 SSRF — `api_domain` parameter

`api/wakatime.js` accepts an arbitrary `api_domain` query parameter that
is used as the host portion of the URL passed to `axios.get`. The only
sanitisation is `api_domain.replace(/\/$/gi, "")` (strip trailing
slashes).

This was originally tracked as
*Security: Server-Side Request Forgery (SSRF) via user-controlled
`api_domain` parameter in Wakatime endpoint* (closed issue #4832) and
the open PR #4859. If you self-host:

- Consider hardcoding `api_domain` to your sanctioned WakaTime/Wakapi
  host and ignoring the query parameter — patch
  `api/wakatime.js` and remove `api_domain` from the destructure.
- At minimum, validate `api_domain` against a static allow-list of
  hosts before passing it to axios.
- The risk surface is requests with arbitrary internal hosts as
  `api_domain` — the function process can issue HTTP GETs from inside
  your VPC if the function has network access.

> [!CAUTION]
> If you operate in a VPC where the function can reach internal
> services (cloud metadata endpoints, RDS, internal admin APIs),
> deploying this code unmodified is dangerous. Either fork-and-pin
> `api_domain` or block egress at the network layer.

### 7.5 GitHub token scope

Run with the **least-privilege scope** that satisfies your users:

| Use case | Scope |
|---|---|
| Public-only stats, public-only repos | `public_repo` |
| Private-contribution counts | `repo` (full) |
| Followers in rank | add `read:user` |

Compose multi-purpose PATs only if you actually need the data.

### 7.6 Dependency hygiene

Issue #4848 tracked a compromised version of `axios` (resolved by
`^1.13.1`). Run `npm audit` periodically; the `dependencies` block is
small (5 packages) so dependency triage is fast.

---

## 8. Developer Internals

### 8.1 Repository layout

```text
github-readme-stats/
├── api/                         five Vercel handlers
│   ├── index.js                 stats card
│   ├── pin.js                   pinned-repo card
│   ├── top-langs.js             languages card
│   ├── wakatime.js              wakatime card
│   ├── gist.js                  gist card
│   └── status/
│       ├── up.js                health probe (5-min cache)
│       └── pat-info.js          per-PAT diagnostics (5-min cache)
├── src/
│   ├── calculateRank.js         pure rank algorithm
│   ├── index.js                 re-exports common + cards
│   ├── translations.js          50+ locales
│   ├── cards/                   SVG renderers
│   │   ├── stats.js
│   │   ├── repo.js
│   │   ├── top-languages.js
│   │   ├── wakatime.js
│   │   ├── gist.js
│   │   ├── index.js             card barrel
│   │   └── types.d.ts           typedefs for options
│   ├── common/                  cross-cutting concerns
│   │   ├── Card.js              SVG envelope class
│   │   ├── I18n.js              translation lookup
│   │   ├── access.js            guardAccess
│   │   ├── blacklist.js         hardcoded deny list
│   │   ├── cache.js             CACHE_TTL + header writers
│   │   ├── color.js             theme + override resolution
│   │   ├── envs.js              WHITELIST/GIST_WHITELIST/EXCLUDE_REPO parsing
│   │   ├── error.js             CustomError + secondary messages
│   │   ├── fmt.js               kFormatter, formatBytes, wrapTextMultiline
│   │   ├── html.js              encodeHTML
│   │   ├── http.js              axios → graphql.api.github.com
│   │   ├── icons.js             SVG paths for stat icons + rank icons
│   │   ├── languageColors.json  ~600 language → hex
│   │   ├── log.js               logger toggle (test → noop)
│   │   ├── ops.js               parseBoolean/parseArray/clampValue/chunkArray/parseEmojis/dateDiff
│   │   ├── render.js            renderError, flexLayout, measureText, createProgressNode
│   │   └── retryer.js           PAT rotation
│   └── fetchers/                upstream client glue
│       ├── stats.js
│       ├── repo.js
│       ├── top-languages.js
│       ├── wakatime.js
│       ├── gist.js
│       └── types.d.ts
├── themes/                      74 theme definitions
│   ├── index.js
│   └── README.md
├── scripts/                     dev tooling
│   ├── close-stale-theme-prs.js
│   ├── generate-langs-json.js   regenerate languageColors.json
│   ├── generate-theme-doc.js    auto-update themes/README.md
│   ├── helpers.js
│   ├── preview-theme.js         live preview locally
│   └── push-theme-readme.sh
├── tests/                       Jest unit tests (one per card + utility)
├── tests/bench/                 Jest benchmark suite
├── tests/e2e/                   Jest end-to-end suite
├── express.js                   Express adapter for non-Vercel hosts
├── vercel.json                  per-function memory + timeout
├── package.json                 deps, scripts, engines >= 22
└── jest.{,bench,e2e}.config.js  separate Jest configs
```

### 8.2 Module reference

#### `api/*.js` — handler shape

Every handler is identically structured:

```javascript
export default async (req, res) => {
  const { username, ...everythingElse } = req.query;
  res.setHeader("Content-Type", "image/svg+xml");

  const access = guardAccess({ res, id: username, type: "username", colors });
  if (!access.isPassed) return access.result;

  if (locale && !isLocaleAvailable(locale))
    return res.send(renderError({ message: "Something went wrong", secondaryMessage: "Language not found", renderOptions }));

  try {
    const data = await fetchSomething(username, ...);
    const cacheSeconds = resolveCacheSeconds({ requested, def, min, max });
    setCacheHeaders(res, cacheSeconds);
    return res.send(renderXxxCard(data, options));
  } catch (err) {
    setErrorCacheHeaders(res);
    if (err instanceof Error)
      return res.send(renderError({ message: err.message, secondaryMessage: retrieveSecondaryMessage(err), renderOptions: { ..., show_repo_link: !(err instanceof MissingParamError) } }));
    return res.send(renderError({ message: "An unknown error occurred", renderOptions }));
  }
};
```

When you add a new endpoint, copy this shape exactly. The
`show_repo_link` toggle suppresses the "file an issue" link when the
error is a `MissingParamError` (the user just made a typo, no need to
nag).

#### `src/cards/stats.js`

The most complex renderer. Builds a `STATS` object dictionary, applies
the `hide` filter and optional `show` keys, generates one `createTextNode`
per entry, lays them out vertically with `flexLayout`, and conditionally
draws the rank circle.

Two width regimes:

```text
hide_rank=true:  CARD_MIN_WIDTH = 287, CARD_DEFAULT_WIDTH = 287
hide_rank=false:
  if any stats:     RANK_CARD_MIN_WIDTH = 420, RANK_CARD_DEFAULT_WIDTH = 450
  rank-only:        RANK_ONLY_CARD_MIN_WIDTH = 290, RANK_ONLY_CARD_DEFAULT_WIDTH = 290
```

`LONG_LOCALES` (a sorted list of 25 locale codes that need extra
horizontal space) shifts the value column by 50 px to prevent clipping.

#### `src/cards/top-languages.js`

Five layouts share infrastructure. Helpers worth knowing:

| Function | Purpose |
|---|---|
| `getDefaultLanguagesCountByLayout` | Maps layout → default `langs_count` |
| `trimTopLanguages` | Sorts by size desc, applies `hide`, slices |
| `polarToCartesian` / `cartesianToPolar` | Pie/donut geometry |
| `createDonutPaths` | SVG `M ... A ... 0 large_arc 0 ...` arcs |
| `createProgressTextNode` | Single language row in normal layout |
| `createCompactLangNode` | Color-circle + label used by compact, donut, donut-vertical, pie |
| `renderNormalLayout` / `renderCompactLayout` / `renderDonutLayout` / `renderDonutVerticalLayout` / `renderPieLayout` | One per layout |

The `MAXIMUM_LANGS_COUNT = 20` constant caps how many languages can be
requested via `langs_count`. Anything higher is clamped silently.

#### `src/cards/wakatime.js`

Two layouts (`compact` and the default), three "no data" sub-states
(`is_coding_activity_visible`, `is_other_usage_visible`, `nocodingactivity`).
The card calls `recalculatePercentages` after `hide` filtering so that
the visible languages still total 100 %.

#### `src/cards/repo.js`

Renders archived/template badges (the `getBadgeSVG` helper) from the
`repocard.template` / `repocard.archived` translation keys. Forces
`disableAnimations()` always — the pin card explicitly does not animate
because GitHub's image proxy renders cards with stale animations
ungracefully.

#### `src/cards/gist.js`

Header is truncated at `HEADER_MAX_LENGTH = 35`. Description wraps to
`linesLimit = 10` lines via `wrapTextMultiline`. The card is always
400 px wide.

#### `src/common/Card.js`

The SVG envelope. Renders:

1. `<style>` block (animations + caller's CSS unless `NODE_ENV=test`)
2. Optional `<linearGradient>` if `bg_color` is array-shaped
3. Background `<rect>` with rounded corners, theme-colored stroke
4. Title row (unless `hideTitle`)
5. Body group (caller-provided)
6. Hidden `<title>` and `<desc>` for accessibility

Methods: `setCSS`, `setHideBorder`, `setHideTitle`, `setTitle`,
`setAccessibilityLabel`, `disableAnimations`. Width and height come from
the constructor. The class deliberately mutates: callers configure
properties between construction and `render()`.

#### `src/common/cache.js`

`resolveCacheSeconds` clamps `requested` between MIN and MAX, then
overrides everything if `process.env.CACHE_SECONDS` is a valid integer.
Note the hierarchy: env wins over query param, query param wins over
default. This is why setting `CACHE_SECONDS` instance-wide is so
powerful — it ignores the per-card range entirely.

#### `src/common/error.js`

The `SECONDARY_ERROR_MESSAGES` table maps `CustomError.type` to a
user-facing secondary message:

| Type | Secondary message |
|---|---|
| `MAX_RETRY` | "You can deploy own instance or wait until public will be no longer limited" |
| `NO_TOKENS` | "Please add an env variable called PAT_1 with your GitHub API token in vercel" |
| `USER_NOT_FOUND` | "Make sure the provided username is not an organization" |
| `GRAPHQL_ERROR` | "Please try again later" |
| `GITHUB_REST_API_ERROR` | "Please try again later" |
| `WAKATIME_USER_NOT_FOUND` | "Make sure you have a public WakaTime profile" |

`MissingParamError` is a separate subclass that carries the missing
parameter list and a usage example URL. The handler suppresses the
"file an issue" link for `MissingParamError` because it's a user typo,
not a bug.

#### `src/common/retryer.js`

```javascript
const PATs = Object.keys(process.env).filter((k) => /PAT_\d*$/.exec(k)).length;
const RETRIES = process.env.NODE_ENV === "test" ? 7 : PATs;
```

`RETRIES` is the count, fixed at module load. The `retryer` recurses on
GraphQL `RATE_LIMITED` AND on response messages matching `/rate limit/i`
(double-check from issue #4425). It also rotates on
`Bad credentials` (a token has been revoked) and on
`Sorry. Your account was suspended.` (suspended PAT). Network errors
without a response object are re-thrown to the caller.

#### `src/common/access.js`

Distinguishes three identity types: `username`, `gist`, `wakatime`.
Each has its own (whitelist, "not whitelisted" message) pair. The
blacklist applies only to `username` AND only when no whitelist is set.
Failed access returns a "This username is not whitelisted" / "This gist
ID is not whitelisted" / "This username is blacklisted" SVG with
`show_repo_link: false` (don't tell users to file an issue when they're
blocked).

#### `src/calculateRank.js`

Pure function, zero dependencies. Two CDFs:

```javascript
exponential_cdf(x) = 1 - 2 ** -x
log_normal_cdf(x)  = x / (1 + x)   // approximation
```

Six weighted contributions:

| Contribution | CDF | Median | Weight |
|---|---|---|---|
| commits | exponential | 250 (or 1000 if `all_commits`) | 2 |
| PRs | exponential | 50 | 3 |
| issues | exponential | 25 | 1 |
| reviews | exponential | 2 | 1 |
| stars | log-normal | 50 | 4 |
| followers | log-normal | 10 | 1 |

Total weight 12. Output range: 0..100 (percentile). Letter mapping:

```text
THRESHOLDS = [1, 12.5, 25, 37.5, 50, 62.5, 75, 87.5, 100]
LEVELS     = ["S", "A+", "A", "A-", "B+", "B", "B-", "C+", "C"]
```

`repos` is destructured but unused (the eslint-disable comment is
there). It's part of the rank function signature for API stability.

### 8.3 Coding conventions

- `// @ts-check` at the top of every file enables TypeScript checking
  in JSDoc.
- Public surface: every function has a JSDoc block with `@param`,
  `@returns`, often `@typedef` for shared shapes.
- ESM only: `import` / `export`. `package.json` has `"type": "module"`.
- No classes for stateless helpers; classes only for `Card`, `I18n`,
  and the two error subclasses.
- Card renderers always export both a named export and a default
  export (legacy interop).
- Constants for magic numbers (e.g., `CARD_MIN_WIDTH`,
  `LONG_LOCALES`, `HEADER_MAX_LENGTH`).
- Tests live in `tests/`, mirroring source structure (`tests/card.test.js`,
  `tests/renderStatsCard.test.js`, etc.).

### 8.4 Testing strategy

| Suite | Config | Purpose |
|---|---|---|
| Unit | `jest.config.js` | Per-card and per-utility tests using `jsdom` env |
| Bench | `jest.bench.config.js` | Performance benches in `tests/bench/` |
| E2E | `jest.e2e.config.js` | End-to-end tests under `node` env in `tests/e2e/` |

`axios-mock-adapter` mocks GitHub responses; tests assert SVG output
contains expected elements (`data-testid="rank-circle"`,
`data-testid="lang-progress"`, etc.). Snapshot tests live under
`tests/__snapshots__/`.

> [!TIP]
> When adding a new card variant, add a render snapshot test the same
> day. The renderers produce strings that humans don't easily diff —
> snapshots save you from accidentally shifting an SVG element by 5 px.

### 8.5 Potential improvements

The codebase is mature but has a few open improvement vectors:

1. **`api_domain` SSRF hardening (issue #4859, #4832).** Move beyond
   trailing-slash strip; whitelist the WakaTime/Wakapi hosts.
2. **Custom font support (issue #4105).** Currently every card
   hardcodes `'Segoe UI', Ubuntu, Sans-Serif`. A `font_family=` query
   parameter would unlock big customisation gains.
3. **Multi-token weighted retry.** The retryer chooses tokens in
   numeric order; under sustained load, `PAT_1` exhausts before `PAT_2`
   ever activates. A round-robin or remaining-budget-weighted strategy
   would balance better.
4. **Output-size guardrails.** Some `bg_color` gradient configurations
   can produce SVGs in the 30+ KB range with many stops; bounds are
   not enforced.
5. **TypeScript migration.** `// @ts-check` plus JSDoc gives ~80 % of
   the safety; the remaining 20 % is in the renderers where typedefs
   leak into string interpolation.
6. **First-class fine-grained PAT support.** The retryer's
   `Bad credentials` heuristic doesn't differentiate "expired
   classic PAT" from "fine-grained PAT lacking scope". Rich error
   surfacing in `pat-info` would help operators.

### 8.6 Contributing

The repo runs:

```bash
npm run lint         # eslint --max-warnings 0
npm run format:check # prettier --check
npm test             # full unit suite with coverage
```

All three must pass on PRs. Husky's pre-commit hook runs `lint-staged`
which prettifies `*.{js,css,md}`.

For new themes, the workflow is:

```bash
npm run preview-theme  # opens a local preview server
# edit themes/index.js, add your entry
npm run theme-readme-gen  # regenerates themes/README.md
git commit -am "feat: add <theme-name> theme"
```

`scripts/close-stale-theme-prs.js` is run on a schedule by the
maintainers to close abandoned theme PRs. Don't be surprised if a
quiet PR is closed; reopen with the latest commit and a comment.

---

## 9. Integration Points

### 9.1 Inbound

The five card endpoints are the public API. They are designed to be
embedded as `<img src="...">` (Markdown image syntax). HEAD requests
are not specially handled — Vercel's runtime serves the same SVG to
HEAD as to GET, with a `Content-Length` header.

There is no JSON output mode for cards. Every successful render is
SVG. The two status endpoints (`/api/status/up`, `/api/status/pat-info`)
return `application/json`.

### 9.2 Outbound

| Destination | Protocol | Endpoint | Trigger |
|---|---|---|---|
| `api.github.com/graphql` | HTTPS POST | GraphQL v4 | Every render except wakatime/`/api/status/up`/`/api/status/pat-info` |
| `api.github.com/search/commits` | HTTPS GET | REST search-commits with `application/vnd.github.cloak-preview` | Stats card with `include_all_commits=true` |
| `wakatime.com/api/v1/users/<u>/stats` | HTTPS GET | WakaTime REST | `/api/wakatime` |
| Custom (`api_domain`) | HTTPS GET | WakaTime-shape REST | `/api/wakatime?api_domain=...` |

All outbound calls go through `axios` (no local connection pool;
serverless cold-start each time). The retryer wraps the GitHub calls;
the WakaTime call is a single direct `axios.get` with no retry.

### 9.3 README embedding patterns

The cards are designed to slot into:

- GitHub README image syntax: `![alt](URL)`
- HTML `<img>` tags (when you need attributes like `height=` and
  `align=`)
- GitLab READMEs (same Markdown syntax)
- Docusaurus / MkDocs / VitePress
- Any Markdown renderer that fetches external SVGs

Cards intentionally do not require JavaScript on the consuming page.
Animations are pure SVG/CSS and run inside the SVG sandbox.

### 9.4 Observability hooks

There is no built-in metrics emission (no Prometheus, no StatsD). On
Vercel, observability is the hosting platform's job:

- Function logs are visible in the Vercel dashboard.
- Per-invocation duration is exposed in Vercel's analytics.
- Per-endpoint cache hit ratio is visible in Vercel's CDN analytics.

If you need finer-grained metrics, the obvious patch points are the
top of each handler (count requests by endpoint) and immediately after
`retryer` returns (count rate-limit-rotations by PAT). Both are
single-file changes.

---

_Generated April 2026 from anuraghazra/github-readme-stats at 5df91f9. AI-generated documentation. The library is checked by sampling (three repositories so far); this guide was not individually reviewed. Verify against the project before relying on it. Unofficial; not affiliated with or reviewed by the project._
