diff options
Diffstat (limited to 'cgithub')
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. + + + +## 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 Binary files differnew file mode 100644 index 0000000..68d230b --- /dev/null +++ b/cgithub/docs/screenshot.png 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 < 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&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, "&") + .replace(/</g, "<") + .replace(/>/g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); +} + +// 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" + } +} |
