summaryrefslogtreecommitdiff
path: root/cgithub
diff options
context:
space:
mode:
Diffstat (limited to 'cgithub')
-rw-r--r--cgithub/.gitignore4
-rw-r--r--cgithub/.prettierrc3
-rw-r--r--cgithub/CLAUDE.md45
l---------cgithub/GEMINI.md1
-rw-r--r--cgithub/LICENSE21
-rw-r--r--cgithub/README.md66
-rw-r--r--cgithub/docs/screenshot.pngbin0 -> 91956 bytes
-rw-r--r--cgithub/package.json26
-rw-r--r--cgithub/pnpm-lock.yaml1001
-rw-r--r--cgithub/pnpm-workspace.yaml4
-rw-r--r--cgithub/public/static/icons/eye.svg1
-rw-r--r--cgithub/public/static/icons/file.svg1
-rw-r--r--cgithub/public/static/icons/folder-simple.svg1
-rw-r--r--cgithub/public/static/icons/git-branch.svg1
-rw-r--r--cgithub/public/static/icons/git-fork.svg1
-rw-r--r--cgithub/public/static/icons/link.svg1
-rw-r--r--cgithub/public/static/icons/star.svg1
-rw-r--r--cgithub/public/static/refs.js71
-rw-r--r--cgithub/public/static/style.css407
-rw-r--r--cgithub/scripts/build.ts64
-rw-r--r--cgithub/src/app.test.ts483
-rw-r--r--cgithub/src/app.ts284
-rw-r--r--cgithub/src/scraper.test.ts624
-rw-r--r--cgithub/src/scraper.ts1228
-rw-r--r--cgithub/src/worker.ts30
-rw-r--r--cgithub/tsconfig.json12
-rw-r--r--cgithub/views/_latestCommit.eta16
-rw-r--r--cgithub/views/_nav.eta34
-rw-r--r--cgithub/views/_ownerRepo.eta15
-rw-r--r--cgithub/views/_refs.eta21
-rw-r--r--cgithub/views/_release.eta32
-rw-r--r--cgithub/views/_sidebar.eta22
-rw-r--r--cgithub/views/_tree.eta38
-rw-r--r--cgithub/views/_wikiNav.eta4
-rw-r--r--cgithub/views/blob.eta35
-rw-r--r--cgithub/views/commits.eta33
-rw-r--r--cgithub/views/error.eta3
-rw-r--r--cgithub/views/home.eta47
-rw-r--r--cgithub/views/issues.eta20
-rw-r--r--cgithub/views/layouts/base.eta17
-rw-r--r--cgithub/views/layouts/repo.eta59
-rw-r--r--cgithub/views/owner.eta54
-rw-r--r--cgithub/views/pulls.eta15
-rw-r--r--cgithub/views/redirect.eta14
-rw-r--r--cgithub/views/release.eta3
-rw-r--r--cgithub/views/releases.eta5
-rw-r--r--cgithub/views/repo.eta31
-rw-r--r--cgithub/views/tree.eta3
-rw-r--r--cgithub/views/wiki.eta37
-rw-r--r--cgithub/views/wikiPages.eta16
-rw-r--r--cgithub/wrangler.jsonc11
51 files changed, 4966 insertions, 0 deletions
diff --git a/cgithub/.gitignore b/cgithub/.gitignore
new file mode 100644
index 0000000..55b5412
--- /dev/null
+++ b/cgithub/.gitignore
@@ -0,0 +1,4 @@
+/node_modules/
+/.wrangler/
+/src/build.generated.ts
+/src/views.generated.ts
diff --git a/cgithub/.prettierrc b/cgithub/.prettierrc
new file mode 100644
index 0000000..de753c5
--- /dev/null
+++ b/cgithub/.prettierrc
@@ -0,0 +1,3 @@
+{
+ "printWidth": 100
+}
diff --git a/cgithub/CLAUDE.md b/cgithub/CLAUDE.md
new file mode 100644
index 0000000..ef7b133
--- /dev/null
+++ b/cgithub/CLAUDE.md
@@ -0,0 +1,45 @@
+## Project Overview
+
+cgithub is a lightweight GitHub alternative frontend that scrapes GitHub's server-rendered HTML instead of using the API. Data is usually extracted from embedded JSON data from `<script type="application/json">` tags that GitHub includes for hydration.
+
+## Commands
+
+```bash
+# Local dev server (Cloudflare Workers runtime)
+pnpm run dev
+
+# Deploy to Cloudflare Workers
+pnpm run deploy
+
+# Run tests
+pnpm test
+```
+
+## Architecture
+
+**Stack:** Hono (web framework) + Eta (templating) + TypeScript, running on Cloudflare Workers
+
+**Source files:**
+- `src/app.ts` - Hono routes
+- `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. `views/layouts/base.eta` is the HTML shell every page uses; `views/layouts/repo.eta` wraps it with the repository header, nav, and search box. Templates address each other from the views root (`/layouts/base.eta`) or as siblings (`./_tree`). On Workers, runtime code generation is disallowed, so `scripts/build.ts` precompiles templates to `src/views.generated.ts`. The same script records the build's `git describe` output in `src/build.generated.ts` for the page footer. Both are gitignored and regenerated by the `dev`/`deploy` scripts.
+
+**Static files:** `public/static/*`, served by Workers Static Assets.
+
+**Routes:**
+- `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
+- `GET /:owner/:repo/commits/:branch/:path*` - Commit history of the repository, a directory, or a file
+- `GET /:owner/:repo/wiki` - Wiki home page
+- `GET /:owner/:repo/wiki/:page` - Wiki page (also `/:page/:oid` for a revision)
+- `GET /:owner/:repo/wiki/_pages` - Wiki page index
+
+## Instructions
+
+- Use `for (const ... of ...)` instead of `.forEach()`
+- Update `scraper.test.ts`
+- When implementing a new feature update `README.md`
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/LICENSE b/cgithub/LICENSE
new file mode 100644
index 0000000..8375820
--- /dev/null
+++ b/cgithub/LICENSE
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2026 Joe Mou
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/cgithub/README.md b/cgithub/README.md
new file mode 100644
index 0000000..45c94cf
--- /dev/null
+++ b/cgithub/README.md
@@ -0,0 +1,66 @@
+cgithub is a lightweight GitHub alternative frontend. It has no bloated
+JavaScript and doesn't require API keys. It runs on Cloudflare Workers; to use
+it, deploy your own instance then replace `github.com` in your URL with your
+domain.
+
+![Screenshot of cgithub repository page.](docs/screenshot.png)
+
+## Features
+
+Implemented:
+
+- Repository overview
+- Directory listing and file browsing (`tree`/`blob`)
+- Commit history (but this often results in 429 Too Many Requests on Cloudflare)
+- Issue and pull request lists and search
+- Release lists and details
+- Wiki
+- User and organization profiles
+
+Not implemented:
+
+- Issue, pull request, and commit details
+- Issue and pull request status and labels
+- Code search (GitHub requires signing in)
+- Pagination (only the first page of issues, pull requests, commits, releases,
+ branches, and tags is shown)
+- Blame, contributors, stargazer/fork lists, network graphs, and insights
+- Actions, checks, projects, packages, discussions, and gists
+- Profile tabs other than the overview (repositories, projects, packages,
+ people, sponsoring)
+- Anything requiring an account: signing in, notifications, starring,
+ commenting, editing, or any other write action
+- Private repositories
+
+## Browser extension redirects
+
+cgithub redirects unsupported routes back to github.com. A browser extension
+that rewrites github.com URLs to a cgithub instance with `declarativeNetRequest`
+will match those redirects and send the request back, looping forever. To avoid
+this, the extension should configure `excludedInitiatorDomains` with the cgithub
+domain:
+
+```jsonc
+{
+ "action": {
+ "type": "redirect",
+ "redirect": { "transform": { "host": "cgithub.example" } },
+ },
+ "condition": {
+ "urlFilter": "||github.com",
+ "resourceTypes": ["main_frame"],
+ // So cgithub's own meta refresh to github.com isn't redirected back here.
+ "excludedInitiatorDomains": ["cgithub.example"],
+ },
+}
+```
+
+## Related
+
+- [GotHub](https://codeberg.org/gothub/gothub)
+
+## Disclosures
+
+AI coding assistants, in particular Claude, are used in the development process.
+
+[Phospor Icons](https://phosphoricons.com/) used under MIT license.
diff --git a/cgithub/docs/screenshot.png b/cgithub/docs/screenshot.png
new file mode 100644
index 0000000..68d230b
--- /dev/null
+++ b/cgithub/docs/screenshot.png
Binary files differ
diff --git a/cgithub/package.json b/cgithub/package.json
new file mode 100644
index 0000000..f711cf8
--- /dev/null
+++ b/cgithub/package.json
@@ -0,0 +1,26 @@
+{
+ "name": "cgithub",
+ "version": "1.0.0",
+ "description": "Lightweight GitHub alternative frontend",
+ "license": "MIT",
+ "type": "module",
+ "scripts": {
+ "dev": "node scripts/build.ts && wrangler dev",
+ "deploy": "node scripts/build.ts && wrangler deploy",
+ "test": "node --test --test-concurrency=4 src/app.test.ts src/scraper.test.ts"
+ },
+ "dependencies": {
+ "css-select": "^6.0.0",
+ "domhandler": "^5.0.3",
+ "domutils": "^3.2.2",
+ "eta": "^3.5.0",
+ "hono": "^4.6.14",
+ "htmlparser2": "^10.1.0"
+ },
+ "devDependencies": {
+ "@types/node": "^22.10.2",
+ "prettier": "^3.8.1",
+ "typescript": "^5.7.2",
+ "wrangler": "^4.110.0"
+ }
+}
diff --git a/cgithub/pnpm-lock.yaml b/cgithub/pnpm-lock.yaml
new file mode 100644
index 0000000..6f4ff25
--- /dev/null
+++ b/cgithub/pnpm-lock.yaml
@@ -0,0 +1,1001 @@
+lockfileVersion: '9.0'
+
+settings:
+ autoInstallPeers: true
+ excludeLinksFromLockfile: false
+
+importers:
+
+ .:
+ dependencies:
+ css-select:
+ specifier: ^6.0.0
+ version: 6.0.0
+ domhandler:
+ specifier: ^5.0.3
+ version: 5.0.3
+ domutils:
+ specifier: ^3.2.2
+ version: 3.2.2
+ eta:
+ specifier: ^3.5.0
+ version: 3.5.0
+ hono:
+ specifier: ^4.6.14
+ version: 4.11.5
+ htmlparser2:
+ specifier: ^10.1.0
+ version: 10.1.0
+ devDependencies:
+ '@types/node':
+ specifier: ^22.10.2
+ version: 22.19.7
+ prettier:
+ specifier: ^3.8.1
+ version: 3.8.1
+ 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.28.1':
+ resolution: {integrity: sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==}
+ engines: {node: '>=18'}
+ cpu: [ppc64]
+ os: [aix]
+
+ '@esbuild/android-arm64@0.28.1':
+ resolution: {integrity: sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==}
+ engines: {node: '>=18'}
+ cpu: [arm64]
+ os: [android]
+
+ '@esbuild/android-arm@0.28.1':
+ resolution: {integrity: sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==}
+ engines: {node: '>=18'}
+ cpu: [arm]
+ 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.28.1':
+ resolution: {integrity: sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==}
+ engines: {node: '>=18'}
+ cpu: [arm64]
+ os: [darwin]
+
+ '@esbuild/darwin-x64@0.28.1':
+ resolution: {integrity: sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==}
+ engines: {node: '>=18'}
+ cpu: [x64]
+ os: [darwin]
+
+ '@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.28.1':
+ resolution: {integrity: sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==}
+ engines: {node: '>=18'}
+ cpu: [x64]
+ os: [freebsd]
+
+ '@esbuild/linux-arm64@0.28.1':
+ resolution: {integrity: sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==}
+ engines: {node: '>=18'}
+ cpu: [arm64]
+ 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.28.1':
+ resolution: {integrity: sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==}
+ engines: {node: '>=18'}
+ cpu: [ia32]
+ 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.28.1':
+ resolution: {integrity: sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==}
+ engines: {node: '>=18'}
+ cpu: [mips64el]
+ 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.28.1':
+ resolution: {integrity: sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==}
+ engines: {node: '>=18'}
+ cpu: [riscv64]
+ 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.28.1':
+ resolution: {integrity: sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==}
+ engines: {node: '>=18'}
+ cpu: [x64]
+ os: [linux]
+
+ '@esbuild/netbsd-arm64@0.28.1':
+ resolution: {integrity: sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==}
+ engines: {node: '>=18'}
+ cpu: [arm64]
+ os: [netbsd]
+
+ '@esbuild/netbsd-x64@0.28.1':
+ resolution: {integrity: sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==}
+ engines: {node: '>=18'}
+ cpu: [x64]
+ os: [netbsd]
+
+ '@esbuild/openbsd-arm64@0.28.1':
+ resolution: {integrity: sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==}
+ engines: {node: '>=18'}
+ cpu: [arm64]
+ 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.28.1':
+ resolution: {integrity: sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==}
+ engines: {node: '>=18'}
+ cpu: [arm64]
+ os: [openharmony]
+
+ '@esbuild/sunos-x64@0.28.1':
+ resolution: {integrity: sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==}
+ engines: {node: '>=18'}
+ cpu: [x64]
+ os: [sunos]
+
+ '@esbuild/win32-arm64@0.28.1':
+ resolution: {integrity: sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==}
+ engines: {node: '>=18'}
+ cpu: [arm64]
+ 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.28.1':
+ resolution: {integrity: sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==}
+ engines: {node: '>=18'}
+ cpu: [x64]
+ os: [win32]
+
+ '@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==}
+
+ css-what@7.0.0:
+ 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==}
+
+ domelementtype@2.3.0:
+ resolution: {integrity: sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==}
+
+ domhandler@5.0.3:
+ resolution: {integrity: sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==}
+ engines: {node: '>= 4'}
+
+ domutils@3.2.2:
+ resolution: {integrity: sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==}
+
+ entities@4.5.0:
+ resolution: {integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==}
+ engines: {node: '>=0.12'}
+
+ entities@7.0.1:
+ resolution: {integrity: sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==}
+ engines: {node: '>=0.12'}
+
+ error-stack-parser-es@1.0.5:
+ resolution: {integrity: sha512-5qucVt2XcuGMcEGgWI7i+yZpmpByQ8J1lHhcL7PwqCwu9FPP3VUXzT4ltHe5i2z9dePwEHcDVOAfSnHsOlCXRA==}
+
+ 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'}
+
+ 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'}
+ hasBin: true
+
+ 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.28.1':
+ optional: true
+
+ '@esbuild/android-arm64@0.28.1':
+ optional: true
+
+ '@esbuild/android-arm@0.28.1':
+ optional: true
+
+ '@esbuild/android-x64@0.28.1':
+ optional: true
+
+ '@esbuild/darwin-arm64@0.28.1':
+ optional: true
+
+ '@esbuild/darwin-x64@0.28.1':
+ optional: true
+
+ '@esbuild/freebsd-arm64@0.28.1':
+ optional: true
+
+ '@esbuild/freebsd-x64@0.28.1':
+ optional: true
+
+ '@esbuild/linux-arm64@0.28.1':
+ optional: true
+
+ '@esbuild/linux-arm@0.28.1':
+ optional: true
+
+ '@esbuild/linux-ia32@0.28.1':
+ optional: true
+
+ '@esbuild/linux-loong64@0.28.1':
+ optional: true
+
+ '@esbuild/linux-mips64el@0.28.1':
+ optional: true
+
+ '@esbuild/linux-ppc64@0.28.1':
+ optional: true
+
+ '@esbuild/linux-riscv64@0.28.1':
+ optional: true
+
+ '@esbuild/linux-s390x@0.28.1':
+ optional: true
+
+ '@esbuild/linux-x64@0.28.1':
+ optional: true
+
+ '@esbuild/netbsd-arm64@0.28.1':
+ optional: true
+
+ '@esbuild/netbsd-x64@0.28.1':
+ optional: true
+
+ '@esbuild/openbsd-arm64@0.28.1':
+ optional: true
+
+ '@esbuild/openbsd-x64@0.28.1':
+ optional: true
+
+ '@esbuild/openharmony-arm64@0.28.1':
+ optional: true
+
+ '@esbuild/sunos-x64@0.28.1':
+ optional: true
+
+ '@esbuild/win32-arm64@0.28.1':
+ optional: true
+
+ '@esbuild/win32-ia32@0.28.1':
+ optional: true
+
+ '@esbuild/win32-x64@0.28.1':
+ optional: true
+
+ '@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
+ css-what: 7.0.0
+ domhandler: 5.0.3
+ domutils: 3.2.2
+ nth-check: 2.1.1
+
+ css-what@7.0.0: {}
+
+ detect-libc@2.1.2: {}
+
+ dom-serializer@2.0.0:
+ dependencies:
+ domelementtype: 2.3.0
+ domhandler: 5.0.3
+ entities: 4.5.0
+
+ domelementtype@2.3.0: {}
+
+ domhandler@5.0.3:
+ dependencies:
+ domelementtype: 2.3.0
+
+ domutils@3.2.2:
+ dependencies:
+ dom-serializer: 2.0.0
+ domelementtype: 2.3.0
+ domhandler: 5.0.3
+
+ entities@4.5.0: {}
+
+ entities@7.0.1: {}
+
+ error-stack-parser-es@1.0.5: {}
+
+ 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:
+ dependencies:
+ domelementtype: 2.3.0
+ domhandler: 5.0.3
+ 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/pnpm-workspace.yaml b/cgithub/pnpm-workspace.yaml
new file mode 100644
index 0000000..de7e99c
--- /dev/null
+++ b/cgithub/pnpm-workspace.yaml
@@ -0,0 +1,4 @@
+allowBuilds:
+ esbuild: false
+ sharp: false
+ workerd: true
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 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" fill="currentColor"><path d="M247.31,124.76c-.35-.79-8.82-19.58-27.65-38.41C194.57,61.26,162.88,48,128,48S61.43,61.26,36.34,86.35C17.51,105.18,9,124,8.69,124.76a8,8,0,0,0,0,6.5c.35.79,8.82,19.57,27.65,38.4C61.43,194.74,93.12,208,128,208s66.57-13.26,91.66-38.34c18.83-18.83,27.3-37.61,27.65-38.4A8,8,0,0,0,247.31,124.76ZM128,192c-30.78,0-57.67-11.19-79.93-33.25A133.47,133.47,0,0,1,25,128,133.33,133.33,0,0,1,48.07,97.25C70.33,75.19,97.22,64,128,64s57.67,11.19,79.93,33.25A133.46,133.46,0,0,1,231.05,128C223.84,141.46,192.43,192,128,192Zm0-112a48,48,0,1,0,48,48A48.05,48.05,0,0,0,128,80Zm0,80a32,32,0,1,1,32-32A32,32,0,0,1,128,160Z"/></svg> \ 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 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" fill="currentColor"><path d="M213.66,82.34l-56-56A8,8,0,0,0,152,24H56A16,16,0,0,0,40,40V216a16,16,0,0,0,16,16H200a16,16,0,0,0,16-16V88A8,8,0,0,0,213.66,82.34ZM160,51.31,188.69,80H160ZM200,216H56V40h88V88a8,8,0,0,0,8,8h48V216Z"/></svg> \ 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 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" fill="currentColor"><path d="M216,72H130.67L102.93,51.2a16.12,16.12,0,0,0-9.6-3.2H40A16,16,0,0,0,24,64V200a16,16,0,0,0,16,16H216.89A15.13,15.13,0,0,0,232,200.89V88A16,16,0,0,0,216,72Zm0,128H40V64H93.33L123.2,86.4A8,8,0,0,0,128,88h88Z"/></svg> \ 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 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" fill="currentColor"><path d="M232,64a32,32,0,1,0-40,31v17a8,8,0,0,1-8,8H96a23.84,23.84,0,0,0-8,1.38V95a32,32,0,1,0-16,0v66a32,32,0,1,0,16,0V144a8,8,0,0,1,8-8h88a24,24,0,0,0,24-24V95A32.06,32.06,0,0,0,232,64ZM64,64A16,16,0,1,1,80,80,16,16,0,0,1,64,64ZM96,192a16,16,0,1,1-16-16A16,16,0,0,1,96,192ZM200,80a16,16,0,1,1,16-16A16,16,0,0,1,200,80Z"/></svg> \ 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 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" fill="currentColor"><path d="M224,64a32,32,0,1,0-40,31v17a8,8,0,0,1-8,8H80a8,8,0,0,1-8-8V95a32,32,0,1,0-16,0v17a24,24,0,0,0,24,24h40v25a32,32,0,1,0,16,0V136h40a24,24,0,0,0,24-24V95A32.06,32.06,0,0,0,224,64ZM48,64A16,16,0,1,1,64,80,16,16,0,0,1,48,64Zm96,128a16,16,0,1,1-16-16A16,16,0,0,1,144,192ZM192,80a16,16,0,1,1,16-16A16,16,0,0,1,192,80Z"/></svg> \ 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 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" fill="currentColor"><path d="M240,88.23a54.43,54.43,0,0,1-16,37L189.25,160a54.27,54.27,0,0,1-38.63,16h-.05A54.63,54.63,0,0,1,96,119.84a8,8,0,0,1,16,.45A38.62,38.62,0,0,0,150.58,160h0a38.39,38.39,0,0,0,27.31-11.31l34.75-34.75a38.63,38.63,0,0,0-54.63-54.63l-11,11A8,8,0,0,1,135.7,59l11-11A54.65,54.65,0,0,1,224,48,54.86,54.86,0,0,1,240,88.23ZM109,185.66l-11,11A38.41,38.41,0,0,1,70.6,208h0a38.63,38.63,0,0,1-27.29-65.94L78,107.31A38.63,38.63,0,0,1,144,135.71a8,8,0,0,0,16,.45A54.86,54.86,0,0,0,144,96a54.65,54.65,0,0,0-77.27,0L32,130.75A54.62,54.62,0,0,0,70.56,224h0a54.28,54.28,0,0,0,38.64-16l11-11A8,8,0,0,0,109,185.66Z"/></svg> \ 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 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" fill="currentColor"><path d="M239.18,97.26A16.38,16.38,0,0,0,224.92,86l-59-4.76L143.14,26.15a16.36,16.36,0,0,0-30.27,0L90.11,81.23,31.08,86a16.46,16.46,0,0,0-9.37,28.86l45,38.83L53,211.75a16.38,16.38,0,0,0,24.5,17.82L128,198.49l50.53,31.08A16.4,16.4,0,0,0,203,211.75l-13.76-58.07,45-38.83A16.43,16.43,0,0,0,239.18,97.26Zm-15.34,5.47-48.7,42a8,8,0,0,0-2.56,7.91l14.88,62.8a.37.37,0,0,1-.17.48c-.18.14-.23.11-.38,0l-54.72-33.65a8,8,0,0,0-8.38,0L69.09,215.94c-.15.09-.19.12-.38,0a.37.37,0,0,1-.17-.48l14.88-62.8a8,8,0,0,0-2.56-7.91l-48.7-42c-.12-.1-.23-.19-.13-.5s.18-.27.33-.29l63.92-5.16A8,8,0,0,0,103,91.86l24.62-59.61c.08-.17.11-.25.35-.25s.27.08.35.25L153,91.86a8,8,0,0,0,6.75,4.92l63.92,5.16c.15,0,.24,0,.33.29S224,102.63,223.84,102.73Z"/></svg> \ No newline at end of file
diff --git a/cgithub/public/static/refs.js b/cgithub/public/static/refs.js
new file mode 100644
index 0000000..1035593
--- /dev/null
+++ b/cgithub/public/static/refs.js
@@ -0,0 +1,71 @@
+// 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) {
+ const list = dialog.querySelector(".ref-selector-list");
+ // The view and path let the picker link to the same place on another ref
+ // rather than to the repository root.
+ const { owner, repo, view, path = "" } = 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}/${view}/${path}`);
+ 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);
+ });
+ }
+ }
+
+ for (const placeholder of document.querySelectorAll(".placeholder[data-src]")) {
+ fetch(placeholder.dataset.src)
+ .then((res) => (res.ok ? res.text() : ""))
+ .then((html) => {
+ if (html.trim()) {
+ placeholder.outerHTML = html;
+ relocate();
+ } else {
+ placeholder.remove();
+ }
+ })
+ .catch(() => placeholder.remove());
+ }
+
+ // A fragment may carry a node that belongs elsewhere on the page, where an
+ // empty element of that id holds its spot: the sidebar's latest release
+ // reads as part of the release count, far from the languages it arrives with.
+ function relocate() {
+ for (const node of document.querySelectorAll("[data-target]")) {
+ document.getElementById(node.dataset.target)?.replaceWith(node);
+ }
+ }
+});
diff --git a/cgithub/public/static/style.css b/cgithub/public/static/style.css
new file mode 100644
index 0000000..1d277b7
--- /dev/null
+++ b/cgithub/public/static/style.css
@@ -0,0 +1,407 @@
+: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);
+}
+
+.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;
+}
+
+.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;
+ }
+
+ .detail {
+ white-space: nowrap;
+ }
+
+ div.detail {
+ overflow-x: hidden;
+ text-overflow: ellipsis;
+ }
+
+ span.detail + span.detail {
+ margin-left: 1rem;
+ }
+}
+
+object.pdf {
+ display: block;
+ width: 100%;
+ height: 80vh;
+ padding: 0;
+}
+
+pre.box {
+ /* Instead of scrolling overflow for long lines, grow the box. */
+ width: fit-content;
+
+ span:target {
+ background: var(--shade-background);
+ }
+}
+
+.overview, .release, .wiki {
+ 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, .wiki > header {
+ display: flex;
+ align-items: baseline;
+ > :first-child {
+ flex-grow: 1;
+ font-weight: bold;
+ }
+}
+
+.wiki > footer {
+ /* The wiki's _Footer page is rendered content, not a caption for the page. */
+ font-size: .9rem;
+}
+
+.wiki-pages {
+ summary {
+ cursor: pointer;
+ }
+
+ ul {
+ columns: 16rem;
+ list-style: none;
+ padding: 0;
+ }
+}
+
+.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/build.ts b/cgithub/scripts/build.ts
new file mode 100644
index 0000000..4908532
--- /dev/null
+++ b/cgithub/scripts/build.ts
@@ -0,0 +1,64 @@
+// Generates the sources that can't exist at runtime on Workers:
+//
+// src/views.generated.ts - views/*.eta precompiled into plain functions.
+// Workers disallow runtime code generation (`new Function`), so Eta cannot
+// compile templates there at request time.
+// src/build.generated.ts - the commit this build was made from, for the
+// page footer. Workers can't shell out to git at request time either.
+import { execFileSync } from "node:child_process";
+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 });
+
+// Names are the templates' paths under views/, e.g. "layouts/base.eta", which
+// is how the templates address each other (see src/worker.ts).
+function templateNames(dir: string, prefix = ""): string[] {
+ const names: string[] = [];
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
+ const name = prefix + entry.name;
+ if (entry.isDirectory()) {
+ names.push(...templateNames(path.join(dir, entry.name), `${name}/`));
+ } else if (entry.name.endsWith(".eta")) {
+ names.push(name);
+ }
+ }
+ return names.sort();
+}
+
+const names = templateNames(viewsDir);
+
+let out = `// @ts-nocheck
+// Generated by scripts/build.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`);
+
+// Untagged repositories describe as a bare short hash; --dirty marks a build
+// made from an unclean working copy.
+let buildCommit = "unknown";
+try {
+ buildCommit = execFileSync("git", ["describe", "--tags", "--always", "--dirty"], {
+ cwd: root,
+ encoding: "utf8",
+ }).trim();
+} catch {
+ // Building from a source copy without git history.
+}
+
+fs.writeFileSync(
+ path.join(root, "src", "build.generated.ts"),
+ `// Generated by scripts/build.ts -- do not edit.\nexport const buildCommit = ${JSON.stringify(buildCommit)};\n`,
+);
+console.log(`Wrote commit ${buildCommit} to src/build.generated.ts`);
diff --git a/cgithub/src/app.test.ts b/cgithub/src/app.test.ts
new file mode 100644
index 0000000..450c1cc
--- /dev/null
+++ b/cgithub/src/app.test.ts
@@ -0,0 +1,483 @@
+import assert from "node:assert";
+import path from "node:path";
+import { describe, it, type TestContext } from "node:test";
+import { Eta } from "eta";
+import { createApp } from "./app.ts";
+
+// The routes exercised here redirect without scraping, so no network is needed.
+const eta = new Eta({ views: path.join(import.meta.dirname, "..", "views") });
+const app = createApp(eta);
+
+describe("branch picker", () => {
+ const repo = { owner: "actions", name: "deploy-pages" };
+ const refs = [{ name: "main", isDefault: true }, { name: "v4" }];
+
+ it("should keep the current view and path", () => {
+ const blob = eta.render("_refs.eta", {
+ repo,
+ refs,
+ type: "branches",
+ view: "blob",
+ path: "src/main.ts",
+ });
+ const tree = eta.render("_refs.eta", {
+ repo,
+ refs,
+ type: "branches",
+ view: "tree",
+ path: "src",
+ });
+
+ assert.match(blob, /href="\/actions\/deploy-pages\/blob\/main\/src\/main.ts"/);
+ assert.match(blob, /href="\/actions\/deploy-pages\/blob\/v4\/src\/main.ts"/);
+ assert.match(tree, /href="\/actions\/deploy-pages\/tree\/v4\/src"/);
+ });
+
+ it("should link to the root tree without a path", () => {
+ const html = eta.render("_refs.eta", { repo, refs, type: "branches", view: "tree", path: "" });
+
+ assert.match(html, /href="\/actions\/deploy-pages\/tree\/main"/);
+ });
+
+ it("should not build ref links for views it does not serve", async () => {
+ const res = await app.request("http://cgithub.example/api/a/b/refs/branches/raw/README.md");
+
+ assert.strictEqual(res.status, 404);
+ });
+
+ it("should tell the refs endpoint where the picker was opened", () => {
+ const nav = { repo, branch: "main", path: "src" };
+
+ const blob = eta.render("_nav.eta", { ...nav, view: "blob", path: "src/main.ts" });
+ assert.match(blob, /data-view="blob" data-path="src\/main.ts"/);
+ assert.match(
+ eta.render("_nav.eta", { ...nav, view: "tree" }),
+ /data-view="tree" data-path="src"/,
+ );
+ assert.doesNotMatch(eta.render("_nav.eta", { ...nav, view: "tree", path: "" }), /data-path=/);
+ });
+});
+
+describe("sidebar fragment", () => {
+ const repo = { owner: "actions", name: "deploy-pages" };
+ const languages = [{ name: "JavaScript", percentage: 100, color: "#f1e05a" }];
+ const latestRelease = { tag: "v5.0.0", publishedAt: "2026-03-25T16:59:14Z" };
+
+ // The release replaces the empty element it names, beside the release count.
+ it("should render the latest release and the languages", () => {
+ const html = eta.render("_sidebar.eta", { repo, languages, latestRelease });
+
+ assert.match(html, /<span data-target="latest-release">\(<a href/);
+ assert.match(html, /href="\/actions\/deploy-pages\/releases\/tag\/v5.0.0"/);
+ assert.match(html, /<time class="timestamp" title="2026-03-25T16:59:14Z">2026-03-25<\/time>/);
+ assert.match(html, /<div class="languages box">/);
+ });
+
+ it("should name a spot the repo page holds open", () => {
+ const html = eta.render("repo.eta", {
+ repo,
+ branch: "main",
+ path: "/",
+ items: [],
+ info: { description: null, website: null, stars: null, forks: null, numReleases: 38 },
+ });
+
+ assert.match(html, /<span id="latest-release"><\/span>/);
+ });
+
+ it("should render nothing when the repository has neither", () => {
+ const html = eta.render("_sidebar.eta", { repo, languages: [], latestRelease: null });
+
+ assert.strictEqual(html.trim(), "");
+ });
+});
+
+describe("blob view", () => {
+ const blob = {
+ repo: { owner: "justrajdeep", name: "fonts" },
+ branch: "master",
+ path: "Times New Roman.ttf",
+ language: null,
+ size: "815 KB",
+ image: false,
+ renderFileType: null,
+ textLines: null,
+ htmlLines: null,
+ htmlContent: null,
+ };
+
+ it("should embed a PDF", () => {
+ const html = eta.render("blob.eta", {
+ ...blob,
+ path: "test/pdfs/basicapi.pdf",
+ renderFileType: "pdf",
+ });
+
+ assert.match(html, /<object[^>]*type="application\/pdf"/);
+ assert.match(html, /data="\/justrajdeep\/fonts\/embed\/pdf\/master\/test\/pdfs\/basicapi.pdf"/);
+ });
+
+ it("should not embed a file type it has no viewer for", () => {
+ const html = eta.render("blob.eta", { ...blob, renderFileType: "stl" });
+
+ assert.doesNotMatch(html, /<object/);
+ assert.match(html, /This file cannot be displayed\./);
+ });
+
+ it("should give each syntax-highlighted line an anchor", () => {
+ const html = eta.render("blob.eta", {
+ ...blob,
+ path: "src/app.ts",
+ htmlLines: ['<span class="pl-k">const</span> x;', "x++;"],
+ });
+
+ assert.match(
+ html,
+ /<code><span id="L1"><span class="pl-k">const<\/span> x;<\/span>\n<span id="L2">x\+\+;<\/span><\/code>/,
+ );
+ });
+
+ it("should give each plain-text line an anchor", () => {
+ const html = eta.render("blob.eta", {
+ ...blob,
+ path: "notes.txt",
+ textLines: ["a < b", "c"],
+ });
+
+ assert.match(html, /<code><span id="L1">a &lt; b<\/span>\n<span id="L2">c<\/span><\/code>/);
+ });
+
+ it("should offer the raw file when there is nothing to display", () => {
+ const html = eta.render("blob.eta", blob);
+
+ assert.match(html, /This file cannot be displayed\./);
+ assert.match(html, /href="\/justrajdeep\/fonts\/raw\/master\/Times New Roman.ttf">View raw/);
+ });
+});
+
+describe("commit history", () => {
+ const repo = { owner: "actions", name: "deploy-pages" };
+ const oid = "b39c421b98f49d83ae50ec502c3ddfc3bf28f2c6";
+ const commitGroups = [
+ {
+ title: "Apr 18, 2023",
+ commits: [
+ { oid, shortMessage: "Remove deprecated input parameters", authors: [{ login: "jmg" }] },
+ ],
+ },
+ ];
+ const history = { repo, branch: "main", path: "src/index.js", commitGroups };
+
+ // A commits payload never says whether its path is a file or a directory, so
+ // every link goes to the tree and GitHub redirects a file's to its blob.
+ it("should browse a file's history at each of its commits", () => {
+ const html = eta.render("commits.eta", history);
+
+ assert.match(html, new RegExp(`href="/actions/deploy-pages/blob/${oid}/src/index.js">Browse`));
+ });
+
+ it("should browse a directory's history the same way", () => {
+ const html = eta.render("commits.eta", { ...history, path: "src" });
+
+ assert.match(html, new RegExp(`href="/actions/deploy-pages/blob/${oid}/src">Browse`));
+ });
+
+ it("should browse the whole repository when the history has no path", () => {
+ const html = eta.render("commits.eta", { ...history, path: "" });
+
+ assert.match(html, new RegExp(`href="/actions/deploy-pages/blob/${oid}">Browse`));
+ });
+
+ // Walking up from a history stays in the history, and its last crumb is the
+ // page you are already on, just as a blob or a tree ends on itself.
+ it("should walk up through the history of each parent directory", () => {
+ const html = eta.render("commits.eta", { ...history, path: "src/lib/index.js" });
+
+ assert.match(html, /href="\/actions\/deploy-pages\/commits\/main">\(top\)</);
+ assert.match(html, /href="\/actions\/deploy-pages\/commits\/main\/src">src</);
+ assert.match(
+ html,
+ /href="\/actions\/deploy-pages\/commits\/main\/src\/lib">lib<\/a>\/index\.js/,
+ );
+ });
+
+ it("should say so when a path has no history", () => {
+ const html = eta.render("commits.eta", { ...history, commitGroups: [] });
+
+ assert.match(html, /No commits found\./);
+ });
+
+ it("should link a blob to its own history", () => {
+ const html = eta.render("blob.eta", { ...history, textLines: ["x"] });
+
+ assert.match(html, /href="\/actions\/deploy-pages\/commits\/main\/src\/index.js">History/);
+ });
+});
+
+describe("embedded files", () => {
+ function stubGitHub(t: TestContext, body: string, contentType: string, status = 200) {
+ t.mock.method(
+ globalThis,
+ "fetch",
+ async () => new Response(body, { status, headers: { "Content-Type": contentType } }),
+ );
+ }
+
+ it("should retype a PDF GitHub sends as a download", async (t) => {
+ stubGitHub(t, "%PDF-1.6", "application/octet-stream");
+
+ const res = await app.request("http://cgithub.example/mozilla/pdf.js/embed/pdf/master/doc.pdf");
+
+ assert.strictEqual(res.status, 200);
+ assert.strictEqual(res.headers.get("Content-Type"), "application/pdf");
+ assert.strictEqual(await res.text(), "%PDF-1.6");
+ });
+
+ it("should pass on a type GitHub already got right", async (t) => {
+ stubGitHub(t, "%PDF-1.6", "application/pdf");
+
+ const res = await app.request("http://cgithub.example/mozilla/pdf.js/embed/pdf/master/doc.pdf");
+
+ assert.strictEqual(res.headers.get("Content-Type"), "application/pdf");
+ });
+
+ it("should not serve a file type it has no viewer for", async (t) => {
+ stubGitHub(t, "<script>", "text/html");
+
+ const res = await app.request("http://cgithub.example/mozilla/pdf.js/embed/html/master/x.html");
+
+ assert.strictEqual(res.status, 404);
+ });
+
+ it("should pass on the status of a file GitHub does not have", async (t) => {
+ stubGitHub(t, "404: Not Found", "text/plain", 404);
+
+ const res = await app.request(
+ "http://cgithub.example/mozilla/pdf.js/embed/pdf/master/nope.pdf",
+ );
+
+ assert.strictEqual(res.status, 404);
+ });
+});
+
+describe("wiki", () => {
+ const repo = { owner: "ariya", name: "phantomjs" };
+ const page = {
+ repo,
+ title: "Home",
+ updatedBy: "pixiuPL",
+ updatedAt: "2018-02-08T18:57:56Z",
+ bodyHtml: "<p>New to <strong>PhantomJS</strong>?</p>",
+ sidebarHtml: null,
+ footerHtml: null,
+ pages: [
+ { name: "Home", href: "/ariya/phantomjs/wiki" },
+ { name: "API Reference WebPage", href: "/ariya/phantomjs/wiki/API-Reference-WebPage" },
+ ],
+ };
+
+ it("should render the page beside who last edited it", () => {
+ const html = eta.render("wiki.eta", page);
+
+ assert.match(html, /<p>New to <strong>PhantomJS<\/strong>\?<\/p>/);
+ assert.match(html, /pixiuPL edited this page/);
+ assert.match(html, /<time title="2018-02-08T18:57:56Z">2018-02-08<\/time>/);
+ });
+
+ // The _Sidebar and _Footer pages a wiki can define, which GitHub renders
+ // alongside every page rather than as pages of their own.
+ it("should render the sidebar and footer only when the wiki has them", () => {
+ // The page's own content is the last thing in its box, and the box is the
+ // only one on the page.
+ const bare = eta.render("wiki.eta", page);
+ assert.match(bare, /New to <strong>PhantomJS<\/strong>\?<\/p>\s*<\/div>/);
+ assert.doesNotMatch(bare, /Sidebar/);
+
+ const html = eta.render("wiki.eta", {
+ ...page,
+ sidebarHtml: "<ul><li>nav</li></ul>",
+ footerHtml: "<p>fine print</p>",
+ });
+ assert.match(html, /<footer>\s*<p>fine print<\/p>/);
+ assert.match(html, /<header><span>Sidebar<\/span><\/header>\s*<ul><li>nav<\/li><\/ul>/);
+ });
+
+ it("should link every page of the wiki", () => {
+ const html = eta.render("wiki.eta", page);
+
+ assert.match(html, /<summary>2 pages<\/summary>/);
+ assert.match(
+ html,
+ /href="\/ariya\/phantomjs\/wiki\/API-Reference-WebPage">API Reference WebPage/,
+ );
+ });
+
+ it("should list the pages with when each was last updated", () => {
+ const html = eta.render("wikiPages.eta", {
+ repo,
+ pages: [
+ {
+ name: "Home",
+ href: "/ariya/phantomjs/wiki",
+ updatedAt: "2018-02-08T18:57:56Z",
+ },
+ ],
+ });
+
+ assert.match(html, /href="\/ariya\/phantomjs\/wiki">Home/);
+ assert.match(html, /<time title="2018-02-08T18:57:56Z">2018-02-08<\/time>/);
+ });
+
+ // The editor and the timestamp are scraped independently, so a page missing
+ // one of them should still show the other.
+ it("should render each part of the header meta it has", () => {
+ assert.doesNotMatch(eta.render("wiki.eta", { ...page, updatedAt: null }), /<time/);
+ assert.match(eta.render("wiki.eta", { ...page, updatedAt: null }), /pixiuPL edited this page/);
+ assert.doesNotMatch(eta.render("wiki.eta", { ...page, updatedBy: null }), /edited this page/);
+ });
+
+ // GitHub reserves underscore-prefixed names for its own actions, so a page
+ // can never be one; the rest of them are for signed-in users only.
+ it("should not scrape a reserved wiki name as a page", async () => {
+ const reserved = ["/ariya/phantomjs/wiki/_new", "/ariya/phantomjs/wiki/Home/_edit"];
+ for (const path of reserved) {
+ const res = await app.request(`http://cgithub.example${path}`);
+
+ assert.strictEqual(res.status, 200);
+ assert.match(await res.text(), new RegExp(`url=https://github.com${path}"`));
+ }
+ });
+
+ it("should only read a revision off a path that names one", async () => {
+ const res = await app.request("http://cgithub.example/ariya/phantomjs/wiki/Home/latest");
+
+ assert.match(
+ await res.text(),
+ /url=https:\/\/github.com\/ariya\/phantomjs\/wiki\/Home\/latest"/,
+ );
+ });
+});
+
+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.
+ it("should redirect unknown paths with a meta refresh", async () => {
+ const res = await app.request("http://cgithub.example/a/b/c?x=1");
+
+ assert.strictEqual(res.status, 200);
+ assert.strictEqual(res.headers.get("location"), null);
+
+ const body = await res.text();
+ assert.match(body, /<meta name="referrer" content="no-referrer">/);
+ assert.match(
+ body,
+ /<meta http-equiv="refresh" content="1; url=https:\/\/github.com\/a\/b\/c\?x=1">/,
+ );
+ assert.doesNotMatch(body, /<script/);
+ });
+
+ // Same-origin requests are mostly subresources, which render an HTML page as
+ // a broken image rather than following its refresh.
+ it("should redirect over HTTP when the referer is one of our own pages", async () => {
+ const res = await app.request("http://cgithub.example/a/b/c?x=1", {
+ headers: { Referer: "http://cgithub.example/a/b" },
+ });
+
+ assert.strictEqual(res.status, 302);
+ assert.strictEqual(res.headers.get("location"), "https://github.com/a/b/c?x=1");
+ assert.strictEqual(res.headers.get("Referrer-Policy"), "no-referrer");
+ });
+
+ it("should use a meta refresh when the referer is another site", async () => {
+ const res = await app.request("http://cgithub.example/a/b/c?x=1", {
+ headers: { Referer: "https://github.example/a/b" },
+ });
+
+ assert.strictEqual(res.status, 200);
+ assert.strictEqual(res.headers.get("location"), null);
+ });
+
+ it("should redirect unhandled search types", async () => {
+ const res = await app.request(
+ "http://cgithub.example/actions/deploy-pages/search?q=x&type=code",
+ );
+
+ assert.strictEqual(res.status, 200);
+ assert.match(
+ await res.text(),
+ /https:\/\/github.com\/actions\/deploy-pages\/search\?q=x&amp;type=code/,
+ );
+ });
+
+ 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");
+
+ assert.strictEqual(res.status, 200);
+ assert.match(
+ await res.text(),
+ /url=https:\/\/raw.githubusercontent.com\/actions\/deploy-pages\/main\/README.md"/,
+ );
+ });
+});
+
+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, /<title>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/src/app.ts b/cgithub/src/app.ts
new file mode 100644
index 0000000..0b9a7e6
--- /dev/null
+++ b/cgithub/src/app.ts
@@ -0,0 +1,284 @@
+import type { Eta } from "eta";
+import { type Context, Hono } from "hono";
+import type { StatusCode } from "hono/utils/http-status";
+import { buildCommit } from "./build.generated.ts";
+import {
+ getGitHubBlob,
+ getGitHubCommits,
+ getGitHubIssues,
+ getGitHubSidebar,
+ getGitHubLatestCommit,
+ getGitHubOwner,
+ getGitHubPulls,
+ getGitHubRaw,
+ getGitHubRefs,
+ getGitHubRelease,
+ getGitHubReleases,
+ getGitHubRepo,
+ getGitHubTree,
+ getGitHubWiki,
+ getGitHubWikiPages,
+ ExternalRedirectError,
+ GitHubHTTPError,
+ InternalRedirectError,
+ RedirectError,
+} from "./scraper.ts";
+
+declare module "hono" {
+ interface ContextRenderer {
+ (template: string, data?: object): Response;
+ }
+}
+
+function githubUrlFor(c: Context) {
+ const { pathname, search } = new URL(c.req.url);
+ return `https://github.com${pathname}${search}`;
+}
+
+export function createApp(eta: Eta) {
+ const app = new Hono();
+
+ app.use(async (c, next) => {
+ // Include render data for views/layouts/base.eta.
+ c.setRenderer((template, data) =>
+ c.html(eta.render(template, { ...data, githubUrl: githubUrlFor(c), buildCommit })),
+ );
+ await next();
+ });
+
+ // Redirect off-domain.
+ function redirectTo(c: Context, location: string) {
+ const referer = c.req.header("Referer");
+ // If we are the referer, redirect directly so we can hotlink resources
+ // (e.g., <img> in README).
+ if (referer && URL.parse(referer)?.origin === new URL(c.req.url).origin) {
+ c.header("Referrer-Policy", "no-referrer");
+ return c.redirect(location);
+ }
+
+ // Otherwise use a meta refresh to make ourself the initiator. This will
+ // avoid redirect loops if we are part of excludedInitiatorDomains in a
+ // declarativeNetRequest.
+ return c.render("redirect.eta", { location });
+ }
+
+ function redirectToGitHub(c: Context) {
+ return redirectTo(c, githubUrlFor(c));
+ }
+
+ app.onError((e, c) => {
+ // Fragments fetched asynchronously by client-side JS (see public/static/refs.js)
+ // fail as an empty body rather than the full error.eta page.
+ if (c.req.path.startsWith("/api/")) {
+ c.status(
+ e instanceof GitHubHTTPError
+ ? (e.status as StatusCode)
+ : e instanceof RedirectError
+ ? 404
+ : 500,
+ );
+ return c.body(null);
+ }
+
+ if (e instanceof InternalRedirectError) return c.redirect(e.location);
+ if (e instanceof ExternalRedirectError) return redirectTo(c, e.location);
+ if (e instanceof GitHubHTTPError) {
+ c.status(e.status as StatusCode);
+ const message = `GitHub responded with HTTP ${e.status} ${e.message}`;
+ return c.render("error.eta", { title: e.message, message });
+ }
+ c.status(500);
+ return c.render("error.eta", { message: "" + e });
+ });
+
+ app.get("/", async (c) => {
+ 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.
+ 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));
+ });
+
+ app.get("/:owner/:repo", async (c) => {
+ const { owner, repo } = c.req.param();
+ return c.render("repo.eta", await getGitHubRepo(owner, repo));
+ });
+
+ app.get("/:owner/:repo/tree/:branch/:path{.*}?", async (c) => {
+ const { owner, repo, branch, path = "" } = c.req.param();
+ return c.render("tree.eta", await getGitHubTree(owner, repo, branch, path));
+ });
+
+ app.get("/:owner/:repo/blob/:branch/:path{.*}", async (c) => {
+ const { owner, repo, branch, path } = c.req.param();
+ return c.render("blob.eta", await getGitHubBlob(owner, repo, branch, path));
+ });
+
+ app.get("/:owner/:repo/raw/:branch/:path{.*}", async (c) => {
+ const { owner, repo, branch, path } = c.req.param();
+ return redirectTo(c, `https://raw.githubusercontent.com/${owner}/${repo}/${branch}/${path}`);
+ });
+
+ // This is not a native GitHub route, but instead it lets us render blob
+ // content whose raw headers prevent hotlinking.
+ app.get("/:owner/:repo/embed/:type/:branch/:path{.*}", async (c) => {
+ const { owner, repo, type, branch, path } = c.req.param();
+
+ if (type !== "pdf") {
+ return c.notFound();
+ }
+
+ let response: Response;
+ try {
+ response = await getGitHubRaw(owner, repo, branch, path);
+ } catch (e) {
+ c.status(e instanceof GitHubHTTPError ? (e.status as StatusCode) : 500);
+ return c.body(null);
+ }
+
+ let contentType = response.headers.get("Content-Type");
+ if (contentType === null || contentType === "application/octet-stream") {
+ contentType = "application/pdf";
+ }
+
+ return new Response(response.body, { headers: { "Content-Type": contentType } });
+ });
+
+ // GitHub's own wiki URLs, in the order it resolves them: names beginning with
+ // an underscore are reserved for its actions (_pages, _new, _edit, ...), so a
+ // page can never be mistaken for one.
+ app.get("/:owner/:repo/wiki", async (c) => {
+ const { owner, repo } = c.req.param();
+ return c.render("wiki.eta", await getGitHubWiki(owner, repo));
+ });
+
+ app.get("/:owner/:repo/wiki/_pages", async (c) => {
+ const { owner, repo } = c.req.param();
+ return c.render("wikiPages.eta", await getGitHubWikiPages(owner, repo));
+ });
+
+ // An older revision of a page renders exactly like its current one.
+ app.get("/:owner/:repo/wiki/:page/:oid", async (c) => {
+ const { owner, repo, page, oid } = c.req.param();
+ if (page.startsWith("_") || !/^[0-9a-f]{40}$/.test(oid)) {
+ return redirectToGitHub(c);
+ }
+ return c.render("wiki.eta", await getGitHubWiki(owner, repo, page, oid));
+ });
+
+ app.get("/:owner/:repo/wiki/:page", async (c) => {
+ const { owner, repo, page } = c.req.param();
+ if (page.startsWith("_")) {
+ return redirectToGitHub(c);
+ }
+ return c.render("wiki.eta", await getGitHubWiki(owner, repo, page));
+ });
+
+ 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 c.render("issues.eta", await getGitHubIssues(owner, repo, { q }));
+ });
+
+ app.get("/:owner/:repo/pulls", async (c) => {
+ const { owner, repo } = c.req.param();
+ const q = c.req.query("q");
+ return c.render("pulls.eta", await 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).
+ return redirectToGitHub(c);
+ });
+
+ app.get("/:owner/:repo/commits/:branch/:path{.*}?", async (c) => {
+ const { owner, repo, branch, path = "" } = c.req.param();
+ return c.render("commits.eta", await 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.
+ // The view and path are where the picker was opened, so each ref can link to
+ // the same page on another ref.
+ app.get("/api/:owner/:repo/refs/:type/:view/:path{.*}?", async (c) => {
+ const { owner, repo, type, view, path = "" } = c.req.param();
+ if (type !== "branches" && type !== "tags") {
+ return c.notFound();
+ }
+ if (view !== "tree" && view !== "blob" && view !== "commits") {
+ return c.notFound();
+ }
+ const refs = await getGitHubRefs(owner, repo, type);
+ return c.render("_refs.eta", { ...refs, view, path });
+ });
+
+ app.get("/api/:owner/:repo/latest-commit/:branch/:path{.*}?", async (c) => {
+ const { owner, repo, branch, path = "" } = c.req.param();
+ return c.render("_latestCommit.eta", await getGitHubLatestCommit(owner, repo, branch, path));
+ });
+
+ app.get("/api/:owner/:repo/sidebar", async (c) => {
+ const { owner, repo } = c.req.param();
+ return c.render("_sidebar.eta", await getGitHubSidebar(owner, repo));
+ });
+
+ app.get("/:owner/:repo/releases/tag/:tag", async (c) => {
+ const { owner, repo, tag } = c.req.param();
+ return c.render("release.eta", await getGitHubRelease(owner, repo, tag));
+ });
+
+ app.get("/:owner/:repo/releases", async (c) => {
+ const { owner, repo } = c.req.param();
+ return c.render("releases.eta", await getGitHubReleases(owner, repo));
+ });
+
+ app.all("*", async (c) => {
+ return redirectToGitHub(c);
+ });
+
+ return app;
+}
diff --git a/cgithub/src/scraper.test.ts b/cgithub/src/scraper.test.ts
new file mode 100644
index 0000000..8dcb0b8
--- /dev/null
+++ b/cgithub/src/scraper.test.ts
@@ -0,0 +1,624 @@
+import assert from "node:assert";
+import { describe, it } from "node:test";
+import {
+ getGitHubBlob,
+ getGitHubCommits,
+ getGitHubIssues,
+ getGitHubSidebar,
+ getGitHubLatestCommit,
+ getGitHubPulls,
+ getGitHubRaw,
+ getGitHubRefs,
+ getGitHubRelease,
+ getGitHubReleases,
+ getGitHubRepo,
+ getGitHubOwner,
+ getGitHubTree,
+ getGitHubWiki,
+ getGitHubWikiPages,
+ ExternalRedirectError,
+ GitHubHTTPError,
+ InternalRedirectError,
+} 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.forks?.length, 3);
+ assert.strictEqual(data.info.numReleases, 39);
+
+ assert.ok(data.items.length > 0);
+ assert.ok(typeof data.overviewHtml?.["README.md"] === "string");
+ });
+
+ it("should fetch tree", async () => {
+ const data = await getGitHubTree("github", "rally", "main", "lib");
+
+ assert.strictEqual(data.repo.owner, "github");
+ assert.strictEqual(data.repo.name, "rally");
+ assert.strictEqual(data.branch, "main");
+ assert.strictEqual(data.path, "lib");
+
+ assert.deepStrictEqual(data.items, [
+ {
+ name: "README.md",
+ path: "lib/README.md",
+ contentType: "file",
+ },
+ {
+ name: "RallyValidate.js",
+ path: "lib/RallyValidate.js",
+ contentType: "file",
+ },
+ ]);
+
+ assert.ok(typeof data.overviewHtml?.["README.md"] === "string");
+ });
+
+ it("should fetch text 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, "1.04 KB / 21 lines / 17 loc");
+ assert.strictEqual(data.language, "Text");
+ assert.strictEqual(data.image, false);
+
+ assert.strictEqual(data.textLines?.length, 21);
+ assert.strictEqual(data.textLines[0], "MIT License");
+ assert.strictEqual(data.htmlLines?.length, 21);
+ assert.strictEqual(data.htmlLines[0], "MIT License");
+ assert.strictEqual(data.htmlContent, null);
+ });
+
+ it("should fetch Markdown blob", async () => {
+ const data = await getGitHubBlob("actions", "deploy-pages", "main", "README.md");
+
+ assert.strictEqual(data.repo.owner, "actions");
+ assert.strictEqual(data.repo.name, "deploy-pages");
+ assert.strictEqual(data.branch, "main");
+ assert.strictEqual(data.path, "README.md");
+
+ assert.strictEqual(data.size, "9.12 KB / 133 lines / 94 loc");
+ assert.strictEqual(data.language, "Markdown");
+ assert.strictEqual(data.image, false);
+
+ assert.strictEqual(data.textLines?.[0], "# deploy-pages 🚀");
+ assert.strictEqual(
+ data.htmlLines?.[0],
+ '<span class="pl-mh"># <span class="pl-en">deploy-pages 🚀</span></span>',
+ );
+ const firstLine =
+ /^<article class="markdown-body entry-content container-lg" itemprop="text"><div class="markdown-heading" dir="auto"><h1 tabindex="-1" class="heading-element" dir="auto">deploy-pages 🚀<\/h1>/;
+ assert.match(data.htmlContent ?? "", firstLine);
+ });
+
+ it("should fetch code blob", async () => {
+ const data = await getGitHubBlob("actions", "deploy-pages", "main", ".gitattributes");
+
+ assert.strictEqual(data.repo.owner, "actions");
+ assert.strictEqual(data.repo.name, "deploy-pages");
+ assert.strictEqual(data.branch, "main");
+ assert.strictEqual(data.path, ".gitattributes");
+
+ assert.strictEqual(data.size, "39 Bytes / 1 lines / 1 loc");
+ assert.strictEqual(data.language, "Git Attributes");
+ assert.strictEqual(data.image, false);
+
+ assert.deepStrictEqual(data.textLines, ["dist/** -diff linguist-generated=true "]);
+ assert.deepStrictEqual(data.htmlLines, [
+ '\u003cspan class="pl-e"\u003edist\u003c/span\u003e/\u003cspan class="pl-k"\u003e**\u003c/span\u003e \u003cspan class="pl-k"\u003e-\u003c/span\u003e\u003cspan class="pl-v"\u003ediff\u003c/span\u003e \u003cspan class="pl-v"\u003elinguist-generated\u003c/span\u003e=\u003cspan class="pl-c1"\u003etrue\u003c/span\u003e ',
+ ]);
+ assert.strictEqual(data.htmlContent, null);
+ });
+
+ it("should fetch issues", async () => {
+ const data = await getGitHubIssues("actions", "deploy-pages");
+
+ 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);
+ assert.strictEqual(issue402.title, "Dry Run");
+ assert.strictEqual(issue402.state, "OPEN");
+ 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 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", "");
+
+ assert.strictEqual(data.repo.owner, "actions");
+ assert.strictEqual(data.repo.name, "deploy-pages");
+ assert.strictEqual(data.branch, "main");
+ assert.strictEqual(data.path, "");
+
+ assert.ok(data.commitGroups.length > 0);
+ const firstGroup = data.commitGroups[0];
+ assert.ok(firstGroup.title.length > 0);
+ assert.ok(firstGroup.commits.length > 0);
+
+ const firstCommit = firstGroup.commits[0];
+ assert.ok(firstCommit.oid.length === 40);
+ assert.ok(firstCommit.shortMessage.length > 0);
+ assert.ok(firstCommit.authors.length > 0);
+ });
+
+ it("should fetch commits with path", async () => {
+ const data = await getGitHubCommits("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.ok(data.commitGroups.length > 0);
+ });
+
+ // GitHub serves a file's history from the same route as a directory's.
+ it("should fetch the history of a blob", async () => {
+ const data = await getGitHubCommits("actions", "deploy-pages", "main", "src/index.js");
+
+ assert.strictEqual(data.path, "src/index.js");
+
+ assert.ok(data.commitGroups.length > 0);
+ const commit = data.commitGroups[0].commits[0];
+ assert.strictEqual(commit.oid.length, 40);
+ assert.ok(commit.shortMessage.length > 0);
+ });
+
+ // What a history page's "Browse" link resolves to: the file as it stood at
+ // one of its commits. Nothing in a commits payload says whether the path is
+ // a file or a directory, so those links all point at the tree and rely on
+ // GitHub redirecting a file's to its blob.
+ it("should redirect a blob browsed at a commit to its blob view", async () => {
+ const oid = "b39c421b98f49d83ae50ec502c3ddfc3bf28f2c6";
+
+ await assert.rejects(getGitHubTree("actions", "deploy-pages", oid, "src/index.js"), (err) => {
+ assert(err instanceof InternalRedirectError, "error should be an InternalRedirectError");
+ assert.strictEqual(err.location, `/actions/deploy-pages/blob/${oid}/src/index.js`);
+ return true;
+ });
+
+ const data = await getGitHubBlob("actions", "deploy-pages", oid, "src/index.js");
+
+ assert.strictEqual(data.branch, oid);
+ assert.strictEqual(data.path, "src/index.js");
+ assert.strictEqual(data.size, "1.37 KB / 51 lines / 40 loc");
+ assert.strictEqual(data.textLines?.length, 51);
+ });
+
+ it("should fetch releases", async () => {
+ const data = await getGitHubReleases("actions", "deploy-pages");
+
+ assert.strictEqual(data.repo.owner, "actions");
+ assert.strictEqual(data.repo.name, "deploy-pages");
+
+ assert.ok(data.releases.length > 0);
+ const firstRelease = data.releases[0];
+ assert.ok(firstRelease.tagName.length > 0);
+ assert.ok(firstRelease.title.match(/^v\d/));
+ assert.ok(firstRelease.publishedAt.length > 0);
+ 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 the sidebar", async () => {
+ const data = await getGitHubSidebar("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" },
+ ]);
+
+ assert.strictEqual(data.latestRelease?.tag, "v5.0.1");
+ assert.ok(data.latestRelease?.publishedAt?.startsWith("2026-09-01"));
+ });
+
+ it("should fetch the sidebar for a multi-language repository", async () => {
+ const data = await getGitHubSidebar("rust-lang", "rust");
+
+ const rust = data.languages.find((language) => language.name === "Rust");
+ assert.ok(rust);
+ 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");
+
+ 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");
+
+ assert.strictEqual(data.repo.owner, "actions");
+ assert.strictEqual(data.repo.name, "deploy-pages");
+
+ assert.ok(data.release.tagName.length > 0);
+ assert.ok(data.release.title.length > 0);
+ assert.ok(data.release.publishedAt.length > 0);
+ assert.ok(data.release.bodyHtml.length > 0);
+ assert.ok(data.release.assets && data.release.assets.length >= 2);
+ assert.strictEqual(data.release.assets[0].name, "Source code (zip)");
+ assert.strictEqual(
+ data.release.assets[0].url,
+ "https://github.com/actions/deploy-pages/archive/refs/tags/v4.0.5.zip",
+ );
+ });
+ });
+
+ 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");
+
+ assert.strictEqual(data.repo.owner, "github");
+ assert.strictEqual(data.repo.name, "docs");
+ assert.strictEqual(data.branch, "main");
+ assert.strictEqual(data.path, "assets/images/site/logo.png");
+
+ assert.strictEqual(data.size, "1.26 KB");
+ assert.strictEqual(data.language, null);
+ assert.strictEqual(data.image, true);
+ assert.strictEqual(data.renderFileType, null);
+ assert.strictEqual(data.textLines, null);
+ assert.strictEqual(data.htmlLines, null);
+ assert.strictEqual(data.htmlContent, null);
+ });
+
+ // GitHub renders a PDF with a viewer of its own, so the page carries no
+ // content for it beyond the file type.
+ it("should support PDF", async () => {
+ const data = await getGitHubBlob("mozilla", "pdf.js", "master", "test/pdfs/basicapi.pdf");
+
+ assert.strictEqual(data.repo.owner, "mozilla");
+ assert.strictEqual(data.repo.name, "pdf.js");
+ assert.strictEqual(data.branch, "master");
+ assert.strictEqual(data.path, "test/pdfs/basicapi.pdf");
+
+ assert.strictEqual(data.size, "103 KB");
+ assert.strictEqual(data.language, null);
+ assert.strictEqual(data.image, false);
+ assert.strictEqual(data.renderFileType, "pdf");
+ assert.strictEqual(data.textLines, null);
+ assert.strictEqual(data.htmlLines, null);
+ assert.strictEqual(data.htmlContent, null);
+ });
+
+ // Why the embed route exists rather than the blob view pointing a viewer
+ // at GitHub: as application/octet-stream a browser downloads the file
+ // instead of displaying it.
+ it("should fetch a raw PDF that GitHub types as a download", async () => {
+ const response = await getGitHubRaw("mozilla", "pdf.js", "master", "test/pdfs/basicapi.pdf");
+
+ assert.strictEqual(response.headers.get("Content-Type"), "application/octet-stream");
+ assert.ok((await response.text()).startsWith("%PDF-"));
+ });
+
+ // A binary file GitHub has no preview for: no content of any kind, and the
+ // path exercises escaping of a space.
+ it("should support an unpreviewable binary file", async () => {
+ const data = await getGitHubBlob("justrajdeep", "fonts", "master", "Times New Roman.ttf");
+
+ assert.strictEqual(data.repo.owner, "justrajdeep");
+ assert.strictEqual(data.repo.name, "fonts");
+ assert.strictEqual(data.branch, "master");
+ assert.strictEqual(data.path, "Times New Roman.ttf");
+
+ assert.strictEqual(data.size, "815 KB");
+ assert.strictEqual(data.language, null);
+ assert.strictEqual(data.image, false);
+ assert.strictEqual(data.renderFileType, null);
+ assert.strictEqual(data.textLines, null);
+ assert.strictEqual(data.htmlLines, null);
+ assert.strictEqual(data.htmlContent, null);
+ });
+ });
+
+ describe("wikis", () => {
+ it("should fetch the home page a wiki serves off /wiki itself", async () => {
+ const data = await getGitHubWiki("microsoft", "vscode");
+
+ assert.strictEqual(data.repo.owner, "microsoft");
+ assert.strictEqual(data.repo.name, "vscode");
+ assert.strictEqual(data.title, "Home");
+ assert.strictEqual(data.updatedBy, "Greg Van Liew");
+ assert.strictEqual(data.updatedAt, "2019-11-22T03:08:32Z");
+
+ assert.match(data.bodyHtml, /Welcome to the Visual Studio Code Wiki/);
+ // The _Sidebar and _Footer pages this wiki defines.
+ assert.match(data.sidebarHtml ?? "", /href="\/microsoft\/vscode\/wiki\/Roadmap"/);
+ assert.match(data.footerHtml ?? "", /Want to contribute to this Wiki\?/);
+ });
+
+ it("should fetch a page by its slug", async () => {
+ const data = await getGitHubWiki("microsoft", "vscode", "Commit-Signing");
+
+ assert.strictEqual(data.title, "Commit Signing");
+ assert.match(data.bodyHtml, /GPG/);
+ });
+
+ // The slug arrives from our route already decoded, so it has to be encoded
+ // again rather than interpolated into the URL as-is.
+ it("should fetch a page whose slug is not path-safe", async () => {
+ const data = await getGitHubWiki(
+ "microsoft",
+ "vscode",
+ "[DEV]-Perf-Tools-for-VS-Code-Development",
+ );
+
+ assert.strictEqual(data.title, "[DEV] Perf Tools for VS Code Development");
+ assert.ok(data.bodyHtml.length > 0);
+ });
+
+ it("should fetch an older revision of a page", async () => {
+ const oid = "f1c60035ba38b68ba383f58e482c312218daf86c";
+ const data = await getGitHubWiki("microsoft", "vscode", "Commit-Signing", oid);
+
+ assert.strictEqual(data.title, "Commit Signing");
+ assert.strictEqual(data.updatedBy, "Joaqu\u00edn Ruales");
+ assert.strictEqual(data.updatedAt, "2025-10-17T15:07:14Z");
+ });
+
+ // The sidebar list is complete; its "Show N more pages…" button only
+ // unhides the rest, so no second request is needed to link every page.
+ it("should collect every page from the sidebar of any page", async () => {
+ const data = await getGitHubWiki("microsoft", "vscode");
+
+ assert.ok(data.pages.length > 15);
+ assert.deepStrictEqual(data.pages[0], { name: "Home", href: "/microsoft/vscode/wiki" });
+ assert.ok(data.pages.some((page) => page.href === "/microsoft/vscode/wiki/Commit-Signing"));
+ });
+
+ // An archived repository, so neither its wiki nor these dates can move.
+ it("should fetch the page index with its last-updated dates", async () => {
+ const data = await getGitHubWikiPages("ariya", "phantomjs");
+
+ assert.strictEqual(data.repo.owner, "ariya");
+ assert.ok(data.pages.length > 1);
+ assert.ok(data.pages.every((page) => page.updatedAt));
+
+ const home = data.pages.find((page) => page.name === "Home");
+ assert.ok(home);
+ assert.strictEqual(home.href, "/ariya/phantomjs/wiki");
+ assert.strictEqual(home.updatedAt, "2018-02-08T18:57:56Z");
+ });
+
+ it("should redirect to the repository when it has no wiki", async () => {
+ await assert.rejects(getGitHubWiki("git", "git"), (err) => {
+ assert(err instanceof InternalRedirectError, "error should be an InternalRedirectError");
+ assert.strictEqual(err.location, "/git/git");
+ return true;
+ });
+ });
+ });
+
+ describe("owner pages", () => {
+ it("should fetch user profile", async () => {
+ 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");
+ 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);
+
+ 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 () => {
+ const data = await getGitHubOwner("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://")));
+ assert.ok(data.nav.some((item) => item.text === "Sponsoring"));
+ });
+
+ 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.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 () => {
+ await assert.rejects(getGitHubOwner("features"), (err) => {
+ assert(err instanceof ExternalRedirectError, "error should be an ExternalRedirectError");
+ assert.strictEqual(err.location, "https://github.com/features");
+ return true;
+ });
+ });
+
+ 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;
+ });
+ });
+ });
+
+ 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;
+ });
+ });
+
+ it("should redirect when fetching a directory as a blob", async () => {
+ await assert.rejects(
+ getGitHubBlob("earendil-works", "pi", "main", "packages/agent"),
+ (err) => {
+ assert(err instanceof InternalRedirectError, "error should be an InternalRedirectError");
+ assert.strictEqual(err.location, "/earendil-works/pi/tree/main/packages/agent");
+ return true;
+ },
+ );
+ });
+
+ it("should redirect when fetching a file as a tree", async () => {
+ await assert.rejects(
+ getGitHubTree("earendil-works", "pi", "main", "packages/agent/package.json"),
+ (err) => {
+ assert(err instanceof InternalRedirectError, "error should be an InternalRedirectError");
+ assert.strictEqual(
+ err.location,
+ "/earendil-works/pi/blob/main/packages/agent/package.json",
+ );
+ return true;
+ },
+ );
+ });
+ });
+});
diff --git a/cgithub/src/scraper.ts b/cgithub/src/scraper.ts
new file mode 100644
index 0000000..fb48700
--- /dev/null
+++ b/cgithub/src/scraper.ts
@@ -0,0 +1,1228 @@
+import { parseDocument } from "htmlparser2";
+import { isText, type AnyNode, type Element } from "domhandler";
+import { getAttributeValue, getInnerHTML, textContent } from "domutils";
+import * as cssSelect from "css-select";
+
+export class GitHubHTTPError extends Error {
+ status: number;
+
+ constructor(status: number, message: string) {
+ super(message);
+ this.name = "GitHubHTTPError";
+ this.status = status;
+ }
+}
+
+export abstract class RedirectError extends Error {
+ location: string;
+
+ constructor(location: string) {
+ super(`Redirect to ${location}`);
+ this.name = this.constructor.name;
+ this.location = location;
+ }
+}
+
+export class InternalRedirectError extends RedirectError {}
+
+export class ExternalRedirectError extends RedirectError {}
+
+interface TreeItem {
+ contentType: "directory" | "file";
+ name: string;
+ path: string;
+}
+
+interface OverviewFile {
+ displayName: string;
+ // null when GitHub includes the file without rendered content.
+ richText?: string | null;
+}
+
+interface IssueNode {
+ number: number;
+ title?: string;
+ titleHtml?: string;
+ author?: {
+ login: string;
+ };
+ createdAt: string;
+ state: string;
+ labels?: {
+ edges: {
+ node: {
+ name: string;
+ };
+ }[];
+ };
+}
+
+interface IssueIndexPageQuery {
+ queryName: "IssueIndexPageQuery";
+ result: {
+ data: {
+ repository: {
+ search: {
+ edges: {
+ node: IssueNode;
+ }[];
+ };
+ };
+ };
+ };
+}
+
+type StylingDirective = [number, number, string];
+
+export interface Commit {
+ oid: string;
+ url: string;
+ authoredDate: string;
+ committedDate: string;
+ shortMessage: string;
+ bodyMessageHtml: string;
+ authors: {
+ login: string;
+ displayName: string;
+ avatarUrl: string;
+ path: string;
+ }[];
+}
+
+export interface CommitGroup {
+ title: string;
+ commits: Commit[];
+}
+
+interface CodeViewLayoutRoute {
+ repo: {
+ ownerLogin: string;
+ name: string;
+ };
+ refInfo: {
+ name: string;
+ };
+ path: string;
+}
+
+interface CodeViewRepoRoute {
+ tree: {
+ items: TreeItem[];
+ };
+ overview?: {
+ overviewFiles?: OverviewFile[];
+ };
+}
+
+interface CodeViewTreeRoute {
+ tree: {
+ items: TreeItem[];
+ readme?: OverviewFile;
+ };
+}
+
+interface BlobHeaderInfo {
+ blobSize: string;
+ lineInfo?: {
+ truncatedLoc: string | null;
+ truncatedSloc: string | null;
+ };
+}
+
+interface CodeViewBlobLayoutRoute {
+ blob: {
+ headerInfo: BlobHeaderInfo;
+ language: string | null;
+ image: boolean;
+ };
+}
+
+interface CodeViewBlobLayoutRouteStyledBlob {
+ rawLines: string[] | null;
+ colorizedLines: string[] | null;
+ stylingDirectives: StylingDirective[][] | null;
+}
+
+interface CommitsRefRoute {
+ commitGroups: CommitGroup[];
+}
+
+interface CodeViewBlobRoute {
+ richText: string | null;
+ renderedFileInfo?: { renderFileType: string | null } | null;
+}
+
+interface SidebarAbout {
+ description?: string | null;
+ website?: string | null;
+ stargazerCount?: number;
+ forksCount?: number;
+ // A section is false when the repository has none of that thing.
+ sections?: { releases?: { releaseCount?: number } | false };
+}
+
+interface AppPayload {
+ sidebarAbout?: SidebarAbout;
+ codeViewLayoutRoute?: CodeViewLayoutRoute;
+ codeViewRepoRoute?: CodeViewRepoRoute;
+ codeViewTreeRoute?: CodeViewTreeRoute;
+ codeViewBlobLayoutRoute?: CodeViewBlobLayoutRoute;
+ "codeViewBlobLayoutRoute.StyledBlob"?: CodeViewBlobLayoutRouteStyledBlob;
+ codeViewBlobRoute?: CodeViewBlobRoute;
+ commitsRefRoute?: CommitsRefRoute;
+ // Actually there may be other query types.
+ preloadedQueries?: IssueIndexPageQuery[];
+}
+
+function escapeHtml(text: string): string {
+ return text
+ .replace(/&/g, "&amp;")
+ .replace(/</g, "&lt;")
+ .replace(/>/g, "&gt;")
+ .replace(/"/g, "&quot;")
+ .replace(/'/g, "&#039;");
+}
+
+// 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 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 += `<span class="${classes}">${innerContent}</span>`;
+ pos = end;
+ i = nextI;
+ }
+
+ 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;
+}
+
+interface GitHubCommon {
+ repo: {
+ owner: string;
+ name: string;
+ };
+}
+
+interface GitHubNav extends GitHubCommon {
+ branch: string;
+ path: string;
+}
+
+export interface GitHubTree extends GitHubNav {
+ items: TreeItem[];
+ overviewHtml?: Record<string, string | null>;
+}
+
+export interface RepoInfo {
+ description: string | null;
+ website: string | null;
+ stars: string | null;
+ forks: string | null;
+ numReleases?: number;
+}
+
+export interface GitHubRepo extends GitHubTree {
+ info: RepoInfo;
+}
+
+export interface GitHubBlob extends GitHubNav {
+ language: string | null;
+ size: string;
+ image: boolean;
+ renderFileType: string | null;
+ textLines: string[] | null;
+ htmlLines: string[] | null;
+ htmlContent: string | null;
+}
+
+interface Issue {
+ number: number;
+ title: string;
+ state: string;
+ createdAt: string;
+ numReplies?: number;
+ numPRs?: number;
+}
+
+export interface GitHubIssues extends GitHubCommon {
+ issues: Issue[];
+ q?: string;
+}
+
+export interface GitHubPulls extends GitHubCommon {
+ pulls: Issue[];
+ q?: string;
+}
+
+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 Language {
+ // "Other" for the aggregate bucket GitHub reports without a name.
+ name: string;
+ percentage: number;
+ color: string | null;
+}
+
+export interface LatestRelease {
+ tag: string;
+ publishedAt: string | null;
+}
+
+export interface GitHubSidebar extends GitHubCommon {
+ languages: Language[];
+ latestRelease: LatestRelease | null;
+}
+
+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;
+ publishedAt: string;
+ bodyHtml: string;
+ assets?: ReleaseAsset[];
+}
+
+export interface ReleaseAsset {
+ name: string;
+ url: string;
+ size?: string;
+ publishedAt?: string;
+}
+
+export interface GitHubReleases extends GitHubCommon {
+ releases: Release[];
+}
+
+export interface GitHubRelease extends GitHubCommon {
+ release: Release;
+}
+
+export interface WikiPage {
+ name: string;
+ href: string;
+ updatedAt?: string;
+}
+
+export interface GitHubWiki extends GitHubCommon {
+ title: string;
+ updatedBy: string | null;
+ updatedAt: string | null;
+ bodyHtml: string;
+ sidebarHtml: string | null;
+ footerHtml: string | null;
+ pages: WikiPage[];
+}
+
+export interface GitHubWikiPages extends GitHubCommon {
+ pages: WikiPage[];
+}
+
+interface OwnerRepo {
+ owner: string;
+ name: string;
+ description?: string;
+ language?: string;
+ stars?: string;
+ forks?: string;
+}
+
+interface OwnerNavItem {
+ text: string;
+ href: string;
+}
+
+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 }[];
+ nav: OwnerNavItem[];
+ pinned: OwnerRepo[];
+ // The repository list on organization overviews; empty for users.
+ repos: OwnerRepo[];
+}
+
+// GitHub throttles/blocks requests without realistic browser headers.
+// These headers make the request appear as a standard browser visit.
+function browserHeaders(accept: string): Record<string, string> {
+ return {
+ "User-Agent":
+ "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36",
+ Accept: accept,
+ "Accept-Language": "en-US,en;q=0.5",
+ };
+}
+
+async function fetchGitHubResponse(path: string, accept: string): Promise<Response> {
+ const headers = browserHeaders(accept);
+ 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 InternalRedirectError(url.pathname);
+ }
+ }
+ }
+ if (!response.ok) throw new GitHubHTTPError(response.status, response.statusText);
+ return response;
+}
+
+async function fetchGitHubPage(path: string): Promise<string> {
+ const response = await fetchGitHubResponse(
+ path,
+ "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8",
+ );
+ return response.text();
+}
+
+// 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<T>(path: string): Promise<T> {
+ const response = await fetchGitHubResponse(path, "application/json");
+ return (await response.json()) as T;
+}
+
+function parseEmbeddedPayload<T>(html: string): T | null {
+ const regex = new RegExp(
+ `<script type="application/json" data-target="react-app.embeddedData">([^<]+)</script>`,
+ "g",
+ );
+
+ match: for (const match of html.matchAll(regex)) {
+ let data;
+ try {
+ data = JSON.parse(match[1]);
+ } catch {
+ continue;
+ }
+ if (data.payload === undefined) continue match;
+ return data.payload;
+ }
+ return null;
+}
+
+function parsePayload(html: string): AppPayload | null {
+ return parseEmbeddedPayload<AppPayload>(html);
+}
+
+function extractOverviewHtml(
+ overviewFiles: OverviewFile[] = [],
+): Record<string, string | null> | undefined {
+ const result: Record<string, string | null> = {};
+ for (const file of overviewFiles) {
+ if (file.richText !== undefined) {
+ result[file.displayName] = file.richText;
+ }
+ }
+ return Object.keys(result).length > 0 ? result : undefined;
+}
+
+function extractGitHub<T>(payload: AppPayload | null, extra: T): GitHubNav & T {
+ const layoutRoute = payload?.codeViewLayoutRoute;
+ if (layoutRoute === undefined) {
+ throw new Error("Missing codeViewLayoutRoute");
+ }
+
+ return {
+ repo: {
+ owner: layoutRoute.repo.ownerLogin,
+ name: layoutRoute.repo.name,
+ },
+ branch: layoutRoute.refInfo.name,
+ path: layoutRoute.path,
+ ...extra,
+ };
+}
+
+export async function getGitHubRepo(owner: string, repo: string): Promise<GitHubRepo> {
+ const html = await fetchGitHubPage(`${owner}/${repo}`);
+
+ const payload = parsePayload(html);
+ const repoRoute = payload?.codeViewRepoRoute;
+
+ if (repoRoute?.tree === undefined) {
+ throw new Error("Could not find tree data in embedded JSON");
+ }
+
+ const about = payload?.sidebarAbout;
+ const releases = about?.sections?.releases;
+ const info = {
+ description: about?.description ?? null,
+ website: about?.website ?? null,
+ stars: about?.stargazerCount?.toLocaleString("en-US") ?? null,
+ forks: about?.forksCount?.toLocaleString("en-US") ?? null,
+ numReleases: releases === false ? undefined : releases?.releaseCount,
+ };
+ const overviewHtml = extractOverviewHtml(repoRoute.overview?.overviewFiles);
+
+ return extractGitHub(payload, { items: repoRoute.tree.items, info, overviewHtml });
+}
+
+export async function getGitHubTree(
+ owner: string,
+ repo: string,
+ branch: string,
+ path: string,
+): Promise<GitHubTree> {
+ const html = await fetchGitHubPage(`${owner}/${repo}/tree/${branch}/${path}`);
+
+ const payload = parsePayload(html);
+ const treeRoute = payload?.codeViewTreeRoute;
+
+ if (treeRoute?.tree === undefined) {
+ throw new Error("Could not find tree data in embedded JSON");
+ }
+
+ const overviewHtml = extractOverviewHtml(treeRoute.tree.readme ? [treeRoute.tree.readme] : []);
+
+ return extractGitHub(payload, { items: treeRoute.tree.items, overviewHtml });
+}
+
+export async function getGitHubBlob(
+ owner: string,
+ repo: string,
+ branch: string,
+ path: string,
+): Promise<GitHubBlob> {
+ const html = await fetchGitHubPage(`${owner}/${repo}/blob/${branch}/${path}`);
+
+ const payload = parsePayload(html);
+ const blob = payload?.codeViewBlobLayoutRoute?.blob;
+ const styledBlob = payload?.["codeViewBlobLayoutRoute.StyledBlob"];
+
+ if (blob === undefined) {
+ throw new Error("Could not find blob data in embedded JSON");
+ }
+
+ 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 = blob.headerInfo.blobSize;
+ if (blob.headerInfo.lineInfo?.truncatedLoc) {
+ size += ` / ${blob.headerInfo.lineInfo.truncatedLoc} lines`;
+ }
+ if (blob.headerInfo.lineInfo?.truncatedSloc) {
+ size += ` / ${blob.headerInfo.lineInfo.truncatedSloc} loc`;
+ }
+
+ return extractGitHub(payload, {
+ language: blob.language || null,
+ size,
+ image: blob.image,
+ renderFileType: payload?.codeViewBlobRoute?.renderedFileInfo?.renderFileType ?? null,
+ textLines: styledBlob?.rawLines ?? null,
+ htmlLines,
+ htmlContent: payload?.codeViewBlobRoute?.richText ?? null,
+ });
+}
+
+// For proxying embeds.
+export async function getGitHubRaw(
+ owner: string,
+ repo: string,
+ branch: string,
+ path: string,
+): Promise<Response> {
+ const url = `https://raw.githubusercontent.com/${owner}/${repo}/${branch}/${path}`;
+ const response = await fetch(url, { headers: browserHeaders("*/*") });
+ if (!response.ok) throw new GitHubHTTPError(response.status, response.statusText);
+ return response;
+}
+
+export async function getGitHubIssues(
+ owner: string,
+ repo: string,
+ // We only support is:issue searches (no PR results).
+ { q }: { q?: string } = {},
+): Promise<GitHubIssues> {
+ const suffix = q ? `?q=${encodeURIComponent(q)}` : "";
+ const html = await fetchGitHubPage(`${owner}/${repo}/issues${suffix}`);
+ const payload = parsePayload(html);
+
+ let issuesQuery: IssueIndexPageQuery | undefined;
+ for (const query of payload?.preloadedQueries ?? []) {
+ if (query.queryName === "IssueIndexPageQuery") {
+ issuesQuery = query;
+ }
+ }
+ if (issuesQuery === undefined) {
+ throw new Error("Could not find IssueIndexPageQuery in embedded JSON");
+ }
+
+ const issues: Issue[] = issuesQuery.result.data.repository.search.edges.map((edge) => {
+ const { number, title, titleHtml, state, createdAt } = edge.node;
+ const finalTitle = (title || titleHtml || "").replace(/<[^>]+>/g, "");
+ return { number, title: finalTitle, state, createdAt };
+ });
+
+ return { repo: { owner, name: repo }, issues, q };
+}
+
+export async function getGitHubPulls(
+ owner: string,
+ repo: string,
+ { q }: { q?: string } = {},
+): Promise<GitHubPulls> {
+ 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,
+ branch: string,
+ path: string,
+): Promise<GitHubCommits> {
+ const urlPath = path
+ ? `${owner}/${repo}/commits/${branch}/${path}`
+ : `${owner}/${repo}/commits/${branch}`;
+ const html = await fetchGitHubPage(urlPath);
+
+ const payload = parsePayload(html);
+ const commitsRoute = payload?.commitsRefRoute;
+
+ if (commitsRoute === undefined) {
+ throw new Error("Could not find commit history in embedded JSON");
+ }
+
+ return {
+ repo: {
+ owner,
+ name: repo,
+ },
+ branch,
+ path,
+ commitGroups: commitsRoute.commitGroups,
+ };
+}
+
+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<GitHubLatestCommit> {
+ const urlPath = path
+ ? `${owner}/${repo}/latest-commit/${branch}/${path}`
+ : `${owner}/${repo}/latest-commit/${branch}`;
+ const payload = await fetchGitHubJson<LatestCommitPayload>(urlPath);
+
+ return {
+ repo: { owner, name: repo },
+ commit: {
+ oid: payload.oid,
+ date: payload.date,
+ shortMessageHtml: payload.shortMessageHtmlLink,
+ authors: payload.authors,
+ },
+ };
+}
+
+interface SidebarPayload {
+ languages?: {
+ languages: {
+ // Absent for the "Other" bucket.
+ name?: string;
+ percentage: number;
+ color?: string;
+ }[];
+ };
+ releases?: {
+ // The name is the release title, which need not be the tag; the path is.
+ latestRelease?: { name?: string; path: string; publishedAt?: string } | null;
+ };
+}
+
+// The repository sidebar (languages, releases, contributors, ...) is not
+// rendered into the repo page at all; GitHub's own UI lazily fetches it as
+// JSON from here.
+export async function getGitHubSidebar(owner: string, repo: string): Promise<GitHubSidebar> {
+ const payload = await fetchGitHubJson<SidebarPayload>(`${owner}/${repo}/_sidebar`);
+
+ const languages: Language[] = [];
+ for (const entry of payload.languages?.languages ?? []) {
+ languages.push({
+ name: entry.name ?? "Other",
+ percentage: entry.percentage,
+ color: entry.color ?? null,
+ });
+ }
+
+ const release = payload.releases?.latestRelease;
+ const latestRelease = release
+ ? {
+ tag: decodeURIComponent(release.path.split("/releases/tag/")[1] ?? release.name ?? ""),
+ publishedAt: release.publishedAt ?? null,
+ }
+ : null;
+
+ return { repo: { owner, name: repo }, languages, latestRelease };
+}
+
+interface BranchRefEntry {
+ name: string;
+ isDefault: boolean;
+ authoredDate?: string;
+ author?: { login: string };
+}
+
+interface BranchesPayload {
+ branches: {
+ default?: BranchRefEntry;
+ yours?: BranchRefEntry[];
+ active?: BranchRefEntry[];
+ stale?: BranchRefEntry[];
+ };
+ hasMore?: Record<string, boolean>;
+}
+
+async function getGitHubBranchRefs(owner: string, repo: string): Promise<GitHubRefs> {
+ const html = await fetchGitHubPage(`${owner}/${repo}/branches`);
+ const payload = parseEmbeddedPayload<BranchesPayload>(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<string>();
+ 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<GitHubRefs> {
+ 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 <relative-time> 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<GitHubRefs> {
+ 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);
+ let tagName = title;
+ if (tagLink) {
+ const href = getAttributeValue(tagLink, "href");
+ if (href) {
+ tagName = href.split("/").pop() || title;
+ }
+ }
+
+ // Extract published date
+ const relativeTime = cssSelect.selectOne("relative-time", section);
+ const publishedAt = relativeTime ? getAttributeValue(relativeTime, "datetime") || "" : "";
+
+ // Extract body HTML (markdown content)
+ const body = cssSelect.selectOne(".markdown-body", section);
+ const bodyHtml = body ? getInnerHTML(body).trim() : "";
+
+ return {
+ tagName,
+ title,
+ publishedAt,
+ bodyHtml,
+ };
+}
+
+export async function getGitHubReleases(owner: string, repo: string): Promise<GitHubReleases> {
+ const html = await fetchGitHubPage(`${owner}/${repo}/releases`);
+
+ const document = parseDocument(html);
+ const releases: Release[] = [];
+
+ // Not really sure the right way to express this type.
+ const sections = cssSelect.selectAll("section", document) as unknown as Element[];
+
+ for (const section of sections) {
+ const h2 = cssSelect.selectOne("h2[id]", section);
+ if (!h2) continue;
+
+ const title = textContent(h2).trim();
+
+ releases.push(parseRelease(section, title));
+ }
+
+ return {
+ repo: {
+ owner,
+ name: repo,
+ },
+ releases,
+ };
+}
+
+export async function getGitHubReleaseBase(
+ owner: string,
+ repo: string,
+ tag: string,
+): Promise<GitHubRelease> {
+ const html = await fetchGitHubPage(`${owner}/${repo}/releases/tag/${tag}`);
+
+ const document = parseDocument(html);
+
+ const box = cssSelect.selectOne(".Box:has(h1)", document) as Element | null;
+ if (!box) throw new Error("Could not find release box");
+
+ const h1 = cssSelect.selectOne("h1", box);
+ const title = h1 ? textContent(h1).trim() : "";
+
+ const release = parseRelease(box, title);
+
+ return {
+ repo: {
+ owner,
+ name: repo,
+ },
+ release,
+ };
+}
+
+export async function getGitHubReleaseAssets(
+ owner: string,
+ repo: string,
+ tag: string,
+): Promise<ReleaseAsset[]> {
+ const html = await fetchGitHubPage(`${owner}/${repo}/releases/expanded_assets/${tag}`);
+ const document = parseDocument(html);
+ const assets: ReleaseAsset[] = [];
+
+ for (const row of cssSelect.selectAll("li", document) as unknown as Element[]) {
+ const link = cssSelect.selectOne("a[href]", row);
+ if (!link) continue;
+
+ const url = getAttributeValue(link, "href") || "";
+ // Filename is in the link, often split into multiple spans
+ const name = textContent(link).replace(/\s+/g, " ").trim();
+
+ let size: string | undefined;
+ let publishedAt: string | undefined;
+ for (const s of cssSelect.selectAll("span.color-fg-muted.text-right", row)) {
+ // Size is in a span with color-fg-muted and text-right, but avoid the one with relative-time
+ const timeElem = cssSelect.selectOne("relative-time", row);
+ if (timeElem) {
+ publishedAt = timeElem ? getAttributeValue(timeElem, "datetime") || "" : "";
+ } else {
+ const text = textContent(s).trim();
+ if (text) size = text;
+ }
+ }
+
+ assets.push({
+ name,
+ url: url.startsWith("/") ? `https://github.com${url}` : url,
+ size,
+ publishedAt,
+ });
+ }
+
+ return assets;
+}
+
+export async function getGitHubRelease(
+ owner: string,
+ repo: string,
+ tag: string,
+): Promise<GitHubRelease> {
+ const [base, assets] = await Promise.all([
+ getGitHubReleaseBase(owner, repo, tag),
+ getGitHubReleaseAssets(owner, repo, tag),
+ ]);
+ 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;
+}
+
+function parseRepoItems(items: Element[]): OwnerRepo[] {
+ const repos: OwnerRepo[] = [];
+ 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;
+
+ repos.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 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<string>();
+ 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 {
+ 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 });
+ }
+
+ return {
+ type: "user",
+ 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,
+ nav: parseOwnerNav(document),
+ pinned: parsePinnedRepos(document),
+ repos: [],
+ };
+}
+
+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,
+ nav: parseOwnerNav(document),
+ pinned: parsePinnedRepos(document),
+ repos: parseRepoItems(
+ cssSelect.selectAll("#org-repositories [itemprop='owns']", document) as unknown as Element[],
+ ),
+ };
+}
+
+export async function getGitHubOwner(owner: string): Promise<GitHubOwner> {
+ 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 ExternalRedirectError(`https://github.com/${owner}`);
+}
+
+// The text directly inside an element, ignoring any nested elements' text.
+function directText(elem: Element): string {
+ return elem.children
+ .filter(isText)
+ .map((node) => node.data)
+ .join("")
+ .replace(/\s+/g, " ")
+ .trim();
+}
+
+// The sidebar holds every wiki page, which are revealed by JavaScript.
+function parseWikiSidebarPages(document: AnyNode): WikiPage[] {
+ const pages: WikiPage[] = [];
+ const anchors = cssSelect.selectAll(
+ ".js-wiki-sidebar-page-container > div > a[href]",
+ document,
+ ) as unknown as Element[];
+ for (const anchor of anchors) {
+ const href = getAttributeValue(anchor, "href");
+ const name = textContent(anchor).replace(/\s+/g, " ").trim();
+ if (href && name) pages.push({ name, href });
+ }
+ return pages;
+}
+
+function wikiHtml(selector: string, document: AnyNode): string | null {
+ const elem = cssSelect.selectOne(selector, document);
+ if (!elem) return null;
+ const html = getInnerHTML(elem).trim();
+ return html || null;
+}
+
+export async function getGitHubWiki(
+ owner: string,
+ repo: string,
+ // Empty for the wiki's home page, which GitHub serves off /wiki itself.
+ page = "",
+ // A revision SHA renders that older version of the page.
+ oid = "",
+): Promise<GitHubWiki> {
+ // Page names keep characters that are not path-safe (C#, [DEV] ...), so the
+ // slug has to be re-encoded rather than interpolated the way a ref is.
+ const path = [`${owner}/${repo}/wiki`, page && encodeURIComponent(page), oid]
+ .filter(Boolean)
+ .join("/");
+ const html = await fetchGitHubPage(path);
+ const document = parseDocument(html);
+
+ const heading = cssSelect.selectOne("h1.gh-header-title", document) as Element | null;
+ if (!heading) {
+ throw new Error("Could not find the wiki page heading");
+ }
+
+ // "<author> edited this page <date> · <n> revisions", where the author is the
+ // only part not wrapped in an element of its own.
+ const meta = cssSelect.selectOne(".gh-header-meta", document) as Element | null;
+ const updatedBy = meta ? (/^(.+?) edited this page\b/.exec(directText(meta))?.[1] ?? null) : null;
+ const time = meta ? cssSelect.selectOne("relative-time", meta) : null;
+
+ return {
+ repo: { owner, name: repo },
+ title: directText(heading),
+ updatedBy,
+ updatedAt: time ? getAttributeValue(time, "datetime") || null : null,
+ bodyHtml: wikiHtml("#wiki-body .markdown-body", document) ?? "",
+ // Wikis can define _Sidebar and _Footer pages, rendered alongside every page.
+ sidebarHtml: wikiHtml(".wiki-custom-sidebar", document),
+ footerHtml: wikiHtml("#wiki-footer .markdown-body", document),
+ pages: parseWikiSidebarPages(document),
+ };
+}
+
+export async function getGitHubWikiPages(owner: string, repo: string): Promise<GitHubWikiPages> {
+ const html = await fetchGitHubPage(`${owner}/${repo}/wiki/_pages`);
+ const document = parseDocument(html);
+
+ const pages: WikiPage[] = [];
+ for (const row of cssSelect.selectAll(
+ "#wiki-content li.Box-row",
+ document,
+ ) as unknown as Element[]) {
+ const anchor = cssSelect.selectOne("a[href]", row) as Element | null;
+ const href = anchor ? getAttributeValue(anchor, "href") : undefined;
+ const name = anchor ? textContent(anchor).replace(/\s+/g, " ").trim() : "";
+ if (!href || !name) continue;
+
+ const time = cssSelect.selectOne("relative-time", row) as Element | null;
+ pages.push({ name, href, updatedAt: time ? getAttributeValue(time, "datetime") : undefined });
+ }
+
+ return { repo: { owner, name: repo }, pages };
+}
diff --git a/cgithub/src/worker.ts b/cgithub/src/worker.ts
new file mode 100644
index 0000000..e59c690
--- /dev/null
+++ b/cgithub/src/worker.ts
@@ -0,0 +1,30 @@
+// 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/build.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 });
+
+// Templates name each other as siblings ("./_tree") or from the views root
+// ("/layouts/base.eta"); either way the name is the path under views/ that
+// scripts/build.ts keyed the template by.
+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/tsconfig.json b/cgithub/tsconfig.json
new file mode 100644
index 0000000..2e9fe6a
--- /dev/null
+++ b/cgithub/tsconfig.json
@@ -0,0 +1,12 @@
+{
+ "compilerOptions": {
+ "target": "ESNext",
+ "module": "NodeNext",
+ "lib": ["ESNext"],
+ "noEmit": true,
+ "strict": true,
+ "erasableSyntaxOnly": true,
+ "rewriteRelativeImportExtensions": true,
+ "verbatimModuleSyntax": true
+ }
+}
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 @@
+<div class="commit box">
+ <div>
+ <div>
+ <%~ it.commit.shortMessageHtml %>
+ </div>
+ <div class="commit-author">
+ <% for (const author of it.commit.authors) { %>
+ <a href="/<%= author.login %>"><%= author.login %></a>
+ <% } %>
+ <time class="timestamp" title="<%= it.commit.date %>"><%= it.commit.date.split("T")[0] %></time>
+ </div>
+ </div>
+ <div class="commit-sha">
+ <code><%= it.commit.oid.substring(0, 7) %></code>
+ </div>
+</div>
diff --git a/cgithub/views/_nav.eta b/cgithub/views/_nav.eta
new file mode 100644
index 0000000..a20741b
--- /dev/null
+++ b/cgithub/views/_nav.eta
@@ -0,0 +1,34 @@
+<section>
+ <%
+ const isCommitHash = /^[0-9a-f]{40}$/.test(it.branch);
+ const refLabel = isCommitHash ? it.branch.slice(0, 7) : it.branch;
+ %>
+ <button type="button" class="ref-selector-button branch" command="show-modal" commandfor="ref-selector-menu">
+ <img src="/static/icons/git-branch.svg" alt="branch" class="icon"> <%= refLabel %> ▾
+ </button>
+ <dialog class="ref-selector-menu" id="ref-selector-menu" closedby="any" data-owner="<%= it.repo.owner %>" data-repo="<%= it.repo.name %>" data-view="<%= it.view %>"<% if (it.path && it.path !== "/") { %> data-path="<%= it.path %>"<% } %>>
+ <div class="ref-selector-tabs">
+ <button type="button" class="active" data-ref-type="branches">Branches</button>
+ <button type="button" data-ref-type="tags">Tags</button>
+ </div>
+ <div class="ref-selector-list">Loading…</div>
+ </dialog>
+ <% if (it.path && it.path !== "/") { %>
+ <%
+ const crumbHrefBase = `/${it.repo.owner}/${it.repo.name}/${it.view === "blob" ? "tree" : it.view}/${it.branch}`;
+ %>
+ <a href="<%= crumbHrefBase %>">(top)</a><%
+ const pathParts = it.path.split('/').filter(p => p);
+ let currentPath = '';
+ for (let i = 0; i < pathParts.length; i++) {
+ const part = pathParts[i];
+ currentPath += (currentPath ? '/' : '') + part;
+ const isLast = i === pathParts.length - 1;
+ if (isLast) {
+ %>/<%= part %><%= it.view === "tree" ? "/" : "" %><%
+ } else {
+ %>/<a href="<%= crumbHrefBase %>/<%= currentPath %>"><%= part %></a><%
+ }
+ } %>
+ <% } %>
+</section>
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 @@
+<div class="box repo-info">
+ <div><a href="/<%= it.repo.owner %>/<%= it.repo.name %>"><%= it.repo.owner === it.login ? it.repo.name : `${it.repo.owner}/${it.repo.name}` %></a></div>
+ <% if (it.repo.description) { %>
+ <div class="description"><%= it.repo.description %></div>
+ <% } %>
+ <% if (it.repo.language) { %>
+ <span class="detail"><%= it.repo.language %></span>
+ <% } %>
+ <% if (it.repo.stars) { %>
+ <span class="detail"><img src="/static/icons/star.svg" alt="stars" class="icon"> <%= it.repo.stars %></span>
+ <% } %>
+ <% if (it.repo.forks) { %>
+ <span class="detail"><img src="/static/icons/git-fork.svg" alt="forks" class="icon"> <%= it.repo.forks %></span>
+ <% } %>
+</div>
diff --git a/cgithub/views/_refs.eta b/cgithub/views/_refs.eta
new file mode 100644
index 0000000..9d951cc
--- /dev/null
+++ b/cgithub/views/_refs.eta
@@ -0,0 +1,21 @@
+<%
+const linkPrefix = `/${it.repo.owner}/${it.repo.name}/${it.view}/`;
+const linkSuffix = it.path ? `/${it.path}` : "";
+%>
+<% if (it.refs.length === 0) { %>
+ <div class="ref-selector-empty">No <%= it.type %> found.</div>
+<% } else { %>
+ <ul class="ref-selector-items">
+ <% for (const ref of it.refs) { %>
+ <li>
+ <a href="<%= linkPrefix %><%= ref.name %><%= linkSuffix %>"><%= ref.name %></a>
+ <% if (ref.isDefault) { %><span class="ref-selector-badge">default</span><% } %>
+ </li>
+ <% } %>
+ </ul>
+<% } %>
+<% if (it.hasMore) { %>
+ <div class="ref-selector-more">
+ <a href="/<%= it.repo.owner %>/<%= it.repo.name %>/<%= it.type %>">View all <%= it.type %></a>
+ </div>
+<% } %>
diff --git a/cgithub/views/_release.eta b/cgithub/views/_release.eta
new file mode 100644
index 0000000..a8838fb
--- /dev/null
+++ b/cgithub/views/_release.eta
@@ -0,0 +1,32 @@
+<div class="box release">
+ <header>
+ <% if (it.linkToDetail) { %>
+ <a href="/<%= it.repo.owner %>/<%= it.repo.name %>/releases/tag/<%= it.release.tagName %>">
+ <%= it.release.title %>
+ </a>
+ <% } else { %>
+ <span><%= it.release.title %></span>
+ <% } %>
+ <% if (it.release.publishedAt) { %>
+ <time class="timestamp" title="<%= it.release.publishedAt %>">
+ <%= it.release.publishedAt.split("T")[0] %>
+ </time>
+ <% } %>
+ </header>
+ <% if (it.release.bodyHtml) { %>
+ <%~ it.release.bodyHtml %>
+ <% } %>
+ <% if (it.release.assets && it.release.assets.length > 0) { %>
+ <footer>
+ <h3>Assets</h3>
+ <% for (const asset of it.release.assets) { %>
+ <div>
+ <a href="<%= asset.url %>"><%= asset.name %></a>
+ <% if (asset.publishedAt) { %>
+ <time class="timestamp" title="<%= asset.publishedAt %>"><%= asset.publishedAt.split("T")[0] %></time>
+ <% } %>
+ </div>
+ <% } %>
+ </footer>
+ <% } %>
+</div>
diff --git a/cgithub/views/_sidebar.eta b/cgithub/views/_sidebar.eta
new file mode 100644
index 0000000..bceef7c
--- /dev/null
+++ b/cgithub/views/_sidebar.eta
@@ -0,0 +1,22 @@
+<% if (it.latestRelease) { %>
+ <span data-target="latest-release">(<a href="/<%= it.repo.owner %>/<%= it.repo.name %>/releases/tag/<%= it.latestRelease.tag %>"><%= it.latestRelease.tag %></a><%
+ if (it.latestRelease.publishedAt) { %> <time class="timestamp" title="<%= it.latestRelease.publishedAt %>"><%= it.latestRelease.publishedAt.split("T")[0] %></time><%
+ } %>)</span>
+<% } %>
+<% if (it.languages.length > 0) { %>
+ <div class="languages box">
+ <div class="language-bar">
+ <% for (const lang of it.languages) { %>
+ <span style="width: <%= lang.percentage %>%; background: <%= lang.color ?? "#ededed" %>" title="<%= lang.name %> <%= lang.percentage %>%"></span>
+ <% } %>
+ </div>
+ <ul class="language-list">
+ <% for (const lang of it.languages) { %>
+ <li>
+ <span class="language-dot" style="background: <%= lang.color ?? "#ededed" %>"></span>
+ <%= lang.name %> <span class="language-percentage"><%= lang.percentage %>%</span>
+ </li>
+ <% } %>
+ </ul>
+ </div>
+<% } %>
diff --git a/cgithub/views/_tree.eta b/cgithub/views/_tree.eta
new file mode 100644
index 0000000..a3c943d
--- /dev/null
+++ b/cgithub/views/_tree.eta
@@ -0,0 +1,38 @@
+<%~ include("./_nav.eta", {...it, view: "tree"}) %>
+
+<% const commitPath = it.path && it.path !== "/" ? it.path : ""; %>
+<div class="placeholder commit box" data-src="/api/<%= it.repo.owner %>/<%= it.repo.name %>/latest-commit/<%= it.branch %>/<%= commitPath %>">
+ <div>
+ <div>…</div>
+ <div class="commit-author">…</div>
+ </div>
+</div>
+
+<table>
+ <% for (const item of it.items) { %>
+ <tr>
+ <td>
+ <% if (item.contentType === 'directory') { %>
+ <img src="/static/icons/folder-simple.svg" alt="tree" class="icon">
+ <a href="/<%= it.repo.owner %>/<%= it.repo.name %>/tree/<%= it.branch %>/<%= item.path %>"><%= item.name %>/</a>
+ <% } else { %>
+ <img src="/static/icons/file.svg" alt="blob" class="icon">
+ <a href="/<%= it.repo.owner %>/<%= it.repo.name %>/blob/<%= it.branch %>/<%= item.path %>"><%= item.name %></a>
+ <% } %>
+ </td>
+ </tr>
+ <% } %>
+</table>
+
+<% if (it.overviewHtml) { %>
+ <% for (const [filename, html] of Object.entries(it.overviewHtml)) { %>
+ <% if (html !== null) { %>
+ <div class="overview box">
+ <header><%= filename %></header>
+ <%~ html %>
+ </div>
+ <% } else { %>
+ <!-- No rendered content for <%= filename %> -->
+ <% } %>
+ <% } %>
+<% } %>
diff --git a/cgithub/views/_wikiNav.eta b/cgithub/views/_wikiNav.eta
new file mode 100644
index 0000000..cc177c7
--- /dev/null
+++ b/cgithub/views/_wikiNav.eta
@@ -0,0 +1,4 @@
+<nav>
+ <a href="/<%= it.repo.owner %>/<%= it.repo.name %>/wiki">Wiki home</a>
+ <a href="/<%= it.repo.owner %>/<%= it.repo.name %>/wiki/_pages">Pages</a>
+</nav>
diff --git a/cgithub/views/blob.eta b/cgithub/views/blob.eta
new file mode 100644
index 0000000..9f6706e
--- /dev/null
+++ b/cgithub/views/blob.eta
@@ -0,0 +1,35 @@
+<% layout("/layouts/repo.eta"); it.searchType = "code" %>
+
+<%~ include("./_nav.eta", {...it, view: "blob"}) %>
+
+<div>
+ <% if (it.language) { %>
+ <div>language: <%= it.language %></div>
+ <% } %>
+ <div><%= it.size %></div>
+ <div>
+ <a href="/<%= it.repo.owner %>/<%= it.repo.name %>/commits/<%= it.branch %>/<%= it.path %>">History</a>
+ </div>
+ <div>
+ <a href="/<%= it.repo.owner %>/<%= it.repo.name %>/raw/<%= it.branch %>/<%= it.path %>">View raw</a>
+ </div>
+</div>
+
+<% if (it.image) { %>
+ <div class="box"><img src="/<%= it.repo.owner %>/<%= it.repo.name %>/raw/<%= it.branch %>/<%= it.path %>"></div>
+<% } else if (it.renderFileType === "pdf") { %>
+ <object class="box pdf" type="application/pdf"
+ data="/<%= it.repo.owner %>/<%= it.repo.name %>/embed/<%= it.renderFileType %>/<%= it.branch %>/<%= it.path %>">
+ This file cannot be displayed.
+ </object>
+<% } else if (it.htmlContent) { %>
+ <div class="box"><%~ it.htmlContent %></div>
+<% } else if (it.htmlLines) { %>
+ <pre class="box"><code><% for (const [i, line] of it.htmlLines.entries()) {
+ %><%= i ? "\n" : "" %><span id="L<%= i + 1 %>"><%~ line %></span><% } %></code></pre>
+<% } else if (it.textLines) { %>
+ <pre class="box"><code><% for (const [i, line] of it.textLines.entries()) {
+ %><%= i ? "\n" : "" %><span id="L<%= i + 1 %>"><%= line %></span><% } %></code></pre>
+<% } else { %>
+ <div class="box">This file cannot be displayed.</div>
+<% } %>
diff --git a/cgithub/views/commits.eta b/cgithub/views/commits.eta
new file mode 100644
index 0000000..a80ef07
--- /dev/null
+++ b/cgithub/views/commits.eta
@@ -0,0 +1,33 @@
+<% layout("/layouts/repo.eta") %>
+
+<%~ include("./_nav.eta", {...it, view: "commits"}) %>
+
+<% const pathSuffix = it.path && it.path !== "/" ? "/" + it.path : "" %>
+
+<% if (it.commitGroups.length === 0) { %>
+ <div class="box">No commits found.</div>
+<% } %>
+
+<% for (const group of it.commitGroups) { %>
+ <h3><%= group.title %></h3>
+ <% for (const commit of group.commits) { %>
+ <div class="commit">
+ <div>
+ <div>
+ <a href="/<%= it.repo.owner %>/<%= it.repo.name %>/commit/<%= commit.oid %>"><%= commit.shortMessage %></a>
+ </div>
+ <div class="commit-author">
+ by
+ <% for (const author of commit.authors) { %>
+ <a href="/<%= author.login %>"><%= author.login %></a>
+ <% } %>
+ </div>
+ </div>
+ <div class="commit-sha">
+ <code><%= commit.oid.substring(0, 7) %></code>
+ <% /* Like GitHub, blindly link to blob and redirect for trees */ %>
+ <a href="/<%= it.repo.owner %>/<%= it.repo.name %>/blob/<%= commit.oid %><%= pathSuffix %>">Browse</a>
+ </div>
+ </div>
+ <% } %>
+<% } %>
diff --git a/cgithub/views/error.eta b/cgithub/views/error.eta
new file mode 100644
index 0000000..a46411e
--- /dev/null
+++ b/cgithub/views/error.eta
@@ -0,0 +1,3 @@
+<% layout("/layouts/base.eta", { title: it.title ?? it.message }) %>
+
+<%= it.message %>
diff --git a/cgithub/views/home.eta b/cgithub/views/home.eta
new file mode 100644
index 0000000..604e4d0
--- /dev/null
+++ b/cgithub/views/home.eta
@@ -0,0 +1,47 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+ <meta charset="UTF-8">
+ <title>cgithub</title>
+ <link rel="stylesheet" href="/static/style.css">
+</head>
+<body>
+ <header>
+ <h1>cgithub</h1>
+ <p>Lightweight GitHub alternative frontend</p>
+ </header>
+
+ <main>
+ <p>Browse any GitHub repository by navigating to <code>/:owner/:repo</code>, or a user or organization profile at <code>/:owner</code></p>
+ </main>
+
+ <h2>Credits</h2>
+ <p>Icons by <a href="https://phosphoricons.com">Phosphor Icons</a></p>
+ <details>
+ <summary>Phosphor Icons License (MIT)</summary>
+ <pre>
+MIT License
+
+Copyright (c) 2023 Phosphor Icons
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
+ </pre>
+ </details>
+</body>
+</html>
diff --git a/cgithub/views/issues.eta b/cgithub/views/issues.eta
new file mode 100644
index 0000000..754afb8
--- /dev/null
+++ b/cgithub/views/issues.eta
@@ -0,0 +1,20 @@
+<% layout("/layouts/repo.eta"); it.searchType = "issues" %>
+
+<table>
+ <% for (const issue of it.issues) { %>
+ <tr>
+ <td>#<%= issue.number %></td>
+ <td>
+ <a href="/<%= it.repo.owner %>/<%= it.repo.name %>/issues/<%= issue.number %>">
+ <%= issue.title %>
+ </a>
+ </td>
+ <td>
+ <% if (issue.commentCount > 0) { %>
+ <img src="/static/icons/link.svg" alt="comments" class="icon"> <%= issue.commentCount %>
+ <% } %>
+ </td>
+ <td class="timestamp"><time title="<%= issue.createdAt %>"><%= issue.createdAt.split("T")[0] %></time></td>
+ </tr>
+ <% } %>
+</table>
diff --git a/cgithub/views/layouts/base.eta b/cgithub/views/layouts/base.eta
new file mode 100644
index 0000000..fb644f5
--- /dev/null
+++ b/cgithub/views/layouts/base.eta
@@ -0,0 +1,17 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+ <meta charset="UTF-8">
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
+ <title><%= it.title %></title>
+ <link rel="stylesheet" href="/static/style.css">
+ <% for (const src of it.scripts ?? []) { %>
+ <script src="<%= src %>" defer></script>
+ <% } %>
+</head>
+<body>
+ <%~ it.body %>
+
+ <footer>Generated by cgithub commit <code><%= it.buildCommit %></code> · <a href="<%= it.githubUrl %>" rel="noreferrer">View original</a></footer>
+</body>
+</html>
diff --git a/cgithub/views/layouts/repo.eta b/cgithub/views/layouts/repo.eta
new file mode 100644
index 0000000..e4f7880
--- /dev/null
+++ b/cgithub/views/layouts/repo.eta
@@ -0,0 +1,59 @@
+<%
+layout("/layouts/base.eta", {
+ title: `${it.repo.owner}/${it.repo.name} - ${it.path || "/"}`,
+ scripts: ["/static/refs.js"],
+})
+%>
+
+<div class="box">
+ <div class="repo"><a href="/<%= it.repo.owner %>"><%= it.repo.owner %></a>/<a href="/<%= it.repo.owner %>/<%= it.repo.name %>"><%= it.repo.name %></a></div>
+ <pre><code>git clone https://github.com/<%= it.repo.owner %>/<%= it.repo.name %>.git
+git clone git@github.com:<%= it.repo.owner %>/<%= it.repo.name %>.git</code></pre>
+ <nav>
+ <a href="/<%= it.repo.owner %>/<%= it.repo.name %>">Code</a>
+ <a href="/<%= it.repo.owner %>/<%= it.repo.name %>/issues">Issues</a>
+ <a href="/<%= it.repo.owner %>/<%= it.repo.name %>/pulls">Pull requests</a>
+ <a href="/<%= it.repo.owner %>/<%= it.repo.name %>/releases">Releases</a>
+ <a href="/<%= it.repo.owner %>/<%= it.repo.name %>/wiki">Wiki</a>
+ <% if (it.branch) { %>
+ <a href="/<%= it.repo.owner %>/<%= it.repo.name %>/commits/<%= it.branch %><%= it.path && it.path !== '/' ? '/' + it.path : '' %>">Commits</a>
+ <% } %>
+ </nav>
+ <form action="/<%= it.repo.owner %>/<%= it.repo.name %>/search" method="get">
+ <input id="search-input" type="text" name="q" placeholder="Search…" value="<%= it.q ?? "" %>">
+ <select name="type">
+ <option value="issues" <%= it.searchType === "issues" ? "selected" : "" %>>Issues</option>
+ <option value="pullrequests" <%= it.searchType === "pullrequests" ? "selected" : "" %>>Pull requests</option>
+ <option value="code" <%= it.searchType === "code" || it.searchType == null ? "selected" : "" %>>Code</option>
+ </select>
+ <button type="submit">Search</button>
+ </form>
+</div>
+
+<%~ it.body %>
+
+<script>
+ document.addEventListener("keydown", function(e) {
+ let shortcut = "";
+ if (e.ctrlKey || e.metaKey) shortcut += "C-";
+ if (e.altKey) shortcut += "A-";
+ if (e.shiftKey) shortcut += "S-";
+ shortcut += e.key;
+
+ if (shortcut === "Escape") {
+ document.activeElement.blur();
+ return;
+ }
+
+ if (["INPUT", "TEXTAREA", "SELECT"].includes(document.activeElement.tagName)) {
+ return;
+ }
+
+ if (shortcut === "/") {
+ e.preventDefault();
+ const input = document.getElementById('search-input');
+ input.select();
+ input.scrollIntoView({ block: "nearest" });
+ }
+ });
+</script>
diff --git a/cgithub/views/owner.eta b/cgithub/views/owner.eta
new file mode 100644
index 0000000..b3304fb
--- /dev/null
+++ b/cgithub/views/owner.eta
@@ -0,0 +1,54 @@
+<% layout("/layouts/base.eta", { title: it.name ? `${it.name} (${it.login})` : it.login }) %>
+
+<div class="box">
+ <% if (it.avatarUrl) { %>
+ <img class="avatar" src="<%= it.avatarUrl %>" alt="" width="64" height="64">
+ <% } %>
+ <div class="repo"><%= it.name || it.login %></div>
+ <div><%= it.login %><%= it.type === "organization" ? " (organization)" : "" %></div>
+ <% if (it.location) { %>
+ <div><%= it.location %></div>
+ <% } %>
+ <% if (it.nav.length > 0) { %>
+ <nav>
+ <% for (const item of it.nav) { %>
+ <a href="<%= item.href %>"><%= item.text %></a>
+ <% } %>
+ </nav>
+ <% } %>
+</div>
+
+<% if (it.bio || it.links.length > 0 || it.followers || it.company) { %>
+ <div class="box repo-info">
+ <% if (it.bio) { %>
+ <div class="description"><%= it.bio %></div>
+ <% } %>
+ <% for (const link of it.links) { %>
+ <div class="detail"><img src="/static/icons/link.svg" alt="" class="icon"> <a href="<%= link.href %>" rel="noopener noreferrer"><%= link.text %></a></div>
+ <% } %>
+ <% if (it.followers) { %>
+ <div class="detail"><img src="/static/icons/eye.svg" alt="" class="icon"> <%= it.followers %> followers<%= it.following ? ` · ${it.following} following` : "" %></div>
+ <% } %>
+ <% if (it.company) { %>
+ <div class="detail"><%= it.company %></div>
+ <% } %>
+ </div>
+<% } %>
+
+<% if (it.pinned.length > 0) { %>
+ <section>
+ <h2>Pinned</h2>
+ <% for (const repo of it.pinned) { %>
+ <%~ include("./_ownerRepo", { repo, login: it.login }) %>
+ <% } %>
+ </section>
+<% } %>
+
+<% if (it.repos.length > 0) { %>
+ <section>
+ <h2>Repositories</h2>
+ <% for (const repo of it.repos) { %>
+ <%~ include("./_ownerRepo", { repo, login: it.login }) %>
+ <% } %>
+ </section>
+<% } %>
diff --git a/cgithub/views/pulls.eta b/cgithub/views/pulls.eta
new file mode 100644
index 0000000..697f6b1
--- /dev/null
+++ b/cgithub/views/pulls.eta
@@ -0,0 +1,15 @@
+<% layout("/layouts/repo.eta"); it.searchType = "pullrequests" %>
+
+<table>
+ <% for (const pull of it.pulls) { %>
+ <tr>
+ <td>#<%= pull.number %></td>
+ <td>
+ <a href="/<%= it.repo.owner %>/<%= it.repo.name %>/pull/<%= pull.number %>">
+ <%= pull.title %>
+ </a>
+ </td>
+ <td class="timestamp"><time title="<%= pull.createdAt %>"><%= pull.createdAt.split("T")[0] %></time></td>
+ </tr>
+ <% } %>
+</table>
diff --git a/cgithub/views/redirect.eta b/cgithub/views/redirect.eta
new file mode 100644
index 0000000..393a527
--- /dev/null
+++ b/cgithub/views/redirect.eta
@@ -0,0 +1,14 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+ <meta charset="UTF-8">
+ <meta name="referrer" content="no-referrer">
+ <meta http-equiv="refresh" content="1; url=<%= it.location %>">
+ <title>Redirecting to GitHub</title>
+ <link rel="stylesheet" href="/static/style.css">
+</head>
+<body>
+ <p>cgithub can't render this page. Redirecting to
+ <a href="<%= it.location %>" rel="noreferrer"><%= it.location %></a>…</p>
+</body>
+</html>
diff --git a/cgithub/views/release.eta b/cgithub/views/release.eta
new file mode 100644
index 0000000..64cca35
--- /dev/null
+++ b/cgithub/views/release.eta
@@ -0,0 +1,3 @@
+<% 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
new file mode 100644
index 0000000..4ede28b
--- /dev/null
+++ b/cgithub/views/releases.eta
@@ -0,0 +1,5 @@
+<% 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
new file mode 100644
index 0000000..60746df
--- /dev/null
+++ b/cgithub/views/repo.eta
@@ -0,0 +1,31 @@
+<% layout("/layouts/repo.eta") %>
+
+<% if (it.info) { %>
+ <div class="box repo-info">
+ <% if (it.info.description) { %>
+ <div class="description"><%= it.info.description %></div>
+ <% } %>
+ <% if (it.info.website) { %>
+ <div class="detail"><img src="/static/icons/link.svg" alt="" class="icon"> <a href="<%= it.info.website %>" rel="noopener noreferrer"><%= it.info.website %></a></div>
+ <% } %>
+ <% if (it.info.stars) { %>
+ <span class="detail"><img src="/static/icons/star.svg" alt="stars" class="icon"> <%= it.info.stars %></span>
+ <% } %>
+ <% if (it.info.forks) { %>
+ <span class="detail"><img src="/static/icons/git-fork.svg" alt="forks" class="icon"> <%= it.info.forks %></span>
+ <% } %>
+ <% if (it.info.numReleases) { %>
+ <div class="detail">
+ <a href="/<%= it.repo.owner %>/<%= it.repo.name %>/releases"><%= it.info.numReleases %> releases</a>
+ <span id="latest-release"></span>
+ </div>
+ <% } %>
+ </div>
+<% } %>
+
+<div class="placeholder languages box" data-src="/api/<%= it.repo.owner %>/<%= it.repo.name %>/sidebar">
+ <div class="language-bar"><span style="width: 100%; background: #ededed"></span></div>
+ <ul class="language-list"><li>…</li></ul>
+</div>
+
+<%~ include("./_tree", it) %>
diff --git a/cgithub/views/tree.eta b/cgithub/views/tree.eta
new file mode 100644
index 0000000..fabf5b9
--- /dev/null
+++ b/cgithub/views/tree.eta
@@ -0,0 +1,3 @@
+<% layout("/layouts/repo.eta"); it.searchType = "code" %>
+
+<%~ include("./_tree.eta", it) %>
diff --git a/cgithub/views/wiki.eta b/cgithub/views/wiki.eta
new file mode 100644
index 0000000..781caff
--- /dev/null
+++ b/cgithub/views/wiki.eta
@@ -0,0 +1,37 @@
+<% layout("/layouts/repo.eta"); it.path = "wiki/" + it.title %>
+
+<%~ include("./_wikiNav.eta", it) %>
+
+<div class="box wiki">
+ <header>
+ <span><%= it.title %></span>
+ <% if (it.updatedBy || it.updatedAt) { %>
+ <span class="timestamp">
+ <% if (it.updatedBy) { %><%= it.updatedBy %> edited this page<% } %>
+ <% if (it.updatedAt) { %><time title="<%= it.updatedAt %>"><%= it.updatedAt.split("T")[0] %></time><% } %>
+ </span>
+ <% } %>
+ </header>
+ <%~ it.bodyHtml %>
+ <% if (it.footerHtml) { %>
+ <footer><%~ it.footerHtml %></footer>
+ <% } %>
+</div>
+
+<% if (it.sidebarHtml) { %>
+ <div class="box wiki">
+ <header><span>Sidebar</span></header>
+ <%~ it.sidebarHtml %>
+ </div>
+<% } %>
+
+<% if (it.pages.length > 0) { %>
+ <details class="box wiki-pages">
+ <summary><%= it.pages.length %> page<%= it.pages.length === 1 ? "" : "s" %></summary>
+ <ul>
+ <% for (const page of it.pages) { %>
+ <li><a href="<%= page.href %>"><%= page.name %></a></li>
+ <% } %>
+ </ul>
+ </details>
+<% } %>
diff --git a/cgithub/views/wikiPages.eta b/cgithub/views/wikiPages.eta
new file mode 100644
index 0000000..eb90781
--- /dev/null
+++ b/cgithub/views/wikiPages.eta
@@ -0,0 +1,16 @@
+<% layout("/layouts/repo.eta"); it.path = "wiki/_pages" %>
+
+<%~ include("./_wikiNav.eta", it) %>
+
+<table>
+ <% for (const page of it.pages) { %>
+ <tr>
+ <td><a href="<%= page.href %>"><%= page.name %></a></td>
+ <td class="timestamp">
+ <% if (page.updatedAt) { %>
+ <time title="<%= page.updatedAt %>"><%= page.updatedAt.split("T")[0] %></time>
+ <% } %>
+ </td>
+ </tr>
+ <% } %>
+</table>
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"
+ }
+}