mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
fix(readme): self-host the star history chart (#625)
GitHub restricted the stargazers API to a repo's own admins/collaborators (June 2026), so star-history.com's shared token pool 503s and the README chart went blank. Generate the chart from our own stargazer timeline instead (default GITHUB_TOKEN has access), publish it to the star-history branch, and embed it by raw URL. Weekly workflow keeps it fresh.
This commit is contained in:
@@ -0,0 +1,55 @@
|
||||
name: Star History
|
||||
|
||||
# Regenerates the README star-history chart from the repo's own stargazer timeline
|
||||
# and publishes it to the unprotected `star-history` branch, which the README embeds
|
||||
# by raw URL. GitHub restricted the public stargazers API to a repo's own
|
||||
# admins/collaborators (June 2026), which broke the hosted api.star-history.com badge.
|
||||
# Running here with the default GITHUB_TOKEN reads our own stars, so there's no
|
||||
# third-party service and no rate-limited shared token pool. See scripts/generate-star-history.mjs.
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: "23 7 * * 1" # Mondays 07:23 UTC
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: star-history
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
refresh:
|
||||
name: Refresh chart
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
|
||||
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
|
||||
with:
|
||||
node-version: 22
|
||||
|
||||
- name: Generate chart
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
STAR_HISTORY_REPO: ${{ github.repository }}
|
||||
STAR_HISTORY_OUT: ${{ runner.temp }}/star-history.svg
|
||||
run: node scripts/generate-star-history.mjs
|
||||
|
||||
- name: Publish to star-history branch
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
work="$(mktemp -d)"
|
||||
cp "${{ runner.temp }}/star-history.svg" "$work/star-history.svg"
|
||||
cd "$work"
|
||||
git init -q
|
||||
git checkout -q -b star-history
|
||||
git add star-history.svg
|
||||
git -c user.name="SnapOtter" -c user.email="snapotter.hq@gmail.com" \
|
||||
commit -q -m "chore: refresh star history chart [skip ci]"
|
||||
git push -q --force \
|
||||
"https://x-access-token:${GITHUB_TOKEN}@github.com/${GITHUB_REPOSITORY}.git" \
|
||||
HEAD:star-history
|
||||
@@ -156,8 +156,8 @@ If SnapOtter has replaced a paid subscription or two in your workflow, a small s
|
||||
<!-- sponsors -->
|
||||
|
||||
<p align="center">
|
||||
<a href="https://star-history.com/#snapotter-hq/SnapOtter&Date">
|
||||
<img src="https://api.star-history.com/svg?repos=snapotter-hq/SnapOtter&type=Date&theme=dark" alt="Star History Chart">
|
||||
<a href="https://github.com/snapotter-hq/SnapOtter/stargazers">
|
||||
<img src="https://raw.githubusercontent.com/snapotter-hq/SnapOtter/star-history/star-history.svg" width="760" alt="SnapOtter star history chart">
|
||||
</a>
|
||||
</p>
|
||||
|
||||
|
||||
@@ -0,0 +1,200 @@
|
||||
#!/usr/bin/env node
|
||||
// Generates a self-contained star-history SVG for the README from the repo's own
|
||||
// stargazer timeline. GitHub restricted the public stargazers API to a repo's own
|
||||
// admins/collaborators (June 2026), which broke the hosted api.star-history.com badge
|
||||
// for everyone. Running here with the repo's GITHUB_TOKEN reads its own stars, so no
|
||||
// third-party service and no rate-limited shared token pool. Pure Node, no deps.
|
||||
//
|
||||
// Env:
|
||||
// GITHUB_TOKEN / GH_TOKEN token with read access to the repo (Actions default works)
|
||||
// STAR_HISTORY_REPO "owner/repo" (default snapotter-hq/SnapOtter)
|
||||
// STAR_HISTORY_OUT output path (default branding/star-history.svg)
|
||||
|
||||
import { writeFileSync } from "node:fs";
|
||||
|
||||
const REPO = process.env.STAR_HISTORY_REPO ?? "snapotter-hq/SnapOtter";
|
||||
const OUT = process.env.STAR_HISTORY_OUT ?? "branding/star-history.svg";
|
||||
const TOKEN = process.env.GITHUB_TOKEN ?? process.env.GH_TOKEN ?? "";
|
||||
|
||||
// Otter palette (dark theme), mirrored from apps/landing globals.css.
|
||||
const COLOR = {
|
||||
bg: "#1A1210",
|
||||
panel: "#241a15",
|
||||
grid: "#33271f",
|
||||
line: "#E07832",
|
||||
lineSoft: "#F09550",
|
||||
text: "#F0EBE4",
|
||||
muted: "#9a8b7d",
|
||||
};
|
||||
|
||||
const WIDTH = 800;
|
||||
const HEIGHT = 480;
|
||||
const M = { top: 74, right: 30, bottom: 54, left: 70 };
|
||||
const plotW = WIDTH - M.left - M.right;
|
||||
const plotH = HEIGHT - M.top - M.bottom;
|
||||
|
||||
const MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
|
||||
const nf = new Intl.NumberFormat("en-US");
|
||||
|
||||
async function fetchStarredAt() {
|
||||
if (!TOKEN) throw new Error("GITHUB_TOKEN (or GH_TOKEN) is required");
|
||||
const times = [];
|
||||
for (let page = 1; page <= 400; page++) {
|
||||
const url = `https://api.github.com/repos/${REPO}/stargazers?per_page=100&page=${page}`;
|
||||
const res = await fetch(url, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${TOKEN}`,
|
||||
Accept: "application/vnd.github.star+json",
|
||||
"X-GitHub-Api-Version": "2022-11-28",
|
||||
"User-Agent": "snapotter-star-history",
|
||||
},
|
||||
});
|
||||
if (!res.ok) {
|
||||
throw new Error(`GitHub API ${res.status} ${res.statusText}: ${await res.text()}`);
|
||||
}
|
||||
const batch = await res.json();
|
||||
if (!Array.isArray(batch) || batch.length === 0) break;
|
||||
for (const item of batch) {
|
||||
if (item?.starred_at) times.push(Date.parse(item.starred_at));
|
||||
}
|
||||
if (batch.length < 100) break;
|
||||
}
|
||||
times.sort((a, b) => a - b);
|
||||
return times;
|
||||
}
|
||||
|
||||
// Loose "nice" number for axis ticks (Heckbert).
|
||||
function niceNum(range, round) {
|
||||
const exp = Math.floor(Math.log10(range));
|
||||
const frac = range / 10 ** exp;
|
||||
let nice;
|
||||
if (round) nice = frac < 1.5 ? 1 : frac < 3 ? 2 : frac < 7 ? 5 : 10;
|
||||
else nice = frac <= 1 ? 1 : frac <= 2 ? 2 : frac <= 5 ? 5 : 10;
|
||||
return nice * 10 ** exp;
|
||||
}
|
||||
|
||||
function yTicks(max) {
|
||||
const target = 5;
|
||||
const spacing = niceNum(Math.max(max, 1) / (target - 1), true);
|
||||
const niceMax = Math.ceil(max / spacing) * spacing;
|
||||
const ticks = [];
|
||||
for (let v = 0; v <= niceMax + 1e-9; v += spacing) ticks.push(Math.round(v));
|
||||
return { ticks, niceMax };
|
||||
}
|
||||
|
||||
const fmtMonth = (d) => `${MONTHS[d.getUTCMonth()]} ${d.getUTCFullYear()}`;
|
||||
|
||||
// Ticks on month boundaries, anchored at the first star, thinned to <= 8 for old
|
||||
// repos. Avoids the duplicate "Apr 2026 / Apr 2026" that evenly-spaced ticks produce
|
||||
// on a short span, and never crowds a label against either end.
|
||||
function xTicks(min, max) {
|
||||
const range = max - min;
|
||||
const minGap = range * 0.06;
|
||||
const first = new Date(min);
|
||||
const ticks = [{ t: min, label: fmtMonth(first) }];
|
||||
let d = Date.UTC(first.getUTCFullYear(), first.getUTCMonth() + 1, 1);
|
||||
while (d <= max - minGap) {
|
||||
const dd = new Date(d);
|
||||
if (d - min >= minGap) ticks.push({ t: d, label: fmtMonth(dd) });
|
||||
d = Date.UTC(dd.getUTCFullYear(), dd.getUTCMonth() + 1, 1);
|
||||
}
|
||||
const maxTicks = 8;
|
||||
if (ticks.length > maxTicks) {
|
||||
const step = Math.ceil(ticks.length / maxTicks);
|
||||
const thinned = ticks.filter((_, i) => i % step === 0);
|
||||
const lastGen = ticks[ticks.length - 1];
|
||||
if (thinned[thinned.length - 1] !== lastGen) thinned.push(lastGen);
|
||||
return thinned;
|
||||
}
|
||||
return ticks;
|
||||
}
|
||||
|
||||
// Even-index sample of the cumulative curve so the SVG stays small and smooth.
|
||||
function samplePoints(times, maxPoints) {
|
||||
const n = times.length;
|
||||
const pts = [];
|
||||
if (n === 0) return pts;
|
||||
const step = Math.max(1, Math.ceil(n / maxPoints));
|
||||
for (let i = 0; i < n; i += step) pts.push({ t: times[i], y: i + 1 });
|
||||
pts.push({ t: times[n - 1], y: n }); // real last star
|
||||
pts.push({ t: Date.now(), y: n }); // extend the line to today
|
||||
return pts;
|
||||
}
|
||||
|
||||
function esc(s) {
|
||||
return String(s).replace(/[<>&]/g, (c) => ({ "<": "<", ">": ">", "&": "&" })[c]);
|
||||
}
|
||||
|
||||
function render(times) {
|
||||
const n = times.length;
|
||||
const xMin = times[0];
|
||||
const xMax = Date.now();
|
||||
const { ticks: yt, niceMax } = yTicks(n);
|
||||
const xt = xTicks(xMin, xMax);
|
||||
const pts = samplePoints(times, 60);
|
||||
|
||||
const xPx = (t) => M.left + ((t - xMin) / (xMax - xMin)) * plotW;
|
||||
const yPx = (v) => M.top + plotH - (v / niceMax) * plotH;
|
||||
const baseline = M.top + plotH;
|
||||
|
||||
const linePts = pts.map((p) => `${xPx(p.t).toFixed(1)},${yPx(p.y).toFixed(1)}`).join(" ");
|
||||
const areaD =
|
||||
`M ${xPx(pts[0].t).toFixed(1)},${baseline.toFixed(1)} ` +
|
||||
pts.map((p) => `L ${xPx(p.t).toFixed(1)},${yPx(p.y).toFixed(1)}`).join(" ") +
|
||||
` L ${xPx(pts[pts.length - 1].t).toFixed(1)},${baseline.toFixed(1)} Z`;
|
||||
|
||||
const gridLines = yt
|
||||
.map((v) => {
|
||||
const y = yPx(v).toFixed(1);
|
||||
return (
|
||||
`<line x1="${M.left}" y1="${y}" x2="${M.left + plotW}" y2="${y}" stroke="${COLOR.grid}" stroke-width="1"/>` +
|
||||
`<text x="${M.left - 10}" y="${y}" fill="${COLOR.muted}" font-size="12" text-anchor="end" dominant-baseline="middle">${nf.format(v)}</text>`
|
||||
);
|
||||
})
|
||||
.join("\n ");
|
||||
|
||||
const xLabels = xt
|
||||
.map((tk) => {
|
||||
const x = xPx(tk.t).toFixed(1);
|
||||
return `<text x="${x}" y="${baseline + 22}" fill="${COLOR.muted}" font-size="12" text-anchor="middle">${tk.label}</text>`;
|
||||
})
|
||||
.join("\n ");
|
||||
|
||||
const last = pts[pts.length - 1];
|
||||
const lastX = xPx(last.t);
|
||||
const lastY = yPx(last.y);
|
||||
const fontStack = "-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Helvetica,Arial,sans-serif";
|
||||
|
||||
return `<svg xmlns="http://www.w3.org/2000/svg" width="${WIDTH}" height="${HEIGHT}" viewBox="0 0 ${WIDTH} ${HEIGHT}" font-family="${fontStack}" role="img" aria-label="Star history for ${esc(REPO)}: ${nf.format(n)} stars">
|
||||
<defs>
|
||||
<linearGradient id="area" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stop-color="${COLOR.line}" stop-opacity="0.28"/>
|
||||
<stop offset="100%" stop-color="${COLOR.line}" stop-opacity="0"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect x="0.5" y="0.5" width="${WIDTH - 1}" height="${HEIGHT - 1}" rx="14" fill="${COLOR.bg}" stroke="${COLOR.grid}"/>
|
||||
<text x="${M.left - 0}" y="34" fill="${COLOR.text}" font-size="19" font-weight="700">${esc(REPO)}</text>
|
||||
<text x="${M.left - 0}" y="54" fill="${COLOR.muted}" font-size="13">Star history</text>
|
||||
<g>
|
||||
<circle cx="${WIDTH - M.right - 96}" cy="30" r="4.5" fill="${COLOR.line}"/>
|
||||
<text x="${WIDTH - M.right - 84}" y="34" fill="${COLOR.muted}" font-size="13">GitHub stars</text>
|
||||
</g>
|
||||
<g>
|
||||
${gridLines}
|
||||
</g>
|
||||
<path d="${areaD}" fill="url(#area)"/>
|
||||
<polyline points="${linePts}" fill="none" stroke="${COLOR.line}" stroke-width="2.6" stroke-linejoin="round" stroke-linecap="round"/>
|
||||
<circle cx="${lastX.toFixed(1)}" cy="${lastY.toFixed(1)}" r="4.5" fill="${COLOR.line}" stroke="${COLOR.bg}" stroke-width="1.5"/>
|
||||
<text x="${(lastX - 8).toFixed(1)}" y="${(lastY - 12).toFixed(1)}" fill="${COLOR.lineSoft}" font-size="14" font-weight="700" text-anchor="end">${nf.format(n)}</text>
|
||||
<g>
|
||||
${xLabels}
|
||||
</g>
|
||||
</svg>
|
||||
`;
|
||||
}
|
||||
|
||||
const times = await fetchStarredAt();
|
||||
if (times.length === 0) throw new Error(`No stargazers returned for ${REPO}`);
|
||||
const svg = render(times);
|
||||
writeFileSync(OUT, svg);
|
||||
console.log(`Wrote ${OUT} — ${times.length} stars, ${(svg.length / 1024).toFixed(1)} KiB`);
|
||||
Reference in New Issue
Block a user