Files
awesome-ai-dev-tools/site/index.html
T
tiennm99 00c90c5be9 feat: split data update from site build; publish via Cloudflare Pages
The updater fetched GitHub metadata and rendered the dashboard payload in
a single step, so anything that published the site needed a GITHUB_TOKEN.
Cloudflare Pages builds from a Git webhook and has no business holding
one.

Split the tool into three modes. The default update step still fetches
GitHub and now records every API-sourced field in data/metadata.json,
which is committed alongside README.md and data/history.jsonl. The new
-build mode joins that snapshot with data/agents.yml and
data/history.jsonl to render dist/ with no network access and no token;
-check is unchanged.

Supporting changes:

- computeDeltaAt anchors the delta windows to when the data was fetched
  rather than the wall clock, so a redeploy days later reproduces the
  same Δ7d instead of sliding the window past its slack allowance.
- writeSiteData takes updatedAt explicitly for the same reason: the
  timestamp labels data freshness, not build time.
- sortStats is extracted from fetchStats so the build step re-ranks
  identically from committed metadata.
- An agents.yml entry with no metadata yet is omitted with a warning
  instead of failing the build, which would otherwise block every deploy
  between merging a new entry and the next nightly run.
- update.yml drops the GitHub Pages deploy steps and commits
  data/metadata.json; ci.yml runs `go run . -build` so a build that would
  break on deploy breaks in CI first.
- site/_headers stops the edge serving a stale data.json after a refresh.
- docs/DEPLOY.md covers the Cloudflare setup, including the one-time
  bootstrap of data/metadata.json that the build depends on.

Also carries the in-progress curation work already in the tree: the
module rename to awesome-ai-dev-tools, retagged entries, and removal of
the archived Roo-Code, void, continue and suna entries.
2026-09-16 22:24:48 +07:00

