summaryrefslogtreecommitdiff
path: root/cgithub
diff options
context:
space:
mode:
authorJoe Mou <dev@mou.fo>2026-08-11 08:09:47 -0400
committerJoe Mou <dev@mou.fo>2026-08-11 08:13:58 -0400
commit0be1fdd2635fbc49d7451d3711b5772e1e9990e2 (patch)
treed317fae68cce476f57eb50eb33e2c6d21be8a765 /cgithub
parent6240cf1c6b73b7ed34ff5816b53333382c229f5c (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>
Diffstat (limited to 'cgithub')
-rw-r--r--cgithub/public/static/refs.js21
-rw-r--r--cgithub/public/static/style.css37
-rw-r--r--cgithub/src/app.ts6
-rw-r--r--cgithub/src/scraper.test.ts28
-rw-r--r--cgithub/src/scraper.ts39
-rw-r--r--cgithub/views/_languages.eta17
-rw-r--r--cgithub/views/_tree.eta2
-rw-r--r--cgithub/views/repo.eta2
8 files changed, 139 insertions, 13 deletions
diff --git a/cgithub/public/static/refs.js b/cgithub/public/static/refs.js
index 28b8373..ffff505 100644
--- a/cgithub/public/static/refs.js
+++ b/cgithub/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/cgithub/public/static/style.css b/cgithub/public/static/style.css
index 983cb86..ed64bc6 100644
--- a/cgithub/public/static/style.css
+++ b/cgithub/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;
diff --git a/cgithub/src/app.ts b/cgithub/src/app.ts
index ca79b1f..e34c7b1 100644
--- a/cgithub/src/app.ts
+++ b/cgithub/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/cgithub/src/scraper.test.ts b/cgithub/src/scraper.test.ts
index 56f14b0..5b5e78b 100644
--- a/cgithub/src/scraper.test.ts
+++ b/cgithub/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/cgithub/src/scraper.ts b/cgithub/src/scraper.ts
index 39ad2cd..223839f 100644
--- a/cgithub/src/scraper.ts
+++ b/cgithub/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/cgithub/views/_languages.eta b/cgithub/views/_languages.eta
new file mode 100644
index 0000000..8aca447
--- /dev/null
+++ b/cgithub/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/cgithub/views/_tree.eta b/cgithub/views/_tree.eta
index a0ecfab..2eb445f 100644
--- a/cgithub/views/_tree.eta
+++ b/cgithub/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/cgithub/views/repo.eta b/cgithub/views/repo.eta
index 683a9ad..9122594 100644
--- a/cgithub/views/repo.eta
+++ b/cgithub/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) %>