diff options
| author | Joe Mou <dev@mou.fo> | 2026-08-11 08:09:47 -0400 |
|---|---|---|
| committer | Joe Mou <dev@mou.fo> | 2026-08-11 08:13:58 -0400 |
| commit | b43ce92c7b16bdefedb7c4c2aef41f9417f1ddf6 (patch) | |
| tree | 29ff6b47a16e62a58e17921f2c1200249c1845c0 | |
| parent | ecd01c4617ec0a6af05cfc4388bdc82986f25961 (diff) | |
Add language percentages to repo pages
GitHub no longer server-renders the repository sidebar; its React UI
lazily fetches /:owner/:repo/_sidebar as JSON, which carries the
language breakdown (name, percentage, color). getGitHubLanguages() uses
that endpoint, and a new /api/:owner/:repo/languages fragment route
renders the bar and legend the same way refs and latest-commit already
work, so repo page loads still cost a single scrape.
The latest-commit placeholder loader in refs.js is generalized over
.placeholder[data-src] so both placeholders share it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
| -rw-r--r-- | public/static/refs.js | 21 | ||||
| -rw-r--r-- | public/static/style.css | 37 | ||||
| -rw-r--r-- | src/app.ts | 6 | ||||
| -rw-r--r-- | src/scraper.test.ts | 28 | ||||
| -rw-r--r-- | src/scraper.ts | 39 | ||||
| -rw-r--r-- | views/_languages.eta | 17 | ||||
| -rw-r--r-- | views/_tree.eta | 2 | ||||
| -rw-r--r-- | views/repo.eta | 2 |
8 files changed, 139 insertions, 13 deletions
diff --git a/public/static/refs.js b/public/static/refs.js index 28b8373..ffff505 100644 --- a/public/static/refs.js +++ b/public/static/refs.js @@ -1,8 +1,8 @@ -// Progressive enhancement for two bits of data GitHub itself loads lazily: -// the branch/tag dropdown and the latest-commit bar above a file listing. -// Both degrade gracefully without JS: the button's command/commandfor still -// opens the (empty) dialog natively; the commit placeholder just never fills -// in. +// Progressive enhancement for the bits of data GitHub itself loads lazily: +// the branch/tag dropdown, the latest-commit bar above a file listing, and the +// language breakdown. All degrade gracefully without JS: the button's +// command/commandfor still opens the (empty) dialog natively; the placeholders +// just never fill in. document.addEventListener("DOMContentLoaded", () => { const dialog = document.querySelector(".ref-selector-menu"); if (dialog) { @@ -44,17 +44,16 @@ document.addEventListener("DOMContentLoaded", () => { } } - const commitPlaceholder = document.querySelector(".latest-commit-placeholder"); - if (commitPlaceholder) { - fetch(commitPlaceholder.dataset.src) + for (const placeholder of document.querySelectorAll(".placeholder[data-src]")) { + fetch(placeholder.dataset.src) .then((res) => (res.ok ? res.text() : "")) .then((html) => { if (html.trim()) { - commitPlaceholder.outerHTML = html; + placeholder.outerHTML = html; } else { - commitPlaceholder.remove(); + placeholder.remove(); } }) - .catch(() => commitPlaceholder.remove()); + .catch(() => placeholder.remove()); } }); diff --git a/public/static/style.css b/public/static/style.css index 983cb86..ed64bc6 100644 --- a/public/static/style.css +++ b/public/static/style.css @@ -218,10 +218,45 @@ body > footer { font-size: 0.85rem; } -.latest-commit-placeholder { +.placeholder { opacity: 0.7; } +.languages { + .language-bar { + display: flex; + height: .5rem; + border-radius: .25rem; + overflow: hidden; + + span { + /* Keep slivers of tiny languages visible. */ + min-width: 1px; + } + } + + .language-list { + display: flex; + flex-wrap: wrap; + gap: .2rem 1rem; + list-style: none; + margin: .5rem 0 0; + padding: 0; + font-size: 0.85rem; + } + + .language-dot { + display: inline-block; + width: .6rem; + height: .6rem; + border-radius: 50%; + } + + .language-percentage { + opacity: 0.7; + } +} + .repo-info { .description { margin-bottom: .2rem; @@ -5,6 +5,7 @@ import { getGitHubBlob, getGitHubCommits, getGitHubIssues, + getGitHubLanguages, getGitHubLatestCommit, getGitHubOwner, getGitHubPulls, @@ -158,6 +159,11 @@ export function createApp(eta: Eta) { ); }); + app.get("/api/:owner/:repo/languages", async (c) => { + const { owner, repo } = c.req.param(); + return tryRenderFragment(c, "_languages.eta", getGitHubLanguages(owner, repo)); + }); + app.get("/:owner/:repo/releases/tag/:tag", async (c) => { const { owner, repo, tag } = c.req.param(); return tryRender(c, "release.eta", getGitHubRelease(owner, repo, tag)); diff --git a/src/scraper.test.ts b/src/scraper.test.ts index 56f14b0..5b5e78b 100644 --- a/src/scraper.test.ts +++ b/src/scraper.test.ts @@ -4,6 +4,7 @@ import { getGitHubBlob, getGitHubCommits, getGitHubIssues, + getGitHubLanguages, getGitHubLatestCommit, getGitHubPulls, getGitHubRefs, @@ -219,6 +220,33 @@ describe("GitHub scraper", () => { assert.ok(data.commit.authors.length > 0); }); + it("should fetch languages", async () => { + const data = await getGitHubLanguages("actions", "deploy-pages"); + + assert.strictEqual(data.repo.owner, "actions"); + assert.strictEqual(data.repo.name, "deploy-pages"); + + assert.deepStrictEqual(data.languages, [ + { name: "JavaScript", percentage: 100, color: "#f1e05a" }, + ]); + }); + + it("should fetch languages for a multi-language repository", async () => { + const data = await getGitHubLanguages("rust-lang", "rust"); + + const rust = data.languages.find((language) => language.name === "Rust"); + assert.ok(rust); + assert.ok(rust.percentage > 50); + assert.strictEqual(rust.color, "#dea584"); + + // The aggregate bucket comes back without a name. + const other = data.languages.find((language) => language.name === "Other"); + assert.ok(other); + + const total = data.languages.reduce((sum, language) => sum + language.percentage, 0); + assert.ok(Math.abs(total - 100) < 1); + }); + it("should fetch branches", async () => { const data = await getGitHubRefs("actions", "deploy-pages", "branches"); diff --git a/src/scraper.ts b/src/scraper.ts index 39ad2cd..223839f 100644 --- a/src/scraper.ts +++ b/src/scraper.ts @@ -285,6 +285,17 @@ export interface GitHubLatestCommit extends GitHubCommon { commit: LatestCommit; } +export interface Language { + // "Other" for the aggregate bucket GitHub reports without a name. + name: string; + percentage: number; + color: string | null; +} + +export interface GitHubLanguages extends GitHubCommon { + languages: Language[]; +} + export interface Ref { name: string; isDefault?: boolean; @@ -722,6 +733,34 @@ export async function getGitHubLatestCommit( }; } +interface SidebarPayload { + languages?: { + languages: { + // Absent for the "Other" bucket. + name?: string; + percentage: number; + color?: string; + }[]; + }; +} + +// The repository sidebar (languages, contributors, ...) is not rendered into +// the repo page at all; GitHub's own UI lazily fetches it as JSON from here. +export async function getGitHubLanguages(owner: string, repo: string): Promise<GitHubLanguages> { + const payload = await fetchGitHubJson<SidebarPayload>(`${owner}/${repo}/_sidebar`); + + const languages: Language[] = []; + for (const entry of payload.languages?.languages ?? []) { + languages.push({ + name: entry.name ?? "Other", + percentage: entry.percentage, + color: entry.color ?? null, + }); + } + + return { repo: { owner, name: repo }, languages }; +} + interface BranchRefEntry { name: string; isDefault: boolean; diff --git a/views/_languages.eta b/views/_languages.eta new file mode 100644 index 0000000..8aca447 --- /dev/null +++ b/views/_languages.eta @@ -0,0 +1,17 @@ +<% if (it.languages.length > 0) { %> + <div class="languages box"> + <div class="language-bar"> + <% for (const lang of it.languages) { %> + <span style="width: <%= lang.percentage %>%; background: <%= lang.color ?? "#ededed" %>" title="<%= lang.name %> <%= lang.percentage %>%"></span> + <% } %> + </div> + <ul class="language-list"> + <% for (const lang of it.languages) { %> + <li> + <span class="language-dot" style="background: <%= lang.color ?? "#ededed" %>"></span> + <%= lang.name %> <span class="language-percentage"><%= lang.percentage %>%</span> + </li> + <% } %> + </ul> + </div> +<% } %> diff --git a/views/_tree.eta b/views/_tree.eta index a0ecfab..2eb445f 100644 --- a/views/_tree.eta +++ b/views/_tree.eta @@ -1,7 +1,7 @@ <%~ include("./_nav.eta", {...it, isTree: true}) %> <% const commitPath = it.path && it.path !== "/" ? it.path : ""; %> -<div class="latest-commit-placeholder box" data-src="/api/<%= it.repo.owner %>/<%= it.repo.name %>/latest-commit/<%= it.branch %>/<%= commitPath %>">Loading latest commit…</div> +<div class="placeholder box" data-src="/api/<%= it.repo.owner %>/<%= it.repo.name %>/latest-commit/<%= it.branch %>/<%= commitPath %>">Loading latest commit…</div> <table> <% for (const item of it.items) { %> diff --git a/views/repo.eta b/views/repo.eta index 683a9ad..9122594 100644 --- a/views/repo.eta +++ b/views/repo.eta @@ -29,4 +29,6 @@ </div> <% } %> +<div class="placeholder box" data-src="/api/<%= it.repo.owner %>/<%= it.repo.name %>/languages">Loading languages…</div> + <%~ include("./_tree", it) %> |
