From b43ce92c7b16bdefedb7c4c2aef41f9417f1ddf6 Mon Sep 17 00:00:00 2001 From: Joe Mou Date: Tue, 11 Aug 2026 08:09:47 -0400 Subject: 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 --- src/app.ts | 6 ++++++ src/scraper.test.ts | 28 ++++++++++++++++++++++++++++ src/scraper.ts | 39 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 73 insertions(+) (limited to 'src') diff --git a/src/app.ts b/src/app.ts index ca79b1f..e34c7b1 100644 --- a/src/app.ts +++ b/src/app.ts @@ -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 { + const payload = await fetchGitHubJson(`${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; -- cgit v1.3.1