From 9b556ffe6c0c0528697e15f7f4f35107de212ce1 Mon Sep 17 00:00:00 2001 From: Joe Mou Date: Wed, 15 Jul 2026 02:08:17 -0400 Subject: Add Cloudflare Workers deployment target Split routes into a shared createApp() (src/app.ts) used by both the Node entry (src/index.ts) and a new Workers entry (src/worker.ts). Workers disallow runtime code generation, so scripts/compile-views.ts precompiles the Eta templates into src/views.generated.ts and rendering switches from renderAsync to the sync render. Static files move to public/static so Workers Static Assets can serve them; the Node build and serveStatic root are updated to match. Deploy with pnpm run deploy:cf, local dev with pnpm run dev:cf. Co-Authored-By: Claude Fable 5 --- cgithub/src/app.ts | 132 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 132 insertions(+) create mode 100644 cgithub/src/app.ts (limited to 'cgithub/src/app.ts') diff --git a/cgithub/src/app.ts b/cgithub/src/app.ts new file mode 100644 index 0000000..78b59ae --- /dev/null +++ b/cgithub/src/app.ts @@ -0,0 +1,132 @@ +import type { Eta } from "eta"; +import { type Context, Hono } from "hono"; +import type { StatusCode } from "hono/utils/http-status"; +import { + getGitHubBlob, + getGitHubCommits, + getGitHubIssues, + getGitHubOwner, + getGitHubPulls, + getGitHubRelease, + getGitHubReleases, + getGitHubRepo, + getGitHubTree, + GitHubHTTPError, + RedirectError, +} from "./scraper.ts"; + +export function createApp(eta: Eta) { + const app = new Hono(); + + app.get("/", async (c) => { + return c.html(eta.render("home.eta", {})); + }); + + async function tryRender(c: Context, template: string, promise: Promise) { + let data: T; + try { + data = await promise; + } catch (e) { + if (e instanceof RedirectError) { + if (e.location.startsWith("https://")) { + c.header("Referrer-Policy", "no-referrer"); + } + return c.redirect(e.location); + } else if (e instanceof GitHubHTTPError) { + c.status(e.status as StatusCode); + const message = `GitHub responded with HTTP ${e.status} ${e.message}`; + return c.html(eta.render("error.eta", { title: e.message, message })); + } else { + c.status(500); + return c.html(eta.render("error.eta", { message: "" + e })); + } + } + return c.html(eta.render(template, data)); + } + + app.get("/:owner", async (c) => { + const { owner } = c.req.param(); + return tryRender(c, "owner.eta", getGitHubOwner(owner)); + }); + + app.get("/:owner/:repo", async (c) => { + const { owner, repo } = c.req.param(); + return tryRender(c, "repo.eta", getGitHubRepo(owner, repo)); + }); + + app.get("/:owner/:repo/tree/:branch/:path{.*}?", async (c) => { + const { owner, repo, branch, path = "" } = c.req.param(); + return tryRender(c, "tree.eta", getGitHubTree(owner, repo, branch, path)); + }); + + app.get("/:owner/:repo/blob/:branch/:path{.*}", async (c) => { + const { owner, repo, branch, path } = c.req.param(); + return tryRender(c, "blob.eta", getGitHubBlob(owner, repo, branch, path)); + }); + + app.get("/:owner/:repo/raw/:branch/:path{.*}", async (c) => { + const { owner, repo, branch, path } = c.req.param(); + c.header("Referrer-Policy", "no-referrer"); + return c.redirect(`https://raw.githubusercontent.com/${owner}/${repo}/${branch}/${path}`); + }); + + app.get("/:owner/:repo/issues", async (c) => { + const { owner, repo } = c.req.param(); + const q = c.req.query("q"); + + // GitHub supports combined issue/PR search, apparently for legacy. We just + // redirect to issue-only search. + if (q && !/\bis:issue\b/.test(q)) { + return c.redirect(`/${owner}/${repo}/issues?q=${encodeURIComponent(`is:issue ${q}`)}`); + } + + return tryRender(c, "issues.eta", getGitHubIssues(owner, repo, { q })); + }); + + app.get("/:owner/:repo/pulls", async (c) => { + const { owner, repo } = c.req.param(); + const q = c.req.query("q"); + return tryRender(c, "pulls.eta", getGitHubPulls(owner, repo, { q })); + }); + + app.get("/:owner/:repo/search", async (c) => { + const { owner, repo } = c.req.param(); + const q = c.req.query("q") ?? ""; + const type = c.req.query("type") ?? "issues"; + + if (type === "issues") { + return c.redirect(`/${owner}/${repo}/issues?q=${encodeURIComponent(q)}`); + } else if (type === "pullrequests") { + return c.redirect(`/${owner}/${repo}/pulls?q=${encodeURIComponent(q)}`); + } + + // Redirect unhandled search types (like code, which requires sign in anyway). + c.header("Referrer-Policy", "no-referrer"); + return c.redirect( + `https://github.com/${owner}/${repo}/search?${new URLSearchParams(c.req.query()).toString()}`, + ); + }); + + app.get("/:owner/:repo/commits/:branch/:path{.*}?", async (c) => { + const { owner, repo, branch, path = "" } = c.req.param(); + return tryRender(c, "commits.eta", getGitHubCommits(owner, repo, branch, path)); + }); + + 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)); + }); + + app.get("/:owner/:repo/releases", async (c) => { + const { owner, repo } = c.req.param(); + return tryRender(c, "releases.eta", getGitHubReleases(owner, repo)); + }); + + app.all("*", async (c) => { + const path = c.req.path; + c.header("Referrer-Policy", "no-referrer"); + return c.redirect(`https://github.com${path}`); + }); + + return app; +} -- cgit v1.3.1 From 6240cf1c6b73b7ed34ff5816b53333382c229f5c Mon Sep 17 00:00:00 2001 From: Joe Mou Date: Tue, 21 Jul 2026 17:46:58 -0400 Subject: Add async branch/tag dropdown and latest-commit bar Adds two lazily-fetched UI pieces to match vanilla GitHub while keeping initial page loads plain server-rendered HTML: a branch/tag selector (native , CSS anchor-positioned to its button, light-dismissed via closedby="any") and a latest-commit bar above the file listing. Both are populated by new /api/:owner/:repo/refs/:type and /api/:owner/:repo/latest-commit/:branch/:path routes; the latter calls GitHub's own /latest-commit JSON endpoint (the same one the React UI uses) rather than scraping the full commit history page. --- cgithub/public/static/refs.js | 60 ++++++++++++++ cgithub/public/static/style.css | 62 ++++++++++++++ cgithub/src/app.ts | 46 +++++++++++ cgithub/src/scraper.test.ts | 41 ++++++++++ cgithub/src/scraper.ts | 177 ++++++++++++++++++++++++++++++++++++++-- cgithub/views/_latestCommit.eta | 16 ++++ cgithub/views/_nav.eta | 11 ++- cgithub/views/_refs.eta | 17 ++++ cgithub/views/_tree.eta | 3 + cgithub/views/layout.eta | 1 + 10 files changed, 426 insertions(+), 8 deletions(-) create mode 100644 cgithub/public/static/refs.js create mode 100644 cgithub/views/_latestCommit.eta create mode 100644 cgithub/views/_refs.eta (limited to 'cgithub/src/app.ts') diff --git a/cgithub/public/static/refs.js b/cgithub/public/static/refs.js new file mode 100644 index 0000000..28b8373 --- /dev/null +++ b/cgithub/public/static/refs.js @@ -0,0 +1,60 @@ +// 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. +document.addEventListener("DOMContentLoaded", () => { + const dialog = document.querySelector(".ref-selector-menu"); + if (dialog) { + const list = dialog.querySelector(".ref-selector-list"); + const { owner, repo } = dialog.dataset; + const cache = {}; + + async function load(type) { + if (Object.prototype.hasOwnProperty.call(cache, type)) { + list.innerHTML = cache[type]; + return; + } + list.textContent = "Loading…"; + try { + const res = await fetch(`/api/${owner}/${repo}/refs/${type}`); + const html = res.ok ? await res.text() : ""; + cache[type] = html || "Failed to load."; + list.innerHTML = cache[type]; + } catch { + list.textContent = "Failed to load."; + } + } + + let opened = false; + dialog.addEventListener("toggle", () => { + if (dialog.open && !opened) { + opened = true; + load("branches"); + } + }); + + for (const tab of dialog.querySelectorAll("[data-ref-type]")) { + tab.addEventListener("click", () => { + for (const other of dialog.querySelectorAll("[data-ref-type]")) { + other.classList.toggle("active", other === tab); + } + load(tab.dataset.refType); + }); + } + } + + const commitPlaceholder = document.querySelector(".latest-commit-placeholder"); + if (commitPlaceholder) { + fetch(commitPlaceholder.dataset.src) + .then((res) => (res.ok ? res.text() : "")) + .then((html) => { + if (html.trim()) { + commitPlaceholder.outerHTML = html; + } else { + commitPlaceholder.remove(); + } + }) + .catch(() => commitPlaceholder.remove()); + } +}); diff --git a/cgithub/public/static/style.css b/cgithub/public/static/style.css index e698993..983cb86 100644 --- a/cgithub/public/static/style.css +++ b/cgithub/public/static/style.css @@ -160,6 +160,68 @@ body > footer { border: 1px solid var(--border-color); } +.ref-selector-button { + anchor-name: --ref-selector-anchor; +} + +.ref-selector-menu { + position: absolute; + position-anchor: --ref-selector-anchor; + position-area: bottom span-right; + width: 16rem; + max-height: 20rem; + overflow-y: auto; + overflow-wrap: break-word; + background: var(--body-background); + border: 1px solid var(--border-color); + padding: 0.5rem; + + &::backdrop { + background: transparent; + } +} + +.ref-selector-tabs { + display: flex; + margin-bottom: 0.5rem; + + button { + flex: 1; + padding: 0.25rem; + cursor: pointer; + &.active { + font-weight: bold; + border-bottom: 2px solid var(--link-color); + } + } +} + +.ref-selector-items { + list-style: none; + margin: 0; + padding: 0; + + li { + padding: 0.2rem 0; + } +} + +.ref-selector-badge { + margin-left: 0.3rem; + font-size: 0.75rem; + opacity: 0.7; +} + +.ref-selector-empty, +.ref-selector-more { + padding: 0.2rem 0; + font-size: 0.85rem; +} + +.latest-commit-placeholder { + opacity: 0.7; +} + .repo-info { .description { margin-bottom: .2rem; diff --git a/cgithub/src/app.ts b/cgithub/src/app.ts index 78b59ae..ca79b1f 100644 --- a/cgithub/src/app.ts +++ b/cgithub/src/app.ts @@ -5,8 +5,10 @@ import { getGitHubBlob, getGitHubCommits, getGitHubIssues, + getGitHubLatestCommit, getGitHubOwner, getGitHubPulls, + getGitHubRefs, getGitHubRelease, getGitHubReleases, getGitHubRepo, @@ -44,6 +46,29 @@ export function createApp(eta: Eta) { return c.html(eta.render(template, data)); } + // For fragments fetched asynchronously by client-side JS (see public/static/refs.js): + // errors render as an empty body rather than the full error.eta page. + async function tryRenderFragment( + c: Context, + template: string, + promise: Promise, + ) { + let data: T; + try { + data = await promise; + } catch (e) { + const status = + e instanceof GitHubHTTPError + ? (e.status as StatusCode) + : e instanceof RedirectError + ? 404 + : 500; + c.status(status); + return c.body(null); + } + return c.html(eta.render(template, data)); + } + app.get("/:owner", async (c) => { const { owner } = c.req.param(); return tryRender(c, "owner.eta", getGitHubOwner(owner)); @@ -112,6 +137,27 @@ export function createApp(eta: Eta) { return tryRender(c, "commits.eta", getGitHubCommits(owner, repo, branch, path)); }); + // Internal endpoints fetched asynchronously by client-side JS, not meant to + // be visited directly. Keep the site's initial page loads plain server- + // rendered HTML while still surfacing data (branches/tags, latest commit) + // that would otherwise require an extra scrape on every page view. + app.get("/api/:owner/:repo/refs/:type", async (c) => { + const { owner, repo, type } = c.req.param(); + if (type !== "branches" && type !== "tags") { + return c.notFound(); + } + return tryRenderFragment(c, "_refs.eta", getGitHubRefs(owner, repo, type)); + }); + + app.get("/api/:owner/:repo/latest-commit/:branch/:path{.*}?", async (c) => { + const { owner, repo, branch, path = "" } = c.req.param(); + return tryRenderFragment( + c, + "_latestCommit.eta", + getGitHubLatestCommit(owner, repo, branch, path), + ); + }); + 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 f9b60e8..56f14b0 100644 --- a/cgithub/src/scraper.test.ts +++ b/cgithub/src/scraper.test.ts @@ -4,7 +4,9 @@ import { getGitHubBlob, getGitHubCommits, getGitHubIssues, + getGitHubLatestCommit, getGitHubPulls, + getGitHubRefs, getGitHubRelease, getGitHubReleases, getGitHubRepo, @@ -205,6 +207,45 @@ describe("GitHub scraper", () => { assert.ok(firstRelease.bodyHtml.length > 0); }); + it("should fetch latest commit for a path", async () => { + const data = await getGitHubLatestCommit("actions", "deploy-pages", "main", "src"); + + assert.strictEqual(data.repo.owner, "actions"); + assert.strictEqual(data.repo.name, "deploy-pages"); + assert.ok(data.commit); + assert.strictEqual(data.commit.oid.length, 40); + assert.ok(data.commit.shortMessageHtml.length > 0); + assert.ok(data.commit.date.length > 0); + assert.ok(data.commit.authors.length > 0); + }); + + it("should fetch branches", async () => { + const data = await getGitHubRefs("actions", "deploy-pages", "branches"); + + assert.strictEqual(data.repo.owner, "actions"); + assert.strictEqual(data.repo.name, "deploy-pages"); + assert.strictEqual(data.type, "branches"); + + assert.ok(data.refs.length > 0); + const main = data.refs.find((ref) => ref.name === "main"); + assert.ok(main); + assert.strictEqual(main.isDefault, true); + }); + + it("should fetch tags", async () => { + const data = await getGitHubRefs("actions", "deploy-pages", "tags"); + + assert.strictEqual(data.repo.owner, "actions"); + assert.strictEqual(data.repo.name, "deploy-pages"); + assert.strictEqual(data.type, "tags"); + + assert.ok(data.refs.length > 0); + const tag = data.refs.find((ref) => ref.name === "v5.0.0"); + assert.ok(tag); + assert.ok(tag.date?.length); + assert.strictEqual(data.hasMore, true); + }); + it("should fetch single release", async () => { const data = await getGitHubRelease("actions", "deploy-pages", "v4.0.5"); diff --git a/cgithub/src/scraper.ts b/cgithub/src/scraper.ts index 53f2bb1..39ad2cd 100644 --- a/cgithub/src/scraper.ts +++ b/cgithub/src/scraper.ts @@ -70,7 +70,7 @@ interface IssueIndexPageQuery { type StylingDirective = [number, number, string]; -interface Commit { +export interface Commit { oid: string; url: string; authoredDate: string; @@ -270,6 +270,34 @@ export interface GitHubCommits extends GitHubNav { commitGroups: CommitGroup[]; } +export interface LatestCommit { + oid: string; + date: string; + shortMessageHtml: string; + authors: { + login: string; + displayName: string; + avatarUrl: string; + }[]; +} + +export interface GitHubLatestCommit extends GitHubCommon { + commit: LatestCommit; +} + +export interface Ref { + name: string; + isDefault?: boolean; + date?: string; + authorLogin?: string; +} + +export interface GitHubRefs extends GitHubCommon { + type: "branches" | "tags"; + refs: Ref[]; + hasMore: boolean; +} + interface Release { tagName: string; title: string; @@ -325,13 +353,13 @@ export interface GitHubOwner { repos: OwnerRepo[]; } -async function fetchGitHubPage(path: string): Promise { +async function fetchGitHubResponse(path: string, accept: string): Promise { // GitHub throttles/blocks requests without realistic browser headers. // These headers make the request appear as a standard browser visit. const headers = { "User-Agent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", - Accept: "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8", + Accept: accept, "Accept-Language": "en-US,en;q=0.5", }; const response = await fetch(`https://github.com/${path}`, { headers, redirect: "manual" }); @@ -345,10 +373,25 @@ async function fetchGitHubPage(path: string): Promise { } } if (!response.ok) throw new GitHubHTTPError(response.status, response.statusText); + return response; +} + +async function fetchGitHubPage(path: string): Promise { + const response = await fetchGitHubResponse( + path, + "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8", + ); return response.text(); } -function parsePayload(html: string): AppPayload | null { +// Some routes (e.g. latest-commit) are the same endpoints the React UI itself +// calls to lazily fill in data; they 406 without an explicit JSON Accept. +async function fetchGitHubJson(path: string): Promise { + const response = await fetchGitHubResponse(path, "application/json"); + return (await response.json()) as T; +} + +function parseEmbeddedPayload(html: string): T | null { const regex = new RegExp( ``, "g", @@ -367,6 +410,10 @@ function parsePayload(html: string): AppPayload | null { return null; } +function parsePayload(html: string): AppPayload | null { + return parseEmbeddedPayload(html); +} + function parseRepoInfo(html: string): RepoInfo { const document = parseDocument(html); @@ -639,6 +686,121 @@ export async function getGitHubCommits( }; } +interface LatestCommitPayload { + oid: string; + date: string; + shortMessageHtmlLink: string; + authors: { + login: string; + displayName: string; + avatarUrl: string; + }[]; +} + +// This is the same endpoint the React UI itself calls to lazily fill in the +// commit bar above a file listing, rather than the much heavier /commits +// history page: it returns just the single latest commit as JSON. +export async function getGitHubLatestCommit( + owner: string, + repo: string, + branch: string, + path: string, +): Promise { + const urlPath = path + ? `${owner}/${repo}/latest-commit/${branch}/${path}` + : `${owner}/${repo}/latest-commit/${branch}`; + const payload = await fetchGitHubJson(urlPath); + + return { + repo: { owner, name: repo }, + commit: { + oid: payload.oid, + date: payload.date, + shortMessageHtml: payload.shortMessageHtmlLink, + authors: payload.authors, + }, + }; +} + +interface BranchRefEntry { + name: string; + isDefault: boolean; + authoredDate?: string; + author?: { login: string }; +} + +interface BranchesPayload { + branches: { + default?: BranchRefEntry; + yours?: BranchRefEntry[]; + active?: BranchRefEntry[]; + stale?: BranchRefEntry[]; + }; + hasMore?: Record; +} + +async function getGitHubBranchRefs(owner: string, repo: string): Promise { + const html = await fetchGitHubPage(`${owner}/${repo}/branches`); + const payload = parseEmbeddedPayload(html); + + if (payload?.branches === undefined) { + throw new Error("Could not find branches data in embedded JSON"); + } + + const { default: defaultBranch, yours = [], active = [], stale = [] } = payload.branches; + const entries = [...(defaultBranch ? [defaultBranch] : []), ...yours, ...active, ...stale]; + + const seen = new Set(); + const refs: Ref[] = []; + for (const entry of entries) { + if (seen.has(entry.name)) continue; + seen.add(entry.name); + refs.push({ + name: entry.name, + isDefault: entry.isDefault || undefined, + date: entry.authoredDate, + authorLogin: entry.author?.login, + }); + } + + const hasMore = Object.values(payload.hasMore ?? {}).some(Boolean); + + return { repo: { owner, name: repo }, type: "branches", refs, hasMore }; +} + +async function getGitHubTagRefs(owner: string, repo: string): Promise { + const html = await fetchGitHubPage(`${owner}/${repo}/tags`); + const document = parseDocument(html); + + const refs: Ref[] = []; + for (const row of cssSelect.selectAll(".Box-row", document) as unknown as Element[]) { + const nameElem = cssSelect.selectOne("h2 a", row); + if (!nameElem) continue; + const name = textContent(nameElem).trim(); + if (!name) continue; + + // The row has two elements: a release/tag creation + // timestamp, then the tagged commit's authored date. We want the latter. + const timeElems = cssSelect.selectAll("relative-time", row); + const timeElem = timeElems[1] as Element | undefined; + const date = timeElem ? getAttributeValue(timeElem, "datetime") || undefined : undefined; + + refs.push({ name, date }); + } + + const hasMore = cssSelect.selectOne(".pagination a", document) !== null; + + return { repo: { owner, name: repo }, type: "tags", refs, hasMore }; +} + +export async function getGitHubRefs( + owner: string, + repo: string, + type: "branches" | "tags", +): Promise { + return type === "branches" ? getGitHubBranchRefs(owner, repo) : getGitHubTagRefs(owner, repo); +} + function parseRelease(section: Element, title: string): Release { // Extract tag name from link const tagLink = cssSelect.selectOne('a[href*="/tree/"]', section); @@ -867,9 +1029,10 @@ function parseUser(document: AnyNode, login: string): GitHubOwner { } function parseOrganization(document: AnyNode, owner: string): GitHubOwner { - const avatarElem = cssSelect.selectOne(".orghead img[itemprop='image']", document) as - | Element - | null; + const avatarElem = cssSelect.selectOne( + ".orghead img[itemprop='image']", + document, + ) as Element | null; const avatarUrl = avatarElem ? getAttributeValue(avatarElem, "src") || null : null; // The login only appears in the avatar's "@login" alt text. const alt = avatarElem ? getAttributeValue(avatarElem, "alt") : undefined; diff --git a/cgithub/views/_latestCommit.eta b/cgithub/views/_latestCommit.eta new file mode 100644 index 0000000..18d22a1 --- /dev/null +++ b/cgithub/views/_latestCommit.eta @@ -0,0 +1,16 @@ +
+
+
+ <%~ it.commit.shortMessageHtml %> +
+
+ <% for (const author of it.commit.authors) { %> + <%= author.login %> + <% } %> + +
+
+
+ <%= it.commit.oid.substring(0, 7) %> +
+
diff --git a/cgithub/views/_nav.eta b/cgithub/views/_nav.eta index 338a6a6..e3fbdcf 100644 --- a/cgithub/views/_nav.eta +++ b/cgithub/views/_nav.eta @@ -1,5 +1,14 @@
-
branch <%= it.branch %>
+ + +
+ + +
+
Loading…
+
<% if (it.path && it.path !== "/") { %> (top)<% const pathParts = it.path.split('/').filter(p => p); diff --git a/cgithub/views/_refs.eta b/cgithub/views/_refs.eta new file mode 100644 index 0000000..2450c4b --- /dev/null +++ b/cgithub/views/_refs.eta @@ -0,0 +1,17 @@ +<% if (it.refs.length === 0) { %> +
No <%= it.type %> found.
+<% } else { %> +
    + <% for (const ref of it.refs) { %> +
  • + <%= ref.name %> + <% if (ref.isDefault) { %>default<% } %> +
  • + <% } %> +
+<% } %> +<% if (it.hasMore) { %> + +<% } %> diff --git a/cgithub/views/_tree.eta b/cgithub/views/_tree.eta index ed6eee9..a0ecfab 100644 --- a/cgithub/views/_tree.eta +++ b/cgithub/views/_tree.eta @@ -1,5 +1,8 @@ <%~ include("./_nav.eta", {...it, isTree: true}) %> +<% const commitPath = it.path && it.path !== "/" ? it.path : ""; %> +
Loading latest commit…
+ <% for (const item of it.items) { %> diff --git a/cgithub/views/layout.eta b/cgithub/views/layout.eta index dd523d1..a80bd1d 100644 --- a/cgithub/views/layout.eta +++ b/cgithub/views/layout.eta @@ -5,6 +5,7 @@ <%= it.repo.owner %>/<%= it.repo.name %> - <%= it.path || '/' %> +
-- cgit v1.3.1 From 0be1fdd2635fbc49d7451d3711b5772e1e9990e2 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 --- cgithub/public/static/refs.js | 21 ++++++++++----------- cgithub/public/static/style.css | 37 ++++++++++++++++++++++++++++++++++++- cgithub/src/app.ts | 6 ++++++ cgithub/src/scraper.test.ts | 28 ++++++++++++++++++++++++++++ cgithub/src/scraper.ts | 39 +++++++++++++++++++++++++++++++++++++++ cgithub/views/_languages.eta | 17 +++++++++++++++++ cgithub/views/_tree.eta | 2 +- cgithub/views/repo.eta | 2 ++ 8 files changed, 139 insertions(+), 13 deletions(-) create mode 100644 cgithub/views/_languages.eta (limited to 'cgithub/src/app.ts') 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 { + 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; 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) { %> +
+
+ <% for (const lang of it.languages) { %> + " title="<%= lang.name %> <%= lang.percentage %>%"> + <% } %> +
+
    + <% for (const lang of it.languages) { %> +
  • + "> + <%= lang.name %> <%= lang.percentage %>% +
  • + <% } %> +
+
+<% } %> 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 : ""; %> -
Loading latest commit…
+
Loading latest commit…
<% 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 @@ <% } %> +
Loading languages…
+ <%~ include("./_tree", it) %> -- cgit v1.3.1 From 9cef0850f8049a8f1cb11097852738de4cad4a30 Mon Sep 17 00:00:00 2001 From: Joe Mou Date: Tue, 11 Aug 2026 08:17:54 -0400 Subject: Link repo header and add a "View original" link MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The owner/name header in the layout now links to the owner page and the repository root. tryRender passes the request's path and query through as githubUrl — our URLs mirror GitHub's, so the same path is the page we scraped — and the footer links there with rel="noreferrer", matching the no-referrer policy used on redirects to github.com. Co-Authored-By: Claude Opus 5 --- cgithub/src/app.ts | 5 ++++- cgithub/views/layout.eta | 4 ++-- 2 files changed, 6 insertions(+), 3 deletions(-) (limited to 'cgithub/src/app.ts') diff --git a/cgithub/src/app.ts b/cgithub/src/app.ts index e34c7b1..5bed45d 100644 --- a/cgithub/src/app.ts +++ b/cgithub/src/app.ts @@ -44,7 +44,10 @@ export function createApp(eta: Eta) { return c.html(eta.render("error.eta", { message: "" + e })); } } - return c.html(eta.render(template, data)); + // Our URLs mirror GitHub's, so the page we scraped is the same path there. + const { pathname, search } = new URL(c.req.url); + const githubUrl = `https://github.com${pathname}${search}`; + return c.html(eta.render(template, { ...data, githubUrl })); } // For fragments fetched asynchronously by client-side JS (see public/static/refs.js): diff --git a/cgithub/views/layout.eta b/cgithub/views/layout.eta index a80bd1d..f803caf 100644 --- a/cgithub/views/layout.eta +++ b/cgithub/views/layout.eta @@ -9,7 +9,7 @@
-
<%= it.repo.owner %>/<%= it.repo.name %>
+
git clone https://github.com/<%= it.repo.owner %>/<%= it.repo.name %>.git
 git clone git@github.com:<%= it.repo.owner %>/<%= it.repo.name %>.git
<% } %> +
+ +<% if (it.bio || it.links.length > 0 || it.followers || it.company) { %> +
+ <% if (it.bio) { %> +
<%= it.bio %>
+ <% } %> + <% for (const link of it.links) { %> + + <% } %> + <% if (it.followers) { %> +
<%= it.followers %> followers<%= it.following ? ` · ${it.following} following` : "" %>
+ <% } %> + <% if (it.company) { %> +
<%= it.company %>
+ <% } %> +
+<% } %> -
Generated by cgithub
- - +<% if (it.pinned.length > 0) { %> +
+

Pinned

+ <% for (const repo of it.pinned) { %> + <%~ include("./_ownerRepo", { repo, login: it.login }) %> + <% } %> +
+<% } %> + +<% if (it.repos.length > 0) { %> +
+

Repositories

+ <% for (const repo of it.repos) { %> + <%~ include("./_ownerRepo", { repo, login: it.login }) %> + <% } %> +
+<% } %> diff --git a/cgithub/views/pulls.eta b/cgithub/views/pulls.eta index 9ec5ca1..697f6b1 100644 --- a/cgithub/views/pulls.eta +++ b/cgithub/views/pulls.eta @@ -1,4 +1,4 @@ -<% layout("./layout"); it.searchType = "pullrequests" %> +<% layout("/layouts/repo.eta"); it.searchType = "pullrequests" %>
<% for (const pull of it.pulls) { %> diff --git a/cgithub/views/release.eta b/cgithub/views/release.eta index 46dd5db..64cca35 100644 --- a/cgithub/views/release.eta +++ b/cgithub/views/release.eta @@ -1,3 +1,3 @@ -<% layout("./layout.eta") %> +<% layout("/layouts/repo.eta") %> <%~ include("./_release.eta", { repo: it.repo, release: it.release, linkToDetail: false }) %> diff --git a/cgithub/views/releases.eta b/cgithub/views/releases.eta index 840908c..4ede28b 100644 --- a/cgithub/views/releases.eta +++ b/cgithub/views/releases.eta @@ -1,4 +1,4 @@ -<% layout("./layout.eta") %> +<% layout("/layouts/repo.eta") %> <% for (const release of it.releases) { %> <%~ include("./_release.eta", { repo: it.repo, release, linkToDetail: true }) %> diff --git a/cgithub/views/repo.eta b/cgithub/views/repo.eta index 5dc6beb..60746df 100644 --- a/cgithub/views/repo.eta +++ b/cgithub/views/repo.eta @@ -1,4 +1,4 @@ -<% layout("./layout.eta") %> +<% layout("/layouts/repo.eta") %> <% if (it.info) { %>
diff --git a/cgithub/views/tree.eta b/cgithub/views/tree.eta index 41ded06..fabf5b9 100644 --- a/cgithub/views/tree.eta +++ b/cgithub/views/tree.eta @@ -1,3 +1,3 @@ -<% layout("./layout.eta"); it.searchType = "code" %> +<% layout("/layouts/repo.eta"); it.searchType = "code" %> <%~ include("./_tree.eta", it) %> diff --git a/cgithub/views/wiki.eta b/cgithub/views/wiki.eta index d6047e1..781caff 100644 --- a/cgithub/views/wiki.eta +++ b/cgithub/views/wiki.eta @@ -1,4 +1,4 @@ -<% layout("./layout.eta"); it.path = "wiki/" + it.title %> +<% layout("/layouts/repo.eta"); it.path = "wiki/" + it.title %> <%~ include("./_wikiNav.eta", it) %> diff --git a/cgithub/views/wikiPages.eta b/cgithub/views/wikiPages.eta index 58d6d21..eb90781 100644 --- a/cgithub/views/wikiPages.eta +++ b/cgithub/views/wikiPages.eta @@ -1,4 +1,4 @@ -<% layout("./layout.eta"); it.path = "wiki/_pages" %> +<% layout("/layouts/repo.eta"); it.path = "wiki/_pages" %> <%~ include("./_wikiNav.eta", it) %> -- cgit v1.3.1 From a7bc82cad42b7b8c4c8c52e1217f7f005b632e6d Mon Sep 17 00:00:00 2001 From: Joe Mou Date: Wed, 9 Sep 2026 11:03:02 -0400 Subject: Serve GitHub's global search when it names one repository /search?q=repo:owner/name+...&type=code matched the /:owner route and rendered a profile page for an owner named "search", which GitHub 404s. It is the URL GitHub's own search box produces, so it arrives whenever a search is redirected here. A /search route now takes it: when the query carries a repo: qualifier and a type, it strips the qualifier and redirects to that repository's search route, which already maps issues and pull requests to pages we render and hands the rest to GitHub. Searches that name no single repository -- bare queries, org:/user: scopes, a missing type -- keep redirecting to github.com/search as they did before. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01GkGX6puS45xD5KzdV9tHEA --- cgithub/src/app.test.ts | 32 ++++++++++++++++++++++++++++++++ cgithub/src/app.ts | 19 +++++++++++++++++++ 2 files changed, 51 insertions(+) (limited to 'cgithub/src/app.ts') diff --git a/cgithub/src/app.test.ts b/cgithub/src/app.test.ts index 274b2d5..8720a23 100644 --- a/cgithub/src/app.test.ts +++ b/cgithub/src/app.test.ts @@ -358,6 +358,38 @@ describe("wiki", () => { }); }); +describe("global search", () => { + it("should keep the rest of the query around the qualifier", async () => { + const res = await app.request( + "http://cgithub.example/search?q=" + + encodeURIComponent("is:issue repo:actions/deploy-pages label:bug") + + "&type=issues", + ); + + assert.strictEqual( + res.headers.get("location"), + "/actions/deploy-pages/search?q=is%3Aissue+label%3Abug&type=issues", + ); + }); + + it("should redirect searches it cannot scope to a repository", async () => { + for (const url of [ + "http://cgithub.example/search?q=dms&type=code", + // An owner-wide search is not a single repository. + "http://cgithub.example/search?q=" + + encodeURIComponent("org:nix-community dms") + + "&type=code", + // Without a type there is no search to hand over. + "http://cgithub.example/search?q=" + encodeURIComponent("repo:actions/deploy-pages dms"), + ]) { + const res = await app.request(url); + + assert.strictEqual(res.status, 200, url); + assert.match(await res.text(), /url=https:\/\/github.com\/search\?/); + } + }); +}); + describe("redirects to GitHub", () => { // An HTTP redirect would be caught by the extension's declarativeNetRequest // rules and bounced straight back here. diff --git a/cgithub/src/app.ts b/cgithub/src/app.ts index a040aaa..7e5e9e8 100644 --- a/cgithub/src/app.ts +++ b/cgithub/src/app.ts @@ -95,6 +95,25 @@ export function createApp(eta: Eta) { return c.render("home.eta"); }); + // GitHub's global search, which we only serve when it is scoped to a single + // repository: strip the qualifier and let that repository's search route + // decide what to do with the rest. + app.get("/search", async (c) => { + const q = c.req.query("q") ?? ""; + const type = c.req.query("type"); + + const repoQualifier = /(?:^|\s)repo:([^/\s]+)\/([^/\s]+)(?=\s|$)/.exec(q); + if (type && repoQualifier) { + const [qualifier, owner, repo] = repoQualifier; + const rest = + q.slice(0, repoQualifier.index) + q.slice(repoQualifier.index + qualifier.length); + const params = new URLSearchParams({ q: rest.trim(), type }); + return c.redirect(`/${owner}/${repo}/search?${params}`); + } + + return redirectToGitHub(c); + }); + app.get("/:owner", async (c) => { const { owner } = c.req.param(); return c.render("owner.eta", await getGitHubOwner(owner)); -- cgit v1.3.1 From 198f2a07f68a348dc0823b2bde3bec541e06db01 Mon Sep 17 00:00:00 2001 From: Joe Mou Date: Thu, 10 Sep 2026 00:53:48 -0400 Subject: Redirect /notifications instead of scraping it as an owner A signed-out request for github.com/notifications is bounced to a login page, so the owner route followed that redirect and left the visitor on /login. Hand the path over to GitHub before it reaches the route. --- cgithub/README.md | 4 ++-- cgithub/src/app.test.ts | 7 +++++++ cgithub/src/app.ts | 5 +++++ 3 files changed, 14 insertions(+), 2 deletions(-) (limited to 'cgithub/src/app.ts') diff --git a/cgithub/README.md b/cgithub/README.md index 533c88f..45c94cf 100644 --- a/cgithub/README.md +++ b/cgithub/README.md @@ -28,8 +28,8 @@ Not implemented: - Actions, checks, projects, packages, discussions, and gists - Profile tabs other than the overview (repositories, projects, packages, people, sponsoring) -- Anything requiring an account: signing in, starring, commenting, editing, or - any other write action +- Anything requiring an account: signing in, notifications, starring, + commenting, editing, or any other write action - Private repositories ## Browser extension redirects diff --git a/cgithub/src/app.test.ts b/cgithub/src/app.test.ts index 8720a23..c028e1b 100644 --- a/cgithub/src/app.test.ts +++ b/cgithub/src/app.test.ts @@ -441,6 +441,13 @@ describe("redirects to GitHub", () => { ); }); + it("should redirect notifications without scraping them as a profile", async () => { + const res = await app.request("http://cgithub.example/notifications?query=is%3Aunread"); + + assert.strictEqual(res.status, 200); + assert.match(await res.text(), /url=https:\/\/github.com\/notifications\?query=is%3Aunread"/); + }); + it("should redirect raw file requests to raw.githubusercontent.com", async () => { const res = await app.request("http://cgithub.example/actions/deploy-pages/raw/main/README.md"); diff --git a/cgithub/src/app.ts b/cgithub/src/app.ts index 7e5e9e8..0b9a7e6 100644 --- a/cgithub/src/app.ts +++ b/cgithub/src/app.ts @@ -95,6 +95,11 @@ export function createApp(eta: Eta) { return c.render("home.eta"); }); + // Exclusion that immediately redirects to GitHub. + app.get("/notifications", async (c) => { + return redirectToGitHub(c); + }); + // GitHub's global search, which we only serve when it is scoped to a single // repository: strip the qualifier and let that repository's search route // decide what to do with the rest. -- cgit v1.3.1