696 lines
33 KiB
HTML

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Awesome AI Dev Tools — Rankings &amp; History</title>
<meta name="description" content="Daily-refreshed ranking of open-source AI coding agents by GitHub stars, with 7-day deltas and star-history charts.">
<meta property="og:title" content="Awesome AI Dev Tools — Rankings &amp; History">
<meta property="og:description" content="Daily-refreshed ranking of open-source AI coding agents by GitHub stars, with 7-day deltas and star-history charts.">
<meta property="og:url" content="https://awesome-ai-dev-tools.pages.dev/">
<meta property="og:type" content="website">
<meta name="theme-color" content="#f8fafc" media="(prefers-color-scheme: light)">
<meta name="theme-color" content="#0f172a" media="(prefers-color-scheme: dark)">
<link rel="icon" href="data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%20100%20100%22%3E%3Ctext%20y%3D%22.9em%22%20font-size%3D%2290%22%3E%E2%AD%90%3C%2Ftext%3E%3C%2Fsvg%3E">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Fira+Code:wght@500;600&family=Fira+Sans:wght@400;500;600&display=swap">
<style>
/* Design system: "Data-Dense Dashboard" (ui-ux-pro-max) — blue primary,
amber accent, slate neutrals, Fira Code (data/headings) + Fira Sans (body). */
:root {
--bg: #f8fafc; --surface: #ffffff; --surface-2: #f1f5f9;
--fg: #1e293b; --muted: #5b6779; --border: #e2e8f0;
--primary: #2563eb; --primary-fg: #ffffff; --link: #1d4ed8;
--accent: #b45309; --pos: #15803d; --neg: #b91c1c;
--row-hover: #f1f5f9; --shadow: 0 1px 2px rgb(15 23 42 / .06);
color-scheme: light dark;
}
@media (prefers-color-scheme: dark) {
:root {
--bg: #0f172a; --surface: #1e293b; --surface-2: #263449;
--fg: #e2e8f0; --muted: #a5b1c5; --border: #334155;
--primary: #3b82f6; --primary-fg: #ffffff; --link: #93c5fd;
--accent: #fbbf24; --pos: #4ade80; --neg: #f87171;
--row-hover: #263449; --shadow: 0 1px 2px rgb(0 0 0 / .4);
}
}
* { box-sizing: border-box; }
body {
margin: 0; padding: 1.5rem; background: var(--bg); color: var(--fg);
font: 16px/1.5 "Fira Sans", -apple-system, "Segoe UI", Roboto, sans-serif;
max-width: 1200px; margin-inline: auto;
}
code, .mono, h1, h2, .stat b, td.num, th.num, a.repo, .cat, .stalebadge, .dir {
font-family: "Fira Code", ui-monospace, SFMono-Regular, Consolas, monospace;
}
header.hero { display: flex; align-items: flex-start; justify-content: space-between; gap: 1rem; flex-wrap: wrap; margin-bottom: 1.25rem; }
h1 { font-size: 1.55rem; font-weight: 600; margin: 0 0 .3rem; letter-spacing: -.02em; display: flex; align-items: center; gap: .55rem; }
h1 svg { color: var(--accent); flex: none; }
.sub { color: var(--muted); max-width: 46rem; }
.ghlink {
display: inline-flex; align-items: center; gap: .45rem; white-space: nowrap;
border: 1px solid var(--border); background: var(--surface); color: var(--fg);
border-radius: 8px; padding: .45rem .9rem; text-decoration: none; font-weight: 500;
box-shadow: var(--shadow); transition: border-color .15s ease, background-color .15s ease;
}
.ghlink:hover { border-color: var(--primary); }
.vh {
position: absolute; width: 1px; height: 1px; margin: -1px; padding: 0;
overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; border: 0;
}
/* KPI stat cards */
.stats { display: grid; grid-template-columns: repeat(auto-fit, minmax(11.5rem, 1fr)); gap: .75rem; margin: 0 0 1.25rem; }
.stat {
background: var(--surface); border: 1px solid var(--border); border-radius: 10px;
padding: .8rem 1rem; box-shadow: var(--shadow); min-width: 0;
}
.stat small { display: block; color: var(--muted); font-size: .74rem; text-transform: uppercase; letter-spacing: .06em; margin-bottom: .25rem; }
.stat b { display: block; font-size: 1.3rem; font-weight: 600; font-variant-numeric: tabular-nums; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.stat .statsub { color: var(--muted); font-size: .82rem; }
.stat .statsub.pos { color: var(--pos); }
/* Toolbar */
.toolbar { display: flex; flex-wrap: wrap; gap: .6rem .75rem; align-items: center; margin: 0 0 .6rem; }
.searchbox { position: relative; flex: 1 1 15rem; max-width: 26rem; }
.searchbox svg { position: absolute; left: .7rem; top: 50%; translate: 0 -50%; color: var(--muted); pointer-events: none; }
#q {
width: 100%; border: 1px solid var(--border); background: var(--surface); color: var(--fg);
border-radius: 8px; padding: .5rem .75rem .5rem 2.2rem; font: inherit; box-shadow: var(--shadow);
transition: border-color .15s ease;
}
#q::placeholder { color: var(--muted); opacity: 1; }
#q:hover { border-color: var(--primary); }
.chips { display: flex; flex-wrap: wrap; gap: .5rem; margin: 0 0 1rem; }
.chips button, .btn {
border: 1px solid var(--border); background: var(--surface); color: var(--fg);
border-radius: 999px; padding: .35rem .9rem; cursor: pointer; font: inherit; font-size: .9rem; font-weight: 500;
box-shadow: var(--shadow); transition: background-color .15s ease, border-color .15s ease, color .15s ease;
}
.btn { border-radius: 8px; }
.chips button.on, .btn[aria-pressed="true"] { background: var(--primary); color: var(--primary-fg); border-color: var(--primary); }
.chips button:hover, .btn:hover { border-color: var(--primary); }
a:focus-visible, button:focus-visible, input:focus-visible, th[data-k]:focus-visible {
outline: 2px solid var(--primary); outline-offset: 2px;
}
/* Table card */
.tablewrap {
overflow-x: auto; border: 1px solid var(--border); border-radius: 12px;
background: var(--surface); box-shadow: var(--shadow);
}
table { border-collapse: collapse; width: 100%; min-width: 840px; }
th, td { padding: .55rem .75rem; text-align: left; border-top: 1px solid var(--border); }
thead th {
border-top: none; background: var(--surface-2); user-select: none; white-space: nowrap;
font-size: .8rem; text-transform: uppercase; letter-spacing: .05em; color: var(--muted); font-weight: 600;
}
thead th[data-k] { cursor: pointer; }
thead th[data-k]:hover { color: var(--fg); }
thead th .dir { color: var(--primary); }
tbody tr { transition: background-color .15s ease; }
tbody tr:hover { background: var(--row-hover); }
td.num, th.num { text-align: right; font-variant-numeric: tabular-nums; font-size: .92rem; }
td.rankcell { color: var(--muted); }
td.desc { color: var(--muted); font-size: .9rem; max-width: 34rem; }
td.lang { font-size: .92rem; }
a.repo { color: var(--link); text-decoration: none; font-weight: 500; white-space: nowrap; font-size: .92rem; }
a.repo:hover { text-decoration: underline; }
.pos { color: var(--pos); } .neg { color: var(--neg); }
/* Tag pills: tinted per facet; the text label carries the meaning */
td.tags { max-width: 20rem; }
.taglist { display: flex; flex-wrap: wrap; gap: .25rem; }
.tag {
display: inline-block; font-size: .72rem; font-weight: 500; padding: .1rem .5rem;
border: 1px solid var(--border); border-radius: 999px; color: var(--muted); background: var(--surface-2);
white-space: nowrap;
}
.tag-surface { border-color: color-mix(in srgb, var(--primary) 45%, var(--border)); color: var(--link); background: color-mix(in srgb, var(--primary) 12%, var(--surface)); }
.tag-model { border-color: color-mix(in srgb, #9333ea 45%, var(--border)); background: color-mix(in srgb, #9333ea 12%, var(--surface)); }
.tag-workflow { border-color: color-mix(in srgb, #0d9488 45%, var(--border)); background: color-mix(in srgb, #0d9488 12%, var(--surface)); }
.tag-integration { border-color: color-mix(in srgb, #ea580c 45%, var(--border)); background: color-mix(in srgb, #ea580c 12%, var(--surface)); }
.tag-origin { border-color: color-mix(in srgb, #db2777 45%, var(--border)); background: color-mix(in srgb, #db2777 12%, var(--surface)); }
.tag-model, .tag-workflow, .tag-integration, .tag-origin { color: var(--fg); }
/* Facet filter rows */
.facets { margin: 0 0 1rem; display: grid; gap: .4rem; }
.facet { display: flex; flex-wrap: wrap; gap: .4rem; align-items: center; }
.facet > .flabel {
font-size: .72rem; text-transform: uppercase; letter-spacing: .06em; color: var(--muted);
font-weight: 600; min-width: 6.5rem;
}
.facet button {
border: 1px solid var(--border); background: var(--surface); color: var(--fg);
border-radius: 999px; padding: .22rem .7rem; cursor: pointer; font: inherit; font-size: .82rem;
transition: background-color .15s ease, border-color .15s ease, color .15s ease;
}
.facet button:hover { border-color: var(--primary); }
.facet button[aria-pressed="true"] { background: var(--primary); color: var(--primary-fg); border-color: var(--primary); }
#clearfilters[hidden] { display: none; }
tr.stale td { color: var(--muted); }
.stalebadge {
display: inline-block; font-size: .7rem; padding: 0 .45rem; margin-left: .4rem;
border: 1px solid var(--border); border-radius: 999px; color: var(--muted); background: var(--surface-2);
text-transform: uppercase; letter-spacing: .03em; vertical-align: middle;
}
td.empty { text-align: center; color: var(--muted); padding: 1.5rem; }
/* Chart card */
h2 { font-size: 1.1rem; font-weight: 600; margin: 0; letter-spacing: -.01em; }
section.chartcard {
margin-top: 1.5rem; background: var(--surface); border: 1px solid var(--border);
border-radius: 12px; box-shadow: var(--shadow); padding: 1rem;
}
.charthead { display: flex; align-items: center; justify-content: space-between; gap: .75rem; margin-bottom: .75rem; flex-wrap: wrap; }
.chartwrap { position: relative; height: 460px; }
.chartmsg { color: var(--muted); text-align: center; padding-top: 4rem; }
/* Hover readout. Chart.js paints its tooltip on the canvas, which cannot
bold one row, so the ranking is HTML and the built-in tooltip stays off. */
#hovercard {
position: absolute; left: 0; top: 0; z-index: 2; pointer-events: none; opacity: 0;
background: var(--surface); border: 1px solid var(--border); border-radius: 8px;
box-shadow: 0 4px 16px rgb(0 0 0 / 18%); padding: .5rem .6rem; max-width: 100%;
transition: opacity .12s ease;
}
#hovercard .hdate {
font-size: .7rem; text-transform: uppercase; letter-spacing: .06em;
color: var(--muted); font-weight: 600; margin-bottom: .35rem;
}
#hovercard .cols { display: flex; gap: .85rem; align-items: flex-start; }
#hovercard ul { list-style: none; margin: 0; padding: 0; }
/* Grid, not flex: every row in a column shares the template, so names
truncate at one width and the star counts line up. */
#hovercard li {
display: grid; grid-template-columns: .5rem 1.15rem minmax(0, 9.5rem) auto;
align-items: center; gap: .35rem; font-size: .72rem; line-height: 1.6;
color: var(--muted);
}
#hovercard li.on { color: var(--fg); font-weight: 700; }
#hovercard .sw { width: .5rem; height: .5rem; border-radius: 2px; opacity: .75; }
#hovercard li.on .sw { opacity: 1; }
#hovercard .rk { text-align: right; font-variant-numeric: tabular-nums; opacity: .75; }
#hovercard .nm { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
#hovercard .val { text-align: right; font-variant-numeric: tabular-nums; padding-left: .5rem; }
/* Touch devices have no hover, so the card would only ever be in the way. */
@media (hover: none) { #hovercard { display: none; } }
.hint { color: var(--muted); font-size: .85rem; margin: .75rem 0 0; }
#err { color: var(--neg); }
footer { color: var(--muted); font-size: .85rem; margin: 1.5rem 0 .5rem; display: flex; gap: .5rem; flex-wrap: wrap; }
footer a { color: var(--link); }
noscript p { border: 1px solid var(--border); border-radius: 12px; padding: 1rem; background: var(--surface); }
noscript a { color: var(--link); }
@media (pointer: coarse) {
.chips button, .btn, #q, .ghlink { min-height: 44px; }
thead th { padding-block: .85rem; }
}
@media (max-width: 480px) { body { padding: 1rem; } .stats { grid-template-columns: 1fr 1fr; } }
@media (prefers-reduced-motion: reduce) {
#hovercard { transition: none; }
*, *::before, *::after { transition: none !important; animation: none !important; }
}
</style>
</head>
<body>
<header class="hero">
<div>
<h1>
<svg width="22" height="22" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M12 2l2.9 6.26L21.5 9.1l-4.9 4.5 1.3 6.7L12 17l-5.9 3.3 1.3-6.7-4.9-4.5 6.6-.84L12 2z"/></svg>
Awesome AI Dev Tools
</h1>
<div class="sub">Curated ranking of open-source AI coding agents by GitHub stars, refreshed daily. <span id="meta"></span></div>
</div>
<a class="ghlink" href="https://github.com/tiennm99/awesome-ai-dev-tools">
<svg width="18" height="18" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true"><path d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27s1.36.09 2 .27c1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.01 8.01 0 0 0 16 8c0-4.42-3.58-8-8-8z"/></svg>
GitHub
</a>
</header>
<div id="err" role="alert"></div>
<noscript>
<p>This dashboard needs JavaScript to render the ranking table and charts.
You can view the same ranking in the
<a href="https://github.com/tiennm99/awesome-ai-dev-tools#readme">project README on GitHub</a>.</p>
</noscript>
<div class="stats" id="stats" hidden>
<div class="stat"><small>Tracked repos</small><b id="statTracked">—</b></div>
<div class="stat"><small>Total stars</small><b id="statStars">—</b></div>
<div class="stat"><small>Top 7-day gainer</small><b id="statGainer">—</b><span class="statsub pos" id="statGainerDelta"></span></div>
<div class="stat"><small>Last updated</small><b id="statUpdated">—</b><span class="statsub">UTC, daily via Actions</span></div>
</div>
<div class="toolbar">
<label class="vh" for="q">Search repositories by name, description, or tag</label>
<div class="searchbox">
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" aria-hidden="true"><circle cx="11" cy="11" r="7"/><path d="m21 21-4.3-4.3"/></svg>
<input id="q" type="search" placeholder="Search repos…" autocomplete="off">
</div>
<button type="button" class="btn" id="clearfilters" hidden>Clear filters</button>
</div>
<div class="facets" id="facets"></div>
<div class="tablewrap"><table id="tbl">
<thead><tr>
<th scope="col" data-k="rank" class="num" tabindex="0">#</th>
<th scope="col" data-k="nameWithOwner" tabindex="0">Repo</th>
<th scope="col" data-k="stars" class="num" tabindex="0">Stars</th>
<th scope="col" data-k="delta7d" class="num" tabindex="0">Δ7d</th>
<th scope="col" data-k="delta30d" class="num" tabindex="0">Δ30d</th>
<th scope="col">Tags</th>
<th scope="col" data-k="language" tabindex="0">Language</th>
<th scope="col" data-k="pushedAt" tabindex="0">Last push</th>
<th scope="col">Description</th>
</tr></thead>
<tbody></tbody>
</table></div>
<section class="chartcard">
<div class="charthead">
<h2>Star history</h2>
<button type="button" id="scaleToggle" class="btn" aria-pressed="false">Log scale</button>
</div>
<div class="chartwrap"><canvas id="chart" role="img"
aria-label="Line chart of GitHub star history over time for the repositories listed in the table above."></canvas><div
id="hovercard" aria-hidden="true"></div></div>
<p class="hint">Every tracked repo is plotted — hover a date for the full ranking at that point, click legend entries to hide series. The tag filters and search above also filter the chart.</p>
</section>
<footer>
<span>Data refreshed daily by <a href="https://github.com/tiennm99/awesome-ai-dev-tools/actions">GitHub Actions</a>.</span>
<span>Add an agent via <a href="https://github.com/tiennm99/awesome-ai-dev-tools#contributing">a pull request</a>.</span>
</footer>
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.9/dist/chart.umd.min.js"></script>
<script>
(async function () {
let data;
try {
const res = await fetch('data.json', { cache: 'no-cache' });
if (!res.ok) throw new Error('HTTP ' + res.status);
data = await res.json();
} catch (e) {
document.getElementById('err').textContent = 'Failed to load data.json: ' + e.message;
return;
}
const rows = data.rows.map((r, i) => ({ ...r, rank: i + 1 }));
const esc = s => String(s ?? '').replace(/[&<>"]/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c]));
const fmtStars = n => n >= 1e6 ? (n / 1e6).toFixed(1) + 'M' : n >= 1e3 ? (n / 1e3).toFixed(1) + 'k' : String(n);
// KPI stat cards
document.getElementById('stats').hidden = false;
document.getElementById('statTracked').textContent = rows.length;
document.getElementById('statStars').textContent = fmtStars(rows.reduce((s, r) => s + r.stars, 0));
const gainer = rows.filter(r => r.hasDelta).sort((a, b) => b.delta7d - a.delta7d)[0];
if (gainer) {
document.getElementById('statGainer').textContent = gainer.nameWithOwner.split('/')[1];
document.getElementById('statGainerDelta').textContent = '+' + gainer.delta7d.toLocaleString() + ' stars this week';
}
document.getElementById('statUpdated').textContent = data.updatedAt.replace(' UTC', '');
const staleCutoff = new Date();
staleCutoff.setMonth(staleCutoff.getMonth() - 6);
const isStale = r => r.pushedAt && new Date(r.pushedAt) < staleCutoff;
// --- facet filter chips (multi-select) ---
// Vocabulary comes from the generated data.json, which the Go build step fills from
// tagVocabulary — no second copy of the tag list lives in this file.
const facets = Array.isArray(data.facets) ? data.facets : [];
// Only offer a tag some row actually carries, so the rows never disagree
// with the chips after an agents.yml edit.
const inUse = new Set(rows.flatMap(r => r.tags || []));
const active = new Set();
const facetBox = document.getElementById('facets');
const clearBtn = document.getElementById('clearfilters');
const syncChips = () => {
facetBox.querySelectorAll('button[data-tag]').forEach(b => {
b.setAttribute('aria-pressed', String(active.has(b.dataset.tag)));
});
clearBtn.hidden = active.size === 0;
};
for (const f of facets) {
const usable = (f.tags || []).filter(t => inUse.has(t));
if (!usable.length) continue;
const row = document.createElement('div');
row.className = 'facet';
row.setAttribute('role', 'group');
row.setAttribute('aria-label', 'Filter by ' + f.label);
const label = document.createElement('span');
label.className = 'flabel';
label.textContent = f.label;
row.appendChild(label);
for (const tag of usable) {
const b = document.createElement('button');
b.type = 'button';
b.dataset.tag = tag;
b.dataset.facet = f.id;
b.textContent = tag;
b.setAttribute('aria-pressed', 'false');
b.onclick = () => {
active.has(tag) ? active.delete(tag) : active.add(tag);
syncChips(); renderTable(); renderChart();
};
row.appendChild(b);
}
facetBox.appendChild(row);
}
clearBtn.onclick = () => { active.clear(); syncChips(); renderTable(); renderChart(); };
// Selected tags grouped by facet: a row must match at least one tag in every
// facet that has a selection (OR inside a facet, AND across facets).
const selectionsByFacet = () => {
const groups = new Map();
for (const f of facets) {
const picked = (f.tags || []).filter(t => active.has(t));
if (picked.length) groups.set(f.id, picked);
}
return groups;
};
const matchesTags = r => {
const groups = selectionsByFacet();
if (!groups.size) return true;
const own = new Set(r.tags || []);
for (const picked of groups.values()) {
if (!picked.some(t => own.has(t))) return false;
}
return true;
};
// --- text search (filters table + chart) ---
let query = '';
const qInput = document.getElementById('q');
let qTimer;
qInput.addEventListener('input', () => {
clearTimeout(qTimer);
qTimer = setTimeout(() => {
query = qInput.value.trim().toLowerCase();
renderTable(); renderChart();
}, 150);
});
const visible = () => rows.filter(r =>
matchesTags(r) &&
(!query ||
r.nameWithOwner.toLowerCase().includes(query) ||
String(r.description || '').toLowerCase().includes(query) ||
(r.tags || []).some(t => t.includes(query))));
// --- sortable table ---
let sortKey = 'rank', sortAsc = true;
document.querySelectorAll('thead th[data-k]').forEach(th => {
const activate = () => {
const k = th.dataset.k;
if (sortKey === k) sortAsc = !sortAsc;
else { sortKey = k; sortAsc = (k === 'rank' || k === 'nameWithOwner' || k === 'language'); }
renderTable();
};
th.onclick = activate;
th.addEventListener('keydown', e => {
if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); activate(); }
});
});
// tag → facet class; strict allowlist so third-party data can't inject class names
const facetOf = new Map(facets.flatMap(f => (f.tags || []).map(t => [t, f.id])));
const tagClass = t => /^[a-z][a-z0-9-]*$/.test(facetOf.get(t) || '') ? ' tag-' + facetOf.get(t) : '';
function renderTable() {
const list = [...visible()].sort((a, b) => {
if (sortKey === 'delta7d' || sortKey === 'delta30d') {
// repos without a baseline for this window always sort last, either direction
const hasKey = sortKey === 'delta7d' ? 'hasDelta' : 'hasDelta30';
if (a[hasKey] !== b[hasKey]) return a[hasKey] ? -1 : 1;
if (!a[hasKey]) return 0;
return sortAsc ? a[sortKey] - b[sortKey] : b[sortKey] - a[sortKey];
}
const va = a[sortKey], vb = b[sortKey];
const cmp = typeof va === 'number' ? va - vb : String(va).localeCompare(String(vb));
return sortAsc ? cmp : -cmp;
});
document.querySelectorAll('thead th[data-k]').forEach(th => {
th.querySelector('.dir')?.remove();
if (th.dataset.k === sortKey) {
th.setAttribute('aria-sort', sortAsc ? 'ascending' : 'descending');
th.insertAdjacentHTML('beforeend', ' <span class="dir" aria-hidden="true">' + (sortAsc ? '▲' : '▼') + '</span>');
} else {
th.removeAttribute('aria-sort');
}
});
const tbody = document.querySelector('#tbl tbody');
if (!list.length) {
tbody.innerHTML = '<tr><td class="empty" colspan="9">No repos match your filters.</td></tr>';
return;
}
tbody.innerHTML = list.map(r => `
<tr${(isStale(r) || r.archived) ? ' class="stale"' : ''}>
<td class="num rankcell">${r.rank}</td>
<td><a class="repo" href="${esc(r.url)}">${esc(r.nameWithOwner)}</a></td>
<td class="num">${fmtStars(r.stars)}</td>
<td class="num ${r.hasDelta && r.delta7d > 0 ? 'pos' : r.hasDelta && r.delta7d < 0 ? 'neg' : ''}">${r.hasDelta ? (r.delta7d > 0 ? '+' : '') + r.delta7d : '—'}</td>
<td class="num ${r.hasDelta30 && r.delta30d > 0 ? 'pos' : r.hasDelta30 && r.delta30d < 0 ? 'neg' : ''}">${r.hasDelta30 ? (r.delta30d > 0 ? '+' : '') + r.delta30d : '—'}</td>
<td class="tags">${(r.tags || []).length
? '<div class="taglist">' + r.tags.map(t => `<span class="tag${tagClass(t)}">${esc(t)}</span>`).join('') + '</div>'
: '—'}</td>
<td class="lang">${esc(r.language || '')}</td>
<td>${esc(r.pushedAt)}${isStale(r) ? ' <span class="stalebadge">stale</span>' : ''}${r.archived ? ' <span class="stalebadge">archived</span>' : ''}</td>
<td class="desc">${esc(r.description)}${r.notes ? ' · <em>' + esc(r.notes) + '</em>' : ''}</td>
</tr>`).join('');
}
renderTable();
// --- star-history line chart ---
const chartWrap = document.querySelector('.chartwrap');
if (!window.Chart) {
chartWrap.innerHTML = '<p class="chartmsg">Chart could not be loaded (the Chart.js CDN is unreachable or blocked). The ranking table above still works.</p>';
document.getElementById('scaleToggle').disabled = true;
return;
}
const dates = data.history.map(s => s.date);
const isDark = matchMedia('(prefers-color-scheme: dark)').matches;
// darker lines on light bg / lighter on dark bg to keep >=3:1 against background
const color = i => 'hsl(' + Math.round(i * 137.508) % 360 + ' 70% ' + (isDark ? 62 : 38) + '%)';
const styles = getComputedStyle(document.documentElement);
Chart.defaults.color = styles.getPropertyValue('--muted').trim();
Chart.defaults.borderColor = styles.getPropertyValue('--border').trim();
Chart.defaults.font.family = '"Fira Sans", sans-serif';
let chart;
let yType = 'linear';
const hiddenState = {}; // repo key -> hidden bool, survives chart re-renders
// --- hover readout ---
// Hover is handled from the canvas rather than through Chart.js
// interaction modes: an index-mode tooltip only recomputes when the x index
// changes, so moving straight up and down inside one date never re-picked
// the series under the cursor. Tracking the pointer here makes both the
// emphasis and the bolded row follow vertical movement.
const canvas = document.getElementById('chart');
const hoverCard = document.getElementById('hovercard');
let hoverAt = -1; // x index under the cursor, -1 when the cursor is away
let hoverIdx = -1; // datasetIndex nearest the cursor, -1 when none
// Vertical guide at the hovered date plus a dot where the emphasized series
// crosses it — the anchor Chart.js's own caret would have drawn.
const crosshair = {
id: 'crosshair',
afterDatasetsDraw(c) {
if (hoverAt < 0) return;
const x = c.scales.x.getPixelForValue(hoverAt);
if (!Number.isFinite(x)) return;
const { ctx, chartArea } = c;
ctx.save();
ctx.lineWidth = 1;
ctx.setLineDash([3, 3]);
ctx.strokeStyle = Chart.defaults.borderColor;
ctx.beginPath();
ctx.moveTo(x, chartArea.top);
ctx.lineTo(x, chartArea.bottom);
ctx.stroke();
const ds = hoverIdx >= 0 ? c.data.datasets[hoverIdx] : null;
const v = ds ? ds.data[hoverAt] : null;
if (v != null) {
ctx.setLineDash([]);
ctx.fillStyle = ds.borderColor;
ctx.beginPath();
ctx.arc(x, c.scales.y.getPixelForValue(v), 4, 0, Math.PI * 2);
ctx.fill();
}
ctx.restore();
},
};
// The series under the cursor stays solid and thick; the rest go thin and
// dashed, so one line reads clearly out of forty overlapping ones.
function paintEmphasis() {
chart.data.datasets.forEach((ds, i) => {
const on = i === hoverIdx;
ds.borderWidth = hoverIdx === -1 ? 2 : on ? 3.5 : 1;
ds.borderDash = hoverIdx === -1 || on ? [] : [4, 3];
});
chart.update('none'); // dash and width are resolved at update time, not draw
}
function hideHover() {
hoverCard.style.opacity = '0';
const wasOn = hoverAt !== -1 || hoverIdx !== -1;
hoverAt = -1;
hoverIdx = -1;
if (wasOn && chart) paintEmphasis();
}
// Rank every plotted series at the hovered date, highest first, and bold the
// one under the cursor. Columns keep forty rows inside the chart card.
function renderHoverCard(items) {
const cols = items.length > 28 ? 3 : items.length > 14 ? 2 : 1;
const perCol = Math.ceil(items.length / cols);
let html = '<div class="hdate">' + esc(dates[hoverAt]) + '</div><div class="cols">';
for (let c = 0; c < cols; c++) {
html += '<ul>';
items.slice(c * perCol, (c + 1) * perCol).forEach((it, j) => {
// colors come from color() in this file, never from data.json
html += '<li class="' + (it.i === hoverIdx ? 'on' : '') + '">'
+ '<span class="sw" style="background:' + it.color + '"></span>'
+ '<span class="rk">' + (c * perCol + j + 1) + '</span>'
+ '<span class="nm">' + esc(it.label) + '</span>'
+ '<span class="val">' + it.v.toLocaleString('en-US') + '</span></li>';
});
html += '</ul>';
}
hoverCard.innerHTML = html + '</div>';
}
// Beside the guide line, flipped or clamped to stay inside the chart card.
function placeHoverCard(anchorX, cursorY) {
hoverCard.style.opacity = '1';
const wrap = chartWrap.getBoundingClientRect();
const card = hoverCard.getBoundingClientRect();
let left = anchorX + 18;
if (left + card.width > wrap.width) left = anchorX - card.width - 18;
hoverCard.style.left = Math.max(0, Math.min(left, Math.max(0, wrap.width - card.width))) + 'px';
hoverCard.style.top = Math.max(0, Math.min(cursorY - card.height / 2, Math.max(0, wrap.height - card.height))) + 'px';
}
function trackHover(x, y) {
if (!chart) return;
const { chartArea: area, scales } = chart;
if (x < area.left || x > area.right || y < area.top || y > area.bottom) { hideHover(); return; }
// Nearest date by pixel distance, using the same scale call the crosshair
// draws with, so the guide line always lands on the date being reported.
let idx = 0;
let idxDist = Infinity;
for (let i = 0; i < dates.length; i++) {
const dist = Math.abs(scales.x.getPixelForValue(i) - x);
if (dist < idxDist) { idxDist = dist; idx = i; }
}
// One pass: collect the plotted values at this date and, from their pixel
// positions, the series closest to the cursor's height.
const items = [];
let nearest = -1;
let nearestDist = Infinity;
chart.data.datasets.forEach((ds, i) => {
if (!chart.isDatasetVisible(i)) return;
const v = ds.data[idx];
if (v == null) return;
items.push({ i, v, label: ds.label, color: ds.borderColor });
const dist = Math.abs(scales.y.getPixelForValue(v) - y);
if (dist < nearestDist) { nearestDist = dist; nearest = i; }
});
if (!items.length) { hideHover(); return; }
items.sort((a, b) => b.v - a.v);
const movedDate = idx !== hoverAt;
const movedSeries = nearest !== hoverIdx;
hoverAt = idx;
hoverIdx = nearest;
if (movedDate || movedSeries) renderHoverCard(items);
placeHoverCard(scales.x.getPixelForValue(idx), y);
if (movedSeries) paintEmphasis(); // re-resolves dash and width, then redraws
else if (movedDate) chart.render(); // only the guide line moved
}
// One read per frame: mousemove fires far faster than the chart can redraw.
let framePending = false;
let cursor = null;
canvas.addEventListener('mousemove', e => {
const rect = canvas.getBoundingClientRect();
cursor = { x: e.clientX - rect.left, y: e.clientY - rect.top };
if (framePending) return;
framePending = true;
requestAnimationFrame(() => {
framePending = false;
if (cursor) trackHover(cursor.x, cursor.y);
});
});
canvas.addEventListener('mouseleave', () => { cursor = null; hideHover(); });
function renderChart() {
// Degraded mode: filter/search handlers still call this after the early
// CDN-failure return; without the guard they hit TDZ consts and throw.
if (!window.Chart) return;
hoverAt = -1;
hoverIdx = -1;
hoverCard.style.opacity = '0';
const list = visible();
const datasets = list.map((r, i) => {
const pts = data.history.map(s => s.stars[r.key] ?? null);
const nPoints = pts.filter(v => v != null).length;
return {
label: r.nameWithOwner,
repoKey: r.key,
data: pts,
borderColor: color(i),
backgroundColor: color(i),
pointRadius: nPoints < 2 ? 3 : 0, // single snapshots need a visible dot
pointHoverRadius: 0, // the crosshair plugin draws the dot instead
borderWidth: 2,
spanGaps: true,
hidden: hiddenState[r.key] ?? false, // every series plotted until the legend hides it
};
});
if (chart) chart.destroy();
chart = new Chart(canvas, {
type: 'line',
plugins: [crosshair],
data: { labels: dates, datasets },
options: {
responsive: true, maintainAspectRatio: false, animation: false,
scales: {
y: { type: yType, ticks: { callback: v => fmtStars(v) } },
x: { ticks: { maxTicksLimit: 12 } },
},
plugins: {
legend: {
position: 'bottom',
labels: { boxWidth: 12, boxHeight: 12 },
onClick(e, item, legend) {
Chart.defaults.plugins.legend.onClick.call(this, e, item, legend);
const ds = legend.chart.data.datasets[item.datasetIndex];
hiddenState[ds.repoKey] = !legend.chart.isDatasetVisible(item.datasetIndex);
},
},
tooltip: { enabled: false }, // replaced by the HTML readout above
},
},
});
document.getElementById('chart').setAttribute('aria-label',
'Line chart of GitHub star history over time for ' + list.length + ' repositories. The same data is in the table above.');
}
const scaleBtn = document.getElementById('scaleToggle');
scaleBtn.onclick = () => {
yType = yType === 'linear' ? 'logarithmic' : 'linear';
scaleBtn.setAttribute('aria-pressed', String(yType === 'logarithmic'));
if (chart) { chart.options.scales.y.type = yType; chart.update(); }
};
renderChart();
})();
</script>
</body>
</html>