// Generates the README star-history chart from first-party GitHub data.
//
// GitHub restricted the public stargazers API to a repository's own admins and
// collaborators (announced 2026-06-30), which broke every third-party chart
// service — api.star-history.com now serves an error card instead of a chart.
// We own this repo, so we read the star data ourselves and commit the result,
// keeping the README free of any third-party image host.
//
// The committed dataset, not the drawing, is the source of truth: the SVG is a
// pure function of assets/star-history.json. That keeps the chart re-renderable
// with no credentials if the endpoint tightens further, keeps the weekly diff
// readable (dates, not shifted path coordinates), and lets `npm run validate`
// prove the two files agree.
//
// Run: node scripts/gen-star-history.mjs → refetch, rewrite both files
// node scripts/gen-star-history.mjs --render-only → redraw the SVG offline
import { execFileSync } from "node:child_process";
import { readFileSync, writeFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { dirname, join } from "node:path";
const ROOT = join(dirname(fileURLToPath(import.meta.url)), "..");
// README-only assets, so they live in assets/ alone — unlike the banners, the
// GitHub Pages site under docs/ does not render them.
const OUT = join(ROOT, "assets", "star-history.svg");
const DATA = join(ROOT, "assets", "star-history.json");
const REPO = process.env.GITHUB_REPOSITORY ?? "hyhmrright/brooks-lint";
const W = 800, H = 400;
const PAD = { top: 44, right: 24, bottom: 48, left: 64 };
const PLOT_W = W - PAD.left - PAD.right;
const PLOT_H = H - PAD.top - PAD.bottom;
const ACCENT = "#3b82f6"; // matches the logo palette used by gen-banner.mjs
const FONT = `-apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif`;
const esc = (s) => String(s).replace(/&/g, "&").replace(//g, ">");
// The stargazers endpoint is no longer public: it needs credentials that can
// read this repo. CI supplies GITHUB_TOKEN; locally we borrow the gh CLI's.
function resolveToken() {
if (process.env.GITHUB_TOKEN) return process.env.GITHUB_TOKEN;
try {
return execFileSync("gh", ["auth", "token"], { encoding: "utf8" }).trim();
} catch {
throw new Error(
"No GITHUB_TOKEN set and `gh auth token` failed. Since GitHub restricted " +
"the stargazers API, this script needs credentials for a repo admin or collaborator.",
);
}
}
// Returns the raw starred_at strings, oldest first — exactly what we commit.
async function fetchStarTimestamps(token) {
const stamps = [];
for (let page = 1; ; page++) {
const url = `https://api.github.com/repos/${REPO}/stargazers?per_page=100&page=${page}`;
const res = await fetch(url, {
headers: {
// The star+json media type is what adds starred_at to each entry.
Accept: "application/vnd.github.star+json",
Authorization: `Bearer ${token}`,
"X-GitHub-Api-Version": "2022-11-28",
"User-Agent": "brooks-lint-gen-star-history",
},
});
// Past 400 pages GitHub answers 422 rather than paginating, so a repo above
// 40,000 stars can no longer be read in full — the committed dataset is what
// preserves the history when that day comes.
if (!res.ok) {
throw new Error(`GitHub API ${res.status} on page ${page}: ${(await res.text()).slice(0, 200)}`);
}
const batch = await res.json();
for (const entry of batch) {
// A missing starred_at means the star+json media type stopped being
// honoured. Fail loudly rather than plot NaN coordinates.
if (Number.isNaN(Date.parse(entry.starred_at))) {
throw new Error(`Stargazer without a usable starred_at on page ${page}.`);
}
stamps.push(entry.starred_at);
}
if (batch.length < 100) return stamps.sort((a, b) => Date.parse(a) - Date.parse(b));
}
}
export function readStamps() {
return JSON.parse(readFileSync(DATA, "utf8")).starredAt;
}
function writeStamps(stamps) {
// Deliberately no generated-at field: the file has to stay byte-identical when
// no star was added, or the workflow's "commit only when it moved" guard would
// fire every single run. Git already records when it last changed.
writeFileSync(DATA, `${JSON.stringify({ repo: REPO, starredAt: stamps }, null, 2)}\n`);
}
// Round the axis maximum up to a 1/2/5 × 10ⁿ step so tick labels stay readable.
function niceStep(max, targetTicks) {
const raw = max / targetTicks;
const mag = 10 ** Math.floor(Math.log10(raw));
for (const m of [1, 2, 5]) if (raw <= m * mag) return m * mag;
return 10 * mag;
}
// One tick per month, thinned out so labels never collide on a long history.
function monthTicks(from, to) {
const all = [];
const cursor = new Date(from);
cursor.setUTCDate(1);
cursor.setUTCHours(0, 0, 0, 0);
if (cursor.getTime() < from) cursor.setUTCMonth(cursor.getUTCMonth() + 1);
while (cursor.getTime() <= to) {
all.push(cursor.getTime());
cursor.setUTCMonth(cursor.getUTCMonth() + 1);
}
const stride = Math.ceil(all.length / 8) || 1;
return all.filter((_, i) => i % stride === 0);
}
const MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
function monthLabel(ms, showYear) {
const d = new Date(ms);
const month = MONTHS[d.getUTCMonth()];
return showYear ? `${month} ${d.getUTCFullYear()}` : month;
}
// 1,392 points would bloat the committed SVG for no visible gain. Keep every
// nth sample plus the exact final point, so the headline count stays truthful.
function downsample(points, limit) {
if (points.length <= limit) return points;
const stride = Math.ceil(points.length / limit);
const kept = points.filter((_, i) => i % stride === 0);
if (kept.at(-1) !== points.at(-1)) kept.push(points.at(-1));
return kept;
}
export function render(stamps) {
const times = stamps.map((iso) => Date.parse(iso));
const total = times.length;
const t0 = times[0];
// The axis ends at the newest star rather than at "now". Anchoring it to the
// clock would shift every x coordinate on every run, so the workflow could
// never tell a real change from a redraw and would commit noise weekly.
const t1 = times.at(-1);
// Stars are whole numbers, so never let a tiny repo produce fractional ticks.
const step = Math.max(1, niceStep(total, 5));
const yMax = Math.ceil(total / step) * step;
const x = (ms) => PAD.left + ((ms - t0) / (t1 - t0)) * PLOT_W;
const y = (n) => PAD.top + PLOT_H - (n / yMax) * PLOT_H;
const points = downsample(
times.map((ms, i) => [ms, i + 1]),
300,
);
const line = points.map(([ms, n], i) => `${i === 0 ? "M" : "L"}${x(ms).toFixed(1)} ${y(n).toFixed(1)}`).join("");
const area = `${line}L${x(t1).toFixed(1)} ${y(0).toFixed(1)}L${x(t0).toFixed(1)} ${y(0).toFixed(1)}Z`;
const yTicks = [];
for (let n = 0; n <= yMax; n += step) yTicks.push(n);
const xTicks = monthTicks(t0, t1);
const spansYears = new Date(t0).getUTCFullYear() !== new Date(t1).getUTCFullYear();
const gridLines = yTicks
.map(
(n) =>
`` +
`${n.toLocaleString("en-US")}`,
)
.join("\n ");
const xLabels = xTicks
.map(
(ms, i) =>
`` +
`${monthLabel(ms, spansYears || i === 0)}`,
)
.join("\n ");
// Anchoring the axis to the newest star puts the final point exactly on the
// right edge, so the callout always hangs back inside the plot.
const lastX = PAD.left + PLOT_W;
const lastY = y(total);
return `
`;
}
async function main() {
const renderOnly = process.argv.includes("--render-only");
const stamps = renderOnly ? readStamps() : await fetchStarTimestamps(resolveToken());
// Two points are the minimum a time axis can span; one would divide by zero
// and silently write a chart full of NaN coordinates.
if (stamps.length < 2) throw new Error(`Only ${stamps.length} stargazer(s) for ${REPO} — refusing to write a chart.`);
if (!renderOnly) writeStamps(stamps);
writeFileSync(OUT, render(stamps));
console.log(`Wrote ${OUT} — ${stamps.length} stars through ${stamps.at(-1).slice(0, 10)}`);
}
// Importable so `npm run validate` can re-render from the committed data and
// prove the SVG is in sync; only a direct run touches the network or disk.
if (process.argv[1] === fileURLToPath(import.meta.url)) await main();