From d0dccc7a910a579d8c172c3d5a5a02ea3abd964d Mon Sep 17 00:00:00 2001 From: Joe Mou Date: Thu, 22 Jan 2026 13:27:36 -0500 Subject: Simple web server for viewing trees --- cgithub/src/scraper.ts | 91 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 91 insertions(+) create mode 100644 cgithub/src/scraper.ts (limited to 'cgithub/src/scraper.ts') diff --git a/cgithub/src/scraper.ts b/cgithub/src/scraper.ts new file mode 100644 index 0000000..add03b2 --- /dev/null +++ b/cgithub/src/scraper.ts @@ -0,0 +1,91 @@ +export interface GitHubTreeItem { + name: string; + path: string; + contentType: "directory" | "file"; +} + +export interface GitHubTree { + path: string; + branch: string; + items: GitHubTreeItem[]; + repo: { + name: string; + owner: string; + isPublic: boolean; + }; +} + +export async function getGitHubTree( + owner: string, + repo: string, + branch: string, + path: string, +): Promise { + const url = `https://github.com/${owner}/${repo}/tree/${branch}/${path}`; + + // GitHub throttles/blocks requests without realistic browser headers. + // These headers make the request appear as a standard browser visit. + const response = await fetch(url, { + 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-Language": "en-US,en;q=0.5", + }, + }); + const html = await response.text(); + + // GitHub uses different formats for root vs subdirectories. + let payload; + + // Try root format first. + const rootFormatMatches = [ + ...html.matchAll( + /`, + "g", + ); + + match: for (const match of html.matchAll(regex)) { + let data; try { - const data = JSON.parse(match[1]); - if (data.props?.initialPayload?.tree) { - payload = data.props.initialPayload; - break; - } - } catch (e) { + data = JSON.parse(match[1]); + } catch { continue; } - } - - // Try subdirectory format. - if (!payload) { - const dirFormatMatch = html.match( - /`, "g", @@ -99,9 +105,7 @@ function parsePayload( function parseRepoInfo(html: string): RepoInfo { const descMatch = html.match(/]*class="[^"]*f4[^"]*"[^>]*>([\s\S]*?)<\/p>/); const description = descMatch?.[1]?.replace(/<[^>]+>/g, "").trim() || null; - const websiteMatch = html.match( - /]*rel="noopener noreferrer nofollow"[^>]*href="([^"]+)"/, - ); + const websiteMatch = html.match(/]*rel="noopener noreferrer nofollow"[^>]*href="([^"]+)"/); const starsMatch = html.match(/id="repo-stars-counter-star"[^>]*>([^<]+)/); const watchersMatch = html.match(/(\d+)<\/strong>\s*watching/); const forksMatch = html.match(/id="repo-network-counter"[^>]*>([^<]+)/); @@ -115,6 +119,16 @@ function parseRepoInfo(html: string): RepoInfo { }; } +function extractOverviewHtml(payload: RawPayload): Record | undefined { + const result: Record = {}; + for (const file of payload.overview?.overviewFiles ?? []) { + if (file.loaded && file.richText !== undefined) { + result[file.displayName] = file.richText; + } + } + return Object.keys(result).length > 0 ? result : undefined; +} + function extractGitHub(payload: RawPayload, extra: T): GitHubCommon & T { return { repo: { @@ -127,24 +141,19 @@ function extractGitHub(payload: RawPayload, extra: T): GitHubCommon & T { }; } -export async function getGitHubRepo( - owner: string, - repo: string, -): Promise { +export async function getGitHubRepo(owner: string, repo: string): Promise { const html = await fetchGitHubPage(`${owner}/${repo}`); - const payload = parsePayload(html, "react-partial.embeddedData", [ - "props", - "initialPayload", - ]); + const payload = parsePayload(html, "react-partial.embeddedData", ["props", "initialPayload"]); if (payload?.tree === undefined) { throw new Error("Could not find tree data in embedded JSON"); } const info = parseRepoInfo(html); + const overviewHtml = extractOverviewHtml(payload); - return extractGitHub(payload, { items: payload.tree.items, info }); + return extractGitHub(payload, { items: payload.tree.items, info, overviewHtml }); } export async function getGitHubTree( @@ -158,10 +167,7 @@ export async function getGitHubTree( // GitHub uses different formats for root vs subdirectories. const payload = // Root directory format. - parsePayload(html, "react-partial.embeddedData", [ - "props", - "initialPayload", - ]) || + parsePayload(html, "react-partial.embeddedData", ["props", "initialPayload"]) || // Subdirectory format. parsePayload(html, "react-app.embeddedData", ["payload"]); @@ -169,7 +175,9 @@ export async function getGitHubTree( throw new Error("Could not find tree data in embedded JSON"); } - return extractGitHub(payload, { items: payload.tree.items }); + const overviewHtml = extractOverviewHtml(payload); + + return extractGitHub(payload, { items: payload.tree.items, overviewHtml }); } export async function getGitHubBlob( diff --git a/cgithub/static/style.css b/cgithub/static/style.css index 1229fce..ff787d6 100644 --- a/cgithub/static/style.css +++ b/cgithub/static/style.css @@ -3,6 +3,7 @@ body { font-family: Helvetica, Arial, sans-serif; --border-color: #d0d7de; + --shade-background: #eeeeee; } @media (prefers-color-scheme: dark) { @@ -23,10 +24,20 @@ a { text-decoration: none; } -header, .box { +body > header, .box { + --box-padding: .5rem; + margin: 1rem 0; border: 1px solid var(--border-color); - padding: .5rem; + padding: var(--box-padding); + + > header:first-child { + margin: calc(-1 * var(--box-padding)); + margin-bottom: 0; + padding: .5rem; + border-bottom: 1px solid var(--border-color); + font-weight: bold; + } } footer { @@ -64,3 +75,31 @@ pre.box { /* Instead of scrolling overflow for long lines, grow the box. */ width: fit-content; } + +.overview { + overflow-wrap: break-word; + &.box { + --box-padding: 1rem; + } + + a { + text-decoration: underline; + } + + h1, h2 { + border-bottom: 1px solid var(--border-color); + } + + img { + max-width: 100%; + } + + code { + background: var(--shade-background); + } + + /* TODO Strip permalinks? */ + svg.octicon-link { + display: none; + } +} diff --git a/cgithub/views/_tree.eta b/cgithub/views/_tree.eta index 2549bb8..f287c03 100644 --- a/cgithub/views/_tree.eta +++ b/cgithub/views/_tree.eta @@ -13,3 +13,12 @@ <% } %> + +<% if (it.overviewHtml) { %> + <% for (const [filename, html] of Object.entries(it.overviewHtml)) { %> +
+
<%= filename %>
+ <%~ html %> +
+ <% } %> +<% } %> diff --git a/cgithub/views/blob.eta b/cgithub/views/blob.eta index 9b478b5..7328a62 100644 --- a/cgithub/views/blob.eta +++ b/cgithub/views/blob.eta @@ -7,6 +7,9 @@ <% if (it.size !== undefined) { %>
size: <%= it.size %> bytes
<% } %> +
+ Raw +
<%= it.rawContent %>
-- cgit v1.3.1 From 744f42a2ae7968881e4d8f1ce0ec0d5209c8d7a4 Mon Sep 17 00:00:00 2001 From: Joe Mou Date: Mon, 26 Jan 2026 13:27:28 -0500 Subject: Show releases --- cgithub/src/scraper.ts | 10 ++++++++++ cgithub/views/repo.eta | 3 +++ 2 files changed, 13 insertions(+) (limited to 'cgithub/src/scraper.ts') diff --git a/cgithub/src/scraper.ts b/cgithub/src/scraper.ts index c1bdef5..4ffdc43 100644 --- a/cgithub/src/scraper.ts +++ b/cgithub/src/scraper.ts @@ -54,6 +54,7 @@ export interface RepoInfo { stars: string | null; watchers: string | null; forks: string | null; + numReleases?: number; } export interface GitHubRepo extends GitHubTree { @@ -110,12 +111,21 @@ function parseRepoInfo(html: string): RepoInfo { const watchersMatch = html.match(/(\d+)<\/strong>\s*watching/); const forksMatch = html.match(/id="repo-network-counter"[^>]*>([^<]+)/); + let numReleases: number | undefined; + const releaseCountMatch = html.match( + /]*href="[^"]*\/releases"[^>]*>[\s\S]*?]*title="(\d+)"[^>]*(?:class="[^"]*Counter|data-view-component="true")[^>]*>/, + ); + if (releaseCountMatch) { + numReleases = parseInt(releaseCountMatch[1], 10); + } + return { description, website: websiteMatch?.[1] || null, stars: starsMatch?.[1]?.trim() || null, watchers: watchersMatch?.[1] || null, forks: forksMatch?.[1]?.trim() || null, + numReleases, }; } diff --git a/cgithub/views/repo.eta b/cgithub/views/repo.eta index 4965e75..5bebb95 100644 --- a/cgithub/views/repo.eta +++ b/cgithub/views/repo.eta @@ -17,6 +17,9 @@ <% if (it.info.forks) { %> forks <%= it.info.forks %> <% } %> + <% if (it.info.numReleases) { %> + + <% } %> <% } %> -- cgit v1.3.1 From dac0a13f247fa17667ac5983ec65df0756e0829f Mon Sep 17 00:00:00 2001 From: Joe Mou Date: Mon, 26 Jan 2026 15:36:23 -0500 Subject: GitHub scraper test --- cgithub/package.json | 3 +- cgithub/src/scraper.test.ts | 70 +++++++++++++++++++++++++++++++++++++++++++++ cgithub/src/scraper.ts | 7 +++-- 3 files changed, 77 insertions(+), 3 deletions(-) create mode 100644 cgithub/src/scraper.test.ts (limited to 'cgithub/src/scraper.ts') diff --git a/cgithub/package.json b/cgithub/package.json index b3d778b..71dfb4d 100644 --- a/cgithub/package.json +++ b/cgithub/package.json @@ -5,7 +5,8 @@ "type": "module", "scripts": { "dev": "node --watch --watch-preserve-output src/index.ts", - "build": "rm -rf dist/* && esbuild --bundle --format=esm --platform=node --outdir=dist/build --sourcemap src/index.ts && cp -R static views dist/" + "build": "rm -rf dist/* && esbuild --bundle --format=esm --platform=node --outdir=dist/build --sourcemap src/index.ts && cp -R static views dist/", + "test": "node --test src/scraper.test.ts" }, "dependencies": { "@hono/node-server": "^1.13.7", diff --git a/cgithub/src/scraper.test.ts b/cgithub/src/scraper.test.ts new file mode 100644 index 0000000..3784190 --- /dev/null +++ b/cgithub/src/scraper.test.ts @@ -0,0 +1,70 @@ +import assert from "node:assert"; +import { describe, it } from "node:test"; +import { getGitHubBlob, getGitHubRepo, getGitHubTree } from "./scraper.ts"; + +describe("GitHub scraper", () => { + describe("actions/deploy-pages repository", () => { + it("should fetch repository", async () => { + const data = await getGitHubRepo("actions", "deploy-pages"); + + assert.strictEqual(data.repo.owner, "actions"); + assert.strictEqual(data.repo.name, "deploy-pages"); + assert.strictEqual(data.branch, "main"); + assert.strictEqual(data.path, "/"); + + assert.strictEqual( + data.info.description, + "GitHub Action to publish artifacts to GitHub Pages for deployments", + ); + assert.strictEqual(data.info.website, "https://pages.github.com"); + assert.strictEqual(data.info.stars?.length, 3); + assert.strictEqual(data.info.watchers?.length, 2); + assert.strictEqual(data.info.forks?.length, 3); + assert.strictEqual(data.info.numReleases, 36); + + assert.ok(data.items.length > 0); + assert.ok(Object.keys(data.overviewHtml ?? {}).includes("README.md")); + }); + + it("should fetch tree", async () => { + const data = await getGitHubTree("actions", "deploy-pages", "main", "src"); + + assert.strictEqual(data.repo.owner, "actions"); + assert.strictEqual(data.repo.name, "deploy-pages"); + assert.strictEqual(data.branch, "main"); + assert.strictEqual(data.path, "src"); + + assert.deepStrictEqual(data.items, [ + { + name: "__tests__", + path: "src/__tests__", + contentType: "directory", + }, + { + name: "internal", + path: "src/internal", + contentType: "directory", + }, + { + name: "index.js", + path: "src/index.js", + contentType: "file", + }, + ]); + }); + + it("should fetch blob", async () => { + const data = await getGitHubBlob("actions", "deploy-pages", "main", "LICENSE"); + + assert.strictEqual(data.repo.owner, "actions"); + assert.strictEqual(data.repo.name, "deploy-pages"); + assert.strictEqual(data.branch, "main"); + assert.strictEqual(data.path, "LICENSE"); + + assert.strictEqual(data.size, 1068); + assert.strictEqual(data.language, "Text"); + assert.match(data.rawContent, /^MIT License\n\n/); + assert.match(data.content, /^MIT License\n\n/); + }); + }); +}); diff --git a/cgithub/src/scraper.ts b/cgithub/src/scraper.ts index 4ffdc43..4feda73 100644 --- a/cgithub/src/scraper.ts +++ b/cgithub/src/scraper.ts @@ -63,7 +63,7 @@ export interface GitHubRepo extends GitHubTree { export interface GitHubBlob extends GitHubCommon { language: string | null; - size: number; + size: number | null; content: string; rawContent: string; } @@ -210,10 +210,13 @@ export async function getGitHubBlob( const highlightedLines = blob.highlightedLines || []; const content = highlightedLines.map((line) => line.text || "").join("\n"); + // Calculate size from raw content if not provided + const size = blob.size ?? (rawContent.length > 0 ? Buffer.byteLength(rawContent, "utf8") : null); + return extractGitHub(payload, { content: content || rawContent, rawContent, language: blob.language || null, - size: blob.size, + size, }); } -- cgit v1.3.1 From 04bbba0a7b71f1fb090d3e3a73110a00c36bf765 Mon Sep 17 00:00:00 2001 From: Joe Mou Date: Tue, 27 Jan 2026 11:23:11 -0500 Subject: Propagate GitHub HTTP errors Render `error.eta` on HTTP error from GitHub. Implemented with Gemini. --- cgithub/GEMINI.md | 1 + cgithub/package.json | 2 +- cgithub/src/index.ts | 31 +++++++++++++++++++++++-------- cgithub/src/scraper.test.ts | 12 +++++++++++- cgithub/src/scraper.ts | 11 +++++++++++ cgithub/views/error.eta | 11 +++++++++++ 6 files changed, 58 insertions(+), 10 deletions(-) create mode 120000 cgithub/GEMINI.md create mode 100644 cgithub/views/error.eta (limited to 'cgithub/src/scraper.ts') diff --git a/cgithub/GEMINI.md b/cgithub/GEMINI.md new file mode 120000 index 0000000..681311e --- /dev/null +++ b/cgithub/GEMINI.md @@ -0,0 +1 @@ +CLAUDE.md \ No newline at end of file diff --git a/cgithub/package.json b/cgithub/package.json index 6faa90b..3d339a4 100644 --- a/cgithub/package.json +++ b/cgithub/package.json @@ -7,7 +7,7 @@ "scripts": { "dev": "node --watch --watch-preserve-output src/index.ts", "build": "rm -rf dist/* && esbuild --bundle --format=esm --platform=node --outdir=dist/build --sourcemap src/index.ts && cp -R static views dist/", - "test": "node --test src/scraper.test.ts" + "test": "node --test --test-concurrency=4 src/scraper.test.ts" }, "dependencies": { "@hono/node-server": "^1.13.7", diff --git a/cgithub/src/index.ts b/cgithub/src/index.ts index 388b1e0..51e8cf2 100644 --- a/cgithub/src/index.ts +++ b/cgithub/src/index.ts @@ -1,10 +1,11 @@ import { createAdaptorServer, serve } from "@hono/node-server"; import { serveStatic } from "@hono/node-server/serve-static"; import { Eta } from "eta"; -import { Hono } from "hono"; +import { type Context, Hono } from "hono"; +import type { StatusCode } from "hono/utils/http-status"; import path from "node:path"; import url from "node:url"; -import { getGitHubBlob, getGitHubRepo, getGitHubTree } from "./scraper.ts"; +import { getGitHubBlob, getGitHubRepo, getGitHubTree, GitHubHTTPError } from "./scraper.ts"; const __dirname = path.dirname(url.fileURLToPath(import.meta.url)); @@ -17,22 +18,36 @@ app.get("/", async (c) => { return c.html(await eta.renderAsync("home.eta", {})); }); +async function tryRender(c: Context, template: string, promise: Promise) { + let data: T; + try { + data = await promise; + } catch (e) { + if (e instanceof GitHubHTTPError) { + c.status(e.status as StatusCode); + const message = `GitHub responded with HTTP ${e.status} ${e.message}`; + return c.html(await eta.renderAsync("error.eta", { title: e.message, message })); + } else { + c.status(500); + return c.html(await eta.renderAsync("error.eta", { message: "" + e })); + } + } + return c.html(await eta.renderAsync(template, data)); +} + app.get("/:owner/:repo", async (c) => { const { owner, repo } = c.req.param(); - const data = await getGitHubRepo(owner, repo); - return c.html(await eta.renderAsync("repo.eta", data)); + 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(); - const data = await getGitHubTree(owner, repo, branch, path); - return c.html(await eta.renderAsync("tree.eta", data)); + 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(); - const data = await getGitHubBlob(owner, repo, branch, path); - return c.html(await eta.renderAsync("blob.eta", data)); + return tryRender(c, "blob.eta", getGitHubBlob(owner, repo, branch, path)); }); app.get("/:owner/:repo/raw/:branch/:path{.*}", async (c) => { diff --git a/cgithub/src/scraper.test.ts b/cgithub/src/scraper.test.ts index 3784190..adf70ae 100644 --- a/cgithub/src/scraper.test.ts +++ b/cgithub/src/scraper.test.ts @@ -1,6 +1,6 @@ import assert from "node:assert"; import { describe, it } from "node:test"; -import { getGitHubBlob, getGitHubRepo, getGitHubTree } from "./scraper.ts"; +import { getGitHubBlob, getGitHubRepo, getGitHubTree, GitHubHTTPError } from "./scraper.ts"; describe("GitHub scraper", () => { describe("actions/deploy-pages repository", () => { @@ -67,4 +67,14 @@ describe("GitHub scraper", () => { assert.match(data.content, /^MIT License\n\n/); }); }); + + describe("error handling", () => { + it("should throw for non-existent repository", async () => { + await assert.rejects(getGitHubRepo("nosuchowner", "nosuchrepo"), (err) => { + assert(err instanceof GitHubHTTPError, "error should be an HTTPError"); + assert.strictEqual(err.status, 404); + return true; + }); + }); + }); }); diff --git a/cgithub/src/scraper.ts b/cgithub/src/scraper.ts index 4feda73..b76f8c8 100644 --- a/cgithub/src/scraper.ts +++ b/cgithub/src/scraper.ts @@ -1,3 +1,13 @@ +export class GitHubHTTPError extends Error { + status: number; + + constructor(status: number, message: string) { + super(message); + this.name = "GitHubHTTPError"; + this.status = status; + } +} + interface TreeItem { contentType: "directory" | "file"; name: string; @@ -78,6 +88,7 @@ async function fetchGitHubPage(path: string): Promise { "Accept-Language": "en-US,en;q=0.5", }; const response = await fetch(`https://github.com/${path}`, { headers }); + if (!response.ok) throw new GitHubHTTPError(response.status, response.statusText); return response.text(); } diff --git a/cgithub/views/error.eta b/cgithub/views/error.eta new file mode 100644 index 0000000..784a843 --- /dev/null +++ b/cgithub/views/error.eta @@ -0,0 +1,11 @@ + + + + + <%= it.title ?? it.message %> + + + + <%= it.message %> + + -- cgit v1.3.1 From d29bdad2f5c8326d111901373f0a56b3a09a2cd8 Mon Sep 17 00:00:00 2001 From: Joe Mou Date: Tue, 27 Jan 2026 15:05:36 -0500 Subject: List issues --- cgithub/CLAUDE.md | 18 ++++------ cgithub/src/index.ts | 13 ++++++- cgithub/src/scraper.test.ts | 21 ++++++++++- cgithub/src/scraper.ts | 85 +++++++++++++++++++++++++++++++++++++++++++-- cgithub/static/style.css | 40 +++++++++++++++++++++ cgithub/views/issues.eta | 20 +++++++++++ cgithub/views/layout.eta | 4 +++ cgithub/views/repo.eta | 2 +- 8 files changed, 185 insertions(+), 18 deletions(-) create mode 100644 cgithub/views/issues.eta (limited to 'cgithub/src/scraper.ts') diff --git a/cgithub/CLAUDE.md b/cgithub/CLAUDE.md index e258e55..8feb529 100644 --- a/cgithub/CLAUDE.md +++ b/cgithub/CLAUDE.md @@ -1,10 +1,6 @@ -# CLAUDE.md - -This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. - ## Project Overview -cgithub is a lightweight GitHub alternative frontend that scrapes GitHub's server-rendered HTML instead of using the API. This approach avoids API rate limits entirely by extracting embedded JSON data from ``, + ``, "g", ); @@ -241,11 +271,8 @@ function parsePayload(html: string, dataTarget: string, prop: string[]): RawPayl } catch { continue; } - for (const key of prop) { - data = data[key]; - if (data === undefined) continue match; - } - return data; + if (data.payload === undefined) continue match; + return data.payload; } return null; } @@ -329,24 +356,31 @@ function parseRepoInfo(html: string): RepoInfo { }; } -function extractOverviewHtml(payload: RawPayload): Record | undefined { +function extractOverviewHtml( + overviewFiles: OverviewFile[] = [], +): Record | undefined { const result: Record = {}; - for (const file of payload.overview?.overviewFiles ?? []) { - if (file.loaded && file.richText !== undefined) { + for (const file of overviewFiles) { + if (file.richText !== undefined) { result[file.displayName] = file.richText; } } return Object.keys(result).length > 0 ? result : undefined; } -function extractGitHub(payload: RawPayload, extra: T): GitHubNav & T { +function extractGitHub(payload: AppPayload | null, extra: T): GitHubNav & T { + const layoutRoute = payload?.codeViewLayoutRoute; + if (layoutRoute === undefined) { + throw new Error("Missing codeViewLayoutRoute"); + } + return { repo: { - owner: payload.repo.ownerLogin, - name: payload.repo.name, + owner: layoutRoute.repo.ownerLogin, + name: layoutRoute.repo.name, }, - branch: payload.refInfo.name, - path: payload.path, + branch: layoutRoute.refInfo.name, + path: layoutRoute.path, ...extra, }; } @@ -354,16 +388,17 @@ function extractGitHub(payload: RawPayload, extra: T): GitHubNav & T { export async function getGitHubRepo(owner: string, repo: string): Promise { const html = await fetchGitHubPage(`${owner}/${repo}`); - const payload = parsePayload(html, "react-partial.embeddedData", ["props", "initialPayload"]); + const payload = parsePayload(html); + const repoRoute = payload?.codeViewRepoRoute; - if (payload?.tree === undefined) { + if (repoRoute?.tree === undefined) { throw new Error("Could not find tree data in embedded JSON"); } const info = parseRepoInfo(html); - const overviewHtml = extractOverviewHtml(payload); + const overviewHtml = extractOverviewHtml(repoRoute.overview?.overviewFiles); - return extractGitHub(payload, { items: payload.tree.items, info, overviewHtml }); + return extractGitHub(payload, { items: repoRoute.tree.items, info, overviewHtml }); } export async function getGitHubTree( @@ -374,20 +409,14 @@ export async function getGitHubTree( ): Promise { const html = await fetchGitHubPage(`${owner}/${repo}/tree/${branch}/${path}`); - // GitHub uses different formats for root vs subdirectories. - const payload = - // Root directory format. - parsePayload(html, "react-partial.embeddedData", ["props", "initialPayload"]) || - // Subdirectory format. - parsePayload(html, "react-app.embeddedData", ["payload"]); + const payload = parsePayload(html); + const treeRoute = payload?.codeViewTreeRoute; - if (payload?.tree === undefined) { + if (treeRoute?.tree === undefined) { throw new Error("Could not find tree data in embedded JSON"); } - const overviewHtml = extractOverviewHtml(payload); - - return extractGitHub(payload, { items: payload.tree.items, overviewHtml }); + return extractGitHub(payload, { items: treeRoute.tree.items }); } export async function getGitHubBlob( @@ -398,42 +427,42 @@ export async function getGitHubBlob( ): Promise { const html = await fetchGitHubPage(`${owner}/${repo}/blob/${branch}/${path}`); - const payload = parsePayload(html, "react-app.embeddedData", ["payload"]); + const payload = parsePayload(html); + const blob = payload?.codeViewBlobLayoutRoute?.blob; + const styledBlob = payload?.["codeViewBlobLayoutRoute.StyledBlob"]; - if (payload?.blob === undefined) { + if (blob === undefined) { throw new Error("Could not find blob data in embedded JSON"); } - const blob = payload.blob; - const { headerInfo, stylingDirectives } = blob; - - let htmlLines = blob.colorizedLines; - if (!htmlLines && stylingDirectives && blob.rawLines) { - htmlLines = blob.rawLines.map((line, i) => applyStyling(line, stylingDirectives[i])); + let htmlLines = styledBlob?.colorizedLines ?? null; + const stylingDirectives = styledBlob?.stylingDirectives; + if (!htmlLines && stylingDirectives && styledBlob?.rawLines) { + htmlLines = styledBlob.rawLines.map((line, i) => applyStyling(line, stylingDirectives[i])); } - let size = headerInfo.blobSize; - if (headerInfo.lineInfo.truncatedLoc) { - size += ` / ${headerInfo.lineInfo.truncatedLoc} lines`; + let size = blob.headerInfo.blobSize; + if (blob.headerInfo.lineInfo?.truncatedLoc) { + size += ` / ${blob.headerInfo.lineInfo.truncatedLoc} lines`; } - if (headerInfo.lineInfo.truncatedSloc) { - size += ` / ${headerInfo.lineInfo.truncatedSloc} loc`; + if (blob.headerInfo.lineInfo?.truncatedSloc) { + size += ` / ${blob.headerInfo.lineInfo.truncatedSloc} loc`; } return extractGitHub(payload, { language: blob.language || null, size, image: blob.image, - textLines: blob.rawLines, + textLines: styledBlob?.rawLines ?? null, htmlLines, - htmlContent: blob.richText, + htmlContent: payload?.codeViewBlobRoute?.richText ?? null, }); } export async function getGitHubIssues(owner: string, repo: string): Promise { const html = await fetchGitHubPage(`${owner}/${repo}/issues`); - const payload = parsePayload(html, "react-app.embeddedData", ["payload"]); + const payload = parsePayload(html); let issuesQuery: IssueIndexPageQuery | undefined; for (const query of payload?.preloadedQueries ?? []) { @@ -470,7 +499,7 @@ export async function getGitHubCommits( : `${owner}/${repo}/commits/${branch}`; const html = await fetchGitHubPage(urlPath); - const payload = parsePayload(html, "react-app.embeddedData", ["payload"]); + const payload = parsePayload(html); if (payload?.commitGroups === undefined) { throw new Error("Could not find commit history in embedded JSON"); -- cgit v1.3.1 From 4662a229e06468a0805a18c6175bc0fd3a4ea985 Mon Sep 17 00:00:00 2001 From: Joe Mou Date: Sat, 14 Mar 2026 00:35:48 -0400 Subject: Fix tree overviewHtml: use tree.readme instead of overview.overviewFiles --- cgithub/src/scraper.test.ts | 25 +++++++++++-------------- cgithub/src/scraper.ts | 5 ++++- 2 files changed, 15 insertions(+), 15 deletions(-) (limited to 'cgithub/src/scraper.ts') diff --git a/cgithub/src/scraper.test.ts b/cgithub/src/scraper.test.ts index ad3ed46..1abab29 100644 --- a/cgithub/src/scraper.test.ts +++ b/cgithub/src/scraper.test.ts @@ -38,30 +38,27 @@ describe("GitHub scraper", () => { }); it("should fetch tree", async () => { - const data = await getGitHubTree("actions", "deploy-pages", "main", "src"); + const data = await getGitHubTree("github", "rally", "main", "lib"); - assert.strictEqual(data.repo.owner, "actions"); - assert.strictEqual(data.repo.name, "deploy-pages"); + assert.strictEqual(data.repo.owner, "github"); + assert.strictEqual(data.repo.name, "rally"); assert.strictEqual(data.branch, "main"); - assert.strictEqual(data.path, "src"); + assert.strictEqual(data.path, "lib"); assert.deepStrictEqual(data.items, [ { - name: "__tests__", - path: "src/__tests__", - contentType: "directory", - }, - { - name: "internal", - path: "src/internal", - contentType: "directory", + name: "README.md", + path: "lib/README.md", + contentType: "file", }, { - name: "index.js", - path: "src/index.js", + name: "RallyValidate.js", + path: "lib/RallyValidate.js", contentType: "file", }, ]); + + assert.ok(Object.keys(data.overviewHtml ?? {}).includes("README.md")); }); it("should fetch text blob", async () => { diff --git a/cgithub/src/scraper.ts b/cgithub/src/scraper.ts index ac30259..cd743a0 100644 --- a/cgithub/src/scraper.ts +++ b/cgithub/src/scraper.ts @@ -101,6 +101,7 @@ interface CodeViewRepoRoute { interface CodeViewTreeRoute { tree: { items: TreeItem[]; + readme?: OverviewFile; }; } @@ -416,7 +417,9 @@ export async function getGitHubTree( throw new Error("Could not find tree data in embedded JSON"); } - return extractGitHub(payload, { items: treeRoute.tree.items }); + const overviewHtml = extractOverviewHtml(treeRoute.tree.readme ? [treeRoute.tree.readme] : []); + + return extractGitHub(payload, { items: treeRoute.tree.items, overviewHtml }); } export async function getGitHubBlob( -- cgit v1.3.1 From d9a6f6cc7c6a374647486530c6246df980c4be6b Mon Sep 17 00:00:00 2001 From: Joe Mou Date: Mon, 20 Apr 2026 17:14:11 -0400 Subject: Fallback to issue titleHtml --- cgithub/src/scraper.ts | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) (limited to 'cgithub/src/scraper.ts') diff --git a/cgithub/src/scraper.ts b/cgithub/src/scraper.ts index cd743a0..5d17e3f 100644 --- a/cgithub/src/scraper.ts +++ b/cgithub/src/scraper.ts @@ -26,7 +26,8 @@ interface OverviewFile { interface IssueNode { number: number; - title: string; + title?: string; + titleHtml?: string; author?: { login: string; }; @@ -478,8 +479,9 @@ export async function getGitHubIssues(owner: string, repo: string): Promise { - const { number, title, state, createdAt } = edge.node; - return { number, title, state, createdAt }; + const { number, title, titleHtml, state, createdAt } = edge.node; + const finalTitle = (title || titleHtml || "").replace(/<[^>]+>/g, ""); + return { number, title: finalTitle, state, createdAt }; }); return { -- cgit v1.3.1 From dbe8141dce07559c1e16f17800dfd505b0ecdb46 Mon Sep 17 00:00:00 2001 From: Joe Mou Date: Mon, 20 Apr 2026 17:14:11 -0400 Subject: Apply overlapping styling drectives instead of duplicating content --- cgithub/src/scraper.test.ts | 24 +++++++++++++++++++++++- cgithub/src/scraper.ts | 45 ++++++++++++++++++++++++++++++++++++++------- 2 files changed, 61 insertions(+), 8 deletions(-) (limited to 'cgithub/src/scraper.ts') diff --git a/cgithub/src/scraper.test.ts b/cgithub/src/scraper.test.ts index a843561..4432231 100644 --- a/cgithub/src/scraper.test.ts +++ b/cgithub/src/scraper.test.ts @@ -95,7 +95,7 @@ describe("GitHub scraper", () => { assert.strictEqual(data.textLines?.[0], "# deploy-pages πŸš€"); assert.strictEqual( data.htmlLines?.[0], - '# deploy-pages πŸš€deploy-pages πŸš€deploy-pages πŸš€', + '# deploy-pages πŸš€', ); const firstLine = /^

deploy-pages πŸš€<\/h1>/; @@ -197,6 +197,28 @@ describe("GitHub scraper", () => { }); }); + describe("dustinlyons/nixos-config repository", () => { + it("should fetch nix blob with combined overlapping styling directives", async () => { + const data = await getGitHubBlob("dustinlyons", "nixos-config", "main", "flake.nix"); + + assert.strictEqual(data.repo.owner, "dustinlyons"); + assert.strictEqual(data.repo.name, "nixos-config"); + assert.strictEqual(data.path, "flake.nix"); + assert.strictEqual(data.image, false); + assert.strictEqual(data.htmlContent, null); + assert.ok(data.htmlLines !== null && data.htmlLines.length > 0); + + // Line 2 has directives [2,13,"pl-c1"] and [2,13,"pl-s1"] with identical ranges. + // They should be combined into one span, not duplicated. + const line2 = data.htmlLines[1]; + assert.ok(line2.includes('class="pl-c1 pl-s1"'), `expected combined classes in: ${line2}`); + assert.ok( + (line2.match(/description/g) ?? []).length === 1, + `expected "description" to appear once in: ${line2}`, + ); + }); + }); + describe("rich file types", () => { it("should support PNG", async () => { const data = await getGitHubBlob("github", "docs", "main", "assets/images/site/logo.png"); diff --git a/cgithub/src/scraper.ts b/cgithub/src/scraper.ts index 5d17e3f..2dcd1d0 100644 --- a/cgithub/src/scraper.ts +++ b/cgithub/src/scraper.ts @@ -153,15 +153,46 @@ function escapeHtml(text: string): string { .replace(/'/g, "'"); } -function applyStyling(line: string, directives: StylingDirective[]): string { +// Renders directives[i..] within [rangeStart, rangeEnd], producing nested spans. +// Returns the rendered content and the index of the first directive not consumed. +function renderRange( + line: string, + directives: StylingDirective[], + i: number, + rangeStart: number, + rangeEnd: number, +): [string, number] { let result = ""; - let lastPos = 0; - for (const [start, end, className] of directives) { - result += escapeHtml(line.substring(lastPos, start)); - result += `${escapeHtml(line.substring(start, end))}`; - lastPos = end; + let pos = rangeStart; + + while (i < directives.length) { + const [start, end, className] = directives[i]; + // Stop at directives that start outside or extend past this range. + if (start >= rangeEnd || end > rangeEnd) break; + + // Combine classes for directives with identical ranges. + let classes = className; + let j = i + 1; + while (j < directives.length && directives[j][0] === start && directives[j][1] === end) { + classes += " " + directives[j][2]; + j++; + } + + result += escapeHtml(line.substring(pos, start)); + const [innerContent, nextI] = renderRange(line, directives, j, start, end); + result += `${innerContent}`; + pos = end; + i = nextI; } - result += escapeHtml(line.substring(lastPos)); + + result += escapeHtml(line.substring(pos, rangeEnd)); + return [result, i]; +} + +function applyStyling(line: string, directives: StylingDirective[]): string { + // Sort by start ascending, then end descending so outer spans precede nested ones. + const sorted = [...directives].sort((a, b) => (a[0] !== b[0] ? a[0] - b[0] : b[1] - a[1])); + const [result] = renderRange(line, sorted, 0, 0, line.length); return result; } -- cgit v1.3.1 From cfe74db6c177151da3d1bc8549732e3d39448040 Mon Sep 17 00:00:00 2001 From: Joe Mou Date: Sat, 25 Apr 2026 13:35:17 -0400 Subject: Support redirects --- cgithub/src/index.ts | 5 ++++- cgithub/src/scraper.test.ts | 26 ++++++++++++++++++++++++++ cgithub/src/scraper.ts | 21 ++++++++++++++++++++- 3 files changed, 50 insertions(+), 2 deletions(-) (limited to 'cgithub/src/scraper.ts') diff --git a/cgithub/src/index.ts b/cgithub/src/index.ts index 59c98ee..eb3a0ee 100644 --- a/cgithub/src/index.ts +++ b/cgithub/src/index.ts @@ -14,6 +14,7 @@ import { getGitHubRepo, getGitHubTree, GitHubHTTPError, + RedirectError, } from "./scraper.ts"; const __dirname = path.dirname(url.fileURLToPath(import.meta.url)); @@ -32,7 +33,9 @@ async function tryRender(c: Context, template: string, promise try { data = await promise; } catch (e) { - if (e instanceof GitHubHTTPError) { + if (e instanceof RedirectError) { + 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(await eta.renderAsync("error.eta", { title: e.message, message })); diff --git a/cgithub/src/scraper.test.ts b/cgithub/src/scraper.test.ts index 4432231..c20a5ff 100644 --- a/cgithub/src/scraper.test.ts +++ b/cgithub/src/scraper.test.ts @@ -9,6 +9,7 @@ import { getGitHubRepo, getGitHubTree, GitHubHTTPError, + RedirectError, } from "./scraper.ts"; describe("GitHub scraper", () => { @@ -245,5 +246,30 @@ describe("GitHub scraper", () => { return true; }); }); + + it("should redirect when fetching a directory as a blob", async () => { + await assert.rejects( + getGitHubBlob("badlogic", "pi-mono", "main", "packages/agent"), + (err) => { + assert(err instanceof RedirectError, "error should be a RedirectError"); + assert.strictEqual(err.location, "/badlogic/pi-mono/tree/main/packages/agent"); + return true; + }, + ); + }); + + it("should redirect when fetching a file as a tree", async () => { + await assert.rejects( + getGitHubTree("badlogic", "pi-mono", "main", "packages/agent/package.json"), + (err) => { + assert(err instanceof RedirectError, "error should be a RedirectError"); + assert.strictEqual( + err.location, + "/badlogic/pi-mono/blob/main/packages/agent/package.json", + ); + return true; + }, + ); + }); }); }); diff --git a/cgithub/src/scraper.ts b/cgithub/src/scraper.ts index 2dcd1d0..04a685b 100644 --- a/cgithub/src/scraper.ts +++ b/cgithub/src/scraper.ts @@ -13,6 +13,16 @@ export class GitHubHTTPError extends Error { } } +export class RedirectError extends Error { + location: string; + + constructor(location: string) { + super(`Redirect to ${location}`); + this.name = "RedirectError"; + this.location = location; + } +} + interface TreeItem { contentType: "directory" | "file"; name: string; @@ -286,7 +296,16 @@ async function fetchGitHubPage(path: string): Promise { Accept: "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8", "Accept-Language": "en-US,en;q=0.5", }; - const response = await fetch(`https://github.com/${path}`, { headers }); + const response = await fetch(`https://github.com/${path}`, { headers, redirect: "manual" }); + if (response.status === 301 || response.status === 302) { + const location = response.headers.get("location"); + if (location) { + const url = new URL(location); + if (url.hostname === "github.com") { + throw new RedirectError(url.pathname); + } + } + } if (!response.ok) throw new GitHubHTTPError(response.status, response.statusText); return response.text(); } -- cgit v1.3.1 From c57123139794c3ef174b62c6bd2a54a60bbf3327 Mon Sep 17 00:00:00 2001 From: Joe Mou Date: Wed, 27 May 2026 14:02:36 -0400 Subject: Add repo-scoped search support for issues and PRs We do not support hybrid issue/PR search. Any searches on the issues endpoint will redirect to an explicit issue-only search. --- cgithub/src/index.ts | 26 +++++++++++++++++++++++++- cgithub/src/scraper.test.ts | 15 +++++++++++++++ cgithub/src/scraper.ts | 20 ++++++++++---------- cgithub/static/style.css | 13 ++++++++++--- cgithub/views/blob.eta | 2 +- cgithub/views/issues.eta | 2 +- cgithub/views/layout.eta | 8 ++++++++ cgithub/views/tree.eta | 2 +- 8 files changed, 71 insertions(+), 17 deletions(-) (limited to 'cgithub/src/scraper.ts') diff --git a/cgithub/src/index.ts b/cgithub/src/index.ts index eb3a0ee..db9915f 100644 --- a/cgithub/src/index.ts +++ b/cgithub/src/index.ts @@ -70,7 +70,31 @@ app.get("/:owner/:repo/raw/:branch/:path{.*}", async (c) => { app.get("/:owner/:repo/issues", async (c) => { const { owner, repo } = c.req.param(); - return tryRender(c, "issues.eta", getGitHubIssues(owner, repo)); + 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/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)}`); + } + + // 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) => { diff --git a/cgithub/src/scraper.test.ts b/cgithub/src/scraper.test.ts index 42ddd6e..236b0f5 100644 --- a/cgithub/src/scraper.test.ts +++ b/cgithub/src/scraper.test.ts @@ -127,6 +127,7 @@ describe("GitHub scraper", () => { assert.strictEqual(data.repo.owner, "actions"); assert.strictEqual(data.repo.name, "deploy-pages"); + assert.strictEqual(data.q, undefined); const issue402 = data.issues.find((issue) => issue.number === 402); assert.ok(issue402); @@ -135,6 +136,20 @@ describe("GitHub scraper", () => { assert.strictEqual(issue402.createdAt, "2025-07-03T19:07:08Z"); }); + it("should fetch filtered issues with q", async () => { + const data = await getGitHubIssues("actions", "deploy-pages", { + q: "is:issue state:closed", + }); + + assert.strictEqual(data.repo.owner, "actions"); + assert.strictEqual(data.repo.name, "deploy-pages"); + assert.strictEqual(data.q, "is:issue state:closed"); + const issue417 = data.issues.find((issue) => issue.number === 417); + assert.ok(issue417); + assert.strictEqual(issue417.title, "Chats"); + assert.ok(data.issues.every((issue) => issue.title !== "")); + }); + it("should fetch commits", async () => { const data = await getGitHubCommits("actions", "deploy-pages", "main", ""); diff --git a/cgithub/src/scraper.ts b/cgithub/src/scraper.ts index 04a685b..2412e49 100644 --- a/cgithub/src/scraper.ts +++ b/cgithub/src/scraper.ts @@ -258,6 +258,7 @@ interface Issue { export interface GitHubIssues extends GitHubCommon { issues: Issue[]; + q?: string; } export interface GitHubCommits extends GitHubNav { @@ -513,9 +514,14 @@ export async function getGitHubBlob( }); } -export async function getGitHubIssues(owner: string, repo: string): Promise { - const html = await fetchGitHubPage(`${owner}/${repo}/issues`); - +export async function getGitHubIssues( + owner: string, + repo: string, + // We only support is:issue searches (no PR results). + { q }: { q?: string } = {}, +): Promise { + const suffix = q ? `?q=${encodeURIComponent(q)}` : ""; + const html = await fetchGitHubPage(`${owner}/${repo}/issues${suffix}`); const payload = parsePayload(html); let issuesQuery: IssueIndexPageQuery | undefined; @@ -534,13 +540,7 @@ export async function getGitHubIssues(owner: string, repo: string): Promise a { display: block; padding-right: .5rem; diff --git a/cgithub/views/blob.eta b/cgithub/views/blob.eta index ea07059..50f92a9 100644 --- a/cgithub/views/blob.eta +++ b/cgithub/views/blob.eta @@ -1,4 +1,4 @@ -<% layout("./layout.eta") %> +<% layout("./layout.eta"); it.searchType = "code" %> <%~ include("./_nav.eta", it) %> diff --git a/cgithub/views/issues.eta b/cgithub/views/issues.eta index 6e4c9ba..646da46 100644 --- a/cgithub/views/issues.eta +++ b/cgithub/views/issues.eta @@ -1,4 +1,4 @@ -<% layout("./layout") %> +<% layout("./layout"); it.searchType = "issues" %> <% for (const issue of it.issues) { %> diff --git a/cgithub/views/layout.eta b/cgithub/views/layout.eta index ec3c7b8..f31c6e0 100644 --- a/cgithub/views/layout.eta +++ b/cgithub/views/layout.eta @@ -19,6 +19,14 @@ git clone git@github.com:<%= it.repo.owner %>/<%= it.repo.name %>.gitCommits <% } %> + + "> + + + <%~ it.body %> diff --git a/cgithub/views/tree.eta b/cgithub/views/tree.eta index 6e675e1..41ded06 100644 --- a/cgithub/views/tree.eta +++ b/cgithub/views/tree.eta @@ -1,3 +1,3 @@ -<% layout("./layout.eta") %> +<% layout("./layout.eta"); it.searchType = "code" %> <%~ include("./_tree.eta", it) %> -- cgit v1.3.1 From 745ac9d83234cb060bf802527efd90d301ecc491 Mon Sep 17 00:00:00 2001 From: Joe Mou Date: Wed, 27 May 2026 14:02:36 -0400 Subject: Pull request index --- cgithub/src/index.ts | 9 +++++++++ cgithub/src/scraper.test.ts | 11 +++++++++++ cgithub/src/scraper.ts | 40 ++++++++++++++++++++++++++++++++++++++++ cgithub/views/layout.eta | 2 ++ cgithub/views/pulls.eta | 15 +++++++++++++++ 5 files changed, 77 insertions(+) create mode 100644 cgithub/views/pulls.eta (limited to 'cgithub/src/scraper.ts') diff --git a/cgithub/src/index.ts b/cgithub/src/index.ts index db9915f..514f2e2 100644 --- a/cgithub/src/index.ts +++ b/cgithub/src/index.ts @@ -9,6 +9,7 @@ import { getGitHubBlob, getGitHubCommits, getGitHubIssues, + getGitHubPulls, getGitHubRelease, getGitHubReleases, getGitHubRepo, @@ -81,6 +82,12 @@ app.get("/:owner/:repo/issues", async (c) => { 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") ?? ""; @@ -88,6 +95,8 @@ app.get("/:owner/:repo/search", async (c) => { 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). diff --git a/cgithub/src/scraper.test.ts b/cgithub/src/scraper.test.ts index 236b0f5..a4b834e 100644 --- a/cgithub/src/scraper.test.ts +++ b/cgithub/src/scraper.test.ts @@ -4,6 +4,7 @@ import { getGitHubBlob, getGitHubCommits, getGitHubIssues, + getGitHubPulls, getGitHubRelease, getGitHubReleases, getGitHubRepo, @@ -150,6 +151,16 @@ describe("GitHub scraper", () => { assert.ok(data.issues.every((issue) => issue.title !== "")); }); + it("should fetch pull requests", async () => { + const data = await getGitHubPulls("actions", "deploy-pages", { q: "state:closed" }); + + assert.strictEqual(data.repo.owner, "actions"); + assert.strictEqual(data.repo.name, "deploy-pages"); + const pr411 = data.pulls.find((pull) => pull.number === 411); + assert.ok(pr411); + assert.strictEqual(pr411.title, "update node version"); + }); + it("should fetch commits", async () => { const data = await getGitHubCommits("actions", "deploy-pages", "main", ""); diff --git a/cgithub/src/scraper.ts b/cgithub/src/scraper.ts index 2412e49..f32a8da 100644 --- a/cgithub/src/scraper.ts +++ b/cgithub/src/scraper.ts @@ -261,6 +261,11 @@ export interface GitHubIssues extends GitHubCommon { q?: string; } +export interface GitHubPulls extends GitHubCommon { + pulls: Issue[]; + q?: string; +} + export interface GitHubCommits extends GitHubNav { commitGroups: CommitGroup[]; } @@ -543,6 +548,41 @@ export async function getGitHubIssues( return { repo: { owner, name: repo }, issues, q }; } +export async function getGitHubPulls( + owner: string, + repo: string, + { q }: { q?: string } = {}, +): Promise { + const suffix = q ? `?q=${encodeURIComponent(q)}` : ""; + const html = await fetchGitHubPage(`${owner}/${repo}/pulls${suffix}`); + const document = parseDocument(html); + + const pulls: Issue[] = []; + for (const row of cssSelect.selectAll(".js-issue-row", document) as unknown as Element[]) { + const id = getAttributeValue(row, "id"); // "issue_NNN" + const number = id ? parseInt(id.replace("issue_", ""), 10) : 0; + if (!number) continue; + + const titleLink = cssSelect.selectOne("a.markdown-title", row); + const title = titleLink ? textContent(titleLink).trim() : ""; + + const timeElem = cssSelect.selectOne("relative-time", row); + const createdAt = timeElem ? getAttributeValue(timeElem, "datetime") || "" : ""; + + const stateSpan = cssSelect.selectOne(".tooltipped[aria-label]", row) as Element | null; + const ariaLabel = stateSpan ? (getAttributeValue(stateSpan, "aria-label") ?? "") : ""; + const state = ariaLabel.includes("Merged") + ? "MERGED" + : ariaLabel.includes("Closed") + ? "CLOSED" + : "OPEN"; + + pulls.push({ number, title, state, createdAt }); + } + + return { repo: { owner, name: repo }, pulls, q }; +} + export async function getGitHubCommits( owner: string, repo: string, diff --git a/cgithub/views/layout.eta b/cgithub/views/layout.eta index f31c6e0..85f431c 100644 --- a/cgithub/views/layout.eta +++ b/cgithub/views/layout.eta @@ -14,6 +14,7 @@ git clone git@github.com:<%= it.repo.owner %>/<%= it.repo.name %>.git Code Issues + Pull requests Releases <% if (it.branch) { %> Commits @@ -23,6 +24,7 @@ git clone git@github.com:<%= it.repo.owner %>/<%= it.repo.name %>.git"> diff --git a/cgithub/views/pulls.eta b/cgithub/views/pulls.eta new file mode 100644 index 0000000..9ec5ca1 --- /dev/null +++ b/cgithub/views/pulls.eta @@ -0,0 +1,15 @@ +<% layout("./layout"); it.searchType = "pullrequests" %> + +
+ <% for (const pull of it.pulls) { %> + + + + + + <% } %> +
#<%= pull.number %> + + <%= pull.title %> + +
-- cgit v1.3.1 From c07865241ce113cbb26a939fd43fdf3461092c94 Mon Sep 17 00:00:00 2001 From: Joe Mou Date: Wed, 15 Jul 2026 00:16:30 -0400 Subject: User profile pages Co-Authored-By: Claude Fable 5 --- cgithub/CLAUDE.md | 1 + cgithub/src/index.ts | 9 +++++ cgithub/src/scraper.test.ts | 49 +++++++++++++++++++++++ cgithub/src/scraper.ts | 96 ++++++++++++++++++++++++++++++++++++++++++++- cgithub/static/style.css | 5 +++ cgithub/views/home.eta | 2 +- cgithub/views/user.eta | 58 +++++++++++++++++++++++++++ 7 files changed, 218 insertions(+), 2 deletions(-) create mode 100644 cgithub/views/user.eta (limited to 'cgithub/src/scraper.ts') diff --git a/cgithub/CLAUDE.md b/cgithub/CLAUDE.md index 8feb529..f6d8524 100644 --- a/cgithub/CLAUDE.md +++ b/cgithub/CLAUDE.md @@ -26,6 +26,7 @@ pnpm test **Templates:** `views/*.eta` - Eta templates with layout inheritance (`layout.eta` as base) **Routes:** +- `GET /:user` - User profile with pinned repositories - `GET /:owner/:repo` - Repository info with directory listing - `GET /:owner/:repo/tree/:branch/:path*` - Directory listing - `GET /:owner/:repo/blob/:branch/:path` - File content view diff --git a/cgithub/src/index.ts b/cgithub/src/index.ts index 514f2e2..d8bdfec 100644 --- a/cgithub/src/index.ts +++ b/cgithub/src/index.ts @@ -14,6 +14,7 @@ import { getGitHubReleases, getGitHubRepo, getGitHubTree, + getGitHubUser, GitHubHTTPError, RedirectError, } from "./scraper.ts"; @@ -35,6 +36,9 @@ async function tryRender(c: Context, template: string, promise 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); @@ -48,6 +52,11 @@ async function tryRender(c: Context, template: string, promise return c.html(await eta.renderAsync(template, data)); } +app.get("/:user", async (c) => { + const { user } = c.req.param(); + return tryRender(c, "user.eta", getGitHubUser(user)); +}); + app.get("/:owner/:repo", async (c) => { const { owner, repo } = c.req.param(); return tryRender(c, "repo.eta", getGitHubRepo(owner, repo)); diff --git a/cgithub/src/scraper.test.ts b/cgithub/src/scraper.test.ts index a4b834e..4a01853 100644 --- a/cgithub/src/scraper.test.ts +++ b/cgithub/src/scraper.test.ts @@ -9,6 +9,7 @@ import { getGitHubReleases, getGitHubRepo, getGitHubTree, + getGitHubUser, GitHubHTTPError, RedirectError, } from "./scraper.ts"; @@ -264,6 +265,54 @@ describe("GitHub scraper", () => { }); }); + describe("user pages", () => { + it("should fetch user profile", async () => { + const data = await getGitHubUser("torvalds"); + + assert.strictEqual(data.login, "torvalds"); + assert.strictEqual(data.name, "Linus Torvalds"); + assert.strictEqual(data.company, "Linux Foundation"); + assert.strictEqual(data.location, "Portland, OR"); + assert.ok(data.avatarUrl?.startsWith("https://avatars.githubusercontent.com/")); + assert.ok(data.followers); + assert.ok(data.following !== null); + + assert.ok(data.pinned.length > 0); + const linux = data.pinned.find((repo) => repo.name === "linux"); + assert.ok(linux); + assert.strictEqual(linux.owner, "torvalds"); + assert.strictEqual(linux.description, "Linux kernel source tree"); + assert.strictEqual(linux.language, "C"); + assert.ok(linux.stars); + assert.ok(linux.forks); + }); + + it("should fetch user bio and links", async () => { + const data = await getGitHubUser("sindresorhus"); + + assert.strictEqual(data.login, "sindresorhus"); + assert.ok(data.bio && data.bio.length > 0); + assert.ok(data.links.length > 0); + assert.ok(data.links.every((link) => link.href.startsWith("https://"))); + }); + + it("should redirect to GitHub for organizations", async () => { + await assert.rejects(getGitHubUser("github"), (err) => { + assert(err instanceof RedirectError, "error should be a RedirectError"); + assert.strictEqual(err.location, "https://github.com/github"); + return true; + }); + }); + + it("should throw for non-existent user", async () => { + await assert.rejects(getGitHubUser("nosuchuserzzzz"), (err) => { + assert(err instanceof GitHubHTTPError, "error should be an HTTPError"); + assert.strictEqual(err.status, 404); + return true; + }); + }); + }); + describe("error handling", () => { it("should throw for non-existent repository", async () => { await assert.rejects(getGitHubRepo("nosuchowner", "nosuchrepo"), (err) => { diff --git a/cgithub/src/scraper.ts b/cgithub/src/scraper.ts index f32a8da..a44f0a7 100644 --- a/cgithub/src/scraper.ts +++ b/cgithub/src/scraper.ts @@ -1,5 +1,5 @@ import { parseDocument } from "htmlparser2"; -import { type Element } from "domhandler"; +import { type AnyNode, type Element } from "domhandler"; import { getAttributeValue, getInnerHTML, textContent } from "domutils"; import * as cssSelect from "css-select"; @@ -293,6 +293,28 @@ export interface GitHubRelease extends GitHubCommon { release: Release; } +interface UserPinnedRepo { + owner: string; + name: string; + description?: string; + language?: string; + stars?: string; + forks?: string; +} + +export interface GitHubUser { + login: string; + name: string | null; + bio: string | null; + avatarUrl: string | null; + followers: string | null; + following: string | null; + company: string | null; + location: string | null; + links: { text: string; href: string }[]; + pinned: UserPinnedRepo[]; +} + async function fetchGitHubPage(path: string): Promise { // GitHub throttles/blocks requests without realistic browser headers. // These headers make the request appear as a standard browser visit. @@ -744,3 +766,75 @@ export async function getGitHubRelease( base.release.assets = assets; return base; } + +function selectText(selector: string, context: AnyNode): string | null { + const elem = cssSelect.selectOne(selector, context); + if (!elem) return null; + return textContent(elem).replace(/\s+/g, " ").trim() || null; +} + +export async function getGitHubUser(user: string): Promise { + const html = await fetchGitHubPage(user); + const document = parseDocument(html); + + // Organizations and other non-profile pages lack the user vcard. + const login = selectText(".p-nickname.vcard-username", document); + if (!login) { + throw new RedirectError(`https://github.com/${user}`); + } + + const avatarElem = cssSelect.selectOne("a[itemprop='image'] img", document) as Element | null; + const avatarUrl = avatarElem ? getAttributeValue(avatarElem, "src") || null : null; + + const followers = + selectText("a[href$='tab=followers']", document)?.replace(/ followers?$/, "") ?? null; + const following = + selectText("a[href$='tab=following']", document)?.replace(/ following$/, "") ?? null; + + const links: { text: string; href: string }[] = []; + const linkItems = cssSelect.selectAll( + "li[itemprop='url'], li[itemprop='social']", + document, + ) as unknown as Element[]; + for (const item of linkItems) { + const anchor = cssSelect.selectOne("a[href]", item) as Element | null; + const href = anchor ? getAttributeValue(anchor, "href") : undefined; + if (!anchor || !href) continue; + links.push({ text: textContent(anchor).replace(/\s+/g, " ").trim(), href }); + } + + const pinned: UserPinnedRepo[] = []; + const pinnedItems = cssSelect.selectAll( + ".pinned-item-list-item", + document, + ) as unknown as Element[]; + for (const item of pinnedItems) { + const link = cssSelect.selectOne("a:has(span.repo)", item) as Element | null; + const href = link ? getAttributeValue(link, "href") : undefined; + if (!href) continue; + const [owner, name] = href.replace(/^\//, "").split("/"); + if (!owner || !name) continue; + + pinned.push({ + owner, + name, + description: selectText("p.pinned-item-desc", item) ?? undefined, + language: selectText("[itemprop='programmingLanguage']", item) ?? undefined, + stars: selectText("a[href$='/stargazers']", item) ?? undefined, + forks: selectText("a[href$='/forks']", item) ?? undefined, + }); + } + + return { + login, + name: selectText(".p-name.vcard-fullname", document), + bio: selectText(".p-note.user-profile-bio", document), + avatarUrl, + followers, + following, + company: selectText("li[itemprop='worksFor']", document), + location: selectText("li[itemprop='homeLocation']", document), + links, + pinned, + }; +} diff --git a/cgithub/static/style.css b/cgithub/static/style.css index 86a67d8..e698993 100644 --- a/cgithub/static/style.css +++ b/cgithub/static/style.css @@ -148,6 +148,11 @@ body > footer { font-weight: bold; } +.avatar { + float: right; + clip-path: circle(); +} + .branch { display: inline-block; margin-right: 1rem; diff --git a/cgithub/views/home.eta b/cgithub/views/home.eta index adb71f2..0026ea3 100644 --- a/cgithub/views/home.eta +++ b/cgithub/views/home.eta @@ -12,7 +12,7 @@
-

Browse any GitHub repository by navigating to /:owner/:repo

+

Browse any GitHub repository by navigating to /:owner/:repo, or a user profile at /:user

Credits

diff --git a/cgithub/views/user.eta b/cgithub/views/user.eta new file mode 100644 index 0000000..cf2da94 --- /dev/null +++ b/cgithub/views/user.eta @@ -0,0 +1,58 @@ + + + + + + <%= it.name ? `${it.name} (${it.login})` : it.login %> + + + +
+ <% if (it.avatarUrl) { %> + + <% } %> +
<%= it.name || it.login %>
+
<%= it.login %>
+ <% if (it.bio) { %> +
<%= it.bio %>
+ <% } %> + <% if (it.followers) { %> +
<%= it.followers %> followers<%= it.following ? ` Β· ${it.following} following` : "" %>
+ <% } %> + <% if (it.company) { %> +
<%= it.company %>
+ <% } %> + <% if (it.location) { %> +
<%= it.location %>
+ <% } %> + <% for (const link of it.links) { %> + + <% } %> +
+ + <% if (it.pinned.length > 0) { %> +
+

Pinned

+ <% for (const repo of it.pinned) { %> +
+ + <% if (repo.description) { %> +
<%= repo.description %>
+ <% } %> + <% if (repo.language) { %> + <%= repo.language %> + <% } %> + <% if (repo.stars) { %> + stars <%= repo.stars %> + <% } %> + <% if (repo.forks) { %> + forks <%= repo.forks %> + <% } %> +
+ <% } %> +
+ <% } %> + +
Generated by cgithub
+ + -- cgit v1.3.1 From 47ddd107c2f19a977798b8af230c652ba731cf45 Mon Sep 17 00:00:00 2001 From: Joe Mou Date: Wed, 15 Jul 2026 00:21:11 -0400 Subject: Remove watchers, no longer rendered by GitHub Co-Authored-By: Claude Fable 5 --- cgithub/src/scraper.test.ts | 1 - cgithub/src/scraper.ts | 5 ----- cgithub/views/repo.eta | 3 --- 3 files changed, 9 deletions(-) (limited to 'cgithub/src/scraper.ts') diff --git a/cgithub/src/scraper.test.ts b/cgithub/src/scraper.test.ts index 4a01853..2438361 100644 --- a/cgithub/src/scraper.test.ts +++ b/cgithub/src/scraper.test.ts @@ -30,7 +30,6 @@ describe("GitHub scraper", () => { ); assert.strictEqual(data.info.website, "https://pages.github.com"); assert.strictEqual(data.info.stars?.length, 3); - assert.strictEqual(data.info.watchers?.length, 2); assert.strictEqual(data.info.forks?.length, 3); assert.strictEqual(data.info.numReleases, 38); assert.strictEqual(data.info.latestReleaseTag, "v5.0.0"); diff --git a/cgithub/src/scraper.ts b/cgithub/src/scraper.ts index a44f0a7..3434434 100644 --- a/cgithub/src/scraper.ts +++ b/cgithub/src/scraper.ts @@ -227,7 +227,6 @@ export interface RepoInfo { description: string | null; website: string | null; stars: string | null; - watchers: string | null; forks: string | null; numReleases?: number; latestReleaseTag?: string; @@ -386,9 +385,6 @@ function parseRepoInfo(html: string): RepoInfo { ? getAttributeValue(starsElem, "title") || textContent(starsElem).trim() : null; - const watchersElem = cssSelect.selectOne("a[href$='/watchers'] strong", document); - const watchers = watchersElem ? textContent(watchersElem).trim() : null; - const forksElem = cssSelect.selectOne("#repo-network-counter", document) as Element | null; const forks = forksElem ? getAttributeValue(forksElem, "title") || textContent(forksElem).trim() @@ -428,7 +424,6 @@ function parseRepoInfo(html: string): RepoInfo { description, website, stars, - watchers, forks, numReleases, latestReleaseTag, diff --git a/cgithub/views/repo.eta b/cgithub/views/repo.eta index 6204b69..683a9ad 100644 --- a/cgithub/views/repo.eta +++ b/cgithub/views/repo.eta @@ -11,9 +11,6 @@ <% if (it.info.stars) { %> stars <%= it.info.stars %> <% } %> - <% if (it.info.watchers) { %> - watchers <%= it.info.watchers %> - <% } %> <% if (it.info.forks) { %> forks <%= it.info.forks %> <% } %> -- cgit v1.3.1 From 7f38fbb0e3c852fda02986f3a755557938810062 Mon Sep 17 00:00:00 2001 From: Joe Mou Date: Wed, 15 Jul 2026 01:18:50 -0400 Subject: Add organization support to owner pages Co-Authored-By: Claude Fable 5 --- cgithub/CLAUDE.md | 2 +- cgithub/src/index.ts | 8 +-- cgithub/src/scraper.test.ts | 37 ++++++++++---- cgithub/src/scraper.ts | 121 ++++++++++++++++++++++++++++++++------------ cgithub/views/home.eta | 2 +- cgithub/views/owner.eta | 58 +++++++++++++++++++++ cgithub/views/user.eta | 58 --------------------- 7 files changed, 180 insertions(+), 106 deletions(-) create mode 100644 cgithub/views/owner.eta delete mode 100644 cgithub/views/user.eta (limited to 'cgithub/src/scraper.ts') diff --git a/cgithub/CLAUDE.md b/cgithub/CLAUDE.md index f6d8524..827cc3b 100644 --- a/cgithub/CLAUDE.md +++ b/cgithub/CLAUDE.md @@ -26,7 +26,7 @@ pnpm test **Templates:** `views/*.eta` - Eta templates with layout inheritance (`layout.eta` as base) **Routes:** -- `GET /:user` - User profile with pinned repositories +- `GET /:owner` - User or organization profile with pinned repositories - `GET /:owner/:repo` - Repository info with directory listing - `GET /:owner/:repo/tree/:branch/:path*` - Directory listing - `GET /:owner/:repo/blob/:branch/:path` - File content view diff --git a/cgithub/src/index.ts b/cgithub/src/index.ts index d8bdfec..f676bd6 100644 --- a/cgithub/src/index.ts +++ b/cgithub/src/index.ts @@ -9,12 +9,12 @@ import { getGitHubBlob, getGitHubCommits, getGitHubIssues, + getGitHubOwner, getGitHubPulls, getGitHubRelease, getGitHubReleases, getGitHubRepo, getGitHubTree, - getGitHubUser, GitHubHTTPError, RedirectError, } from "./scraper.ts"; @@ -52,9 +52,9 @@ async function tryRender(c: Context, template: string, promise return c.html(await eta.renderAsync(template, data)); } -app.get("/:user", async (c) => { - const { user } = c.req.param(); - return tryRender(c, "user.eta", getGitHubUser(user)); +app.get("/:owner", async (c) => { + const { owner } = c.req.param(); + return tryRender(c, "owner.eta", getGitHubOwner(owner)); }); app.get("/:owner/:repo", async (c) => { diff --git a/cgithub/src/scraper.test.ts b/cgithub/src/scraper.test.ts index 2438361..3048915 100644 --- a/cgithub/src/scraper.test.ts +++ b/cgithub/src/scraper.test.ts @@ -8,8 +8,8 @@ import { getGitHubRelease, getGitHubReleases, getGitHubRepo, + getGitHubOwner, getGitHubTree, - getGitHubUser, GitHubHTTPError, RedirectError, } from "./scraper.ts"; @@ -264,10 +264,11 @@ describe("GitHub scraper", () => { }); }); - describe("user pages", () => { + describe("owner pages", () => { it("should fetch user profile", async () => { - const data = await getGitHubUser("torvalds"); + const data = await getGitHubOwner("torvalds"); + assert.strictEqual(data.type, "user"); assert.strictEqual(data.login, "torvalds"); assert.strictEqual(data.name, "Linus Torvalds"); assert.strictEqual(data.company, "Linux Foundation"); @@ -287,7 +288,7 @@ describe("GitHub scraper", () => { }); it("should fetch user bio and links", async () => { - const data = await getGitHubUser("sindresorhus"); + const data = await getGitHubOwner("sindresorhus"); assert.strictEqual(data.login, "sindresorhus"); assert.ok(data.bio && data.bio.length > 0); @@ -295,16 +296,34 @@ describe("GitHub scraper", () => { assert.ok(data.links.every((link) => link.href.startsWith("https://"))); }); - it("should redirect to GitHub for organizations", async () => { - await assert.rejects(getGitHubUser("github"), (err) => { + it("should fetch organization profile", async () => { + const data = await getGitHubOwner("github"); + + assert.strictEqual(data.type, "organization"); + assert.strictEqual(data.login, "github"); + assert.strictEqual(data.name, "GitHub"); + assert.strictEqual(data.bio, "How people build software."); + assert.strictEqual(data.location, "United States of America"); + assert.ok(data.avatarUrl?.startsWith("https://avatars.githubusercontent.com/")); + assert.ok(data.followers); + assert.strictEqual(data.following, null); + assert.ok(data.links.some((link) => link.href === "https://github.com/about")); + + assert.ok(data.pinned.length > 0); + assert.ok(data.pinned.every((repo) => repo.owner === "github")); + assert.ok(data.pinned.some((repo) => repo.stars && repo.forks)); + }); + + it("should redirect to GitHub for non-profile pages", async () => { + await assert.rejects(getGitHubOwner("features"), (err) => { assert(err instanceof RedirectError, "error should be a RedirectError"); - assert.strictEqual(err.location, "https://github.com/github"); + assert.strictEqual(err.location, "https://github.com/features"); return true; }); }); - it("should throw for non-existent user", async () => { - await assert.rejects(getGitHubUser("nosuchuserzzzz"), (err) => { + it("should throw for non-existent owner", async () => { + await assert.rejects(getGitHubOwner("nosuchuserzzzz"), (err) => { assert(err instanceof GitHubHTTPError, "error should be an HTTPError"); assert.strictEqual(err.status, 404); return true; diff --git a/cgithub/src/scraper.ts b/cgithub/src/scraper.ts index 3434434..3e3ad75 100644 --- a/cgithub/src/scraper.ts +++ b/cgithub/src/scraper.ts @@ -292,7 +292,7 @@ export interface GitHubRelease extends GitHubCommon { release: Release; } -interface UserPinnedRepo { +interface OwnerPinnedRepo { owner: string; name: string; description?: string; @@ -301,17 +301,19 @@ interface UserPinnedRepo { forks?: string; } -export interface GitHubUser { +export interface GitHubOwner { + type: "user" | "organization"; login: string; name: string | null; bio: string | null; avatarUrl: string | null; followers: string | null; + // Users only; always null for organizations. following: string | null; company: string | null; location: string | null; links: { text: string; href: string }[]; - pinned: UserPinnedRepo[]; + pinned: OwnerPinnedRepo[]; } async function fetchGitHubPage(path: string): Promise { @@ -768,16 +770,37 @@ function selectText(selector: string, context: AnyNode): string | null { return textContent(elem).replace(/\s+/g, " ").trim() || null; } -export async function getGitHubUser(user: string): Promise { - const html = await fetchGitHubPage(user); - const document = parseDocument(html); +// Pinned repositories appear as .pinned-item-list-item on user pages and as +// [itemprop='owns'] blocks on organization pages. +function parsePinnedRepos(document: AnyNode): OwnerPinnedRepo[] { + const pinned: OwnerPinnedRepo[] = []; + const items = cssSelect.selectAll( + ".pinned-item-list-item, [itemprop='owns']", + document, + ) as unknown as Element[]; + for (const item of items) { + const link = cssSelect.selectOne( + "a:has(span.repo), a[itemprop~='codeRepository']", + item, + ) as Element | null; + const href = link ? getAttributeValue(link, "href") : undefined; + if (!href) continue; + const [owner, name] = href.replace(/^\//, "").split("/"); + if (!owner || !name) continue; - // Organizations and other non-profile pages lack the user vcard. - const login = selectText(".p-nickname.vcard-username", document); - if (!login) { - throw new RedirectError(`https://github.com/${user}`); + pinned.push({ + owner, + name, + description: selectText("p.pinned-item-desc, [itemprop='description']", item) ?? undefined, + language: selectText("[itemprop='programmingLanguage']", item) ?? undefined, + stars: selectText("a[href$='/stargazers']", item) ?? undefined, + forks: selectText("a[href$='/forks']", item) ?? undefined, + }); } + return pinned; +} +function parseUser(document: AnyNode, login: string): GitHubOwner { const avatarElem = cssSelect.selectOne("a[itemprop='image'] img", document) as Element | null; const avatarUrl = avatarElem ? getAttributeValue(avatarElem, "src") || null : null; @@ -798,29 +821,8 @@ export async function getGitHubUser(user: string): Promise { links.push({ text: textContent(anchor).replace(/\s+/g, " ").trim(), href }); } - const pinned: UserPinnedRepo[] = []; - const pinnedItems = cssSelect.selectAll( - ".pinned-item-list-item", - document, - ) as unknown as Element[]; - for (const item of pinnedItems) { - const link = cssSelect.selectOne("a:has(span.repo)", item) as Element | null; - const href = link ? getAttributeValue(link, "href") : undefined; - if (!href) continue; - const [owner, name] = href.replace(/^\//, "").split("/"); - if (!owner || !name) continue; - - pinned.push({ - owner, - name, - description: selectText("p.pinned-item-desc", item) ?? undefined, - language: selectText("[itemprop='programmingLanguage']", item) ?? undefined, - stars: selectText("a[href$='/stargazers']", item) ?? undefined, - forks: selectText("a[href$='/forks']", item) ?? undefined, - }); - } - return { + type: "user", login, name: selectText(".p-name.vcard-fullname", document), bio: selectText(".p-note.user-profile-bio", document), @@ -830,6 +832,59 @@ export async function getGitHubUser(user: string): Promise { company: selectText("li[itemprop='worksFor']", document), location: selectText("li[itemprop='homeLocation']", document), links, - pinned, + pinned: parsePinnedRepos(document), }; } + +function parseOrganization(document: AnyNode, owner: string): GitHubOwner { + 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; + const login = alt?.replace(/^@/, "") || owner; + + const followers = + selectText("a[href$='/followers']", document)?.replace(/ followers?$/, "") ?? null; + + const links: { text: string; href: string }[] = []; + const anchors = cssSelect.selectAll( + ".orghead a[itemprop='url'], .orghead [itemprop='email'] a", + document, + ) as unknown as Element[]; + for (const anchor of anchors) { + const href = getAttributeValue(anchor, "href"); + if (!href) continue; + links.push({ text: textContent(anchor).replace(/\s+/g, " ").trim(), href }); + } + + return { + type: "organization", + login, + name: selectText(".orghead h1", document), + bio: selectText(".orghead h1 + div", document), + avatarUrl, + followers, + following: null, + company: null, + location: selectText(".orghead [itemprop='location']", document), + links, + pinned: parsePinnedRepos(document), + }; +} + +export async function getGitHubOwner(owner: string): Promise { + const html = await fetchGitHubPage(owner); + const document = parseDocument(html); + + const login = selectText(".p-nickname.vcard-username", document); + if (login) { + return parseUser(document, login); + } + if (cssSelect.selectOne(".orghead", document)) { + return parseOrganization(document, owner); + } + // Not a profile page (marketing pages like github.com/features, etc.). + throw new RedirectError(`https://github.com/${owner}`); +} diff --git a/cgithub/views/home.eta b/cgithub/views/home.eta index 0026ea3..604e4d0 100644 --- a/cgithub/views/home.eta +++ b/cgithub/views/home.eta @@ -12,7 +12,7 @@
-

Browse any GitHub repository by navigating to /:owner/:repo, or a user profile at /:user

+

Browse any GitHub repository by navigating to /:owner/:repo, or a user or organization profile at /:owner

Credits

diff --git a/cgithub/views/owner.eta b/cgithub/views/owner.eta new file mode 100644 index 0000000..4426400 --- /dev/null +++ b/cgithub/views/owner.eta @@ -0,0 +1,58 @@ + + + + + + <%= it.name ? `${it.name} (${it.login})` : it.login %> + + + +
+ <% if (it.avatarUrl) { %> + + <% } %> +
<%= it.name || it.login %>
+
<%= it.login %><%= it.type === "organization" ? " (organization)" : "" %>
+ <% if (it.bio) { %> +
<%= it.bio %>
+ <% } %> + <% if (it.followers) { %> +
<%= it.followers %> followers<%= it.following ? ` Β· ${it.following} following` : "" %>
+ <% } %> + <% if (it.company) { %> +
<%= it.company %>
+ <% } %> + <% if (it.location) { %> +
<%= it.location %>
+ <% } %> + <% for (const link of it.links) { %> + + <% } %> +
+ + <% if (it.pinned.length > 0) { %> +
+

Pinned

+ <% for (const repo of it.pinned) { %> +
+ + <% if (repo.description) { %> +
<%= repo.description %>
+ <% } %> + <% if (repo.language) { %> + <%= repo.language %> + <% } %> + <% if (repo.stars) { %> + stars <%= repo.stars %> + <% } %> + <% if (repo.forks) { %> + forks <%= repo.forks %> + <% } %> +
+ <% } %> +
+ <% } %> + +
Generated by cgithub
+ + diff --git a/cgithub/views/user.eta b/cgithub/views/user.eta deleted file mode 100644 index cf2da94..0000000 --- a/cgithub/views/user.eta +++ /dev/null @@ -1,58 +0,0 @@ - - - - - - <%= it.name ? `${it.name} (${it.login})` : it.login %> - - - -
- <% if (it.avatarUrl) { %> - - <% } %> -
<%= it.name || it.login %>
-
<%= it.login %>
- <% if (it.bio) { %> -
<%= it.bio %>
- <% } %> - <% if (it.followers) { %> -
<%= it.followers %> followers<%= it.following ? ` Β· ${it.following} following` : "" %>
- <% } %> - <% if (it.company) { %> -
<%= it.company %>
- <% } %> - <% if (it.location) { %> -
<%= it.location %>
- <% } %> - <% for (const link of it.links) { %> - - <% } %> -
- - <% if (it.pinned.length > 0) { %> -
-

Pinned

- <% for (const repo of it.pinned) { %> -
- - <% if (repo.description) { %> -
<%= repo.description %>
- <% } %> - <% if (repo.language) { %> - <%= repo.language %> - <% } %> - <% if (repo.stars) { %> - stars <%= repo.stars %> - <% } %> - <% if (repo.forks) { %> - forks <%= repo.forks %> - <% } %> -
- <% } %> -
- <% } %> - -
Generated by cgithub
- - -- cgit v1.3.1 From 59f587d9ffac3d20486ec6773aedebb54012cc32 Mon Sep 17 00:00:00 2001 From: Joe Mou Date: Wed, 15 Jul 2026 01:36:33 -0400 Subject: Owner page nav menu and layout restructure Add a menu bar of profile tab links, split the org repository list from pinned repos, and restructure the owner template to match repo pages. Co-Authored-By: Claude Fable 5 --- cgithub/src/scraper.test.ts | 22 +++++++++++++++ cgithub/src/scraper.ts | 57 ++++++++++++++++++++++++++++++-------- cgithub/views/_ownerRepo.eta | 15 ++++++++++ cgithub/views/owner.eta | 65 ++++++++++++++++++++++++-------------------- 4 files changed, 118 insertions(+), 41 deletions(-) create mode 100644 cgithub/views/_ownerRepo.eta (limited to 'cgithub/src/scraper.ts') diff --git a/cgithub/src/scraper.test.ts b/cgithub/src/scraper.test.ts index 3048915..d6991d7 100644 --- a/cgithub/src/scraper.test.ts +++ b/cgithub/src/scraper.test.ts @@ -285,6 +285,12 @@ describe("GitHub scraper", () => { assert.strictEqual(linux.language, "C"); assert.ok(linux.stars); assert.ok(linux.forks); + + assert.deepStrictEqual(data.repos, []); + const navTexts = data.nav.map((item) => item.text); + assert.deepStrictEqual(navTexts, ["Repositories", "Projects", "Packages"]); + const reposTab = data.nav.find((item) => item.text === "Repositories"); + assert.strictEqual(reposTab?.href, "/torvalds?tab=repositories"); }); it("should fetch user bio and links", async () => { @@ -294,6 +300,7 @@ describe("GitHub scraper", () => { assert.ok(data.bio && data.bio.length > 0); assert.ok(data.links.length > 0); assert.ok(data.links.every((link) => link.href.startsWith("https://"))); + assert.ok(data.nav.some((item) => item.text === "Sponsoring")); }); it("should fetch organization profile", async () => { @@ -310,8 +317,23 @@ describe("GitHub scraper", () => { assert.ok(data.links.some((link) => link.href === "https://github.com/about")); assert.ok(data.pinned.length > 0); + assert.ok(data.pinned.length <= 6, "pinned should not include the repository list"); assert.ok(data.pinned.every((repo) => repo.owner === "github")); assert.ok(data.pinned.some((repo) => repo.stars && repo.forks)); + + assert.ok(data.repos.length > 0); + assert.ok(data.repos.every((repo) => repo.owner === "github")); + + const navTexts = data.nav.map((item) => item.text); + assert.deepStrictEqual(navTexts, [ + "Repositories", + "Projects", + "Packages", + "People", + "Sponsoring", + ]); + const reposTab = data.nav.find((item) => item.text === "Repositories"); + assert.strictEqual(reposTab?.href, "/orgs/github/repositories"); }); it("should redirect to GitHub for non-profile pages", async () => { diff --git a/cgithub/src/scraper.ts b/cgithub/src/scraper.ts index 3e3ad75..760a306 100644 --- a/cgithub/src/scraper.ts +++ b/cgithub/src/scraper.ts @@ -292,7 +292,7 @@ export interface GitHubRelease extends GitHubCommon { release: Release; } -interface OwnerPinnedRepo { +interface OwnerRepo { owner: string; name: string; description?: string; @@ -301,6 +301,11 @@ interface OwnerPinnedRepo { forks?: string; } +interface OwnerNavItem { + text: string; + href: string; +} + export interface GitHubOwner { type: "user" | "organization"; login: string; @@ -313,7 +318,10 @@ export interface GitHubOwner { company: string | null; location: string | null; links: { text: string; href: string }[]; - pinned: OwnerPinnedRepo[]; + nav: OwnerNavItem[]; + pinned: OwnerRepo[]; + // The repository list on organization overviews; empty for users. + repos: OwnerRepo[]; } async function fetchGitHubPage(path: string): Promise { @@ -770,14 +778,8 @@ function selectText(selector: string, context: AnyNode): string | null { return textContent(elem).replace(/\s+/g, " ").trim() || null; } -// Pinned repositories appear as .pinned-item-list-item on user pages and as -// [itemprop='owns'] blocks on organization pages. -function parsePinnedRepos(document: AnyNode): OwnerPinnedRepo[] { - const pinned: OwnerPinnedRepo[] = []; - const items = cssSelect.selectAll( - ".pinned-item-list-item, [itemprop='owns']", - document, - ) as unknown as Element[]; +function parseRepoItems(items: Element[]): OwnerRepo[] { + const repos: OwnerRepo[] = []; for (const item of items) { const link = cssSelect.selectOne( "a:has(span.repo), a[itemprop~='codeRepository']", @@ -788,7 +790,7 @@ function parsePinnedRepos(document: AnyNode): OwnerPinnedRepo[] { const [owner, name] = href.replace(/^\//, "").split("/"); if (!owner || !name) continue; - pinned.push({ + repos.push({ owner, name, description: selectText("p.pinned-item-desc, [itemprop='description']", item) ?? undefined, @@ -797,7 +799,32 @@ function parsePinnedRepos(document: AnyNode): OwnerPinnedRepo[] { forks: selectText("a[href$='/forks']", item) ?? undefined, }); } - return pinned; + return repos; +} + +function parsePinnedRepos(document: AnyNode): OwnerRepo[] { + return parseRepoItems( + cssSelect.selectAll(".pinned-item-list-item", document) as unknown as Element[], + ); +} + +// The tabs we surface from the profile page's own navigation. Stars and +// Overview are deliberately excluded. +const OWNER_NAV_TABS = ["Repositories", "Projects", "Packages", "People", "Sponsoring"]; + +function parseOwnerNav(document: AnyNode): OwnerNavItem[] { + const nav: OwnerNavItem[] = []; + const seen = new Set(); + const anchors = cssSelect.selectAll("a.UnderlineNav-item", document) as unknown as Element[]; + for (const anchor of anchors) { + const href = getAttributeValue(anchor, "href"); + // Label text is e.g. "Repositories 12" including the counter. + const text = textContent(anchor).replace(/\s+/g, " ").trim().split(" ")[0]; + if (!href || seen.has(href) || !OWNER_NAV_TABS.includes(text)) continue; + seen.add(href); + nav.push({ text, href }); + } + return nav; } function parseUser(document: AnyNode, login: string): GitHubOwner { @@ -832,7 +859,9 @@ function parseUser(document: AnyNode, login: string): GitHubOwner { company: selectText("li[itemprop='worksFor']", document), location: selectText("li[itemprop='homeLocation']", document), links, + nav: parseOwnerNav(document), pinned: parsePinnedRepos(document), + repos: [], }; } @@ -870,7 +899,11 @@ function parseOrganization(document: AnyNode, owner: string): GitHubOwner { company: null, location: selectText(".orghead [itemprop='location']", document), links, + nav: parseOwnerNav(document), pinned: parsePinnedRepos(document), + repos: parseRepoItems( + cssSelect.selectAll("#org-repositories [itemprop='owns']", document) as unknown as Element[], + ), }; } diff --git a/cgithub/views/_ownerRepo.eta b/cgithub/views/_ownerRepo.eta new file mode 100644 index 0000000..a857aff --- /dev/null +++ b/cgithub/views/_ownerRepo.eta @@ -0,0 +1,15 @@ +
+ + <% if (it.repo.description) { %> +
<%= it.repo.description %>
+ <% } %> + <% if (it.repo.language) { %> + <%= it.repo.language %> + <% } %> + <% if (it.repo.stars) { %> + stars <%= it.repo.stars %> + <% } %> + <% if (it.repo.forks) { %> + forks <%= it.repo.forks %> + <% } %> +
diff --git a/cgithub/views/owner.eta b/cgithub/views/owner.eta index 4426400..e5667fb 100644 --- a/cgithub/views/owner.eta +++ b/cgithub/views/owner.eta @@ -7,48 +7,55 @@ -
+
<% if (it.avatarUrl) { %> <% } %>
<%= it.name || it.login %>
-
<%= it.login %><%= it.type === "organization" ? " (organization)" : "" %>
- <% if (it.bio) { %> -
<%= it.bio %>
- <% } %> - <% if (it.followers) { %> -
<%= it.followers %> followers<%= it.following ? ` Β· ${it.following} following` : "" %>
- <% } %> - <% if (it.company) { %> -
<%= it.company %>
- <% } %> +
<%= it.login %><%= it.type === "organization" ? " (organization)" : "" %>
<% if (it.location) { %> -
<%= it.location %>
+
<%= it.location %>
<% } %> - <% for (const link of it.links) { %> - + <% if (it.nav.length > 0) { %> + <% } %>
+ <% 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 %>
+ <% } %> +
+ <% } %> + <% if (it.pinned.length > 0) { %>

Pinned

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

Repositories

+ <% for (const repo of it.repos) { %> + <%~ include("./_ownerRepo", { repo, login: it.login }) %> <% } %>
<% } %> -- cgit v1.3.1 From 9fbb5f0d68a131b55bc172d46b75705f2842af04 Mon Sep 17 00:00:00 2001 From: Joe Mou Date: Wed, 15 Jul 2026 01:46:06 -0400 Subject: Replace null overview files with HTML comments Co-Authored-By: Claude Fable 5 --- cgithub/src/scraper.test.ts | 4 ++-- cgithub/src/scraper.ts | 9 +++++---- cgithub/views/_tree.eta | 12 ++++++++---- 3 files changed, 15 insertions(+), 10 deletions(-) (limited to 'cgithub/src/scraper.ts') diff --git a/cgithub/src/scraper.test.ts b/cgithub/src/scraper.test.ts index d6991d7..f9b60e8 100644 --- a/cgithub/src/scraper.test.ts +++ b/cgithub/src/scraper.test.ts @@ -36,7 +36,7 @@ describe("GitHub scraper", () => { assert.ok(data.info.latestReleaseDate?.startsWith("2026-03-25")); assert.ok(data.items.length > 0); - assert.ok(Object.keys(data.overviewHtml ?? {}).includes("README.md")); + assert.ok(typeof data.overviewHtml?.["README.md"] === "string"); }); it("should fetch tree", async () => { @@ -60,7 +60,7 @@ describe("GitHub scraper", () => { }, ]); - assert.ok(Object.keys(data.overviewHtml ?? {}).includes("README.md")); + assert.ok(typeof data.overviewHtml?.["README.md"] === "string"); }); it("should fetch text blob", async () => { diff --git a/cgithub/src/scraper.ts b/cgithub/src/scraper.ts index 760a306..53f2bb1 100644 --- a/cgithub/src/scraper.ts +++ b/cgithub/src/scraper.ts @@ -31,7 +31,8 @@ interface TreeItem { interface OverviewFile { displayName: string; - richText?: string; + // null when GitHub includes the file without rendered content. + richText?: string | null; } interface IssueNode { @@ -220,7 +221,7 @@ interface GitHubNav extends GitHubCommon { export interface GitHubTree extends GitHubNav { items: TreeItem[]; - overviewHtml?: Record; + overviewHtml?: Record; } export interface RepoInfo { @@ -443,8 +444,8 @@ function parseRepoInfo(html: string): RepoInfo { function extractOverviewHtml( overviewFiles: OverviewFile[] = [], -): Record | undefined { - const result: Record = {}; +): Record | undefined { + const result: Record = {}; for (const file of overviewFiles) { if (file.richText !== undefined) { result[file.displayName] = file.richText; diff --git a/cgithub/views/_tree.eta b/cgithub/views/_tree.eta index 311762f..ed6eee9 100644 --- a/cgithub/views/_tree.eta +++ b/cgithub/views/_tree.eta @@ -18,9 +18,13 @@ <% if (it.overviewHtml) { %> <% for (const [filename, html] of Object.entries(it.overviewHtml)) { %> -
-
<%= filename %>
- <%~ html %> -
+ <% if (html !== null) { %> +
+
<%= filename %>
+ <%~ html %> +
+ <% } else { %> + + <% } %> <% } %> <% } %> -- 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/scraper.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/scraper.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 21310d0366c0f21e73c1f4f78d38370edfb59445 Mon Sep 17 00:00:00 2001 From: Joe Mou Date: Tue, 11 Aug 2026 17:41:01 -0400 Subject: Read the About box and latest release from JSON GitHub rebuilt the repository sidebar, so the selectors parseRepoInfo used matched nothing and the About box rendered empty. Everything but the latest release is in the page's embedded payload as sidebarAbout; the latest release is only in the _sidebar JSON already fetched for languages, so it arrives with that fragment and is moved beside the release count. --- cgithub/public/static/refs.js | 10 ++++ cgithub/src/app.test.ts | 34 ++++++++++++ cgithub/src/app.ts | 6 +- cgithub/src/scraper.test.ts | 15 ++--- cgithub/src/scraper.ts | 126 ++++++++++++++---------------------------- cgithub/views/_languages.eta | 17 ------ cgithub/views/_sidebar.eta | 22 ++++++++ cgithub/views/repo.eta | 10 +--- 8 files changed, 122 insertions(+), 118 deletions(-) delete mode 100644 cgithub/views/_languages.eta create mode 100644 cgithub/views/_sidebar.eta (limited to 'cgithub/src/scraper.ts') diff --git a/cgithub/public/static/refs.js b/cgithub/public/static/refs.js index f3e07e8..1035593 100644 --- a/cgithub/public/static/refs.js +++ b/cgithub/public/static/refs.js @@ -52,10 +52,20 @@ document.addEventListener("DOMContentLoaded", () => { .then((html) => { if (html.trim()) { placeholder.outerHTML = html; + relocate(); } else { placeholder.remove(); } }) .catch(() => placeholder.remove()); } + + // A fragment may carry a node that belongs elsewhere on the page, where an + // empty element of that id holds its spot: the sidebar's latest release + // reads as part of the release count, far from the languages it arrives with. + function relocate() { + for (const node of document.querySelectorAll("[data-target]")) { + document.getElementById(node.dataset.target)?.replaceWith(node); + } + } }); diff --git a/cgithub/src/app.test.ts b/cgithub/src/app.test.ts index 6544f03..5474f8d 100644 --- a/cgithub/src/app.test.ts +++ b/cgithub/src/app.test.ts @@ -58,6 +58,40 @@ describe("branch picker", () => { }); }); +describe("sidebar fragment", () => { + const repo = { owner: "actions", name: "deploy-pages" }; + const languages = [{ name: "JavaScript", percentage: 100, color: "#f1e05a" }]; + const latestRelease = { tag: "v5.0.0", publishedAt: "2026-03-25T16:59:14Z" }; + + // The release replaces the empty element it names, beside the release count. + it("should render the latest release and the languages", () => { + const html = eta.render("_sidebar.eta", { repo, languages, latestRelease }); + + assert.match(html, /\(2026-03-25<\/time>/); + assert.match(html, /
/); + }); + + it("should name a spot the repo page holds open", () => { + const html = eta.render("repo.eta", { + repo, + branch: "main", + path: "/", + items: [], + info: { description: null, website: null, stars: null, forks: null, numReleases: 38 }, + }); + + assert.match(html, /<\/span>/); + }); + + it("should render nothing when the repository has neither", () => { + const html = eta.render("_sidebar.eta", { repo, languages: [], latestRelease: null }); + + assert.strictEqual(html.trim(), ""); + }); +}); + 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 81e47fd..e27db59 100644 --- a/cgithub/src/app.ts +++ b/cgithub/src/app.ts @@ -6,7 +6,7 @@ import { getGitHubBlob, getGitHubCommits, getGitHubIssues, - getGitHubLanguages, + getGitHubSidebar, getGitHubLatestCommit, getGitHubOwner, getGitHubPulls, @@ -178,9 +178,9 @@ export function createApp(eta: Eta) { ); }); - app.get("/api/:owner/:repo/languages", async (c) => { + app.get("/api/:owner/:repo/sidebar", async (c) => { const { owner, repo } = c.req.param(); - return tryRenderFragment(c, "_languages.eta", getGitHubLanguages(owner, repo)); + return tryRenderFragment(c, "_sidebar.eta", getGitHubSidebar(owner, repo)); }); app.get("/:owner/:repo/releases/tag/:tag", async (c) => { diff --git a/cgithub/src/scraper.test.ts b/cgithub/src/scraper.test.ts index 5b5e78b..24fdfc0 100644 --- a/cgithub/src/scraper.test.ts +++ b/cgithub/src/scraper.test.ts @@ -4,7 +4,7 @@ import { getGitHubBlob, getGitHubCommits, getGitHubIssues, - getGitHubLanguages, + getGitHubSidebar, getGitHubLatestCommit, getGitHubPulls, getGitHubRefs, @@ -35,8 +35,6 @@ describe("GitHub scraper", () => { assert.strictEqual(data.info.stars?.length, 3); assert.strictEqual(data.info.forks?.length, 3); assert.strictEqual(data.info.numReleases, 38); - assert.strictEqual(data.info.latestReleaseTag, "v5.0.0"); - assert.ok(data.info.latestReleaseDate?.startsWith("2026-03-25")); assert.ok(data.items.length > 0); assert.ok(typeof data.overviewHtml?.["README.md"] === "string"); @@ -220,8 +218,8 @@ describe("GitHub scraper", () => { assert.ok(data.commit.authors.length > 0); }); - it("should fetch languages", async () => { - const data = await getGitHubLanguages("actions", "deploy-pages"); + it("should fetch the sidebar", async () => { + const data = await getGitHubSidebar("actions", "deploy-pages"); assert.strictEqual(data.repo.owner, "actions"); assert.strictEqual(data.repo.name, "deploy-pages"); @@ -229,10 +227,13 @@ describe("GitHub scraper", () => { assert.deepStrictEqual(data.languages, [ { name: "JavaScript", percentage: 100, color: "#f1e05a" }, ]); + + assert.strictEqual(data.latestRelease?.tag, "v5.0.0"); + assert.ok(data.latestRelease?.publishedAt?.startsWith("2026-03-25")); }); - it("should fetch languages for a multi-language repository", async () => { - const data = await getGitHubLanguages("rust-lang", "rust"); + it("should fetch the sidebar for a multi-language repository", async () => { + const data = await getGitHubSidebar("rust-lang", "rust"); const rust = data.languages.find((language) => language.name === "Rust"); assert.ok(rust); diff --git a/cgithub/src/scraper.ts b/cgithub/src/scraper.ts index 223839f..4220dc4 100644 --- a/cgithub/src/scraper.ts +++ b/cgithub/src/scraper.ts @@ -143,7 +143,17 @@ interface CodeViewBlobRoute { richText: string | null; } +interface SidebarAbout { + description?: string | null; + website?: string | null; + stargazerCount?: number; + forksCount?: number; + // A section is false when the repository has none of that thing. + sections?: { releases?: { releaseCount?: number } | false }; +} + interface AppPayload { + sidebarAbout?: SidebarAbout; codeViewLayoutRoute?: CodeViewLayoutRoute; codeViewRepoRoute?: CodeViewRepoRoute; codeViewTreeRoute?: CodeViewTreeRoute; @@ -230,8 +240,6 @@ export interface RepoInfo { stars: string | null; forks: string | null; numReleases?: number; - latestReleaseTag?: string; - latestReleaseDate?: string; } export interface GitHubRepo extends GitHubTree { @@ -292,8 +300,14 @@ export interface Language { color: string | null; } -export interface GitHubLanguages extends GitHubCommon { +export interface LatestRelease { + tag: string; + publishedAt: string | null; +} + +export interface GitHubSidebar extends GitHubCommon { languages: Language[]; + latestRelease: LatestRelease | null; } export interface Ref { @@ -425,81 +439,6 @@ function parsePayload(html: string): AppPayload | null { return parseEmbeddedPayload(html); } -function parseRepoInfo(html: string): RepoInfo { - const document = parseDocument(html); - - const aboutSection = cssSelect.selectOne(".BorderGrid-cell h2:contains('About')", document); - let description: string | null = null; - let website: string | null = null; - - if (aboutSection) { - const parent = aboutSection.parent; - if (parent) { - const descElem = cssSelect.selectOne("p.f4", parent); - if (descElem) { - description = textContent(descElem).trim(); - } - const websiteElem = cssSelect.selectOne( - "a[rel='noopener noreferrer nofollow']", - parent, - ) as Element | null; - if (websiteElem) { - website = getAttributeValue(websiteElem, "href") || null; - } - } - } - - const starsElem = cssSelect.selectOne("#repo-stars-counter-star", document) as Element | null; - const stars = starsElem - ? getAttributeValue(starsElem, "title") || textContent(starsElem).trim() - : null; - - const forksElem = cssSelect.selectOne("#repo-network-counter", document) as Element | null; - const forks = forksElem - ? getAttributeValue(forksElem, "title") || textContent(forksElem).trim() - : null; - - const releaseElem = cssSelect.selectOne( - "a[href$='/releases'] .Counter", - document, - ) as Element | null; - let numReleases: number | undefined; - let latestReleaseTag: string | undefined; - let latestReleaseDate: string | undefined; - - if (releaseElem) { - const releaseText = getAttributeValue(releaseElem, "title") || textContent(releaseElem); - numReleases = parseInt(releaseText.replace(/,/g, ""), 10); - - // Find the latest release tag and date if available - const cell = (releaseElem.parent as Element)?.parent?.parent as Element; // span -> a -> h2 -> cell - if (cell) { - const latestLink = cssSelect.selectOne("a[href*='/releases/tag/']", cell) as Element; - if (latestLink) { - const tagElem = cssSelect.selectOne(".css-truncate-target", latestLink); - if (tagElem) { - latestReleaseTag = textContent(tagElem).trim(); - } - - const timeElem = cssSelect.selectOne("relative-time", latestLink); - if (timeElem) { - latestReleaseDate = getAttributeValue(timeElem, "datetime") || undefined; - } - } - } - } - - return { - description, - website, - stars, - forks, - numReleases, - latestReleaseTag, - latestReleaseDate, - }; -} - function extractOverviewHtml( overviewFiles: OverviewFile[] = [], ): Record | undefined { @@ -539,7 +478,15 @@ export async function getGitHubRepo(owner: string, repo: string): Promise { +// The repository sidebar (languages, releases, contributors, ...) is not +// rendered into the repo page at all; GitHub's own UI lazily fetches it as +// JSON from here. +export async function getGitHubSidebar(owner: string, repo: string): Promise { const payload = await fetchGitHubJson(`${owner}/${repo}/_sidebar`); const languages: Language[] = []; @@ -758,7 +710,15 @@ export async function getGitHubLanguages(owner: string, repo: string): Promise 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/_sidebar.eta b/cgithub/views/_sidebar.eta new file mode 100644 index 0000000..bceef7c --- /dev/null +++ b/cgithub/views/_sidebar.eta @@ -0,0 +1,22 @@ +<% if (it.latestRelease) { %> + (
<%= it.latestRelease.tag %><% + if (it.latestRelease.publishedAt) { %> <% + } %>) +<% } %> +<% 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/repo.eta b/cgithub/views/repo.eta index 1bff97f..5dc6beb 100644 --- a/cgithub/views/repo.eta +++ b/cgithub/views/repo.eta @@ -17,19 +17,13 @@ <% if (it.info.numReleases) { %>
<%= it.info.numReleases %> releases - <% if (it.info.latestReleaseTag) { %> - (<%= it.info.latestReleaseTag %> - <% if (it.info.latestReleaseDate) { %> - - <% } %> - ) - <% } %> +
<% } %>
<% } %> -
+
  • …
-- cgit v1.3.1 From 0d60b8c8235c3c07f675aba7a56dd37b62519f9e Mon Sep 17 00:00:00 2001 From: Joe Mou Date: Thu, 27 Aug 2026 14:08:06 -0400 Subject: Display PDF blobs GitHub renders a PDF with a viewer of its own and so ships no content for one, leaving the blob view with nothing to show. Its raw bytes come as application/octet-stream, which browsers download rather than display, so serve them from a new embed route that retypes them and point an at that. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01BErPR92xppzGFXkPqXPYj2 --- cgithub/public/static/style.css | 7 +++++ cgithub/src/app.test.ts | 68 +++++++++++++++++++++++++++++++++++++++-- cgithub/src/app.ts | 26 ++++++++++++++++ cgithub/src/scraper.test.ts | 32 +++++++++++++++++++ cgithub/src/scraper.ts | 28 ++++++++++++++--- cgithub/views/blob.eta | 7 ++++- 6 files changed, 161 insertions(+), 7 deletions(-) (limited to 'cgithub/src/scraper.ts') diff --git a/cgithub/public/static/style.css b/cgithub/public/static/style.css index ed64bc6..157cc15 100644 --- a/cgithub/public/static/style.css +++ b/cgithub/public/static/style.css @@ -276,6 +276,13 @@ body > footer { } } +object.pdf { + display: block; + width: 100%; + height: 80vh; + padding: 0; +} + pre.box { /* Instead of scrolling overflow for long lines, grow the box. */ width: fit-content; diff --git a/cgithub/src/app.test.ts b/cgithub/src/app.test.ts index 94bf205..e28c375 100644 --- a/cgithub/src/app.test.ts +++ b/cgithub/src/app.test.ts @@ -1,6 +1,6 @@ import assert from "node:assert"; import path from "node:path"; -import { describe, it } from "node:test"; +import { describe, it, type TestContext } from "node:test"; import { Eta } from "eta"; import { createApp } from "./app.ts"; @@ -100,12 +100,30 @@ describe("blob view", () => { language: null, size: "815 KB", image: false, + renderFileType: null, textLines: null, htmlLines: null, htmlContent: null, }; - // GitHub previews neither the text nor the bytes of most binary files. + it("should embed a PDF", () => { + const html = eta.render("blob.eta", { + ...blob, + path: "test/pdfs/basicapi.pdf", + renderFileType: "pdf", + }); + + assert.match(html, /]*type="application\/pdf"/); + assert.match(html, /data="\/justrajdeep\/fonts\/embed\/pdf\/master\/test\/pdfs\/basicapi.pdf"/); + }); + + it("should not embed a file type it has no viewer for", () => { + const html = eta.render("blob.eta", { ...blob, renderFileType: "stl" }); + + assert.doesNotMatch(html, / { const html = eta.render("blob.eta", blob); @@ -114,6 +132,52 @@ describe("blob view", () => { }); }); +describe("embedded files", () => { + function stubGitHub(t: TestContext, body: string, contentType: string, status = 200) { + t.mock.method( + globalThis, + "fetch", + async () => new Response(body, { status, headers: { "Content-Type": contentType } }), + ); + } + + it("should retype a PDF GitHub sends as a download", async (t) => { + stubGitHub(t, "%PDF-1.6", "application/octet-stream"); + + const res = await app.request("http://cgithub.example/mozilla/pdf.js/embed/pdf/master/doc.pdf"); + + assert.strictEqual(res.status, 200); + assert.strictEqual(res.headers.get("Content-Type"), "application/pdf"); + assert.strictEqual(await res.text(), "%PDF-1.6"); + }); + + it("should pass on a type GitHub already got right", async (t) => { + stubGitHub(t, "%PDF-1.6", "application/pdf"); + + const res = await app.request("http://cgithub.example/mozilla/pdf.js/embed/pdf/master/doc.pdf"); + + assert.strictEqual(res.headers.get("Content-Type"), "application/pdf"); + }); + + it("should not serve a file type it has no viewer for", async (t) => { + stubGitHub(t, "