diff options
| author | Joe Mou <dev@mou.fo> | 2026-08-27 14:08:06 -0400 |
|---|---|---|
| committer | Joe Mou <dev@mou.fo> | 2026-08-27 14:53:38 -0400 |
| commit | 4797a627f461204b289f95f61b64e3dab8bc00ff (patch) | |
| tree | a044ef3d0419c95aad9fa98a093e0c39bdce5459 /src | |
| parent | 7c928d30b16b37624841c4615c0d4bda426e4159 (diff) | |
Display PDF blobs
GitHub renders a PDF with a viewer of its own and so ships no content for
one, leaving the blob view with nothing to show. Its raw bytes come as
application/octet-stream, which browsers download rather than display, so
serve them from a new embed route that retypes them and point an <object>
at that.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BErPR92xppzGFXkPqXPYj2
Diffstat (limited to 'src')
| -rw-r--r-- | src/app.test.ts | 68 | ||||
| -rw-r--r-- | src/app.ts | 26 | ||||
| -rw-r--r-- | src/scraper.test.ts | 32 | ||||
| -rw-r--r-- | src/scraper.ts | 28 |
4 files changed, 148 insertions, 6 deletions
diff --git a/src/app.test.ts b/src/app.test.ts index 94bf205..e28c375 100644 --- a/src/app.test.ts +++ b/src/app.test.ts @@ -1,6 +1,6 @@ import assert from "node:assert"; import path from "node:path"; -import { describe, it } from "node:test"; +import { describe, it, type TestContext } from "node:test"; import { Eta } from "eta"; import { createApp } from "./app.ts"; @@ -100,12 +100,30 @@ describe("blob view", () => { language: null, size: "815 KB", image: false, + renderFileType: null, textLines: null, htmlLines: null, htmlContent: null, }; - // GitHub previews neither the text nor the bytes of most binary files. + it("should embed a PDF", () => { + const html = eta.render("blob.eta", { + ...blob, + path: "test/pdfs/basicapi.pdf", + renderFileType: "pdf", + }); + + assert.match(html, /<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 offer the raw file when there is nothing to display", () => { const html = eta.render("blob.eta", blob); @@ -114,6 +132,52 @@ describe("blob view", () => { }); }); +describe("embedded files", () => { + function stubGitHub(t: TestContext, body: string, contentType: string, status = 200) { + t.mock.method( + globalThis, + "fetch", + async () => new Response(body, { status, headers: { "Content-Type": contentType } }), + ); + } + + it("should retype a PDF GitHub sends as a download", async (t) => { + stubGitHub(t, "%PDF-1.6", "application/octet-stream"); + + const res = await app.request("http://cgithub.example/mozilla/pdf.js/embed/pdf/master/doc.pdf"); + + assert.strictEqual(res.status, 200); + assert.strictEqual(res.headers.get("Content-Type"), "application/pdf"); + assert.strictEqual(await res.text(), "%PDF-1.6"); + }); + + it("should pass on a type GitHub already got right", async (t) => { + stubGitHub(t, "%PDF-1.6", "application/pdf"); + + const res = await app.request("http://cgithub.example/mozilla/pdf.js/embed/pdf/master/doc.pdf"); + + assert.strictEqual(res.headers.get("Content-Type"), "application/pdf"); + }); + + it("should not serve a file type it has no viewer for", async (t) => { + stubGitHub(t, "<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("redirects to GitHub", () => { // An HTTP redirect would be caught by the extension's declarativeNetRequest // rules and bounced straight back here. @@ -10,6 +10,7 @@ import { getGitHubLatestCommit, getGitHubOwner, getGitHubPulls, + getGitHubRaw, getGitHubRefs, getGitHubRelease, getGitHubReleases, @@ -120,6 +121,31 @@ export function createApp(eta: Eta) { ); }); + // 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 } }); + }); + app.get("/:owner/:repo/issues", async (c) => { const { owner, repo } = c.req.param(); const q = c.req.query("q"); diff --git a/src/scraper.test.ts b/src/scraper.test.ts index 836235e..88e9271 100644 --- a/src/scraper.test.ts +++ b/src/scraper.test.ts @@ -7,6 +7,7 @@ import { getGitHubSidebar, getGitHubLatestCommit, getGitHubPulls, + getGitHubRaw, getGitHubRefs, getGitHubRelease, getGitHubReleases, @@ -328,11 +329,41 @@ describe("GitHub scraper", () => { 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 () => { @@ -346,6 +377,7 @@ describe("GitHub scraper", () => { 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); diff --git a/src/scraper.ts b/src/scraper.ts index 4220dc4..1a67d05 100644 --- a/src/scraper.ts +++ b/src/scraper.ts @@ -141,6 +141,7 @@ interface CodeViewBlobLayoutRouteStyledBlob { interface CodeViewBlobRoute { richText: string | null; + renderedFileInfo?: { renderFileType: string | null } | null; } interface SidebarAbout { @@ -250,6 +251,7 @@ export interface GitHubBlob extends GitHubNav { language: string | null; size: string; image: boolean; + renderFileType: string | null; textLines: string[] | null; htmlLines: string[] | null; htmlContent: string | null; @@ -378,15 +380,19 @@ export interface GitHubOwner { repos: OwnerRepo[]; } -async function fetchGitHubResponse(path: string, accept: string): Promise<Response> { - // GitHub throttles/blocks requests without realistic browser headers. - // These headers make the request appear as a standard browser visit. - const headers = { +// 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"); @@ -546,12 +552,26 @@ export async function getGitHubBlob( 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, |
