#!/usr/bin/env node
import { mkdirSync, writeFileSync } from 'node:fs';
import { join } from 'node:path';
const TOKEN = process.env.GITHUB_TOKEN;
const REPO = process.env.GITHUB_REPOSITORY || 'KnockOutEZ/wigolo';
const OUT_DIR = process.env.OUT_DIR || 'out';
const FORCE_403 = process.env.TEST_FORCE_403 === '1';
if (!TOKEN) {
process.stderr.write('star-chart: GITHUB_TOKEN is required\n');
process.exit(1);
}
const FONT = "-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif";
const AUTH_HEADERS = { Authorization: `Bearer ${TOKEN}`, 'User-Agent': 'wigolo-star-chart' };
function today() {
return new Date().toISOString().slice(0, 10);
}
async function loadPreviousSeries() {
const url = `https://raw.githubusercontent.com/${REPO}/star-chart/star-data.json`;
try {
const res = await fetch(url, { headers: { 'User-Agent': 'wigolo-star-chart' } });
if (!res.ok) return [];
const data = await res.json();
if (!Array.isArray(data)) return [];
return data
.filter((d) => Array.isArray(d) && typeof d[0] === 'string' && Number.isFinite(d[1]))
.sort((a, b) => a[0].localeCompare(b[0]));
} catch {
return [];
}
}
async function fetchStargazers() {
const times = [];
for (let page = 1; ; page++) {
let res;
if (page === 1 && FORCE_403) {
res = { ok: false, status: 403, statusText: 'Forbidden (TEST_FORCE_403)', text: async () => 'forced 403 for testing', headers: new Map() };
} else {
res = await fetch(`https://api.github.com/repos/${REPO}/stargazers?per_page=100&page=${page}`, {
headers: { Accept: 'application/vnd.github.star+json', ...AUTH_HEADERS },
});
}
if (!res.ok) {
if (res.status === 403 || res.status === 401) return { ok: false, status: res.status };
const body = (await res.text()).replace(/\s+/g, ' ').slice(0, 300);
const remaining = res.headers.get('x-ratelimit-remaining');
const limit = res.headers.get('x-ratelimit-limit');
process.stderr.write(
`star-chart: GitHub API ${res.status} ${res.statusText} on page ${page} ` +
`(ratelimit ${remaining}/${limit}): ${body}\n`,
);
process.exit(1);
}
const batch = await res.json();
for (const entry of batch) {
if (entry && entry.starred_at) times.push(new Date(entry.starred_at).getTime());
}
if (batch.length < 100) break;
}
return { ok: true, times: times.sort((a, b) => a - b) };
}
async function fetchStarCount() {
const res = await fetch(`https://api.github.com/repos/${REPO}`, {
headers: { Accept: 'application/vnd.github+json', ...AUTH_HEADERS },
});
if (!res.ok) {
const body = (await res.text()).replace(/\s+/g, ' ').slice(0, 300);
const remaining = res.headers.get('x-ratelimit-remaining');
const limit = res.headers.get('x-ratelimit-limit');
process.stderr.write(
`star-chart: GitHub API ${res.status} ${res.statusText} on repo GET ` +
`(ratelimit ${remaining}/${limit}): ${body}\n`,
);
process.exit(1);
}
return (await res.json()).stargazers_count;
}
function densifyToDaily(times) {
const byDay = new Map();
times.forEach((t, i) => byDay.set(new Date(t).toISOString().slice(0, 10), i + 1));
return [...byDay.entries()].sort((a, b) => a[0].localeCompare(b[0]));
}
function appendToday(previous, count) {
const merged = previous.slice();
const d = today();
if (merged.length && merged[merged.length - 1][0] === d) merged[merged.length - 1] = [d, count];
else merged.push([d, count]);
return merged;
}
function toRenderSeries(daily) {
const now = Date.now();
if (daily.length === 0) return { points: [[now, 0]], total: 0, now };
const points = daily.map(([d, c]) => [Date.parse(`${d}T00:00:00Z`), c]);
const total = daily[daily.length - 1][1];
if (points[points.length - 1][0] < now) points.push([now, total]);
return { points, total, now };
}
function niceCeil(n) {
if (n <= 5) return 5;
const pow = Math.pow(10, Math.floor(Math.log10(n)));
for (const m of [1, 2, 2.5, 5, 10]) {
const step = m * pow;
if (Math.ceil(n / step) * step >= n) return Math.ceil(n / step) * step;
}
return Math.ceil(n / pow) * pow;
}
function fmtDate(ms) {
return new Date(ms).toISOString().slice(0, 10);
}
function renderSvg({ points, total, now }, theme) {
const W = 880;
const H = 360;
const padL = 64;
const padR = 32;
const padT = 56;
const padB = 44;
const plotW = W - padL - padR;
const plotH = H - padT - padB;
const t0 = points[0][0];
const t1 = points[points.length - 1][0];
const spanT = Math.max(1, t1 - t0);
const yMax = niceCeil(Math.max(total, 1));
const x = (t) => padL + ((t - t0) / spanT) * plotW;
const y = (c) => padT + plotH - (c / yMax) * plotH;
const coords = points.map(([t, c]) => [x(t), y(c)]);
const linePath = coords.map(([px, py], i) => `${i === 0 ? 'M' : 'L'}${px.toFixed(1)} ${py.toFixed(1)}`).join(' ');
const areaPath = `${linePath} L${coords[coords.length - 1][0].toFixed(1)} ${(padT + plotH).toFixed(1)} L${coords[0][0].toFixed(1)} ${(padT + plotH).toFixed(1)} Z`;
const bg = theme === 'dark' ? '#0d1117' : 'transparent';
const textColor = theme === 'dark' ? '#94a3b8' : '#334155';
const gridColor = theme === 'dark' ? '#1e293b' : '#e2e8f0';
const line = '#7c3aed';
const rows = [0, 1, 2, 3, 4];
const grid = rows
.map((r) => {
const val = Math.round((yMax / 4) * r);
const gy = y(val);
return (
`<line x1="${padL}" y1="${gy.toFixed(1)}" x2="${(padL + plotW).toFixed(1)}" y2="${gy.toFixed(1)}" stroke="${gridColor}" stroke-width="1"/>` +
`<text x="${padL - 10}" y="${(gy + 4).toFixed(1)}" text-anchor="end" font-size="14" fill="${textColor}">${val}</text>`
);
})
.join('\n ');
return `<svg xmlns="http://www.w3.org/2000/svg" width="${W}" height="${H}" viewBox="0 0 ${W} ${H}" font-family="${FONT}">
<rect width="${W}" height="${H}" fill="${bg}"/>
<text x="${padL}" y="32" font-size="20" font-weight="600" fill="${textColor}">wigolo — GitHub stars over time</text>
<text x="${W - padR}" y="30" text-anchor="end" font-size="26" font-weight="700" fill="${line}">${total} ★</text>
<text x="${W - padR}" y="48" text-anchor="end" font-size="13" fill="${textColor}" opacity="0.7">updated ${fmtDate(now)}</text>
${grid}
<path d="${areaPath}" fill="${line}" fill-opacity="0.12" stroke="none"/>
<path d="${linePath}" fill="none" stroke="${line}" stroke-width="2.5" stroke-linejoin="round" stroke-linecap="round"/>
<text x="${padL}" y="${H - 16}" text-anchor="start" font-size="14" fill="${textColor}">${fmtDate(t0)}</text>
<text x="${padL + plotW}" y="${H - 16}" text-anchor="end" font-size="14" fill="${textColor}">${fmtDate(t1)}</text>
</svg>
`;
}
async function main() {
const previous = await loadPreviousSeries();
const stargazers = await fetchStargazers();
let daily;
let mode;
if (stargazers.ok) {
daily = densifyToDaily(stargazers.times);
mode = 'dense';
} else {
process.stderr.write(
`star-chart: stargazer list returned ${stargazers.status}; falling back to repo star count (append mode)\n`,
);
const count = await fetchStarCount();
daily = appendToday(previous, count);
mode = 'append';
}
const series = toRenderSeries(daily);
mkdirSync(OUT_DIR, { recursive: true });
writeFileSync(join(OUT_DIR, 'star-history.svg'), renderSvg(series, 'light'), 'utf-8');
writeFileSync(join(OUT_DIR, 'star-history-dark.svg'), renderSvg(series, 'dark'), 'utf-8');
writeFileSync(join(OUT_DIR, 'star-data.json'), JSON.stringify(daily, null, 2) + '\n', 'utf-8');
process.stderr.write(
`star-chart: ${REPO} — ${series.total} stars, ${daily.length} days, wrote 2 SVGs + star-data.json to ${OUT_DIR}/ (${mode})\n`,
);
}
main().catch((err) => {
process.stderr.write(`star-chart: ${err && err.stack ? err.stack : err}\n`);
process.exit(1);
});