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/views/tree.eta | 42 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 42 insertions(+) create mode 100644 cgithub/views/tree.eta (limited to 'cgithub/views') diff --git a/cgithub/views/tree.eta b/cgithub/views/tree.eta new file mode 100644 index 0000000..4194216 --- /dev/null +++ b/cgithub/views/tree.eta @@ -0,0 +1,42 @@ + + + + + <%= it.repo.owner %>/<%= it.repo.name %> - <%= it.path || 'root' %> + + + +
+

<%= it.repo.owner %>/<%= it.repo.name %>

+
branch: <%= it.branch %>
+ <% if (it.path) { %> +
+ path: root<% + const pathParts = it.path.split('/').filter(p => p); + let currentPath = ''; + for (const part of pathParts) { + currentPath += (currentPath ? '/' : '') + part; + %>/<%= part %><% + } %> +
+ <% } %> +
+ + + <% for (const item of it.items) { %> + + + + <% } %> +
+ <%= item.contentType === 'directory' ? 'πŸ“' : 'πŸ“„' %> + <% if (item.contentType === 'directory') { %> + <%= item.name %> + <% } else { %> + <%= item.name %> + <% } %> +
+ + + + -- cgit v1.3.1 From eff2e1e7b31f046c974b88b409296af81b9f5615 Mon Sep 17 00:00:00 2001 From: Joe Mou Date: Thu, 22 Jan 2026 18:35:27 -0500 Subject: Add blob view for displaying file contents --- cgithub/src/index.ts | 23 ++++++++++++++- cgithub/src/scraper.ts | 77 ++++++++++++++++++++++++++++++++++++++++++++++++ cgithub/static/style.css | 14 +++++++++ cgithub/views/blob.eta | 37 +++++++++++++++++++++++ cgithub/views/tree.eta | 2 +- 5 files changed, 151 insertions(+), 2 deletions(-) create mode 100644 cgithub/views/blob.eta (limited to 'cgithub/views') diff --git a/cgithub/src/index.ts b/cgithub/src/index.ts index df0a116..a546f13 100644 --- a/cgithub/src/index.ts +++ b/cgithub/src/index.ts @@ -2,7 +2,7 @@ import { Hono } from "hono"; import { serve } from "@hono/node-server"; import { serveStatic } from "@hono/node-server/serve-static"; import { Eta } from "eta"; -import { getGitHubTree } from "./scraper.js"; +import { getGitHubTree, getGitHubBlob } from "./scraper.js"; const app = new Hono(); const eta = new Eta({ views: "./views" }); @@ -30,6 +30,27 @@ app.get("/:owner/:repo/tree/:branch/:path{.*}?", async (c) => { } }); +app.get("/:owner/:repo/blob/:branch/:path{.*}", async (c) => { + try { + const owner = c.req.param("owner"); + const repo = c.req.param("repo"); + const branch = c.req.param("branch"); + const path = c.req.param("path"); + + const data = await getGitHubBlob(owner, repo, branch, path); + const html = await eta.renderAsync("blob.eta", data); + + return c.html(html); + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : "Unknown error"; + return c.html( + `

Error

${errorMessage}

`, + 500, + ); + } +}); + const port = parseInt(process.env.PORT || "3000", 10); serve({ fetch: app.fetch, port }, (info) => { console.log(`Server running on http://localhost:${info.port}`); diff --git a/cgithub/src/scraper.ts b/cgithub/src/scraper.ts index add03b2..fa8d655 100644 --- a/cgithub/src/scraper.ts +++ b/cgithub/src/scraper.ts @@ -15,6 +15,20 @@ export interface GitHubTree { }; } +export interface GitHubBlob { + path: string; + branch: string; + content: string; + rawContent: string; + language: string | null; + size: number; + repo: { + name: string; + owner: string; + isPublic: boolean; + }; +} + export async function getGitHubTree( owner: string, repo: string, @@ -89,3 +103,66 @@ export async function getGitHubTree( }, }; } + +export async function getGitHubBlob( + owner: string, + repo: string, + branch: string, + path: string, +): Promise { + const url = `https://github.com/${owner}/${repo}/blob/${branch}/${path}`; + + 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 embeds file data in a JSON script tag + let payload; + + const match = 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 51558d04a5ca53b2d2b6f088d1220167ee480b39 Mon Sep 17 00:00:00 2001 From: Joe Mou Date: Mon, 26 Jan 2026 13:12:28 -0500 Subject: git clone commands --- cgithub/static/style.css | 7 +++++++ cgithub/views/layout.eta | 2 ++ 2 files changed, 9 insertions(+) (limited to 'cgithub/views') diff --git a/cgithub/static/style.css b/cgithub/static/style.css index ff787d6..5c27bbc 100644 --- a/cgithub/static/style.css +++ b/cgithub/static/style.css @@ -31,6 +31,13 @@ body > header, .box { border: 1px solid var(--border-color); padding: var(--box-padding); + > *:first-child { + margin-top: 0; + } + > *:last-child { + margin-bottom: 0; + } + > header:first-child { margin: calc(-1 * var(--box-padding)); margin-bottom: 0; diff --git a/cgithub/views/layout.eta b/cgithub/views/layout.eta index a191301..bfcd370 100644 --- a/cgithub/views/layout.eta +++ b/cgithub/views/layout.eta @@ -26,6 +26,8 @@ } %> <% } %> +
git clone https://github.com/<%= it.repo.owner %>/<%= it.repo.name %>.git
+git clone git@github.com:<%= it.repo.owner %>/<%= it.repo.name %>.git
<%~ it.body %> -- cgit v1.3.1 From 6484840a5412de4b003f9007eadf3e120dae5f3c Mon Sep 17 00:00:00 2001 From: Joe Mou Date: Mon, 26 Jan 2026 13:27:28 -0500 Subject: Append / to directories in tree listings --- cgithub/views/_tree.eta | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'cgithub/views') diff --git a/cgithub/views/_tree.eta b/cgithub/views/_tree.eta index f287c03..d0c3453 100644 --- a/cgithub/views/_tree.eta +++ b/cgithub/views/_tree.eta @@ -4,7 +4,7 @@ <% if (item.contentType === 'directory') { %> tree - <%= item.name %> + <%= item.name %>/ <% } else { %> blob <%= item.name %> -- 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/views') 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 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/views') 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 64c397e256b01a2e542e84529e947e237c07edd6 Mon Sep 17 00:00:00 2001 From: Joe Mou Date: Tue, 27 Jan 2026 16:58:56 -0500 Subject: Split branch and path bread crumbs into separate section --- cgithub/static/style.css | 7 +++++-- cgithub/views/_nav.eta | 18 ++++++++++++++++++ cgithub/views/_tree.eta | 2 ++ cgithub/views/blob.eta | 4 +++- cgithub/views/layout.eta | 22 ++-------------------- cgithub/views/repo.eta | 2 +- cgithub/views/tree.eta | 4 ++-- 7 files changed, 33 insertions(+), 26 deletions(-) create mode 100644 cgithub/views/_nav.eta (limited to 'cgithub/views') diff --git a/cgithub/static/style.css b/cgithub/static/style.css index d21bd32..cccb1ba 100644 --- a/cgithub/static/style.css +++ b/cgithub/static/style.css @@ -39,7 +39,11 @@ a { color: var(--link-color); } -body > header, .box { +section { + margin: 1rem 0; +} + +.box { --box-padding: .5rem; margin: 1rem 0; @@ -79,7 +83,6 @@ footer { } .repo { - margin-bottom: 1rem; font-size: 120%; font-weight: bold; } diff --git a/cgithub/views/_nav.eta b/cgithub/views/_nav.eta new file mode 100644 index 0000000..338a6a6 --- /dev/null +++ b/cgithub/views/_nav.eta @@ -0,0 +1,18 @@ +
+
branch <%= it.branch %>
+ <% if (it.path && it.path !== "/") { %> + (top)<% + const pathParts = it.path.split('/').filter(p => p); + let currentPath = ''; + for (let i = 0; i < pathParts.length; i++) { + const part = pathParts[i]; + currentPath += (currentPath ? '/' : '') + part; + const isLast = i === pathParts.length - 1; + if (isLast) { + %>/<%= part %><%= it.isTree ? "/" : "" %><% + } else { + %>/<%= part %><% + } + } %> + <% } %> +
diff --git a/cgithub/views/_tree.eta b/cgithub/views/_tree.eta index d0c3453..311762f 100644 --- a/cgithub/views/_tree.eta +++ b/cgithub/views/_tree.eta @@ -1,3 +1,5 @@ +<%~ include("./_nav.eta", {...it, isTree: true}) %> + <% for (const item of it.items) { %> diff --git a/cgithub/views/blob.eta b/cgithub/views/blob.eta index 7328a62..277b46d 100644 --- a/cgithub/views/blob.eta +++ b/cgithub/views/blob.eta @@ -1,4 +1,6 @@ -<% layout("./layout") %> +<% layout("./layout.eta") %> + +<%~ include("./_nav.eta", it) %>
<% if (it.language) { %> diff --git a/cgithub/views/layout.eta b/cgithub/views/layout.eta index bfcd370..f053b84 100644 --- a/cgithub/views/layout.eta +++ b/cgithub/views/layout.eta @@ -6,29 +6,11 @@ -
+
<%= it.repo.owner %>/<%= it.repo.name %>
-
branch <%= it.branch %>
- <% if (it.path) { %> - - (top)<% - const pathParts = it.path.split('/').filter(p => p); - let currentPath = ''; - for (let i = 0; i < pathParts.length; i++) { - const part = pathParts[i]; - currentPath += (currentPath ? '/' : '') + part; - const isLast = i === pathParts.length - 1; - if (isLast) { - %>/<%= part %><% - } else { - %>/<%= part %><% - } - } %> - - <% } %>
git clone https://github.com/<%= it.repo.owner %>/<%= it.repo.name %>.git
 git clone git@github.com:<%= it.repo.owner %>/<%= it.repo.name %>.git
-
+
<%~ it.body %> diff --git a/cgithub/views/repo.eta b/cgithub/views/repo.eta index 5bebb95..9122775 100644 --- a/cgithub/views/repo.eta +++ b/cgithub/views/repo.eta @@ -1,4 +1,4 @@ -<% layout("./layout") %> +<% layout("./layout.eta") %> <% if (it.info) { %>
diff --git a/cgithub/views/tree.eta b/cgithub/views/tree.eta index 7068754..6e675e1 100644 --- a/cgithub/views/tree.eta +++ b/cgithub/views/tree.eta @@ -1,3 +1,3 @@ -<% layout("./layout") %> +<% layout("./layout.eta") %> -<%~ include("./_tree", it) %> +<%~ include("./_tree.eta", it) %> -- 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/views') 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 ` -- 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/views') 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/views') 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/views') 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/views') 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/views') 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/views') 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/views') diff --git a/cgithub/public/static/refs.js b/cgithub/public/static/refs.js index 28b8373..ffff505 100644 --- a/cgithub/public/static/refs.js +++ b/cgithub/public/static/refs.js @@ -1,8 +1,8 @@ -// Progressive enhancement for two bits of data GitHub itself loads lazily: -// the branch/tag dropdown and the latest-commit bar above a file listing. -// Both degrade gracefully without JS: the button's command/commandfor still -// opens the (empty) dialog natively; the commit placeholder just never fills -// in. +// Progressive enhancement for the bits of data GitHub itself loads lazily: +// the branch/tag dropdown, the latest-commit bar above a file listing, and the +// language breakdown. All degrade gracefully without JS: the button's +// command/commandfor still opens the (empty) dialog natively; the placeholders +// just never fill in. document.addEventListener("DOMContentLoaded", () => { const dialog = document.querySelector(".ref-selector-menu"); if (dialog) { @@ -44,17 +44,16 @@ document.addEventListener("DOMContentLoaded", () => { } } - const commitPlaceholder = document.querySelector(".latest-commit-placeholder"); - if (commitPlaceholder) { - fetch(commitPlaceholder.dataset.src) + for (const placeholder of document.querySelectorAll(".placeholder[data-src]")) { + fetch(placeholder.dataset.src) .then((res) => (res.ok ? res.text() : "")) .then((html) => { if (html.trim()) { - commitPlaceholder.outerHTML = html; + placeholder.outerHTML = html; } else { - commitPlaceholder.remove(); + placeholder.remove(); } }) - .catch(() => commitPlaceholder.remove()); + .catch(() => placeholder.remove()); } }); diff --git a/cgithub/public/static/style.css b/cgithub/public/static/style.css index 983cb86..ed64bc6 100644 --- a/cgithub/public/static/style.css +++ b/cgithub/public/static/style.css @@ -218,10 +218,45 @@ body > footer { font-size: 0.85rem; } -.latest-commit-placeholder { +.placeholder { opacity: 0.7; } +.languages { + .language-bar { + display: flex; + height: .5rem; + border-radius: .25rem; + overflow: hidden; + + span { + /* Keep slivers of tiny languages visible. */ + min-width: 1px; + } + } + + .language-list { + display: flex; + flex-wrap: wrap; + gap: .2rem 1rem; + list-style: none; + margin: .5rem 0 0; + padding: 0; + font-size: 0.85rem; + } + + .language-dot { + display: inline-block; + width: .6rem; + height: .6rem; + border-radius: 50%; + } + + .language-percentage { + opacity: 0.7; + } +} + .repo-info { .description { margin-bottom: .2rem; diff --git a/cgithub/src/app.ts b/cgithub/src/app.ts index ca79b1f..e34c7b1 100644 --- a/cgithub/src/app.ts +++ b/cgithub/src/app.ts @@ -5,6 +5,7 @@ import { getGitHubBlob, getGitHubCommits, getGitHubIssues, + getGitHubLanguages, getGitHubLatestCommit, getGitHubOwner, getGitHubPulls, @@ -158,6 +159,11 @@ export function createApp(eta: Eta) { ); }); + app.get("/api/:owner/:repo/languages", async (c) => { + const { owner, repo } = c.req.param(); + return tryRenderFragment(c, "_languages.eta", getGitHubLanguages(owner, repo)); + }); + app.get("/:owner/:repo/releases/tag/:tag", async (c) => { const { owner, repo, tag } = c.req.param(); return tryRender(c, "release.eta", getGitHubRelease(owner, repo, tag)); diff --git a/cgithub/src/scraper.test.ts b/cgithub/src/scraper.test.ts index 56f14b0..5b5e78b 100644 --- a/cgithub/src/scraper.test.ts +++ b/cgithub/src/scraper.test.ts @@ -4,6 +4,7 @@ import { getGitHubBlob, getGitHubCommits, getGitHubIssues, + getGitHubLanguages, getGitHubLatestCommit, getGitHubPulls, getGitHubRefs, @@ -219,6 +220,33 @@ describe("GitHub scraper", () => { assert.ok(data.commit.authors.length > 0); }); + it("should fetch languages", async () => { + const data = await getGitHubLanguages("actions", "deploy-pages"); + + assert.strictEqual(data.repo.owner, "actions"); + assert.strictEqual(data.repo.name, "deploy-pages"); + + assert.deepStrictEqual(data.languages, [ + { name: "JavaScript", percentage: 100, color: "#f1e05a" }, + ]); + }); + + it("should fetch languages for a multi-language repository", async () => { + const data = await getGitHubLanguages("rust-lang", "rust"); + + const rust = data.languages.find((language) => language.name === "Rust"); + assert.ok(rust); + assert.ok(rust.percentage > 50); + assert.strictEqual(rust.color, "#dea584"); + + // The aggregate bucket comes back without a name. + const other = data.languages.find((language) => language.name === "Other"); + assert.ok(other); + + const total = data.languages.reduce((sum, language) => sum + language.percentage, 0); + assert.ok(Math.abs(total - 100) < 1); + }); + it("should fetch branches", async () => { const data = await getGitHubRefs("actions", "deploy-pages", "branches"); diff --git a/cgithub/src/scraper.ts b/cgithub/src/scraper.ts index 39ad2cd..223839f 100644 --- a/cgithub/src/scraper.ts +++ b/cgithub/src/scraper.ts @@ -285,6 +285,17 @@ export interface GitHubLatestCommit extends GitHubCommon { commit: LatestCommit; } +export interface Language { + // "Other" for the aggregate bucket GitHub reports without a name. + name: string; + percentage: number; + color: string | null; +} + +export interface GitHubLanguages extends GitHubCommon { + languages: Language[]; +} + export interface Ref { name: string; isDefault?: boolean; @@ -722,6 +733,34 @@ export async function getGitHubLatestCommit( }; } +interface SidebarPayload { + languages?: { + languages: { + // Absent for the "Other" bucket. + name?: string; + percentage: number; + color?: string; + }[]; + }; +} + +// The repository sidebar (languages, contributors, ...) is not rendered into +// the repo page at all; GitHub's own UI lazily fetches it as JSON from here. +export async function getGitHubLanguages(owner: string, repo: string): Promise { + const payload = await fetchGitHubJson(`${owner}/${repo}/_sidebar`); + + const languages: Language[] = []; + for (const entry of payload.languages?.languages ?? []) { + languages.push({ + name: entry.name ?? "Other", + percentage: entry.percentage, + color: entry.color ?? null, + }); + } + + return { repo: { owner, name: repo }, languages }; +} + interface BranchRefEntry { name: string; isDefault: boolean; diff --git a/cgithub/views/_languages.eta b/cgithub/views/_languages.eta new file mode 100644 index 0000000..8aca447 --- /dev/null +++ b/cgithub/views/_languages.eta @@ -0,0 +1,17 @@ +<% if (it.languages.length > 0) { %> +
+
+ <% for (const lang of it.languages) { %> + " title="<%= lang.name %> <%= lang.percentage %>%"> + <% } %> +
+
    + <% for (const lang of it.languages) { %> +
  • + "> + <%= lang.name %> <%= lang.percentage %>% +
  • + <% } %> +
+
+<% } %> diff --git a/cgithub/views/_tree.eta b/cgithub/views/_tree.eta index a0ecfab..2eb445f 100644 --- a/cgithub/views/_tree.eta +++ b/cgithub/views/_tree.eta @@ -1,7 +1,7 @@ <%~ include("./_nav.eta", {...it, isTree: true}) %> <% const commitPath = it.path && it.path !== "/" ? it.path : ""; %> -
Loading latest commit…
+
Loading latest commit…
<% for (const item of it.items) { %> diff --git a/cgithub/views/repo.eta b/cgithub/views/repo.eta index 683a9ad..9122594 100644 --- a/cgithub/views/repo.eta +++ b/cgithub/views/repo.eta @@ -29,4 +29,6 @@ <% } %> +
Loading languages…
+ <%~ include("./_tree", it) %> -- cgit v1.3.1 From 9cef0850f8049a8f1cb11097852738de4cad4a30 Mon Sep 17 00:00:00 2001 From: Joe Mou Date: Tue, 11 Aug 2026 08:17:54 -0400 Subject: Link repo header and add a "View original" link MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The owner/name header in the layout now links to the owner page and the repository root. tryRender passes the request's path and query through as githubUrl β€” our URLs mirror GitHub's, so the same path is the page we scraped β€” and the footer links there with rel="noreferrer", matching the no-referrer policy used on redirects to github.com. Co-Authored-By: Claude Opus 5 --- cgithub/src/app.ts | 5 ++++- cgithub/views/layout.eta | 4 ++-- 2 files changed, 6 insertions(+), 3 deletions(-) (limited to 'cgithub/views') diff --git a/cgithub/src/app.ts b/cgithub/src/app.ts index e34c7b1..5bed45d 100644 --- a/cgithub/src/app.ts +++ b/cgithub/src/app.ts @@ -44,7 +44,10 @@ export function createApp(eta: Eta) { return c.html(eta.render("error.eta", { message: "" + e })); } } - return c.html(eta.render(template, data)); + // Our URLs mirror GitHub's, so the page we scraped is the same path there. + const { pathname, search } = new URL(c.req.url); + const githubUrl = `https://github.com${pathname}${search}`; + return c.html(eta.render(template, { ...data, githubUrl })); } // For fragments fetched asynchronously by client-side JS (see public/static/refs.js): diff --git a/cgithub/views/layout.eta b/cgithub/views/layout.eta index a80bd1d..f803caf 100644 --- a/cgithub/views/layout.eta +++ b/cgithub/views/layout.eta @@ -9,7 +9,7 @@
-
<%= it.repo.owner %>/<%= it.repo.name %>
+
git clone https://github.com/<%= it.repo.owner %>/<%= it.repo.name %>.git
 git clone git@github.com:<%= it.repo.owner %>/<%= it.repo.name %>.git
