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/index.ts | 36 ++++++++++++++++++++ cgithub/src/scraper.ts | 91 ++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 127 insertions(+) create mode 100644 cgithub/src/index.ts create mode 100644 cgithub/src/scraper.ts (limited to 'cgithub/src') diff --git a/cgithub/src/index.ts b/cgithub/src/index.ts new file mode 100644 index 0000000..df0a116 --- /dev/null +++ b/cgithub/src/index.ts @@ -0,0 +1,36 @@ +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"; + +const app = new Hono(); +const eta = new Eta({ views: "./views" }); + +app.use("/static/*", serveStatic({ root: "." })); + +app.get("/:owner/:repo/tree/: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 getGitHubTree(owner, repo, branch, path); + const html = await eta.renderAsync("tree.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 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 d91b89f89bd7dcf3258dc5802d6a97c5e341e272 Mon Sep 17 00:00:00 2001 From: Joe Mou Date: Mon, 26 Jan 2026 13:41:55 -0500 Subject: Redirect any unhandled routes to GitHub --- cgithub/src/index.ts | 6 ++++++ 1 file changed, 6 insertions(+) (limited to 'cgithub/src') diff --git a/cgithub/src/index.ts b/cgithub/src/index.ts index 15d464a..38d6d13 100644 --- a/cgithub/src/index.ts +++ b/cgithub/src/index.ts @@ -41,6 +41,12 @@ app.get("/:owner/:repo/raw/:branch/:path{.*}", async (c) => { return c.redirect(`https://raw.githubusercontent.com/${owner}/${repo}/${branch}/${path}`); }); +app.all("*", async (c) => { + const path = c.req.path; + c.header("Referrer-Policy", "no-referrer"); + return c.redirect(`https://github.com${path}`); +}); + if (process.env.LISTEN_FDS === "1") { const server = createAdaptorServer(app); // Listen on SD_LISTEN_FDS_START. -- cgit v1.3.1 From fee5c6f6438316a8d407699370729ac2d0abd9d1 Mon Sep 17 00:00:00 2001 From: Joe Mou Date: Mon, 26 Jan 2026 15:55:52 -0500 Subject: Use Node native TypeScript elision See https://nodejs.org/api/typescript.html#type-stripping --- cgithub/.gitignore | 1 - cgithub/package.json | 3 +-- cgithub/src/index.ts | 6 +++--- cgithub/tsconfig.json | 21 +++++++-------------- 4 files changed, 11 insertions(+), 20 deletions(-) (limited to 'cgithub/src') diff --git a/cgithub/.gitignore b/cgithub/.gitignore index b2636c7..3d2bc62 100644 --- a/cgithub/.gitignore +++ b/cgithub/.gitignore @@ -1,3 +1,2 @@ -/build/ /dist/ /node_modules/ diff --git a/cgithub/package.json b/cgithub/package.json index 461a9bd..b3d778b 100644 --- a/cgithub/package.json +++ b/cgithub/package.json @@ -4,7 +4,7 @@ "description": "Lightweight GitHub alternative frontend", "type": "module", "scripts": { - "dev": "concurrently 'tsc --watch --preserveWatchOutput' 'node --watch --watch-preserve-output build/index.js'", + "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/" }, "dependencies": { @@ -14,7 +14,6 @@ }, "devDependencies": { "@types/node": "^22.10.2", - "concurrently": "^9.1.2", "esbuild": "^0.27.2", "prettier": "^3.8.1", "typescript": "^5.7.2" diff --git a/cgithub/src/index.ts b/cgithub/src/index.ts index 38d6d13..388b1e0 100644 --- a/cgithub/src/index.ts +++ b/cgithub/src/index.ts @@ -2,9 +2,9 @@ import { createAdaptorServer, serve } from "@hono/node-server"; import { serveStatic } from "@hono/node-server/serve-static"; import { Eta } from "eta"; import { Hono } from "hono"; -import * as path from "node:path"; -import * as url from "node:url"; -import { getGitHubBlob, getGitHubRepo, getGitHubTree } from "./scraper.js"; +import path from "node:path"; +import url from "node:url"; +import { getGitHubBlob, getGitHubRepo, getGitHubTree } from "./scraper.ts"; const __dirname = path.dirname(url.fileURLToPath(import.meta.url)); diff --git a/cgithub/tsconfig.json b/cgithub/tsconfig.json index c2f75c1..2e9fe6a 100644 --- a/cgithub/tsconfig.json +++ b/cgithub/tsconfig.json @@ -1,19 +1,12 @@ { "compilerOptions": { - "target": "ES2024", + "target": "ESNext", "module": "NodeNext", - "moduleResolution": "NodeNext", - "lib": ["ES2024"], - "outDir": "./build", - "rootDir": "./src", + "lib": ["ESNext"], + "noEmit": true, "strict": true, - "esModuleInterop": true, - "skipLibCheck": true, - "forceConsistentCasingInFileNames": true, - "resolveJsonModule": true, - "allowSyntheticDefaultImports": true, - "types": ["node"] - }, - "include": ["src/**/*"], - "exclude": ["node_modules", "build"] + "erasableSyntaxOnly": true, + "rewriteRelativeImportExtensions": true, + "verbatimModuleSyntax": true + } } -- 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') 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') 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') 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') 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') 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 294c8f2802c92ab1d85cb6a160740dbf8983a7e2 Mon Sep 17 00:00:00 2001 From: Joe Mou Date: Mon, 20 Apr 2026 21:24:15 -0400 Subject: Update for unexpected actions/deploy-pages release --- cgithub/src/scraper.test.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) (limited to 'cgithub/src') diff --git a/cgithub/src/scraper.test.ts b/cgithub/src/scraper.test.ts index 1abab29..a843561 100644 --- a/cgithub/src/scraper.test.ts +++ b/cgithub/src/scraper.test.ts @@ -29,9 +29,9 @@ describe("GitHub scraper", () => { 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.strictEqual(data.info.latestReleaseTag, "v4.0.5"); - assert.ok(data.info.latestReleaseDate?.startsWith("2024-03-18")); + 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(Object.keys(data.overviewHtml ?? {}).includes("README.md")); -- 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') 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') 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') 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 123da65350888dc0fbe04003c5ffc36fdded3a46 Mon Sep 17 00:00:00 2001 From: Joe Mou Date: Wed, 27 May 2026 14:10:19 -0400 Subject: Update unit tests --- cgithub/src/scraper.test.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) (limited to 'cgithub/src') diff --git a/cgithub/src/scraper.test.ts b/cgithub/src/scraper.test.ts index c20a5ff..42ddd6e 100644 --- a/cgithub/src/scraper.test.ts +++ b/cgithub/src/scraper.test.ts @@ -249,10 +249,10 @@ describe("GitHub scraper", () => { it("should redirect when fetching a directory as a blob", async () => { await assert.rejects( - getGitHubBlob("badlogic", "pi-mono", "main", "packages/agent"), + getGitHubBlob("earendil-works", "pi", "main", "packages/agent"), (err) => { assert(err instanceof RedirectError, "error should be a RedirectError"); - assert.strictEqual(err.location, "/badlogic/pi-mono/tree/main/packages/agent"); + assert.strictEqual(err.location, "/earendil-works/pi/tree/main/packages/agent"); return true; }, ); @@ -260,12 +260,12 @@ describe("GitHub scraper", () => { it("should redirect when fetching a file as a tree", async () => { await assert.rejects( - getGitHubTree("badlogic", "pi-mono", "main", "packages/agent/package.json"), + getGitHubTree("earendil-works", "pi", "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", + "/earendil-works/pi/blob/main/packages/agent/package.json", ); return true; }, -- 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') 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') 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') 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') 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') 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') 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') 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 9b556ffe6c0c0528697e15f7f4f35107de212ce1 Mon Sep 17 00:00:00 2001 From: Joe Mou Date: Wed, 15 Jul 2026 02:08:17 -0400 Subject: Add Cloudflare Workers deployment target Split routes into a shared createApp() (src/app.ts) used by both the Node entry (src/index.ts) and a new Workers entry (src/worker.ts). Workers disallow runtime code generation, so scripts/compile-views.ts precompiles the Eta templates into src/views.generated.ts and rendering switches from renderAsync to the sync render. Static files move to public/static so Workers Static Assets can serve them; the Node build and serveStatic root are updated to match. Deploy with pnpm run deploy:cf, local dev with pnpm run dev:cf. Co-Authored-By: Claude Fable 5 --- cgithub/.gitignore | 2 + cgithub/CLAUDE.md | 12 +- cgithub/package.json | 12 +- cgithub/pnpm-lock.yaml | 839 ++++++++++++++++++++++++++ cgithub/public/static/icons/eye.svg | 1 + cgithub/public/static/icons/file.svg | 1 + cgithub/public/static/icons/folder-simple.svg | 1 + cgithub/public/static/icons/git-branch.svg | 1 + cgithub/public/static/icons/git-fork.svg | 1 + cgithub/public/static/icons/link.svg | 1 + cgithub/public/static/icons/star.svg | 1 + cgithub/public/static/style.css | 282 +++++++++ cgithub/scripts/compile-views.ts | 29 + cgithub/src/app.ts | 132 ++++ cgithub/src/index.ts | 132 +--- cgithub/src/worker.ts | 27 + cgithub/static/icons/eye.svg | 1 - cgithub/static/icons/file.svg | 1 - cgithub/static/icons/folder-simple.svg | 1 - cgithub/static/icons/git-branch.svg | 1 - cgithub/static/icons/git-fork.svg | 1 - cgithub/static/icons/link.svg | 1 - cgithub/static/icons/star.svg | 1 - cgithub/static/style.css | 282 --------- cgithub/wrangler.jsonc | 11 + 25 files changed, 1353 insertions(+), 421 deletions(-) create mode 100644 cgithub/public/static/icons/eye.svg create mode 100644 cgithub/public/static/icons/file.svg create mode 100644 cgithub/public/static/icons/folder-simple.svg create mode 100644 cgithub/public/static/icons/git-branch.svg create mode 100644 cgithub/public/static/icons/git-fork.svg create mode 100644 cgithub/public/static/icons/link.svg create mode 100644 cgithub/public/static/icons/star.svg create mode 100644 cgithub/public/static/style.css create mode 100644 cgithub/scripts/compile-views.ts create mode 100644 cgithub/src/app.ts create mode 100644 cgithub/src/worker.ts delete mode 100644 cgithub/static/icons/eye.svg delete mode 100644 cgithub/static/icons/file.svg delete mode 100644 cgithub/static/icons/folder-simple.svg delete mode 100644 cgithub/static/icons/git-branch.svg delete mode 100644 cgithub/static/icons/git-fork.svg delete mode 100644 cgithub/static/icons/link.svg delete mode 100644 cgithub/static/icons/star.svg delete mode 100644 cgithub/static/style.css create mode 100644 cgithub/wrangler.jsonc (limited to 'cgithub/src') diff --git a/cgithub/.gitignore b/cgithub/.gitignore index 3d2bc62..e502860 100644 --- a/cgithub/.gitignore +++ b/cgithub/.gitignore @@ -1,2 +1,4 @@ /dist/ /node_modules/ +/.wrangler/ +/src/views.generated.ts diff --git a/cgithub/CLAUDE.md b/cgithub/CLAUDE.md index 827cc3b..1bf4d88 100644 --- a/cgithub/CLAUDE.md +++ b/cgithub/CLAUDE.md @@ -13,6 +13,10 @@ pnpm run build # Run tests pnpm test + +# Cloudflare Workers: local dev and deploy +pnpm run dev:cf +pnpm run deploy:cf ``` ## Architecture @@ -20,10 +24,14 @@ pnpm test **Stack:** Hono (web framework) + Eta (templating) + TypeScript, bundled with esbuild **Source files:** -- `src/index.ts` - Hono routes and server setup (supports systemd socket activation via `LISTEN_FDS=1`) +- `src/app.ts` - Hono routes, shared by both entry points +- `src/index.ts` - Node server entry (supports systemd socket activation via `LISTEN_FDS=1`) +- `src/worker.ts` - Cloudflare Workers entry (config in `wrangler.jsonc`) - `src/scraper.ts` - GitHub HTML fetching and JSON extraction logic -**Templates:** `views/*.eta` - Eta templates with layout inheritance (`layout.eta` as base) +**Templates:** `views/*.eta` - Eta templates with layout inheritance (`layout.eta` as base). On Workers, runtime code generation is disallowed, so `scripts/compile-views.ts` precompiles templates to `src/views.generated.ts` (gitignored, regenerated by the `dev:cf`/`deploy:cf` scripts). + +**Static files:** `public/static/*`, served by `@hono/node-server` on Node and by Workers Static Assets on Cloudflare. **Routes:** - `GET /:owner` - User or organization profile with pinned repositories diff --git a/cgithub/package.json b/cgithub/package.json index 06e2876..b5349ca 100644 --- a/cgithub/package.json +++ b/cgithub/package.json @@ -6,8 +6,10 @@ "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/", - "test": "node --test --test-concurrency=4 src/scraper.test.ts" + "build": "rm -rf dist/* && esbuild --bundle --format=esm --platform=node --outdir=dist/build --sourcemap src/index.ts && cp -R public views dist/", + "test": "node --test --test-concurrency=4 src/scraper.test.ts", + "dev:cf": "node scripts/compile-views.ts && wrangler dev", + "deploy:cf": "node scripts/compile-views.ts && wrangler deploy" }, "dependencies": { "@hono/node-server": "^1.13.7", @@ -18,10 +20,14 @@ "hono": "^4.6.14", "htmlparser2": "^10.1.0" }, + "pnpm": { + "onlyBuiltDependencies": ["esbuild", "workerd"] + }, "devDependencies": { "@types/node": "^22.10.2", "esbuild": "^0.27.2", "prettier": "^3.8.1", - "typescript": "^5.7.2" + "typescript": "^5.7.2", + "wrangler": "^4.110.0" } } diff --git a/cgithub/pnpm-lock.yaml b/cgithub/pnpm-lock.yaml index 4fd01e4..b3abd2a 100644 --- a/cgithub/pnpm-lock.yaml +++ b/cgithub/pnpm-lock.yaml @@ -42,177 +42,556 @@ importers: typescript: specifier: ^5.7.2 version: 5.9.3 + wrangler: + specifier: ^4.110.0 + version: 4.110.0 packages: + '@cloudflare/kv-asset-handler@0.5.0': + resolution: {integrity: sha512-jxQYkj8dSIzc0cD6cMMNdOc1UVjqSqu8BZdor5s8cGjW2I8BjODt/kWPVdY+u9zj3ms75Q5qaZgnxUad83+eAg==} + engines: {node: '>=22.0.0'} + + '@cloudflare/unenv-preset@2.16.1': + resolution: {integrity: sha512-ECxObrMfyTl5bhQf/lZCXwo5G6xX9IAUo+nDMKK4SZ8m4Jvvxp52vilxyySSWh2YTZz8+HQ07qGH/2rEom1vDw==} + peerDependencies: + unenv: 2.0.0-rc.24 + workerd: '>1.20260305.0 <2.0.0-0' + peerDependenciesMeta: + workerd: + optional: true + + '@cloudflare/workerd-darwin-64@1.20260708.1': + resolution: {integrity: sha512-HXFCvhS1wpg3uXO0CLUwmwC41i2loM5FSK69EUchOBpmYBAXxT1oHLm6EOA5lqhTk5Mu9kjRiQYxa1GwKPwfJg==} + engines: {node: '>=16'} + cpu: [x64] + os: [darwin] + + '@cloudflare/workerd-darwin-arm64@1.20260708.1': + resolution: {integrity: sha512-JVlJaKDoRTVKSroHIlf8g3UCPjKj4iDbMZE2CNYht5qQ+2rL0FAUiVlV82G3BqKnnw9kHYnnsMzC08b9zVtdzA==} + engines: {node: '>=16'} + cpu: [arm64] + os: [darwin] + + '@cloudflare/workerd-linux-64@1.20260708.1': + resolution: {integrity: sha512-3daE60YdD7YX0Jtuzc9DE/r/qMkmx8ZvHTkF8Mzmp3F5tbzlV0DAzmu5PFUPF2WuvtKbAhZKbvC2cHmWpQYxnA==} + engines: {node: '>=16'} + cpu: [x64] + os: [linux] + + '@cloudflare/workerd-linux-arm64@1.20260708.1': + resolution: {integrity: sha512-VLdNYOx5Hj+9C6isy0ACWZsbMtSxex2DIJWEe7cZxUdlphZ58ZT8zxNXK8yunFiowd34hn3VwGMopdvdj8lvmA==} + engines: {node: '>=16'} + cpu: [arm64] + os: [linux] + + '@cloudflare/workerd-windows-64@1.20260708.1': + resolution: {integrity: sha512-bC/aSAwLy16Vjo24i9XU3aWH+eRgz7NeR5xPKavGbembO18ZywYTQbXh14eXtY6fAqN3RzRG8psijTdhX4xydA==} + engines: {node: '>=16'} + cpu: [x64] + os: [win32] + + '@cspotcode/source-map-support@0.8.1': + resolution: {integrity: sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==} + engines: {node: '>=12'} + + '@emnapi/runtime@1.11.2': + resolution: {integrity: sha512-kyOl3X0DuTiT1h2ft8r2fYO8JYtU9a9Xis/zBSiGArNaagCOWx90N1k2wxp18czFDH+OgcWGb5ZP/XMt3dcyPA==} + '@esbuild/aix-ppc64@0.27.2': resolution: {integrity: sha512-GZMB+a0mOMZs4MpDbj8RJp4cw+w1WV5NYD6xzgvzUJ5Ek2jerwfO2eADyI6ExDSUED+1X8aMbegahsJi+8mgpw==} engines: {node: '>=18'} cpu: [ppc64] os: [aix] + '@esbuild/aix-ppc64@0.28.1': + resolution: {integrity: sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + '@esbuild/android-arm64@0.27.2': resolution: {integrity: sha512-pvz8ZZ7ot/RBphf8fv60ljmaoydPU12VuXHImtAs0XhLLw+EXBi2BLe3OYSBslR4rryHvweW5gmkKFwTiFy6KA==} engines: {node: '>=18'} cpu: [arm64] os: [android] + '@esbuild/android-arm64@0.28.1': + resolution: {integrity: sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + '@esbuild/android-arm@0.27.2': resolution: {integrity: sha512-DVNI8jlPa7Ujbr1yjU2PfUSRtAUZPG9I1RwW4F4xFB1Imiu2on0ADiI/c3td+KmDtVKNbi+nffGDQMfcIMkwIA==} engines: {node: '>=18'} cpu: [arm] os: [android] + '@esbuild/android-arm@0.28.1': + resolution: {integrity: sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + '@esbuild/android-x64@0.27.2': resolution: {integrity: sha512-z8Ank4Byh4TJJOh4wpz8g2vDy75zFL0TlZlkUkEwYXuPSgX8yzep596n6mT7905kA9uHZsf/o2OJZubl2l3M7A==} engines: {node: '>=18'} cpu: [x64] os: [android] + '@esbuild/android-x64@0.28.1': + resolution: {integrity: sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + '@esbuild/darwin-arm64@0.27.2': resolution: {integrity: sha512-davCD2Zc80nzDVRwXTcQP/28fiJbcOwvdolL0sOiOsbwBa72kegmVU0Wrh1MYrbuCL98Omp5dVhQFWRKR2ZAlg==} engines: {node: '>=18'} cpu: [arm64] os: [darwin] + '@esbuild/darwin-arm64@0.28.1': + resolution: {integrity: sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + '@esbuild/darwin-x64@0.27.2': resolution: {integrity: sha512-ZxtijOmlQCBWGwbVmwOF/UCzuGIbUkqB1faQRf5akQmxRJ1ujusWsb3CVfk/9iZKr2L5SMU5wPBi1UWbvL+VQA==} engines: {node: '>=18'} cpu: [x64] os: [darwin] + '@esbuild/darwin-x64@0.28.1': + resolution: {integrity: sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + '@esbuild/freebsd-arm64@0.27.2': resolution: {integrity: sha512-lS/9CN+rgqQ9czogxlMcBMGd+l8Q3Nj1MFQwBZJyoEKI50XGxwuzznYdwcav6lpOGv5BqaZXqvBSiB/kJ5op+g==} engines: {node: '>=18'} cpu: [arm64] os: [freebsd] + '@esbuild/freebsd-arm64@0.28.1': + resolution: {integrity: sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + '@esbuild/freebsd-x64@0.27.2': resolution: {integrity: sha512-tAfqtNYb4YgPnJlEFu4c212HYjQWSO/w/h/lQaBK7RbwGIkBOuNKQI9tqWzx7Wtp7bTPaGC6MJvWI608P3wXYA==} engines: {node: '>=18'} cpu: [x64] os: [freebsd] + '@esbuild/freebsd-x64@0.28.1': + resolution: {integrity: sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + '@esbuild/linux-arm64@0.27.2': resolution: {integrity: sha512-hYxN8pr66NsCCiRFkHUAsxylNOcAQaxSSkHMMjcpx0si13t1LHFphxJZUiGwojB1a/Hd5OiPIqDdXONia6bhTw==} engines: {node: '>=18'} cpu: [arm64] os: [linux] + '@esbuild/linux-arm64@0.28.1': + resolution: {integrity: sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + '@esbuild/linux-arm@0.27.2': resolution: {integrity: sha512-vWfq4GaIMP9AIe4yj1ZUW18RDhx6EPQKjwe7n8BbIecFtCQG4CfHGaHuh7fdfq+y3LIA2vGS/o9ZBGVxIDi9hw==} engines: {node: '>=18'} cpu: [arm] os: [linux] + '@esbuild/linux-arm@0.28.1': + resolution: {integrity: sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + '@esbuild/linux-ia32@0.27.2': resolution: {integrity: sha512-MJt5BRRSScPDwG2hLelYhAAKh9imjHK5+NE/tvnRLbIqUWa+0E9N4WNMjmp/kXXPHZGqPLxggwVhz7QP8CTR8w==} engines: {node: '>=18'} cpu: [ia32] os: [linux] + '@esbuild/linux-ia32@0.28.1': + resolution: {integrity: sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + '@esbuild/linux-loong64@0.27.2': resolution: {integrity: sha512-lugyF1atnAT463aO6KPshVCJK5NgRnU4yb3FUumyVz+cGvZbontBgzeGFO1nF+dPueHD367a2ZXe1NtUkAjOtg==} engines: {node: '>=18'} cpu: [loong64] os: [linux] + '@esbuild/linux-loong64@0.28.1': + resolution: {integrity: sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + '@esbuild/linux-mips64el@0.27.2': resolution: {integrity: sha512-nlP2I6ArEBewvJ2gjrrkESEZkB5mIoaTswuqNFRv/WYd+ATtUpe9Y09RnJvgvdag7he0OWgEZWhviS1OTOKixw==} engines: {node: '>=18'} cpu: [mips64el] os: [linux] + '@esbuild/linux-mips64el@0.28.1': + resolution: {integrity: sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + '@esbuild/linux-ppc64@0.27.2': resolution: {integrity: sha512-C92gnpey7tUQONqg1n6dKVbx3vphKtTHJaNG2Ok9lGwbZil6DrfyecMsp9CrmXGQJmZ7iiVXvvZH6Ml5hL6XdQ==} engines: {node: '>=18'} cpu: [ppc64] os: [linux] + '@esbuild/linux-ppc64@0.28.1': + resolution: {integrity: sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + '@esbuild/linux-riscv64@0.27.2': resolution: {integrity: sha512-B5BOmojNtUyN8AXlK0QJyvjEZkWwy/FKvakkTDCziX95AowLZKR6aCDhG7LeF7uMCXEJqwa8Bejz5LTPYm8AvA==} engines: {node: '>=18'} cpu: [riscv64] os: [linux] + '@esbuild/linux-riscv64@0.28.1': + resolution: {integrity: sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + '@esbuild/linux-s390x@0.27.2': resolution: {integrity: sha512-p4bm9+wsPwup5Z8f4EpfN63qNagQ47Ua2znaqGH6bqLlmJ4bx97Y9JdqxgGZ6Y8xVTixUnEkoKSHcpRlDnNr5w==} engines: {node: '>=18'} cpu: [s390x] os: [linux] + '@esbuild/linux-s390x@0.28.1': + resolution: {integrity: sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + '@esbuild/linux-x64@0.27.2': resolution: {integrity: sha512-uwp2Tip5aPmH+NRUwTcfLb+W32WXjpFejTIOWZFw/v7/KnpCDKG66u4DLcurQpiYTiYwQ9B7KOeMJvLCu/OvbA==} engines: {node: '>=18'} cpu: [x64] os: [linux] + '@esbuild/linux-x64@0.28.1': + resolution: {integrity: sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + '@esbuild/netbsd-arm64@0.27.2': resolution: {integrity: sha512-Kj6DiBlwXrPsCRDeRvGAUb/LNrBASrfqAIok+xB0LxK8CHqxZ037viF13ugfsIpePH93mX7xfJp97cyDuTZ3cw==} engines: {node: '>=18'} cpu: [arm64] os: [netbsd] + '@esbuild/netbsd-arm64@0.28.1': + resolution: {integrity: sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + '@esbuild/netbsd-x64@0.27.2': resolution: {integrity: sha512-HwGDZ0VLVBY3Y+Nw0JexZy9o/nUAWq9MlV7cahpaXKW6TOzfVno3y3/M8Ga8u8Yr7GldLOov27xiCnqRZf0tCA==} engines: {node: '>=18'} cpu: [x64] os: [netbsd] + '@esbuild/netbsd-x64@0.28.1': + resolution: {integrity: sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + '@esbuild/openbsd-arm64@0.27.2': resolution: {integrity: sha512-DNIHH2BPQ5551A7oSHD0CKbwIA/Ox7+78/AWkbS5QoRzaqlev2uFayfSxq68EkonB+IKjiuxBFoV8ESJy8bOHA==} engines: {node: '>=18'} cpu: [arm64] os: [openbsd] + '@esbuild/openbsd-arm64@0.28.1': + resolution: {integrity: sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + '@esbuild/openbsd-x64@0.27.2': resolution: {integrity: sha512-/it7w9Nb7+0KFIzjalNJVR5bOzA9Vay+yIPLVHfIQYG/j+j9VTH84aNB8ExGKPU4AzfaEvN9/V4HV+F+vo8OEg==} engines: {node: '>=18'} cpu: [x64] os: [openbsd] + '@esbuild/openbsd-x64@0.28.1': + resolution: {integrity: sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + '@esbuild/openharmony-arm64@0.27.2': resolution: {integrity: sha512-LRBbCmiU51IXfeXk59csuX/aSaToeG7w48nMwA6049Y4J4+VbWALAuXcs+qcD04rHDuSCSRKdmY63sruDS5qag==} engines: {node: '>=18'} cpu: [arm64] os: [openharmony] + '@esbuild/openharmony-arm64@0.28.1': + resolution: {integrity: sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + '@esbuild/sunos-x64@0.27.2': resolution: {integrity: sha512-kMtx1yqJHTmqaqHPAzKCAkDaKsffmXkPHThSfRwZGyuqyIeBvf08KSsYXl+abf5HDAPMJIPnbBfXvP2ZC2TfHg==} engines: {node: '>=18'} cpu: [x64] os: [sunos] + '@esbuild/sunos-x64@0.28.1': + resolution: {integrity: sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + '@esbuild/win32-arm64@0.27.2': resolution: {integrity: sha512-Yaf78O/B3Kkh+nKABUF++bvJv5Ijoy9AN1ww904rOXZFLWVc5OLOfL56W+C8F9xn5JQZa3UX6m+IktJnIb1Jjg==} engines: {node: '>=18'} cpu: [arm64] os: [win32] + '@esbuild/win32-arm64@0.28.1': + resolution: {integrity: sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + '@esbuild/win32-ia32@0.27.2': resolution: {integrity: sha512-Iuws0kxo4yusk7sw70Xa2E2imZU5HoixzxfGCdxwBdhiDgt9vX9VUCBhqcwY7/uh//78A1hMkkROMJq9l27oLQ==} engines: {node: '>=18'} cpu: [ia32] os: [win32] + '@esbuild/win32-ia32@0.28.1': + resolution: {integrity: sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + '@esbuild/win32-x64@0.27.2': resolution: {integrity: sha512-sRdU18mcKf7F+YgheI/zGf5alZatMUTKj/jNS6l744f9u3WFu4v7twcUI9vu4mknF4Y9aDlblIie0IM+5xxaqQ==} engines: {node: '>=18'} cpu: [x64] os: [win32] + '@esbuild/win32-x64@0.28.1': + resolution: {integrity: sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + '@hono/node-server@1.19.9': resolution: {integrity: sha512-vHL6w3ecZsky+8P5MD+eFfaGTyCeOHUIFYMGpQGbrBTSmNNoxv0if69rEZ5giu36weC5saFuznL411gRX7bJDw==} engines: {node: '>=18.14.1'} peerDependencies: hono: ^4 + '@img/colour@1.1.0': + resolution: {integrity: sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==} + engines: {node: '>=18'} + + '@img/sharp-darwin-arm64@0.34.5': + resolution: {integrity: sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm64] + os: [darwin] + + '@img/sharp-darwin-x64@0.34.5': + resolution: {integrity: sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [x64] + os: [darwin] + + '@img/sharp-libvips-darwin-arm64@1.2.4': + resolution: {integrity: sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==} + cpu: [arm64] + os: [darwin] + + '@img/sharp-libvips-darwin-x64@1.2.4': + resolution: {integrity: sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==} + cpu: [x64] + os: [darwin] + + '@img/sharp-libvips-linux-arm64@1.2.4': + resolution: {integrity: sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==} + cpu: [arm64] + os: [linux] + + '@img/sharp-libvips-linux-arm@1.2.4': + resolution: {integrity: sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==} + cpu: [arm] + os: [linux] + + '@img/sharp-libvips-linux-ppc64@1.2.4': + resolution: {integrity: sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==} + cpu: [ppc64] + os: [linux] + + '@img/sharp-libvips-linux-riscv64@1.2.4': + resolution: {integrity: sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==} + cpu: [riscv64] + os: [linux] + + '@img/sharp-libvips-linux-s390x@1.2.4': + resolution: {integrity: sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==} + cpu: [s390x] + os: [linux] + + '@img/sharp-libvips-linux-x64@1.2.4': + resolution: {integrity: sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==} + cpu: [x64] + os: [linux] + + '@img/sharp-libvips-linuxmusl-arm64@1.2.4': + resolution: {integrity: sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==} + cpu: [arm64] + os: [linux] + + '@img/sharp-libvips-linuxmusl-x64@1.2.4': + resolution: {integrity: sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==} + cpu: [x64] + os: [linux] + + '@img/sharp-linux-arm64@0.34.5': + resolution: {integrity: sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm64] + os: [linux] + + '@img/sharp-linux-arm@0.34.5': + resolution: {integrity: sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm] + os: [linux] + + '@img/sharp-linux-ppc64@0.34.5': + resolution: {integrity: sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [ppc64] + os: [linux] + + '@img/sharp-linux-riscv64@0.34.5': + resolution: {integrity: sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [riscv64] + os: [linux] + + '@img/sharp-linux-s390x@0.34.5': + resolution: {integrity: sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [s390x] + os: [linux] + + '@img/sharp-linux-x64@0.34.5': + resolution: {integrity: sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [x64] + os: [linux] + + '@img/sharp-linuxmusl-arm64@0.34.5': + resolution: {integrity: sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm64] + os: [linux] + + '@img/sharp-linuxmusl-x64@0.34.5': + resolution: {integrity: sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [x64] + os: [linux] + + '@img/sharp-wasm32@0.34.5': + resolution: {integrity: sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [wasm32] + + '@img/sharp-win32-arm64@0.34.5': + resolution: {integrity: sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm64] + os: [win32] + + '@img/sharp-win32-ia32@0.34.5': + resolution: {integrity: sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [ia32] + os: [win32] + + '@img/sharp-win32-x64@0.34.5': + resolution: {integrity: sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [x64] + os: [win32] + + '@jridgewell/resolve-uri@3.1.2': + resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} + engines: {node: '>=6.0.0'} + + '@jridgewell/sourcemap-codec@1.5.5': + resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} + + '@jridgewell/trace-mapping@0.3.9': + resolution: {integrity: sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==} + + '@poppinss/colors@4.1.6': + resolution: {integrity: sha512-H9xkIdFswbS8n1d6vmRd8+c10t2Qe+rZITbbDHHkQixH5+2x1FDGmi/0K+WgWiqQFKPSlIYB7jlH6Kpfn6Fleg==} + + '@poppinss/dumper@0.6.5': + resolution: {integrity: sha512-NBdYIb90J7LfOI32dOewKI1r7wnkiH6m920puQ3qHUeZkxNkQiFnXVWoE6YtFSv6QOiPPf7ys6i+HWWecDz7sw==} + + '@poppinss/exception@1.2.3': + resolution: {integrity: sha512-dCED+QRChTVatE9ibtoaxc+WkdzOSjYTKi/+uacHWIsfodVfpsueo3+DKpgU5Px8qXjgmXkSvhXvSCz3fnP9lw==} + + '@sindresorhus/is@7.2.0': + resolution: {integrity: sha512-P1Cz1dWaFfR4IR+U13mqqiGsLFf1KbayybWwdd2vfctdV6hDpUkgCY0nKOLLTMSoRd/jJNjtbqzf13K8DCCXQw==} + engines: {node: '>=18'} + + '@speed-highlight/core@1.2.17': + resolution: {integrity: sha512-Z92FwKpCtfaW1V0jTU/fh3QzYEZN8wDwrzRIBoADCJfn4mJCNcJN/XegifX7BDrQ8/h9Xh/JnbyMchL0FqXrkg==} + '@types/node@22.19.7': resolution: {integrity: sha512-MciR4AKGHWl7xwxkBa6xUGxQJ4VBOmPTF7sL+iGzuahOFaO0jHCsuEfS80pan1ef4gWId1oWOweIhrDEYLuaOw==} + blake3-wasm@2.1.5: + resolution: {integrity: sha512-F1+K8EbfOZE49dtoPtmxUQrpXaBIl3ICvasLh+nJta0xkz+9kF/7uet9fLnwKqhDrmj6g+6K3Tw9yQPUg2ka5g==} + boolbase@1.0.0: resolution: {integrity: sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==} + cookie@1.1.1: + resolution: {integrity: sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==} + engines: {node: '>=18'} + css-select@6.0.0: resolution: {integrity: sha512-rZZVSLle8v0+EY8QAkDWrKhpgt6SA5OtHsgBnsj6ZaLb5dmDVOWUDtQitd9ydxxvEjhewNudS6eTVU7uOyzvXw==} @@ -220,6 +599,10 @@ packages: resolution: {integrity: sha512-wD5oz5xibMOPHzy13CyGmogB3phdvcDaB5t0W/Nr5Z2O/agcB8YwOz6e2Lsp10pNDzBoDO9nVa3RGs/2BttpHQ==} engines: {node: '>= 6'} + detect-libc@2.1.2: + resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} + engines: {node: '>=8'} + dom-serializer@2.0.0: resolution: {integrity: sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==} @@ -241,15 +624,28 @@ packages: resolution: {integrity: sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==} engines: {node: '>=0.12'} + error-stack-parser-es@1.0.5: + resolution: {integrity: sha512-5qucVt2XcuGMcEGgWI7i+yZpmpByQ8J1lHhcL7PwqCwu9FPP3VUXzT4ltHe5i2z9dePwEHcDVOAfSnHsOlCXRA==} + esbuild@0.27.2: resolution: {integrity: sha512-HyNQImnsOC7X9PMNaCIeAm4ISCQXs5a5YasTXVliKv4uuBo1dKrG0A+uQS8M5eXjVMnLg3WgXaKvprHlFJQffw==} engines: {node: '>=18'} hasBin: true + esbuild@0.28.1: + resolution: {integrity: sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==} + engines: {node: '>=18'} + hasBin: true + eta@3.5.0: resolution: {integrity: sha512-e3x3FBvGzeCIHhF+zhK8FZA2vC5uFn6b4HJjegUbIWrDb4mJ7JjTGMJY9VGIbRVpmSwHopNiaJibhjIr+HfLug==} engines: {node: '>=6.0.0'} + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + hono@4.11.5: resolution: {integrity: sha512-WemPi9/WfyMwZs+ZUXdiwcCh9Y+m7L+8vki9MzDw3jJ+W9Lc+12HGsd368Qc1vZi1xwW8BWMMsnK5efYKPdt4g==} engines: {node: '>=16.9.0'} @@ -257,14 +653,45 @@ packages: htmlparser2@10.1.0: resolution: {integrity: sha512-VTZkM9GWRAtEpveh7MSF6SjjrpNVNNVJfFup7xTY3UpFtm67foy9HDVXneLtFVt4pMz5kZtgNcvCniNFb1hlEQ==} + kleur@4.1.5: + resolution: {integrity: sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==} + engines: {node: '>=6'} + + miniflare@4.20260708.1: + resolution: {integrity: sha512-c94O9zRDISdqO18EHt6l0iF/fWgWt8p18PJvRsA/L/NJZ9Cfke3s/F5Blg1XXF7WDutVRzWVWy8Vy4LaT5ifsA==} + engines: {node: '>=22.0.0'} + hasBin: true + nth-check@2.1.1: resolution: {integrity: sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==} + path-to-regexp@6.3.0: + resolution: {integrity: sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==} + + pathe@2.0.3: + resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + prettier@3.8.1: resolution: {integrity: sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==} engines: {node: '>=14'} hasBin: true + semver@7.8.5: + resolution: {integrity: sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==} + engines: {node: '>=10'} + hasBin: true + + sharp@0.34.5: + resolution: {integrity: sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + + supports-color@10.2.2: + resolution: {integrity: sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g==} + engines: {node: '>=18'} + + tslib@2.8.1: + resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + typescript@5.9.3: resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} engines: {node: '>=14.17'} @@ -273,96 +700,371 @@ packages: undici-types@6.21.0: resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} + undici@7.28.0: + resolution: {integrity: sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA==} + engines: {node: '>=20.18.1'} + + unenv@2.0.0-rc.24: + resolution: {integrity: sha512-i7qRCmY42zmCwnYlh9H2SvLEypEFGye5iRmEMKjcGi7zk9UquigRjFtTLz0TYqr0ZGLZhaMHl/foy1bZR+Cwlw==} + + workerd@1.20260708.1: + resolution: {integrity: sha512-WAK+Kt/VVCSldH2qSr8lx46XCJ4Q+bdlHNaFqUtOHthBEIB8C1N8HVW+VOLrxDoTCk0NGNv0zajnBeQK4JOB9w==} + engines: {node: '>=16'} + hasBin: true + + wrangler@4.110.0: + resolution: {integrity: sha512-xZeXKYi7hxQRF5anL+v77RkufJNpF9f3Eqeyqq2QBsETpLZgh0Agj0jJ6JPtkbgn6ukZdh8OK5egsGPWIditgg==} + engines: {node: '>=22.0.0'} + hasBin: true + peerDependencies: + '@cloudflare/workers-types': ^5.20260708.1 + peerDependenciesMeta: + '@cloudflare/workers-types': + optional: true + + ws@8.21.0: + resolution: {integrity: sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==} + engines: {node: '>=10.0.0'} + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: '>=5.0.2' + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + + youch-core@0.3.3: + resolution: {integrity: sha512-ho7XuGjLaJ2hWHoK8yFnsUGy2Y5uDpqSTq1FkHLK4/oqKtyUU1AFbOOxY4IpC9f0fTLjwYbslUz0Po5BpD1wrA==} + + youch@4.1.0-beta.10: + resolution: {integrity: sha512-rLfVLB4FgQneDr0dv1oddCVZmKjcJ6yX6mS4pU82Mq/Dt9a3cLZQ62pDBL4AUO+uVrCvtWz3ZFUL2HFAFJ/BXQ==} + snapshots: + '@cloudflare/kv-asset-handler@0.5.0': {} + + '@cloudflare/unenv-preset@2.16.1(unenv@2.0.0-rc.24)(workerd@1.20260708.1)': + dependencies: + unenv: 2.0.0-rc.24 + optionalDependencies: + workerd: 1.20260708.1 + + '@cloudflare/workerd-darwin-64@1.20260708.1': + optional: true + + '@cloudflare/workerd-darwin-arm64@1.20260708.1': + optional: true + + '@cloudflare/workerd-linux-64@1.20260708.1': + optional: true + + '@cloudflare/workerd-linux-arm64@1.20260708.1': + optional: true + + '@cloudflare/workerd-windows-64@1.20260708.1': + optional: true + + '@cspotcode/source-map-support@0.8.1': + dependencies: + '@jridgewell/trace-mapping': 0.3.9 + + '@emnapi/runtime@1.11.2': + dependencies: + tslib: 2.8.1 + optional: true + '@esbuild/aix-ppc64@0.27.2': optional: true + '@esbuild/aix-ppc64@0.28.1': + optional: true + '@esbuild/android-arm64@0.27.2': optional: true + '@esbuild/android-arm64@0.28.1': + optional: true + '@esbuild/android-arm@0.27.2': optional: true + '@esbuild/android-arm@0.28.1': + optional: true + '@esbuild/android-x64@0.27.2': optional: true + '@esbuild/android-x64@0.28.1': + optional: true + '@esbuild/darwin-arm64@0.27.2': optional: true + '@esbuild/darwin-arm64@0.28.1': + optional: true + '@esbuild/darwin-x64@0.27.2': optional: true + '@esbuild/darwin-x64@0.28.1': + optional: true + '@esbuild/freebsd-arm64@0.27.2': optional: true + '@esbuild/freebsd-arm64@0.28.1': + optional: true + '@esbuild/freebsd-x64@0.27.2': optional: true + '@esbuild/freebsd-x64@0.28.1': + optional: true + '@esbuild/linux-arm64@0.27.2': optional: true + '@esbuild/linux-arm64@0.28.1': + optional: true + '@esbuild/linux-arm@0.27.2': optional: true + '@esbuild/linux-arm@0.28.1': + optional: true + '@esbuild/linux-ia32@0.27.2': optional: true + '@esbuild/linux-ia32@0.28.1': + optional: true + '@esbuild/linux-loong64@0.27.2': optional: true + '@esbuild/linux-loong64@0.28.1': + optional: true + '@esbuild/linux-mips64el@0.27.2': optional: true + '@esbuild/linux-mips64el@0.28.1': + optional: true + '@esbuild/linux-ppc64@0.27.2': optional: true + '@esbuild/linux-ppc64@0.28.1': + optional: true + '@esbuild/linux-riscv64@0.27.2': optional: true + '@esbuild/linux-riscv64@0.28.1': + optional: true + '@esbuild/linux-s390x@0.27.2': optional: true + '@esbuild/linux-s390x@0.28.1': + optional: true + '@esbuild/linux-x64@0.27.2': optional: true + '@esbuild/linux-x64@0.28.1': + optional: true + '@esbuild/netbsd-arm64@0.27.2': optional: true + '@esbuild/netbsd-arm64@0.28.1': + optional: true + '@esbuild/netbsd-x64@0.27.2': optional: true + '@esbuild/netbsd-x64@0.28.1': + optional: true + '@esbuild/openbsd-arm64@0.27.2': optional: true + '@esbuild/openbsd-arm64@0.28.1': + optional: true + '@esbuild/openbsd-x64@0.27.2': optional: true + '@esbuild/openbsd-x64@0.28.1': + optional: true + '@esbuild/openharmony-arm64@0.27.2': optional: true + '@esbuild/openharmony-arm64@0.28.1': + optional: true + '@esbuild/sunos-x64@0.27.2': optional: true + '@esbuild/sunos-x64@0.28.1': + optional: true + '@esbuild/win32-arm64@0.27.2': optional: true + '@esbuild/win32-arm64@0.28.1': + optional: true + '@esbuild/win32-ia32@0.27.2': optional: true + '@esbuild/win32-ia32@0.28.1': + optional: true + '@esbuild/win32-x64@0.27.2': optional: true + '@esbuild/win32-x64@0.28.1': + optional: true + '@hono/node-server@1.19.9(hono@4.11.5)': dependencies: hono: 4.11.5 + '@img/colour@1.1.0': {} + + '@img/sharp-darwin-arm64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-darwin-arm64': 1.2.4 + optional: true + + '@img/sharp-darwin-x64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-darwin-x64': 1.2.4 + optional: true + + '@img/sharp-libvips-darwin-arm64@1.2.4': + optional: true + + '@img/sharp-libvips-darwin-x64@1.2.4': + optional: true + + '@img/sharp-libvips-linux-arm64@1.2.4': + optional: true + + '@img/sharp-libvips-linux-arm@1.2.4': + optional: true + + '@img/sharp-libvips-linux-ppc64@1.2.4': + optional: true + + '@img/sharp-libvips-linux-riscv64@1.2.4': + optional: true + + '@img/sharp-libvips-linux-s390x@1.2.4': + optional: true + + '@img/sharp-libvips-linux-x64@1.2.4': + optional: true + + '@img/sharp-libvips-linuxmusl-arm64@1.2.4': + optional: true + + '@img/sharp-libvips-linuxmusl-x64@1.2.4': + optional: true + + '@img/sharp-linux-arm64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-arm64': 1.2.4 + optional: true + + '@img/sharp-linux-arm@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-arm': 1.2.4 + optional: true + + '@img/sharp-linux-ppc64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-ppc64': 1.2.4 + optional: true + + '@img/sharp-linux-riscv64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-riscv64': 1.2.4 + optional: true + + '@img/sharp-linux-s390x@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-s390x': 1.2.4 + optional: true + + '@img/sharp-linux-x64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-x64': 1.2.4 + optional: true + + '@img/sharp-linuxmusl-arm64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linuxmusl-arm64': 1.2.4 + optional: true + + '@img/sharp-linuxmusl-x64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linuxmusl-x64': 1.2.4 + optional: true + + '@img/sharp-wasm32@0.34.5': + dependencies: + '@emnapi/runtime': 1.11.2 + optional: true + + '@img/sharp-win32-arm64@0.34.5': + optional: true + + '@img/sharp-win32-ia32@0.34.5': + optional: true + + '@img/sharp-win32-x64@0.34.5': + optional: true + + '@jridgewell/resolve-uri@3.1.2': {} + + '@jridgewell/sourcemap-codec@1.5.5': {} + + '@jridgewell/trace-mapping@0.3.9': + dependencies: + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.5.5 + + '@poppinss/colors@4.1.6': + dependencies: + kleur: 4.1.5 + + '@poppinss/dumper@0.6.5': + dependencies: + '@poppinss/colors': 4.1.6 + '@sindresorhus/is': 7.2.0 + supports-color: 10.2.2 + + '@poppinss/exception@1.2.3': {} + + '@sindresorhus/is@7.2.0': {} + + '@speed-highlight/core@1.2.17': {} + '@types/node@22.19.7': dependencies: undici-types: 6.21.0 + blake3-wasm@2.1.5: {} + boolbase@1.0.0: {} + cookie@1.1.1: {} + css-select@6.0.0: dependencies: boolbase: 1.0.0 @@ -373,6 +1075,8 @@ snapshots: css-what@7.0.0: {} + detect-libc@2.1.2: {} + dom-serializer@2.0.0: dependencies: domelementtype: 2.3.0 @@ -395,6 +1099,8 @@ snapshots: entities@7.0.1: {} + error-stack-parser-es@1.0.5: {} + esbuild@0.27.2: optionalDependencies: '@esbuild/aix-ppc64': 0.27.2 @@ -424,8 +1130,40 @@ snapshots: '@esbuild/win32-ia32': 0.27.2 '@esbuild/win32-x64': 0.27.2 + esbuild@0.28.1: + optionalDependencies: + '@esbuild/aix-ppc64': 0.28.1 + '@esbuild/android-arm': 0.28.1 + '@esbuild/android-arm64': 0.28.1 + '@esbuild/android-x64': 0.28.1 + '@esbuild/darwin-arm64': 0.28.1 + '@esbuild/darwin-x64': 0.28.1 + '@esbuild/freebsd-arm64': 0.28.1 + '@esbuild/freebsd-x64': 0.28.1 + '@esbuild/linux-arm': 0.28.1 + '@esbuild/linux-arm64': 0.28.1 + '@esbuild/linux-ia32': 0.28.1 + '@esbuild/linux-loong64': 0.28.1 + '@esbuild/linux-mips64el': 0.28.1 + '@esbuild/linux-ppc64': 0.28.1 + '@esbuild/linux-riscv64': 0.28.1 + '@esbuild/linux-s390x': 0.28.1 + '@esbuild/linux-x64': 0.28.1 + '@esbuild/netbsd-arm64': 0.28.1 + '@esbuild/netbsd-x64': 0.28.1 + '@esbuild/openbsd-arm64': 0.28.1 + '@esbuild/openbsd-x64': 0.28.1 + '@esbuild/openharmony-arm64': 0.28.1 + '@esbuild/sunos-x64': 0.28.1 + '@esbuild/win32-arm64': 0.28.1 + '@esbuild/win32-ia32': 0.28.1 + '@esbuild/win32-x64': 0.28.1 + eta@3.5.0: {} + fsevents@2.3.3: + optional: true + hono@4.11.5: {} htmlparser2@10.1.0: @@ -435,12 +1173,113 @@ snapshots: domutils: 3.2.2 entities: 7.0.1 + kleur@4.1.5: {} + + miniflare@4.20260708.1: + dependencies: + '@cspotcode/source-map-support': 0.8.1 + sharp: 0.34.5 + undici: 7.28.0 + workerd: 1.20260708.1 + ws: 8.21.0 + youch: 4.1.0-beta.10 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + nth-check@2.1.1: dependencies: boolbase: 1.0.0 + path-to-regexp@6.3.0: {} + + pathe@2.0.3: {} + prettier@3.8.1: {} + semver@7.8.5: {} + + sharp@0.34.5: + dependencies: + '@img/colour': 1.1.0 + detect-libc: 2.1.2 + semver: 7.8.5 + optionalDependencies: + '@img/sharp-darwin-arm64': 0.34.5 + '@img/sharp-darwin-x64': 0.34.5 + '@img/sharp-libvips-darwin-arm64': 1.2.4 + '@img/sharp-libvips-darwin-x64': 1.2.4 + '@img/sharp-libvips-linux-arm': 1.2.4 + '@img/sharp-libvips-linux-arm64': 1.2.4 + '@img/sharp-libvips-linux-ppc64': 1.2.4 + '@img/sharp-libvips-linux-riscv64': 1.2.4 + '@img/sharp-libvips-linux-s390x': 1.2.4 + '@img/sharp-libvips-linux-x64': 1.2.4 + '@img/sharp-libvips-linuxmusl-arm64': 1.2.4 + '@img/sharp-libvips-linuxmusl-x64': 1.2.4 + '@img/sharp-linux-arm': 0.34.5 + '@img/sharp-linux-arm64': 0.34.5 + '@img/sharp-linux-ppc64': 0.34.5 + '@img/sharp-linux-riscv64': 0.34.5 + '@img/sharp-linux-s390x': 0.34.5 + '@img/sharp-linux-x64': 0.34.5 + '@img/sharp-linuxmusl-arm64': 0.34.5 + '@img/sharp-linuxmusl-x64': 0.34.5 + '@img/sharp-wasm32': 0.34.5 + '@img/sharp-win32-arm64': 0.34.5 + '@img/sharp-win32-ia32': 0.34.5 + '@img/sharp-win32-x64': 0.34.5 + + supports-color@10.2.2: {} + + tslib@2.8.1: + optional: true + typescript@5.9.3: {} undici-types@6.21.0: {} + + undici@7.28.0: {} + + unenv@2.0.0-rc.24: + dependencies: + pathe: 2.0.3 + + workerd@1.20260708.1: + optionalDependencies: + '@cloudflare/workerd-darwin-64': 1.20260708.1 + '@cloudflare/workerd-darwin-arm64': 1.20260708.1 + '@cloudflare/workerd-linux-64': 1.20260708.1 + '@cloudflare/workerd-linux-arm64': 1.20260708.1 + '@cloudflare/workerd-windows-64': 1.20260708.1 + + wrangler@4.110.0: + dependencies: + '@cloudflare/kv-asset-handler': 0.5.0 + '@cloudflare/unenv-preset': 2.16.1(unenv@2.0.0-rc.24)(workerd@1.20260708.1) + blake3-wasm: 2.1.5 + esbuild: 0.28.1 + miniflare: 4.20260708.1 + path-to-regexp: 6.3.0 + unenv: 2.0.0-rc.24 + workerd: 1.20260708.1 + optionalDependencies: + fsevents: 2.3.3 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + + ws@8.21.0: {} + + youch-core@0.3.3: + dependencies: + '@poppinss/exception': 1.2.3 + error-stack-parser-es: 1.0.5 + + youch@4.1.0-beta.10: + dependencies: + '@poppinss/colors': 4.1.6 + '@poppinss/dumper': 0.6.5 + '@speed-highlight/core': 1.2.17 + cookie: 1.1.1 + youch-core: 0.3.3 diff --git a/cgithub/public/static/icons/eye.svg b/cgithub/public/static/icons/eye.svg new file mode 100644 index 0000000..f6f8230 --- /dev/null +++ b/cgithub/public/static/icons/eye.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/cgithub/public/static/icons/file.svg b/cgithub/public/static/icons/file.svg new file mode 100644 index 0000000..f2e7629 --- /dev/null +++ b/cgithub/public/static/icons/file.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/cgithub/public/static/icons/folder-simple.svg b/cgithub/public/static/icons/folder-simple.svg new file mode 100644 index 0000000..36acab2 --- /dev/null +++ b/cgithub/public/static/icons/folder-simple.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/cgithub/public/static/icons/git-branch.svg b/cgithub/public/static/icons/git-branch.svg new file mode 100644 index 0000000..4b442e2 --- /dev/null +++ b/cgithub/public/static/icons/git-branch.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/cgithub/public/static/icons/git-fork.svg b/cgithub/public/static/icons/git-fork.svg new file mode 100644 index 0000000..ca318b2 --- /dev/null +++ b/cgithub/public/static/icons/git-fork.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/cgithub/public/static/icons/link.svg b/cgithub/public/static/icons/link.svg new file mode 100644 index 0000000..25ffe17 --- /dev/null +++ b/cgithub/public/static/icons/link.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/cgithub/public/static/icons/star.svg b/cgithub/public/static/icons/star.svg new file mode 100644 index 0000000..4e640f4 --- /dev/null +++ b/cgithub/public/static/icons/star.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/cgithub/public/static/style.css b/cgithub/public/static/style.css new file mode 100644 index 0000000..e698993 --- /dev/null +++ b/cgithub/public/static/style.css @@ -0,0 +1,282 @@ +:root { + color-scheme: light dark; + scrollbar-color: var(--border-color) var(--body-background); + + --body-background: light-dark( + oklch(95% 0.01 250), + oklch(20% 0.01 250) + ); + + --text-color: light-dark( + oklch(5% 0.01 250), + oklch(90% 0.01 250) + ); + + --border-color: light-dark( + oklch(80% 0.01 250), + oklch(40% 0.01 250) + ); + + --shade-background: light-dark( + oklch(90% 0.01 250), + oklch(30% 0.01 250) + ); + + --link-color: light-dark( + oklch(50% 0.15 250), + oklch(60% 0.15 250) + ); + + --pl-c: light-dark(#6e7781, #8b949e); + --pl-c1: light-dark(#0550ae, #79c0ff); + --pl-e: light-dark(#6639ba, #d2a8ff); + --pl-en: light-dark(#0550ae, #d2a8ff); + --pl-k: light-dark(#cf222e, #ff7b72); + --pl-s: light-dark(#0a3069, #a5d6ff); + --pl-v: light-dark(#953800, #ffa657); +} + +body { + margin: 1rem; + font-family: Helvetica, Arial, sans-serif; + background: var(--body-background); + color: var(--text-color); +} + +a { + text-decoration: none; + color: var(--link-color); +} + +h1, h2, h3, h4, h5, h6, +p, pre, ul, ol { + margin: .5em 0; +} + +input, select, button { + background-color: var(--body-background); + color: var(--text-color); + border: 1px solid var(--border-color); +} + +table { + width: 100%; + border-collapse: collapse; +} + +tr { + border: 1px solid var(--border-color); +} + +td { + padding: .25rem; + vertical-align: top; + &:first-child { + padding-left: .5rem; + } + &:last-child { + padding-right: .5rem; + } +} + +nav { + display: flex; + margin: .5em 0; + > a { + display: block; + padding-right: .5rem; + + a { + padding-left: .5rem; + border-left: 1px solid var(--border-color); + } + &:last-child { + padding-right: 0; + } + } +} + +section { + margin: 1rem 0; +} + +.box { + --box-padding: .5rem; + + margin: 1rem 0; + 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; + padding: .5rem; + border-bottom: 1px solid var(--border-color); + } + + > footer:last-child { + margin: 0 calc(-1 * var(--box-padding)); + padding: .5rem; + border-top: 1px solid var(--border-color); + } +} + +body > footer { + margin-top: 1rem; + text-align: center; +} + +.icon { + height: 1lh; + vertical-align: text-bottom; +} + +@media (prefers-color-scheme: dark) { + .icon { + filter: brightness(0) invert(1); + } +} + +.repo { + font-size: 120%; + font-weight: bold; +} + +.avatar { + float: right; + clip-path: circle(); +} + +.branch { + display: inline-block; + margin-right: 1rem; + padding: .2rem; + border: 1px solid var(--border-color); +} + +.repo-info { + .description { + margin-bottom: .2rem; + } + + .detail { + white-space: nowrap; + } + + div.detail { + overflow-x: hidden; + text-overflow: ellipsis; + } + + span.detail + span.detail { + margin-left: 1rem; + } +} + +pre.box { + /* Instead of scrolling overflow for long lines, grow the box. */ + width: fit-content; +} + +.overview, .release { + overflow-wrap: break-word; + + /* This is a bit convoluted: we want the effect of padding but using margins + * vertically so we get margin collapsing. */ + --box-padding: 1rem; + padding-top: 0; + padding-bottom: 0; + > header:first-child { + margin-top: 0; + } + > :first-child, > header:first-child + * { + margin-top: 1rem; + } + > :last-child { + margin-bottom: 1rem; + } + + a { + text-decoration: underline; + } + + h1, h2 { + border-bottom: 1px solid var(--border-color); + } + + img, video { + max-width: 100%; + } + + code, pre { + background: var(--shade-background); + } + + pre { + overflow-x: auto; + scrollbar-color: var(--border-color) var(--shade-background); + } +} + +.overview > header { + font-weight: bold; +} + +.release > header { + display: flex; + align-items: baseline; + > :first-child { + flex-grow: 1; + font-weight: bold; + } +} + +.timestamp { + white-space: nowrap; + font-size: .8rem; +} + +.commit { + display: flex; + justify-content: space-between; + padding: 0.5rem; + border: 1px solid var(--border-color); + + .commit { + border-top: none; + } +} + +.commit-author { + font-size: 0.8rem; + margin-top: 0.2rem; +} + +.commit-sha { + align-self: center; + font-size: 0.8rem; +} + +/* TODO Strip permalinks? */ +svg.octicon-link { + display: none; +} + +.pl-c { color: var(--pl-c); } +.pl-c1 { color: var(--pl-c1); } +.pl-e { color: var(--pl-e); } +.pl-en { color: var(--pl-en); } +.pl-k { color: var(--pl-k); } +.pl-s { color: var(--pl-s); } +.pl-v { color: var(--pl-v); } +.pl-ent { color: var(--pl-k); } +.pl-mh { font-weight: bold; color: var(--pl-en); } +.pl-bu { color: var(--pl-k); } +.pl-ii { color: var(--text-color); background-color: var(--pl-k); } +.pl-pds { color: var(--pl-s); } +.pl-smi { color: var(--text-color); } diff --git a/cgithub/scripts/compile-views.ts b/cgithub/scripts/compile-views.ts new file mode 100644 index 0000000..785efc1 --- /dev/null +++ b/cgithub/scripts/compile-views.ts @@ -0,0 +1,29 @@ +// Precompiles views/*.eta into plain functions for the Cloudflare Workers +// build (src/views.generated.ts). Workers disallow runtime code generation +// (`new Function`), so Eta cannot compile templates there at request time. +import { Eta } from "eta"; +import fs from "node:fs"; +import path from "node:path"; + +const root = path.join(import.meta.dirname, ".."); +const viewsDir = path.join(root, "views"); +const eta = new Eta({ views: viewsDir }); + +const names = fs + .readdirSync(viewsDir) + .filter((name) => name.endsWith(".eta")) + .sort(); + +let out = `// @ts-nocheck +// Generated by scripts/compile-views.ts -- do not edit. +export const views = { +`; +for (const name of names) { + const source = fs.readFileSync(path.join(viewsDir, name), "utf8"); + const body = eta.compileToString(source); + out += ` ${JSON.stringify(name)}: function (it, options) {\n${body}\n },\n`; +} +out += "};\n"; + +fs.writeFileSync(path.join(root, "src", "views.generated.ts"), out); +console.log(`Compiled ${names.length} templates to src/views.generated.ts`); diff --git a/cgithub/src/app.ts b/cgithub/src/app.ts new file mode 100644 index 0000000..78b59ae --- /dev/null +++ b/cgithub/src/app.ts @@ -0,0 +1,132 @@ +import type { Eta } from "eta"; +import { type Context, Hono } from "hono"; +import type { StatusCode } from "hono/utils/http-status"; +import { + getGitHubBlob, + getGitHubCommits, + getGitHubIssues, + getGitHubOwner, + getGitHubPulls, + getGitHubRelease, + getGitHubReleases, + getGitHubRepo, + getGitHubTree, + GitHubHTTPError, + RedirectError, +} from "./scraper.ts"; + +export function createApp(eta: Eta) { + const app = new Hono(); + + app.get("/", async (c) => { + return c.html(eta.render("home.eta", {})); + }); + + async function tryRender(c: Context, template: string, promise: Promise) { + let data: T; + try { + data = await promise; + } catch (e) { + if (e instanceof RedirectError) { + if (e.location.startsWith("https://")) { + c.header("Referrer-Policy", "no-referrer"); + } + return c.redirect(e.location); + } else if (e instanceof GitHubHTTPError) { + c.status(e.status as StatusCode); + const message = `GitHub responded with HTTP ${e.status} ${e.message}`; + return c.html(eta.render("error.eta", { title: e.message, message })); + } else { + c.status(500); + return c.html(eta.render("error.eta", { message: "" + e })); + } + } + return c.html(eta.render(template, data)); + } + + app.get("/:owner", async (c) => { + const { owner } = c.req.param(); + return tryRender(c, "owner.eta", getGitHubOwner(owner)); + }); + + app.get("/:owner/:repo", async (c) => { + const { owner, repo } = c.req.param(); + return tryRender(c, "repo.eta", getGitHubRepo(owner, repo)); + }); + + app.get("/:owner/:repo/tree/:branch/:path{.*}?", async (c) => { + const { owner, repo, branch, path = "" } = c.req.param(); + return tryRender(c, "tree.eta", getGitHubTree(owner, repo, branch, path)); + }); + + app.get("/:owner/:repo/blob/:branch/:path{.*}", async (c) => { + const { owner, repo, branch, path } = c.req.param(); + return tryRender(c, "blob.eta", getGitHubBlob(owner, repo, branch, path)); + }); + + app.get("/:owner/:repo/raw/:branch/:path{.*}", async (c) => { + const { owner, repo, branch, path } = c.req.param(); + c.header("Referrer-Policy", "no-referrer"); + return c.redirect(`https://raw.githubusercontent.com/${owner}/${repo}/${branch}/${path}`); + }); + + app.get("/:owner/:repo/issues", async (c) => { + const { owner, repo } = c.req.param(); + const q = c.req.query("q"); + + // GitHub supports combined issue/PR search, apparently for legacy. We just + // redirect to issue-only search. + if (q && !/\bis:issue\b/.test(q)) { + return c.redirect(`/${owner}/${repo}/issues?q=${encodeURIComponent(`is:issue ${q}`)}`); + } + + return tryRender(c, "issues.eta", getGitHubIssues(owner, repo, { q })); + }); + + app.get("/:owner/:repo/pulls", async (c) => { + const { owner, repo } = c.req.param(); + const q = c.req.query("q"); + return tryRender(c, "pulls.eta", getGitHubPulls(owner, repo, { q })); + }); + + app.get("/:owner/:repo/search", async (c) => { + const { owner, repo } = c.req.param(); + const q = c.req.query("q") ?? ""; + const type = c.req.query("type") ?? "issues"; + + if (type === "issues") { + return c.redirect(`/${owner}/${repo}/issues?q=${encodeURIComponent(q)}`); + } else if (type === "pullrequests") { + return c.redirect(`/${owner}/${repo}/pulls?q=${encodeURIComponent(q)}`); + } + + // Redirect unhandled search types (like code, which requires sign in anyway). + c.header("Referrer-Policy", "no-referrer"); + return c.redirect( + `https://github.com/${owner}/${repo}/search?${new URLSearchParams(c.req.query()).toString()}`, + ); + }); + + app.get("/:owner/:repo/commits/:branch/:path{.*}?", async (c) => { + const { owner, repo, branch, path = "" } = c.req.param(); + return tryRender(c, "commits.eta", getGitHubCommits(owner, repo, branch, path)); + }); + + app.get("/:owner/:repo/releases/tag/:tag", async (c) => { + const { owner, repo, tag } = c.req.param(); + return tryRender(c, "release.eta", getGitHubRelease(owner, repo, tag)); + }); + + app.get("/:owner/:repo/releases", async (c) => { + const { owner, repo } = c.req.param(); + return tryRender(c, "releases.eta", getGitHubReleases(owner, repo)); + }); + + app.all("*", async (c) => { + const path = c.req.path; + c.header("Referrer-Policy", "no-referrer"); + return c.redirect(`https://github.com${path}`); + }); + + return app; +} diff --git a/cgithub/src/index.ts b/cgithub/src/index.ts index f676bd6..65c46d9 100644 --- a/cgithub/src/index.ts +++ b/cgithub/src/index.ts @@ -1,140 +1,18 @@ import { createAdaptorServer, serve } from "@hono/node-server"; import { serveStatic } from "@hono/node-server/serve-static"; import { Eta } from "eta"; -import { type Context, Hono } from "hono"; -import type { StatusCode } from "hono/utils/http-status"; +import { Hono } from "hono"; import path from "node:path"; import url from "node:url"; -import { - getGitHubBlob, - getGitHubCommits, - getGitHubIssues, - getGitHubOwner, - getGitHubPulls, - getGitHubRelease, - getGitHubReleases, - getGitHubRepo, - getGitHubTree, - GitHubHTTPError, - RedirectError, -} from "./scraper.ts"; +import { createApp } from "./app.ts"; const __dirname = path.dirname(url.fileURLToPath(import.meta.url)); -const app = new Hono(); const eta = new Eta({ views: path.join(__dirname, "..", "views") }); -app.use("/static/*", serveStatic({ root: path.join(__dirname, "..") })); - -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 RedirectError) { - if (e.location.startsWith("https://")) { - c.header("Referrer-Policy", "no-referrer"); - } - return c.redirect(e.location); - } else if (e instanceof GitHubHTTPError) { - c.status(e.status as StatusCode); - const message = `GitHub responded with HTTP ${e.status} ${e.message}`; - return c.html(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", async (c) => { - const { owner } = c.req.param(); - return tryRender(c, "owner.eta", getGitHubOwner(owner)); -}); - -app.get("/:owner/:repo", async (c) => { - const { owner, repo } = c.req.param(); - return tryRender(c, "repo.eta", getGitHubRepo(owner, repo)); -}); - -app.get("/:owner/:repo/tree/:branch/:path{.*}?", async (c) => { - const { owner, repo, branch, path = "" } = c.req.param(); - return tryRender(c, "tree.eta", getGitHubTree(owner, repo, branch, path)); -}); - -app.get("/:owner/:repo/blob/:branch/:path{.*}", async (c) => { - const { owner, repo, branch, path } = c.req.param(); - return tryRender(c, "blob.eta", getGitHubBlob(owner, repo, branch, path)); -}); - -app.get("/:owner/:repo/raw/:branch/:path{.*}", async (c) => { - const { owner, repo, branch, path } = c.req.param(); - c.header("Referrer-Policy", "no-referrer"); - return c.redirect(`https://raw.githubusercontent.com/${owner}/${repo}/${branch}/${path}`); -}); - -app.get("/:owner/:repo/issues", async (c) => { - const { owner, repo } = c.req.param(); - const q = c.req.query("q"); - - // GitHub supports combined issue/PR search, apparently for legacy. We just - // redirect to issue-only search. - if (q && !/\bis:issue\b/.test(q)) { - return c.redirect(`/${owner}/${repo}/issues?q=${encodeURIComponent(`is:issue ${q}`)}`); - } - - return tryRender(c, "issues.eta", getGitHubIssues(owner, repo, { q })); -}); - -app.get("/:owner/:repo/pulls", async (c) => { - const { owner, repo } = c.req.param(); - const q = c.req.query("q"); - return tryRender(c, "pulls.eta", getGitHubPulls(owner, repo, { q })); -}); - -app.get("/:owner/:repo/search", async (c) => { - const { owner, repo } = c.req.param(); - const q = c.req.query("q") ?? ""; - const type = c.req.query("type") ?? "issues"; - - if (type === "issues") { - return c.redirect(`/${owner}/${repo}/issues?q=${encodeURIComponent(q)}`); - } else if (type === "pullrequests") { - return c.redirect(`/${owner}/${repo}/pulls?q=${encodeURIComponent(q)}`); - } - - // Redirect unhandled search types (like code, which requires sign in anyway). - c.header("Referrer-Policy", "no-referrer"); - return c.redirect( - `https://github.com/${owner}/${repo}/search?${new URLSearchParams(c.req.query()).toString()}`, - ); -}); - -app.get("/:owner/:repo/commits/:branch/:path{.*}?", async (c) => { - const { owner, repo, branch, path = "" } = c.req.param(); - return tryRender(c, "commits.eta", getGitHubCommits(owner, repo, branch, path)); -}); - -app.get("/:owner/:repo/releases/tag/:tag", async (c) => { - const { owner, repo, tag } = c.req.param(); - return tryRender(c, "release.eta", getGitHubRelease(owner, repo, tag)); -}); - -app.get("/:owner/:repo/releases", async (c) => { - const { owner, repo } = c.req.param(); - return tryRender(c, "releases.eta", getGitHubReleases(owner, repo)); -}); - -app.all("*", async (c) => { - const path = c.req.path; - c.header("Referrer-Policy", "no-referrer"); - return c.redirect(`https://github.com${path}`); -}); +const app = new Hono(); +app.use("/static/*", serveStatic({ root: path.join(__dirname, "..", "public") })); +app.route("/", createApp(eta)); if (process.env.LISTEN_FDS === "1") { const server = createAdaptorServer(app); diff --git a/cgithub/src/worker.ts b/cgithub/src/worker.ts new file mode 100644 index 0000000..024b01a --- /dev/null +++ b/cgithub/src/worker.ts @@ -0,0 +1,27 @@ +// Cloudflare Workers entry point. Static files under public/ are served by +// Workers Static Assets before the worker runs (see wrangler.jsonc). +import { Eta } from "eta"; +import { createApp } from "./app.ts"; +import { views } from "./views.generated.ts"; + +// Workers disallow runtime code generation, so templates are precompiled by +// scripts/compile-views.ts and preloaded into Eta's cache here. With +// cache: true, Eta serves them from the cache and never compiles. +const eta = new Eta({ cache: true }); + +eta.resolvePath = (templatePath: string) => { + let name = templatePath.replace(/^\.\//, ""); + if (!name.endsWith(".eta")) name += ".eta"; + return name; +}; + +// Only reached on a cache miss, i.e. a template missing from the build. +eta.readFile = (path: string) => { + throw new Error(`Unknown template: ${path}`); +}; + +for (const [name, fn] of Object.entries(views)) { + eta.loadTemplate(name, fn); +} + +export default createApp(eta); diff --git a/cgithub/static/icons/eye.svg b/cgithub/static/icons/eye.svg deleted file mode 100644 index f6f8230..0000000 --- a/cgithub/static/icons/eye.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/cgithub/static/icons/file.svg b/cgithub/static/icons/file.svg deleted file mode 100644 index f2e7629..0000000 --- a/cgithub/static/icons/file.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/cgithub/static/icons/folder-simple.svg b/cgithub/static/icons/folder-simple.svg deleted file mode 100644 index 36acab2..0000000 --- a/cgithub/static/icons/folder-simple.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/cgithub/static/icons/git-branch.svg b/cgithub/static/icons/git-branch.svg deleted file mode 100644 index 4b442e2..0000000 --- a/cgithub/static/icons/git-branch.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/cgithub/static/icons/git-fork.svg b/cgithub/static/icons/git-fork.svg deleted file mode 100644 index ca318b2..0000000 --- a/cgithub/static/icons/git-fork.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/cgithub/static/icons/link.svg b/cgithub/static/icons/link.svg deleted file mode 100644 index 25ffe17..0000000 --- a/cgithub/static/icons/link.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/cgithub/static/icons/star.svg b/cgithub/static/icons/star.svg deleted file mode 100644 index 4e640f4..0000000 --- a/cgithub/static/icons/star.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/cgithub/static/style.css b/cgithub/static/style.css deleted file mode 100644 index e698993..0000000 --- a/cgithub/static/style.css +++ /dev/null @@ -1,282 +0,0 @@ -:root { - color-scheme: light dark; - scrollbar-color: var(--border-color) var(--body-background); - - --body-background: light-dark( - oklch(95% 0.01 250), - oklch(20% 0.01 250) - ); - - --text-color: light-dark( - oklch(5% 0.01 250), - oklch(90% 0.01 250) - ); - - --border-color: light-dark( - oklch(80% 0.01 250), - oklch(40% 0.01 250) - ); - - --shade-background: light-dark( - oklch(90% 0.01 250), - oklch(30% 0.01 250) - ); - - --link-color: light-dark( - oklch(50% 0.15 250), - oklch(60% 0.15 250) - ); - - --pl-c: light-dark(#6e7781, #8b949e); - --pl-c1: light-dark(#0550ae, #79c0ff); - --pl-e: light-dark(#6639ba, #d2a8ff); - --pl-en: light-dark(#0550ae, #d2a8ff); - --pl-k: light-dark(#cf222e, #ff7b72); - --pl-s: light-dark(#0a3069, #a5d6ff); - --pl-v: light-dark(#953800, #ffa657); -} - -body { - margin: 1rem; - font-family: Helvetica, Arial, sans-serif; - background: var(--body-background); - color: var(--text-color); -} - -a { - text-decoration: none; - color: var(--link-color); -} - -h1, h2, h3, h4, h5, h6, -p, pre, ul, ol { - margin: .5em 0; -} - -input, select, button { - background-color: var(--body-background); - color: var(--text-color); - border: 1px solid var(--border-color); -} - -table { - width: 100%; - border-collapse: collapse; -} - -tr { - border: 1px solid var(--border-color); -} - -td { - padding: .25rem; - vertical-align: top; - &:first-child { - padding-left: .5rem; - } - &:last-child { - padding-right: .5rem; - } -} - -nav { - display: flex; - margin: .5em 0; - > a { - display: block; - padding-right: .5rem; - + a { - padding-left: .5rem; - border-left: 1px solid var(--border-color); - } - &:last-child { - padding-right: 0; - } - } -} - -section { - margin: 1rem 0; -} - -.box { - --box-padding: .5rem; - - margin: 1rem 0; - 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; - padding: .5rem; - border-bottom: 1px solid var(--border-color); - } - - > footer:last-child { - margin: 0 calc(-1 * var(--box-padding)); - padding: .5rem; - border-top: 1px solid var(--border-color); - } -} - -body > footer { - margin-top: 1rem; - text-align: center; -} - -.icon { - height: 1lh; - vertical-align: text-bottom; -} - -@media (prefers-color-scheme: dark) { - .icon { - filter: brightness(0) invert(1); - } -} - -.repo { - font-size: 120%; - font-weight: bold; -} - -.avatar { - float: right; - clip-path: circle(); -} - -.branch { - display: inline-block; - margin-right: 1rem; - padding: .2rem; - border: 1px solid var(--border-color); -} - -.repo-info { - .description { - margin-bottom: .2rem; - } - - .detail { - white-space: nowrap; - } - - div.detail { - overflow-x: hidden; - text-overflow: ellipsis; - } - - span.detail + span.detail { - margin-left: 1rem; - } -} - -pre.box { - /* Instead of scrolling overflow for long lines, grow the box. */ - width: fit-content; -} - -.overview, .release { - overflow-wrap: break-word; - - /* This is a bit convoluted: we want the effect of padding but using margins - * vertically so we get margin collapsing. */ - --box-padding: 1rem; - padding-top: 0; - padding-bottom: 0; - > header:first-child { - margin-top: 0; - } - > :first-child, > header:first-child + * { - margin-top: 1rem; - } - > :last-child { - margin-bottom: 1rem; - } - - a { - text-decoration: underline; - } - - h1, h2 { - border-bottom: 1px solid var(--border-color); - } - - img, video { - max-width: 100%; - } - - code, pre { - background: var(--shade-background); - } - - pre { - overflow-x: auto; - scrollbar-color: var(--border-color) var(--shade-background); - } -} - -.overview > header { - font-weight: bold; -} - -.release > header { - display: flex; - align-items: baseline; - > :first-child { - flex-grow: 1; - font-weight: bold; - } -} - -.timestamp { - white-space: nowrap; - font-size: .8rem; -} - -.commit { - display: flex; - justify-content: space-between; - padding: 0.5rem; - border: 1px solid var(--border-color); - + .commit { - border-top: none; - } -} - -.commit-author { - font-size: 0.8rem; - margin-top: 0.2rem; -} - -.commit-sha { - align-self: center; - font-size: 0.8rem; -} - -/* TODO Strip permalinks? */ -svg.octicon-link { - display: none; -} - -.pl-c { color: var(--pl-c); } -.pl-c1 { color: var(--pl-c1); } -.pl-e { color: var(--pl-e); } -.pl-en { color: var(--pl-en); } -.pl-k { color: var(--pl-k); } -.pl-s { color: var(--pl-s); } -.pl-v { color: var(--pl-v); } -.pl-ent { color: var(--pl-k); } -.pl-mh { font-weight: bold; color: var(--pl-en); } -.pl-bu { color: var(--pl-k); } -.pl-ii { color: var(--text-color); background-color: var(--pl-k); } -.pl-pds { color: var(--pl-s); } -.pl-smi { color: var(--text-color); } diff --git a/cgithub/wrangler.jsonc b/cgithub/wrangler.jsonc new file mode 100644 index 0000000..ebe0f70 --- /dev/null +++ b/cgithub/wrangler.jsonc @@ -0,0 +1,11 @@ +{ + "$schema": "node_modules/wrangler/config-schema.json", + "name": "cgithub", + "main": "src/worker.ts", + "compatibility_date": "2026-07-01", + // Serve public/ (i.e. /static/*) directly from Cloudflare's edge; requests + // that don't match a file fall through to the worker. + "assets": { + "directory": "./public" + } +} -- 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') 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') diff --git a/cgithub/public/static/refs.js b/cgithub/public/static/refs.js index 28b8373..ffff505 100644 --- a/cgithub/public/static/refs.js +++ b/cgithub/public/static/refs.js @@ -1,8 +1,8 @@ -// Progressive enhancement for two bits of data GitHub itself loads lazily: -// the branch/tag dropdown and the latest-commit bar above a file listing. -// Both degrade gracefully without JS: the button's command/commandfor still -// opens the (empty) dialog natively; the commit placeholder just never fills -// in. +// Progressive enhancement for the bits of data GitHub itself loads lazily: +// the branch/tag dropdown, the latest-commit bar above a file listing, and the +// language breakdown. All degrade gracefully without JS: the button's +// command/commandfor still opens the (empty) dialog natively; the placeholders +// just never fill in. document.addEventListener("DOMContentLoaded", () => { const dialog = document.querySelector(".ref-selector-menu"); if (dialog) { @@ -44,17 +44,16 @@ document.addEventListener("DOMContentLoaded", () => { } } - const commitPlaceholder = document.querySelector(".latest-commit-placeholder"); - if (commitPlaceholder) { - fetch(commitPlaceholder.dataset.src) + for (const placeholder of document.querySelectorAll(".placeholder[data-src]")) { + fetch(placeholder.dataset.src) .then((res) => (res.ok ? res.text() : "")) .then((html) => { if (html.trim()) { - commitPlaceholder.outerHTML = html; + placeholder.outerHTML = html; } else { - commitPlaceholder.remove(); + placeholder.remove(); } }) - .catch(() => commitPlaceholder.remove()); + .catch(() => placeholder.remove()); } }); diff --git a/cgithub/public/static/style.css b/cgithub/public/static/style.css index 983cb86..ed64bc6 100644 --- a/cgithub/public/static/style.css +++ b/cgithub/public/static/style.css @@ -218,10 +218,45 @@ body > footer { font-size: 0.85rem; } -.latest-commit-placeholder { +.placeholder { opacity: 0.7; } +.languages { + .language-bar { + display: flex; + height: .5rem; + border-radius: .25rem; + overflow: hidden; + + span { + /* Keep slivers of tiny languages visible. */ + min-width: 1px; + } + } + + .language-list { + display: flex; + flex-wrap: wrap; + gap: .2rem 1rem; + list-style: none; + margin: .5rem 0 0; + padding: 0; + font-size: 0.85rem; + } + + .language-dot { + display: inline-block; + width: .6rem; + height: .6rem; + border-radius: 50%; + } + + .language-percentage { + opacity: 0.7; + } +} + .repo-info { .description { margin-bottom: .2rem; diff --git a/cgithub/src/app.ts b/cgithub/src/app.ts index ca79b1f..e34c7b1 100644 --- a/cgithub/src/app.ts +++ b/cgithub/src/app.ts @@ -5,6 +5,7 @@ import { getGitHubBlob, getGitHubCommits, getGitHubIssues, + getGitHubLanguages, getGitHubLatestCommit, getGitHubOwner, getGitHubPulls, @@ -158,6 +159,11 @@ export function createApp(eta: Eta) { ); }); + app.get("/api/:owner/:repo/languages", async (c) => { + const { owner, repo } = c.req.param(); + return tryRenderFragment(c, "_languages.eta", getGitHubLanguages(owner, repo)); + }); + app.get("/:owner/:repo/releases/tag/:tag", async (c) => { const { owner, repo, tag } = c.req.param(); return tryRender(c, "release.eta", getGitHubRelease(owner, repo, tag)); diff --git a/cgithub/src/scraper.test.ts b/cgithub/src/scraper.test.ts index 56f14b0..5b5e78b 100644 --- a/cgithub/src/scraper.test.ts +++ b/cgithub/src/scraper.test.ts @@ -4,6 +4,7 @@ import { getGitHubBlob, getGitHubCommits, getGitHubIssues, + getGitHubLanguages, getGitHubLatestCommit, getGitHubPulls, getGitHubRefs, @@ -219,6 +220,33 @@ describe("GitHub scraper", () => { assert.ok(data.commit.authors.length > 0); }); + it("should fetch languages", async () => { + const data = await getGitHubLanguages("actions", "deploy-pages"); + + assert.strictEqual(data.repo.owner, "actions"); + assert.strictEqual(data.repo.name, "deploy-pages"); + + assert.deepStrictEqual(data.languages, [ + { name: "JavaScript", percentage: 100, color: "#f1e05a" }, + ]); + }); + + it("should fetch languages for a multi-language repository", async () => { + const data = await getGitHubLanguages("rust-lang", "rust"); + + const rust = data.languages.find((language) => language.name === "Rust"); + assert.ok(rust); + assert.ok(rust.percentage > 50); + assert.strictEqual(rust.color, "#dea584"); + + // The aggregate bucket comes back without a name. + const other = data.languages.find((language) => language.name === "Other"); + assert.ok(other); + + const total = data.languages.reduce((sum, language) => sum + language.percentage, 0); + assert.ok(Math.abs(total - 100) < 1); + }); + it("should fetch branches", async () => { const data = await getGitHubRefs("actions", "deploy-pages", "branches"); diff --git a/cgithub/src/scraper.ts b/cgithub/src/scraper.ts index 39ad2cd..223839f 100644 --- a/cgithub/src/scraper.ts +++ b/cgithub/src/scraper.ts @@ -285,6 +285,17 @@ export interface GitHubLatestCommit extends GitHubCommon { commit: LatestCommit; } +export interface Language { + // "Other" for the aggregate bucket GitHub reports without a name. + name: string; + percentage: number; + color: string | null; +} + +export interface GitHubLanguages extends GitHubCommon { + languages: Language[]; +} + export interface Ref { name: string; isDefault?: boolean; @@ -722,6 +733,34 @@ export async function getGitHubLatestCommit( }; } +interface SidebarPayload { + languages?: { + languages: { + // Absent for the "Other" bucket. + name?: string; + percentage: number; + color?: string; + }[]; + }; +} + +// The repository sidebar (languages, contributors, ...) is not rendered into +// the repo page at all; GitHub's own UI lazily fetches it as JSON from here. +export async function getGitHubLanguages(owner: string, repo: string): Promise { + const payload = await fetchGitHubJson(`${owner}/${repo}/_sidebar`); + + const languages: Language[] = []; + for (const entry of payload.languages?.languages ?? []) { + languages.push({ + name: entry.name ?? "Other", + percentage: entry.percentage, + color: entry.color ?? null, + }); + } + + return { repo: { owner, name: repo }, languages }; +} + interface BranchRefEntry { name: string; isDefault: boolean; diff --git a/cgithub/views/_languages.eta b/cgithub/views/_languages.eta new file mode 100644 index 0000000..8aca447 --- /dev/null +++ b/cgithub/views/_languages.eta @@ -0,0 +1,17 @@ +<% if (it.languages.length > 0) { %> +
+
+ <% for (const lang of it.languages) { %> + " title="<%= lang.name %> <%= lang.percentage %>%"> + <% } %> +
+
    + <% for (const lang of it.languages) { %> +
  • + "> + <%= lang.name %> <%= lang.percentage %>% +
  • + <% } %> +
+
+<% } %> diff --git a/cgithub/views/_tree.eta b/cgithub/views/_tree.eta index a0ecfab..2eb445f 100644 --- a/cgithub/views/_tree.eta +++ b/cgithub/views/_tree.eta @@ -1,7 +1,7 @@ <%~ include("./_nav.eta", {...it, isTree: true}) %> <% const commitPath = it.path && it.path !== "/" ? it.path : ""; %> -
Loading latest commit…
+
Loading latest commit…
<% for (const item of it.items) { %> diff --git a/cgithub/views/repo.eta b/cgithub/views/repo.eta index 683a9ad..9122594 100644 --- a/cgithub/views/repo.eta +++ b/cgithub/views/repo.eta @@ -29,4 +29,6 @@ <% } %> +
Loading languages…
+ <%~ include("./_tree", it) %> -- cgit v1.3.1 From 9cef0850f8049a8f1cb11097852738de4cad4a30 Mon Sep 17 00:00:00 2001 From: Joe Mou Date: Tue, 11 Aug 2026 08:17:54 -0400 Subject: Link repo header and add a "View original" link MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The owner/name header in the layout now links to the owner page and the repository root. tryRender passes the request's path and query through as githubUrl — our URLs mirror GitHub's, so the same path is the page we scraped — and the footer links there with rel="noreferrer", matching the no-referrer policy used on redirects to github.com. Co-Authored-By: Claude Opus 5 --- cgithub/src/app.ts | 5 ++++- cgithub/views/layout.eta | 4 ++-- 2 files changed, 6 insertions(+), 3 deletions(-) (limited to 'cgithub/src') diff --git a/cgithub/src/app.ts b/cgithub/src/app.ts index e34c7b1..5bed45d 100644 --- a/cgithub/src/app.ts +++ b/cgithub/src/app.ts @@ -44,7 +44,10 @@ export function createApp(eta: Eta) { return c.html(eta.render("error.eta", { message: "" + e })); } } - return c.html(eta.render(template, data)); + // Our URLs mirror GitHub's, so the page we scraped is the same path there. + const { pathname, search } = new URL(c.req.url); + const githubUrl = `https://github.com${pathname}${search}`; + return c.html(eta.render(template, { ...data, githubUrl })); } // For fragments fetched asynchronously by client-side JS (see public/static/refs.js): diff --git a/cgithub/views/layout.eta b/cgithub/views/layout.eta index a80bd1d..f803caf 100644 --- a/cgithub/views/layout.eta +++ b/cgithub/views/layout.eta @@ -9,7 +9,7 @@
-
<%= it.repo.owner %>/<%= it.repo.name %>
+
git clone https://github.com/<%= it.repo.owner %>/<%= it.repo.name %>.git
 git clone git@github.com:<%= it.repo.owner %>/<%= it.repo.name %>.git
<% } %> +
+ +<% if (it.bio || it.links.length > 0 || it.followers || it.company) { %> +
+ <% if (it.bio) { %> +
<%= it.bio %>
+ <% } %> + <% for (const link of it.links) { %> + + <% } %> + <% if (it.followers) { %> +
<%= it.followers %> followers<%= it.following ? ` · ${it.following} following` : "" %>
+ <% } %> + <% if (it.company) { %> +
<%= it.company %>
+ <% } %> +
+<% } %> -
Generated by cgithub
- - +<% if (it.pinned.length > 0) { %> +
+

Pinned

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

Repositories

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