diff --git a/.github/workflows/star-history.yml b/.github/workflows/star-history.yml new file mode 100644 index 000000000..f9c369d22 --- /dev/null +++ b/.github/workflows/star-history.yml @@ -0,0 +1,92 @@ +name: Star History + +on: + workflow_dispatch: + schedule: + - cron: "37 6 * * *" + push: + branches: [main] + paths: + - ".github/workflows/star-history.yml" + - "scripts/star-history/**" + +permissions: {} + +concurrency: + group: star-history + cancel-in-progress: false + +jobs: + publish: + name: Refresh chart + if: github.repository == 'bookorbit/bookorbit' + runs-on: ubuntu-latest + timeout-minutes: 10 + permissions: + contents: write + + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - name: Set up Node.js + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v6 + with: + node-version: 24 + + - name: Test generator + run: node --test scripts/star-history/generate.test.mjs + + - name: Restore accumulated series + env: + SERIES_PATH: ${{ runner.temp }}/star-history.json + run: | + if git ls-remote --exit-code --heads origin star-history >/dev/null; then + git fetch --depth 1 origin \ + '+refs/heads/star-history:refs/remotes/origin/star-history' + git show origin/star-history:star-history.json > "$SERIES_PATH" + else + cp scripts/star-history/bootstrap.json "$SERIES_PATH" + fi + + - name: Generate chart + env: + GITHUB_TOKEN: ${{ github.token }} + STAR_HISTORY_TOKEN: ${{ secrets.STAR_HISTORY_TOKEN }} + SERIES_PATH: ${{ runner.temp }}/star-history.json + SVG_PATH: ${{ runner.temp }}/star-history.svg + run: | + node scripts/star-history/generate.mjs \ + --repo "$GITHUB_REPOSITORY" \ + --series "$SERIES_PATH" \ + --bootstrap scripts/star-history/bootstrap.json \ + --out "$SVG_PATH" + + - name: Publish chart and data + env: + SERIES_PATH: ${{ runner.temp }}/star-history.json + SVG_PATH: ${{ runner.temp }}/star-history.svg + run: | + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + + json_blob=$(git hash-object -w "$SERIES_PATH") + svg_blob=$(git hash-object -w "$SVG_PATH") + tree=$(printf '100644 blob %s\tstar-history.json\n100644 blob %s\tstar-history.svg\n' \ + "$json_blob" "$svg_blob" | git mktree) + + parent=$(git rev-parse --verify refs/remotes/origin/star-history 2>/dev/null || true) + if [ -n "$parent" ]; then + previous_tree=$(git rev-parse "$parent^{tree}") + if [ "$tree" = "$previous_tree" ]; then + echo "Star history is unchanged" + exit 0 + fi + commit=$(printf '%s\n' "chore: update star history" | \ + TZ=UTC git commit-tree "$tree" -p "$parent") + else + commit=$(printf '%s\n' "chore: initialize star history" | \ + TZ=UTC git commit-tree "$tree") + fi + + git push origin "$commit:refs/heads/star-history" diff --git a/README.md b/README.md index 6806f299a..771b0897b 100644 --- a/README.md +++ b/README.md @@ -26,7 +26,7 @@ A self-hosted library management and reading platform for ebooks, PDFs, audioboo --- -![BookOrbit dashboard showing reading stats, widgets, and book shelves](https://bookorbit.app/images/home/dashboard-overview.webp) +![BookOrbit dashboard showing reading stats, widgets, and book shelves](docs/images/dashboard-overview.png) ## What is BookOrbit? @@ -144,6 +144,12 @@ BookOrbit is available in English, German, Dutch, Brazilian Portuguese, and Slov --- +## Community Growth + +[![BookOrbit GitHub star history](https://raw.githubusercontent.com/bookorbit/bookorbit/star-history/star-history.svg)](https://github.com/bookorbit/bookorbit/stargazers) + +--- + ## Support - **Questions and discussion:** [GitHub Discussions](https://github.com/bookorbit/bookorbit/discussions) diff --git a/docs/STAR_HISTORY.md b/docs/STAR_HISTORY.md new file mode 100644 index 000000000..fe360d60b --- /dev/null +++ b/docs/STAR_HISTORY.md @@ -0,0 +1,57 @@ +# Star History Chart + +BookOrbit renders its README star-history chart in the repository instead of +depending on an external chart service. + +## Storage + +The `star-history` branch contains two generated files: + +- `star-history.json`: validated daily aggregate counts and source metadata +- `star-history.svg`: the self-contained README chart + +The workflow creates normal commits on this branch. It does not force-push, so +previous chart data remains recoverable without adding automated commits to +`main`. + +The bootstrap snapshot at `scripts/star-history/bootstrap.json` contains the +initial exact aggregate history. It is used only when the publication branch +does not exist. Raw stargazer records and GitHub usernames are never stored. + +## Refresh behavior + +The `Star History` workflow runs daily and can also be dispatched manually. +It restores the stored series, obtains the current repository star count, +replaces any existing point for the current UTC date, renders the SVG, and +publishes the JSON and SVG in one branch commit. + +The built-in workflow token is sufficient for daily snapshots. An optional +`STAR_HISTORY_TOKEN` repository secret belonging to a collaborator enables +exact-history refreshes from GitHub's timestamped stargazer endpoint. Exact +refreshes replace the aggregate curve and correct any drift from previous +snapshots. If the endpoint is unavailable, the workflow retains the stored +history and falls back to the current count. + +The generator refuses malformed series, data for a different repository, +future points, invalid API responses, and exact histories beyond GitHub's +pagination limit. A failed run publishes nothing. + +## Local commands + +Run the focused test suite: + +```bash +pnpm run test:star-history +``` + +Generate from a stored series and the current count: + +```bash +GITHUB_TOKEN=... node scripts/star-history/generate.mjs \ + --repo bookorbit/bookorbit \ + --series star-history.json \ + --bootstrap scripts/star-history/bootstrap.json \ + --out star-history.svg +``` + +Set `STAR_HISTORY_TOKEN` as well to attempt a complete exact-history refresh. diff --git a/docs/images/dashboard-overview.png b/docs/images/dashboard-overview.png new file mode 100644 index 000000000..459acbc19 Binary files /dev/null and b/docs/images/dashboard-overview.png differ diff --git a/package.json b/package.json index 6ea0e76ea..e2cb99858 100644 --- a/package.json +++ b/package.json @@ -31,7 +31,8 @@ "lint:fix": "pnpm --filter server lint:fix && pnpm --filter client lint:fix", "format": "pnpm --filter server format && pnpm --filter client format", "format:check": "pnpm --filter server format:check && pnpm --filter client format:check", - "test": "pnpm run test:server && pnpm run test:client", + "test": "pnpm run test:star-history && pnpm run test:server && pnpm run test:client", + "test:star-history": "node --test scripts/star-history/generate.test.mjs", "test:server": "pnpm --filter server test", "test:client": "pnpm --filter client test:unit --run", "e2e:run": "node ./scripts/e2e/run-suite.mjs", diff --git a/scripts/star-history/bootstrap.json b/scripts/star-history/bootstrap.json new file mode 100644 index 000000000..6f6a27fc5 --- /dev/null +++ b/scripts/star-history/bootstrap.json @@ -0,0 +1,305 @@ +{ + "schemaVersion": 1, + "repository": "bookorbit/bookorbit", + "updatedAt": "2026-07-27", + "lastExactFetch": "2026-07-27", + "source": "exact", + "points": [ + { + "date": "2026-05-15", + "stars": 1 + }, + { + "date": "2026-05-16", + "stars": 11 + }, + { + "date": "2026-05-17", + "stars": 64 + }, + { + "date": "2026-05-18", + "stars": 86 + }, + { + "date": "2026-05-19", + "stars": 97 + }, + { + "date": "2026-05-20", + "stars": 173 + }, + { + "date": "2026-05-21", + "stars": 213 + }, + { + "date": "2026-05-22", + "stars": 243 + }, + { + "date": "2026-05-23", + "stars": 354 + }, + { + "date": "2026-05-24", + "stars": 460 + }, + { + "date": "2026-05-25", + "stars": 486 + }, + { + "date": "2026-05-26", + "stars": 501 + }, + { + "date": "2026-05-27", + "stars": 518 + }, + { + "date": "2026-05-28", + "stars": 532 + }, + { + "date": "2026-05-29", + "stars": 605 + }, + { + "date": "2026-05-30", + "stars": 624 + }, + { + "date": "2026-05-31", + "stars": 640 + }, + { + "date": "2026-06-01", + "stars": 672 + }, + { + "date": "2026-06-02", + "stars": 729 + }, + { + "date": "2026-06-03", + "stars": 761 + }, + { + "date": "2026-06-04", + "stars": 816 + }, + { + "date": "2026-06-05", + "stars": 836 + }, + { + "date": "2026-06-06", + "stars": 853 + }, + { + "date": "2026-06-07", + "stars": 866 + }, + { + "date": "2026-06-08", + "stars": 871 + }, + { + "date": "2026-06-09", + "stars": 882 + }, + { + "date": "2026-06-10", + "stars": 887 + }, + { + "date": "2026-06-11", + "stars": 897 + }, + { + "date": "2026-06-12", + "stars": 933 + }, + { + "date": "2026-06-13", + "stars": 997 + }, + { + "date": "2026-06-14", + "stars": 1055 + }, + { + "date": "2026-06-15", + "stars": 1078 + }, + { + "date": "2026-06-16", + "stars": 1106 + }, + { + "date": "2026-06-17", + "stars": 1128 + }, + { + "date": "2026-06-18", + "stars": 1138 + }, + { + "date": "2026-06-19", + "stars": 1145 + }, + { + "date": "2026-06-20", + "stars": 1156 + }, + { + "date": "2026-06-21", + "stars": 1164 + }, + { + "date": "2026-06-22", + "stars": 1171 + }, + { + "date": "2026-06-23", + "stars": 1178 + }, + { + "date": "2026-06-24", + "stars": 1189 + }, + { + "date": "2026-06-25", + "stars": 1199 + }, + { + "date": "2026-06-26", + "stars": 1213 + }, + { + "date": "2026-06-27", + "stars": 1224 + }, + { + "date": "2026-06-28", + "stars": 1250 + }, + { + "date": "2026-06-29", + "stars": 1271 + }, + { + "date": "2026-06-30", + "stars": 1284 + }, + { + "date": "2026-07-01", + "stars": 1302 + }, + { + "date": "2026-07-02", + "stars": 1318 + }, + { + "date": "2026-07-03", + "stars": 1334 + }, + { + "date": "2026-07-04", + "stars": 1360 + }, + { + "date": "2026-07-05", + "stars": 1374 + }, + { + "date": "2026-07-06", + "stars": 1393 + }, + { + "date": "2026-07-07", + "stars": 1409 + }, + { + "date": "2026-07-08", + "stars": 1422 + }, + { + "date": "2026-07-09", + "stars": 1431 + }, + { + "date": "2026-07-10", + "stars": 1483 + }, + { + "date": "2026-07-11", + "stars": 1504 + }, + { + "date": "2026-07-12", + "stars": 1520 + }, + { + "date": "2026-07-13", + "stars": 1537 + }, + { + "date": "2026-07-14", + "stars": 1550 + }, + { + "date": "2026-07-15", + "stars": 1563 + }, + { + "date": "2026-07-16", + "stars": 1578 + }, + { + "date": "2026-07-17", + "stars": 1594 + }, + { + "date": "2026-07-18", + "stars": 1613 + }, + { + "date": "2026-07-19", + "stars": 1626 + }, + { + "date": "2026-07-20", + "stars": 1638 + }, + { + "date": "2026-07-21", + "stars": 1654 + }, + { + "date": "2026-07-22", + "stars": 1679 + }, + { + "date": "2026-07-23", + "stars": 1694 + }, + { + "date": "2026-07-24", + "stars": 1715 + }, + { + "date": "2026-07-25", + "stars": 1745 + }, + { + "date": "2026-07-26", + "stars": 1765 + }, + { + "date": "2026-07-27", + "stars": 1768 + } + ] +} diff --git a/scripts/star-history/generate.mjs b/scripts/star-history/generate.mjs new file mode 100644 index 000000000..b41ef5c35 --- /dev/null +++ b/scripts/star-history/generate.mjs @@ -0,0 +1,719 @@ +#!/usr/bin/env node + +import { readFile, rename, unlink, writeFile } from "node:fs/promises"; +import { resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +const API_ROOT = "https://api.github.com"; +const DEFAULT_REPOSITORY = "bookorbit/bookorbit"; +const MAX_PAGES = 400; +const PER_PAGE = 100; +const REQUEST_TIMEOUT_MS = 30_000; +const MAX_RESPONSE_BYTES = 2_000_000; + +const WIDTH = 680; +const HEIGHT = 380; +const PANEL_X = 18; +const PANEL_Y = 58; +const PANEL_WIDTH = 644; +const PANEL_HEIGHT = 252; +const PLOT_LEFT = 76; +const PLOT_RIGHT = 642; +const GRID_RIGHT = 646; +const PLOT_TOP = 86; +const BASELINE = 288; +const AXIS_LABEL_Y = 332; +const MAX_DATE_TICKS = 4; +const MAX_RENDERED_POINTS = 180; + +export class GitHubApiError extends Error { + constructor(message, { status, url, headers }) { + super(message); + this.name = "GitHubApiError"; + this.status = status; + this.url = url; + this.headers = headers; + } +} + +export class HistoryLimitError extends Error { + constructor(limit) { + super(`GitHub returned at least ${limit.toLocaleString("en-US")} stargazers, which exceeds the exact-history pagination limit`); + this.name = "HistoryLimitError"; + } +} + +function parseArguments(argv) { + const options = { + repository: process.env.GITHUB_REPOSITORY || DEFAULT_REPOSITORY, + seriesPath: "star-history.json", + bootstrapPath: undefined, + outputPath: "star-history.svg", + }; + + for (let index = 0; index < argv.length; index += 1) { + const flag = argv[index]; + const value = argv[index + 1]; + + if (!["--repo", "--series", "--bootstrap", "--out"].includes(flag)) { + throw new Error(`Unknown argument: ${flag}`); + } + if (!value) { + throw new Error(`Missing value for ${flag}`); + } + + if (flag === "--repo") options.repository = value; + if (flag === "--series") options.seriesPath = value; + if (flag === "--bootstrap") options.bootstrapPath = value; + if (flag === "--out") options.outputPath = value; + index += 1; + } + + return options; +} + +function utcToday(now = new Date()) { + return now.toISOString().slice(0, 10); +} + +function isIsoDate(value) { + if (!/^\d{4}-\d{2}-\d{2}$/.test(value)) return false; + return new Date(`${value}T00:00:00Z`).toISOString().slice(0, 10) === value; +} + +function dayNumber(value) { + return Date.parse(`${value}T00:00:00Z`) / 86_400_000; +} + +function dateFromDayNumber(value) { + return new Date(value * 86_400_000).toISOString().slice(0, 10); +} + +function xmlEscape(value) { + return String(value).replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">").replaceAll('"', """).replaceAll("'", "'"); +} + +function repositoryApiPath(repository) { + const match = /^([A-Za-z0-9_.-]+)\/([A-Za-z0-9_.-]+)$/.exec(repository); + if (!match || [match[1], match[2]].some((segment) => segment === "." || segment === "..")) { + throw new Error(`Invalid GitHub repository: ${repository}`); + } + return `${encodeURIComponent(match[1])}/${encodeURIComponent(match[2])}`; +} + +export function normalizeSeries(payload, repository) { + if (!payload || typeof payload !== "object" || Array.isArray(payload)) { + throw new Error("Star history must be a JSON object"); + } + if (payload.schemaVersion !== 1) { + throw new Error(`Unsupported star-history schema version: ${payload.schemaVersion}`); + } + if (payload.repository !== repository) { + throw new Error(`Star history belongs to ${payload.repository ?? "an unknown repository"}, not ${repository}`); + } + if (!Array.isArray(payload.points) || payload.points.length === 0) { + throw new Error("Star history must contain at least one point"); + } + + let previousDate; + const points = payload.points.map((point, index) => { + if (!point || typeof point !== "object" || Array.isArray(point)) { + throw new Error(`Point ${index + 1} must be an object`); + } + if (!isIsoDate(point.date)) { + throw new Error(`Point ${index + 1} has an invalid date`); + } + if (!Number.isSafeInteger(point.stars) || point.stars < 0) { + throw new Error(`Point ${index + 1} has an invalid star count`); + } + if (previousDate && point.date <= previousDate) { + throw new Error("Star-history dates must be unique and strictly increasing"); + } + previousDate = point.date; + return { date: point.date, stars: point.stars }; + }); + + if (payload.updatedAt !== undefined && !isIsoDate(payload.updatedAt)) { + throw new Error("Star history has an invalid updatedAt date"); + } + if (payload.lastExactFetch !== undefined && !isIsoDate(payload.lastExactFetch)) { + throw new Error("Star history has an invalid lastExactFetch date"); + } + if (payload.source !== "exact" && payload.source !== "snapshot") { + throw new Error("Star history source must be exact or snapshot"); + } + + return { + schemaVersion: 1, + repository, + updatedAt: payload.updatedAt, + lastExactFetch: payload.lastExactFetch, + source: payload.source, + points, + }; +} + +export async function loadSeries(path, repository) { + try { + const raw = await readFile(path, "utf8"); + if (!raw.trim()) return undefined; + return normalizeSeries(JSON.parse(raw), repository); + } catch (error) { + if (error?.code === "ENOENT") return undefined; + if (error instanceof SyntaxError) { + throw new Error(`${path} is not valid JSON: ${error.message}`); + } + throw error; + } +} + +async function writeFileAtomically(path, contents) { + const temporaryPath = `${path}.${process.pid}.tmp`; + try { + await writeFile(temporaryPath, contents, "utf8"); + await rename(temporaryPath, path); + } catch (error) { + await unlink(temporaryPath).catch(() => {}); + throw error; + } +} + +export async function saveSeries(path, series) { + await writeFileAtomically(path, `${JSON.stringify(series, null, 2)}\n`); +} + +async function readJsonResponse(response) { + const declaredLength = Number(response.headers.get("content-length")); + if (Number.isFinite(declaredLength) && declaredLength > MAX_RESPONSE_BYTES) { + throw new Error(`GitHub response exceeds ${MAX_RESPONSE_BYTES} bytes`); + } + if (!response.body) return undefined; + + const reader = response.body.getReader(); + const chunks = []; + let totalBytes = 0; + + while (true) { + const { done, value } = await reader.read(); + if (done) break; + totalBytes += value.byteLength; + if (totalBytes > MAX_RESPONSE_BYTES) { + await reader.cancel(); + throw new Error(`GitHub response exceeds ${MAX_RESPONSE_BYTES} bytes`); + } + chunks.push(Buffer.from(value)); + } + + const body = Buffer.concat(chunks).toString("utf8"); + try { + return body ? JSON.parse(body) : undefined; + } catch { + return undefined; + } +} + +async function requestJson(url, token, fetchImpl = fetch) { + const response = await fetchImpl(url, { + headers: { + Accept: "application/vnd.github+json", + Authorization: `Bearer ${token}`, + "X-GitHub-Api-Version": "2022-11-28", + "User-Agent": "bookorbit-star-history", + }, + signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), + }); + + const payload = await readJsonResponse(response); + + if (!response.ok) { + const apiMessage = payload && typeof payload.message === "string" ? payload.message : "GitHub returned no message body"; + throw new GitHubApiError(`GitHub API returned ${response.status}: ${apiMessage}`, { + status: response.status, + url, + headers: response.headers, + }); + } + + return payload; +} + +export async function fetchStarDates(repository, token, { fetchImpl = fetch, maxPages = MAX_PAGES, perPage = PER_PAGE } = {}) { + const dates = []; + const repositoryPath = repositoryApiPath(repository); + + for (let page = 1; page <= maxPages; page += 1) { + const url = new URL(`${API_ROOT}/repos/${repositoryPath}/stargazers`); + url.searchParams.set("per_page", String(perPage)); + url.searchParams.set("page", String(page)); + + const response = await fetchImpl(url, { + headers: { + Accept: "application/vnd.github.star+json", + Authorization: `Bearer ${token}`, + "X-GitHub-Api-Version": "2022-11-28", + "User-Agent": "bookorbit-star-history", + }, + signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), + }); + + const batch = await readJsonResponse(response); + + if (!response.ok) { + const apiMessage = batch && typeof batch.message === "string" ? batch.message : "GitHub returned no message body"; + throw new GitHubApiError(`GitHub API returned ${response.status}: ${apiMessage}`, { + status: response.status, + url: url.toString(), + headers: response.headers, + }); + } + if (!Array.isArray(batch)) { + throw new Error("GitHub returned an invalid stargazer response"); + } + + for (const entry of batch) { + const date = entry?.starred_at?.slice(0, 10); + if (!isIsoDate(date)) { + throw new Error("GitHub returned a stargazer without a valid starred_at timestamp"); + } + dates.push(date); + } + + if (batch.length < perPage) { + return dates.sort(); + } + } + + throw new HistoryLimitError(maxPages * perPage); +} + +export async function fetchStarCount(repository, token, { fetchImpl = fetch } = {}) { + const repositoryPath = repositoryApiPath(repository); + const payload = await requestJson(`${API_ROOT}/repos/${repositoryPath}`, token, fetchImpl); + if (!Number.isSafeInteger(payload?.stargazers_count) || payload.stargazers_count < 0) { + throw new Error("GitHub returned an invalid stargazers_count"); + } + return payload.stargazers_count; +} + +export function buildExactSeries(starDates, today) { + if (!isIsoDate(today)) throw new Error("today must be an ISO date"); + if (starDates.length === 0) throw new Error("GitHub returned no stargazers"); + + const points = []; + let count = 0; + + for (const date of [...starDates].sort()) { + if (!isIsoDate(date)) throw new Error(`Invalid stargazer date: ${date}`); + if (date > today) throw new Error(`Stargazer date is in the future: ${date}`); + count += 1; + if (points.at(-1)?.date === date) { + points[points.length - 1] = { date, stars: count }; + } else { + points.push({ date, stars: count }); + } + } + + if (points.at(-1).date < today) { + points.push({ date: today, stars: count }); + } + + return points; +} + +export function appendSnapshot(points, date, stars) { + if (!isIsoDate(date)) throw new Error("Snapshot date must be an ISO date"); + if (!Number.isSafeInteger(stars) || stars < 0) { + throw new Error("Snapshot count must be a non-negative integer"); + } + + const futurePoint = points.find((point) => point.date > date); + if (futurePoint) { + throw new Error(`Stored series contains a future point at ${futurePoint.date}`); + } + + return [...points.filter((point) => point.date !== date), { date, stars }].sort((a, b) => a.date.localeCompare(b.date)); +} + +export function niceYAxis(maximum) { + const target = Math.max(1, maximum * 1.1); + const rawStep = target / 4; + const magnitude = 10 ** Math.floor(Math.log10(rawStep)); + const normalized = rawStep / magnitude; + const factor = normalized <= 1 ? 1 : normalized <= 2 ? 2 : normalized <= 2.5 ? 2.5 : normalized <= 5 ? 5 : 10; + const step = Math.max(1, factor * magnitude); + return { + maximum: Math.ceil(target / step) * step, + step, + }; +} + +function formatCount(value) { + return value.toLocaleString("en-US"); +} + +function formatDateLabel(value, { day = false, year = false } = {}) { + return new Intl.DateTimeFormat("en-US", { + month: "short", + ...(day ? { day: "numeric" } : {}), + ...(year ? { year: "numeric" } : {}), + timeZone: "UTC", + }).format(new Date(`${value}T00:00:00Z`)); +} + +function coordinate(value) { + return Number(value.toFixed(1)); +} + +function fivePointStarPath(centerX, centerY, outerRadius) { + const points = []; + for (let index = 0; index < 10; index += 1) { + const radius = index % 2 === 0 ? outerRadius : outerRadius * 0.44; + const angle = -Math.PI / 2 + (index * Math.PI) / 5; + points.push(`${coordinate(centerX + Math.cos(angle) * radius)},${coordinate(centerY + Math.sin(angle) * radius)}`); + } + return `M${points.join(" L")}Z`; +} + +function seededRandom(seed) { + let state = 2_166_136_261; + for (const character of seed) { + state ^= character.codePointAt(0); + state = Math.imul(state, 16_777_619); + } + return () => { + state ^= state << 13; + state ^= state >>> 17; + state ^= state << 5; + return (state >>> 0) / 4_294_967_296; + }; +} + +/** + * Keeps the rendered path small enough for a README-sized card. Years of daily + * history would otherwise ship thousands of curve segments nobody can see. + */ +export function condensePoints(points, limit = MAX_RENDERED_POINTS) { + if (points.length <= limit) return points; + + const stride = (points.length - 1) / (limit - 1); + const indexes = new Set(); + for (let index = 0; index < limit; index += 1) { + indexes.add(Math.round(index * stride)); + } + indexes.add(points.length - 1); + + return [...indexes].sort((a, b) => a - b).map((index) => points[index]); +} + +/** + * Smooths the series with monotone cubic interpolation so the curve never + * overshoots into a star count the repository never had. + */ +function monotoneCurve(coordinates) { + const [first] = coordinates; + const commands = [`M${coordinate(first[0])} ${coordinate(first[1])}`]; + const widths = []; + const slopes = []; + + for (let index = 0; index < coordinates.length - 1; index += 1) { + const width = coordinates[index + 1][0] - coordinates[index][0]; + widths.push(width); + slopes.push(width === 0 ? 0 : (coordinates[index + 1][1] - coordinates[index][1]) / width); + } + + const tangents = coordinates.map((_, index) => { + if (index === 0) return slopes[0]; + if (index === coordinates.length - 1) return slopes.at(-1); + if (slopes[index - 1] * slopes[index] <= 0) return 0; + const left = 2 * widths[index] + widths[index - 1]; + const right = widths[index] + 2 * widths[index - 1]; + return (left + right) / (left / slopes[index - 1] + right / slopes[index]); + }); + + for (let index = 0; index < coordinates.length - 1; index += 1) { + const [x0, y0] = coordinates[index]; + const [x1, y1] = coordinates[index + 1]; + const third = widths[index] / 3; + commands.push( + `C${coordinate(x0 + third)} ${coordinate(y0 + tangents[index] * third)} ${coordinate(x1 - third)} ${coordinate(y1 - tangents[index + 1] * third)} ${coordinate(x1)} ${coordinate(y1)}`, + ); + } + + return commands.join(" "); +} + +function polylineLength(coordinates) { + let total = 0; + for (let index = 1; index < coordinates.length; index += 1) { + total += Math.hypot(coordinates[index][0] - coordinates[index - 1][0], coordinates[index][1] - coordinates[index - 1][1]); + } + return total; +} + +const RESERVED_TEXT_BOXES = [ + { x: 16, y: 14, width: 290, height: 34 }, + { x: WIDTH - 190, y: 20, width: 174, height: 28 }, + { x: WIDTH - 170, y: HEIGHT - 34, width: 154, height: 26 }, +]; + +function overlapsText(x, y) { + return RESERVED_TEXT_BOXES.some((box) => x >= box.x && x <= box.x + box.width && y >= box.y && y <= box.y + box.height); +} + +function renderStarfield(repository) { + const random = seededRandom(repository); + const dust = []; + const twinkles = []; + + for (let index = 0; index < 48; index += 1) { + const x = 14 + random() * (WIDTH - 28); + const y = 12 + random() * (HEIGHT - 24); + const roll = random(); + + if (overlapsText(x, y)) continue; + + if (roll > 0.87) { + const radius = 2.6 + random() * 1.9; + const duration = 3.6 + random() * 3.4; + const delay = -(random() * duration); + twinkles.push( + ``, + ); + continue; + } + + const radius = 0.6 + random() * 1.1; + const opacity = 0.12 + random() * 0.3; + dust.push(``); + } + + return [...dust, ...twinkles].join("\n "); +} + +function buildDateAxis(firstDate, lastDate) { + const firstDay = dayNumber(firstDate); + const span = Math.max(1, dayNumber(lastDate) - firstDay); + const spansYears = firstDate.slice(0, 4) !== lastDate.slice(0, 4); + + if (span < 45) { + return [0.08, 0.5, 0.92].map((fraction, index) => { + const date = dateFromDayNumber(Math.round(firstDay + span * fraction)); + return { date, label: formatDateLabel(date, { day: true, year: index === 0 && spansYears }) }; + }); + } + + const months = []; + const cursor = new Date(`${firstDate}T00:00:00Z`); + cursor.setUTCDate(1); + cursor.setUTCMonth(cursor.getUTCMonth() + 1); + while (cursor.toISOString().slice(0, 10) < lastDate) { + months.push(cursor.toISOString().slice(0, 10)); + cursor.setUTCMonth(cursor.getUTCMonth() + 1); + } + + const stride = Math.ceil(months.length / MAX_DATE_TICKS); + return months.filter((_, index) => index % stride === 0).map((date, index) => ({ date, label: formatDateLabel(date, { year: index === 0 }) })); +} + +export function renderSvg(series) { + const { repository, points } = series; + if (points.length < 2) { + throw new Error("At least two data points are required to render the chart"); + } + + const first = points[0]; + const latest = points.at(-1); + const firstDay = dayNumber(first.date); + const daySpan = Math.max(1, dayNumber(latest.date) - firstDay); + const plotWidth = PLOT_RIGHT - PLOT_LEFT; + const plotHeight = BASELINE - PLOT_TOP; + const yAxis = niceYAxis(Math.max(...points.map((point) => point.stars))); + + const x = (date) => PLOT_LEFT + ((dayNumber(date) - firstDay) / daySpan) * plotWidth; + const y = (stars) => BASELINE - (stars / yAxis.maximum) * plotHeight; + + const curve = condensePoints(points).map((point) => [x(point.date), y(point.stars)]); + const trendPath = monotoneCurve(curve); + const areaPath = `${trendPath} L${coordinate(curve.at(-1)[0])} ${BASELINE} L${coordinate(curve[0][0])} ${BASELINE} Z`; + const trendLength = Math.ceil(polylineLength(curve) * 1.08); + + const gridLines = []; + for (let value = 0; value <= yAxis.maximum; value += yAxis.step) { + const gridY = y(value); + gridLines.push(``); + if (Number.isInteger(value)) { + gridLines.push(`${formatCount(value)}`); + } + } + + const dateLabels = buildDateAxis(first.date, latest.date).map( + ({ date, label }) => `${label}`, + ); + + const endX = x(latest.date); + const endY = y(latest.stars); + const badgeLabel = `${formatCount(latest.stars)} stars`; + const badgeWidth = Math.round(badgeLabel.length * 7.5 + 20); + const badgeX = Math.min(endX - 14 - badgeWidth, PANEL_X + PANEL_WIDTH - badgeWidth - 12); + const badgeY = Math.max(PANEL_Y + 8, endY - 38); + const repositoryLabel = xmlEscape(repository); + const updatedAt = xmlEscape(series.updatedAt || latest.date); + + return ` + ${repositoryLabel} GitHub stars over time + ${repositoryLabel} reached ${formatCount(latest.stars)} GitHub stars on ${updatedAt}. + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ${renderStarfield(repository)} + + + ${gridLines.join("\n ")} + + + + + + + + + + + + + + + + + ${repositoryLabel} + GitHub stars over time + ${dateLabels.join("\n ")} + + + ${badgeLabel} + + + + + updated ${updatedAt} + + +`; +} + +function describeFallback(error) { + if (error instanceof GitHubApiError) { + const remaining = error.headers?.get?.("x-ratelimit-remaining"); + const rateLimit = remaining === "0" ? " Rate limit exhausted." : ""; + return `${error.message}.${rateLimit}`; + } + return error.message; +} + +export async function updateSeries({ repository, existingSeries, exactToken, metadataToken, today = utcToday(), fetchImpl = fetch }) { + repositoryApiPath(repository); + + if (exactToken) { + try { + const starDates = await fetchStarDates(repository, exactToken, { fetchImpl }); + return { + schemaVersion: 1, + repository, + updatedAt: today, + lastExactFetch: today, + source: "exact", + points: buildExactSeries(starDates, today), + }; + } catch (error) { + if (!existingSeries) throw error; + console.warn(`Exact stargazer history is unavailable. ${describeFallback(error)}`); + } + } + + if (!existingSeries) { + throw new Error("No stored series is available. Provide a bootstrap series or STAR_HISTORY_TOKEN."); + } + if (!metadataToken) { + throw new Error("GITHUB_TOKEN is required to refresh the current star count"); + } + + const count = await fetchStarCount(repository, metadataToken, { fetchImpl }); + return { + schemaVersion: 1, + repository, + updatedAt: today, + ...(existingSeries.lastExactFetch ? { lastExactFetch: existingSeries.lastExactFetch } : {}), + source: "snapshot", + points: appendSnapshot(existingSeries.points, today, count), + }; +} + +export async function run(argv = process.argv.slice(2)) { + const options = parseArguments(argv); + const storedSeries = await loadSeries(options.seriesPath, options.repository); + const bootstrapSeries = storedSeries || !options.bootstrapPath ? undefined : await loadSeries(options.bootstrapPath, options.repository); + const existingSeries = storedSeries || bootstrapSeries; + + const series = await updateSeries({ + repository: options.repository, + existingSeries, + exactToken: process.env.STAR_HISTORY_TOKEN, + metadataToken: process.env.GITHUB_TOKEN || process.env.STAR_HISTORY_TOKEN, + }); + + const svg = renderSvg(series); + await saveSeries(options.seriesPath, series); + await writeFileAtomically(options.outputPath, svg); + + console.log( + `Wrote ${options.outputPath} with ${series.points.length} points and ${formatCount(series.points.at(-1).stars)} stars (${series.source})`, + ); +} + +const entryPath = process.argv[1] ? resolve(process.argv[1]) : undefined; +if (entryPath === fileURLToPath(import.meta.url)) { + run().catch((error) => { + console.error(error.message); + process.exitCode = 1; + }); +} diff --git a/scripts/star-history/generate.test.mjs b/scripts/star-history/generate.test.mjs new file mode 100644 index 000000000..1e52aa2bc --- /dev/null +++ b/scripts/star-history/generate.test.mjs @@ -0,0 +1,239 @@ +import assert from "node:assert/strict"; +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, describe, it, mock } from "node:test"; + +import { + HistoryLimitError, + appendSnapshot, + buildExactSeries, + condensePoints, + fetchStarDates, + loadSeries, + niceYAxis, + normalizeSeries, + renderSvg, + saveSeries, + updateSeries, +} from "./generate.mjs"; + +const REPOSITORY = "bookorbit/bookorbit"; +const temporaryDirectories = []; + +function dateFromDay(value) { + return new Date(value * 86_400_000).toISOString().slice(0, 10); +} + +afterEach(async () => { + mock.restoreAll(); + await Promise.all(temporaryDirectories.splice(0).map((directory) => rm(directory, { recursive: true, force: true }))); +}); + +function series(overrides = {}) { + return { + schemaVersion: 1, + repository: REPOSITORY, + updatedAt: "2026-07-27", + lastExactFetch: "2026-07-26", + source: "snapshot", + points: [ + { date: "2026-05-15", stars: 1 }, + { date: "2026-06-15", stars: 540 }, + { date: "2026-07-27", stars: 1768 }, + ], + ...overrides, + }; +} + +describe("series data", () => { + it("collapses exact timestamps into cumulative daily points", () => { + assert.deepEqual(buildExactSeries(["2026-05-16", "2026-05-15", "2026-05-16", "2026-05-18"], "2026-05-20"), [ + { date: "2026-05-15", stars: 1 }, + { date: "2026-05-16", stars: 3 }, + { date: "2026-05-18", stars: 4 }, + { date: "2026-05-20", stars: 4 }, + ]); + }); + + it("replaces a same-day snapshot and permits legitimate decreases", () => { + assert.deepEqual( + appendSnapshot( + [ + { date: "2026-07-26", stars: 101 }, + { date: "2026-07-27", stars: 103 }, + ], + "2026-07-27", + 100, + ), + [ + { date: "2026-07-26", stars: 101 }, + { date: "2026-07-27", stars: 100 }, + ], + ); + }); + + it("rejects exact timestamps in the future", () => { + assert.throws(() => buildExactSeries(["2026-07-28"], "2026-07-27"), /in the future/); + }); + + it("rejects repository mismatches and duplicate dates", () => { + assert.throws(() => normalizeSeries(series({ repository: "someone/else" }), REPOSITORY), /belongs to someone\/else/); + assert.throws( + () => + normalizeSeries( + series({ + points: [ + { date: "2026-07-27", stars: 10 }, + { date: "2026-07-27", stars: 11 }, + ], + }), + REPOSITORY, + ), + /strictly increasing/, + ); + }); + + it("round-trips an atomically written series", async () => { + const directory = await mkdtemp(join(tmpdir(), "bookorbit-star-history-")); + temporaryDirectories.push(directory); + const path = join(directory, "series.json"); + + await saveSeries(path, series()); + + assert.deepEqual(await loadSeries(path, REPOSITORY), series()); + }); + + it("reports malformed JSON with its path", async () => { + const directory = await mkdtemp(join(tmpdir(), "bookorbit-star-history-")); + temporaryDirectories.push(directory); + const path = join(directory, "broken.json"); + await writeFile(path, "{", "utf8"); + + await assert.rejects(loadSeries(path, REPOSITORY), /broken\.json is not valid JSON/); + }); +}); + +describe("GitHub data sources", () => { + it("requests timestamped stargazers and returns sorted dates", async () => { + const fetchImpl = mock.fn(async (_url, options) => { + assert.equal(options.headers.Accept, "application/vnd.github.star+json"); + return new Response(JSON.stringify([{ starred_at: "2026-05-16T03:20:51Z" }, { starred_at: "2026-05-15T18:12:51Z" }]), { status: 200 }); + }); + + assert.deepEqual(await fetchStarDates(REPOSITORY, "token", { fetchImpl }), ["2026-05-15", "2026-05-16"]); + assert.equal(fetchImpl.mock.callCount(), 1); + }); + + it("fails instead of silently truncating exact history", async () => { + const fetchImpl = async () => + new Response(JSON.stringify([{ starred_at: "2026-05-15T18:12:51Z" }]), { + status: 200, + }); + + await assert.rejects( + fetchStarDates(REPOSITORY, "token", { + fetchImpl, + maxPages: 1, + perPage: 1, + }), + HistoryLimitError, + ); + }); + + it("rejects oversized API responses before reading them", async () => { + const fetchImpl = async () => + new Response("[]", { + status: 200, + headers: { "content-length": "2000001" }, + }); + + await assert.rejects(fetchStarDates(REPOSITORY, "token", { fetchImpl }), /exceeds 2000000 bytes/); + }); + + it("rejects invalid repository identifiers", async () => { + await assert.rejects(fetchStarDates("../private", "token", { fetchImpl: mock.fn() }), /Invalid GitHub repository/); + }); + + it("falls back to the current count while preserving exact-history metadata", async () => { + mock.method(console, "warn", () => {}); + const fetchImpl = async (url) => { + if (String(url).includes("/stargazers")) { + return new Response(JSON.stringify({ message: "Forbidden" }), { + status: 403, + }); + } + return new Response(JSON.stringify({ stargazers_count: 1771 }), { + status: 200, + }); + }; + + const updated = await updateSeries({ + repository: REPOSITORY, + existingSeries: series(), + exactToken: "exact-token", + metadataToken: "metadata-token", + today: "2026-07-28", + fetchImpl, + }); + + assert.equal(updated.source, "snapshot"); + assert.equal(updated.lastExactFetch, "2026-07-26"); + assert.deepEqual(updated.points.at(-1), { + date: "2026-07-28", + stars: 1771, + }); + }); +}); + +describe("SVG rendering", () => { + it("chooses readable rounded y-axis intervals", () => { + assert.deepEqual(niceYAxis(1768), { maximum: 2000, step: 500 }); + assert.deepEqual(niceYAxis(8), { maximum: 10, step: 2.5 }); + }); + + it("renders a deterministic self-contained BookOrbit star card", () => { + const svg = renderSvg(series()); + + assert.equal(svg, renderSvg(series())); + assert.match(svg, /bookorbit\/bookorbit/); + assert.match(svg, /id="bookorbit-mark"/); + assert.match(svg, /id="current-coordinate"/); + assert.match(svg, /id="current-total"/); + assert.match(svg, /class="twinkle"/); + assert.match(svg, /prefers-reduced-motion/); + assert.match(svg, /GitHub stars over time/); + assert.match(svg, /updated 2026-07-27/); + assert.match(svg, /1,768 stars/); + assert.doesNotMatch(svg, / { + const points = Array.from({ length: 900 }, (_, index) => ({ + date: dateFromDay(20_000 + index), + stars: index + 1, + })); + + const condensed = condensePoints(points); + + assert.ok(condensed.length <= 180); + assert.deepEqual(condensed[0], points[0]); + assert.deepEqual(condensed.at(-1), points.at(-1)); + assert.ok(condensed.every((point, index) => index === 0 || point.date > condensed[index - 1].date)); + assert.equal(condensePoints(points.slice(0, 40)).length, 40); + }); + + it("keeps the trend line visible when CSS animations are unsupported", () => { + const svg = renderSvg(series()); + + assert.match(svg, /@keyframes draw \{ from \{ stroke-dashoffset: \d+; \} to \{ stroke-dashoffset: 0; \} \}/); + assert.doesNotMatch(svg, /\.trend[^{]*\{[^}]*stroke-dashoffset:/); + }); + + it("escapes repository text used in SVG markup", () => { + const svg = renderSvg(series({ repository: "book&orbit/" })); + + assert.match(svg, /book&orbit\/<chart>/); + assert.doesNotMatch(svg, /book&orbit\//); + }); +});