<% for (const pull of it.pulls) { %> diff --git a/cgithub/views/release.eta b/cgithub/views/release.eta index 46dd5db..64cca35 100644 --- a/cgithub/views/release.eta +++ b/cgithub/views/release.eta @@ -1,3 +1,3 @@ -<% layout("./layout.eta") %> +<% layout("/layouts/repo.eta") %> <%~ include("./_release.eta", { repo: it.repo, release: it.release, linkToDetail: false }) %> diff --git a/cgithub/views/releases.eta b/cgithub/views/releases.eta index 840908c..4ede28b 100644 --- a/cgithub/views/releases.eta +++ b/cgithub/views/releases.eta @@ -1,4 +1,4 @@ -<% layout("./layout.eta") %> +<% layout("/layouts/repo.eta") %> <% for (const release of it.releases) { %> <%~ include("./_release.eta", { repo: it.repo, release, linkToDetail: true }) %> diff --git a/cgithub/views/repo.eta b/cgithub/views/repo.eta index 5dc6beb..60746df 100644 --- a/cgithub/views/repo.eta +++ b/cgithub/views/repo.eta @@ -1,4 +1,4 @@ -<% layout("./layout.eta") %> +<% layout("/layouts/repo.eta") %> <% if (it.info) { %>
diff --git a/cgithub/views/tree.eta b/cgithub/views/tree.eta index 41ded06..fabf5b9 100644 --- a/cgithub/views/tree.eta +++ b/cgithub/views/tree.eta @@ -1,3 +1,3 @@ -<% layout("./layout.eta"); it.searchType = "code" %> +<% layout("/layouts/repo.eta"); it.searchType = "code" %> <%~ include("./_tree.eta", it) %> diff --git a/cgithub/views/wiki.eta b/cgithub/views/wiki.eta index d6047e1..781caff 100644 --- a/cgithub/views/wiki.eta +++ b/cgithub/views/wiki.eta @@ -1,4 +1,4 @@ -<% layout("./layout.eta"); it.path = "wiki/" + it.title %> +<% layout("/layouts/repo.eta"); it.path = "wiki/" + it.title %> <%~ include("./_wikiNav.eta", it) %> diff --git a/cgithub/views/wikiPages.eta b/cgithub/views/wikiPages.eta index 58d6d21..eb90781 100644 --- a/cgithub/views/wikiPages.eta +++ b/cgithub/views/wikiPages.eta @@ -1,4 +1,4 @@ -<% layout("./layout.eta"); it.path = "wiki/_pages" %> +<% layout("/layouts/repo.eta"); it.path = "wiki/_pages" %> <%~ include("./_wikiNav.eta", it) %> -- cgit v1.3.1 From 01494e6fa1345a509560e5e19a7f08cb2aac5cc8 Mon Sep 17 00:00:00 2001 From: Joe Mou Date: Thu, 10 Sep 2026 14:57:44 -0400 Subject: Render the error page in the shared page shell error.eta carried its own copy of the HTML shell, so error pages missed the viewport meta tag, the build-commit footer, and the link to the original GitHub page. The renderer already passes githubUrl and buildCommit to every template, so the layout needs nothing new from app.ts. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01JZAT3YzYP85DihSvQbDKB6 --- cgithub/src/app.test.ts | 23 +++++++++++++++++++++++ cgithub/views/error.eta | 14 +++----------- 2 files changed, 26 insertions(+), 11 deletions(-) (limited to 'cgithub/views') diff --git a/cgithub/src/app.test.ts b/cgithub/src/app.test.ts index c028e1b..450c1cc 100644 --- a/cgithub/src/app.test.ts +++ b/cgithub/src/app.test.ts @@ -458,3 +458,26 @@ describe("redirects to GitHub", () => { ); }); }); + +describe("error page", () => { + it("should render inside the page shell", () => { + const html = eta.render("error.eta", { + title: "Not Found", + message: "GitHub responded with HTTP 404 Not Found", + buildCommit: "v0-g1234567", + githubUrl: "https://github.com/actions/deploy-pages", + }); + + assert.match(html, /Not Found<\/title>/); + assert.match(html, /GitHub responded with HTTP 404 Not Found/); + assert.match(html, /<footer>Generated by cgithub commit <code>v0-g1234567<\/code>/); + assert.match(html, /href="https:\/\/github.com\/actions\/deploy-pages"/); + }); + + // The title falls back to the message for errors that carry no status text. + it("should title itself with the message when it has no title", () => { + const html = eta.render("error.eta", { message: "Error: boom" }); + + assert.match(html, /<title>Error: boom<\/title>/); + }); +}); diff --git a/cgithub/views/error.eta b/cgithub/views/error.eta index 784a843..a46411e 100644 --- a/cgithub/views/error.eta +++ b/cgithub/views/error.eta @@ -1,11 +1,3 @@ -<!DOCTYPE html> -<html lang="en"> -<head> - <meta charset="UTF-8"> - <title><%= it.title ?? it.message %> - - - - <%= it.message %> - - +<% layout("/layouts/base.eta", { title: it.title ?? it.message }) %> + +<%= it.message %> -- cgit v1.3.1