Public: anyone can read this; the crew can change it.
A PDF opens inline on its blob page in the browser's own viewer, with Open and Download inside it for phones that cannot #7985
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/7386-a-pdf-opens-inline-on-its-blob-page-in-t → main 1 commit fork
6 files
changed +90
−15
docs/design/areas/render.md added +10
| @@ -0,0 +1,10 @@ | ||
| 1 | # Render (PDF on the blob page) | |
| 2 | ||
| 3 | A PDF is drawn by the browser's own viewer (`src/web/render/pdf.ts`, styled in `src/web/style/content.ts`). | |
| 4 | No script, no pdf.js. | |
| 5 | ||
| 6 | - `.pdf-view` — the existing `.card`, clipping the frame. | |
| 7 | - `.pdf-frame` — the `<object type="application/pdf">`: 80vh (min 480px), 60vh (min 360px) at 600px and under, so the page does not shift when the viewer loads. | |
| 8 | - `.pdf-fallback` — inside the object, shown only when the viewer cannot draw: an "Open the PDF" link and a `.btn` Download (`?download=1`). | |
| 9 | ||
| 10 | The header shows the size (`1.2 MB · PDF`) through `Rendered.summary`. Both themes use tokens only. |
docs/features/file-media.md +2 −2
| @@ -24,8 +24,8 @@ media type, for `<img>`, `<video>`, `<audio>`, `<object>` and `fetch()`. | ||
| 24 | 24 | - **Framing:** every media answer carries `frame-ancestors 'self'` and |
| 25 | 25 | `X-Frame-Options: SAMEORIGIN`: the blob page may embed it, no other site. |
| 26 | 26 | - **The page's side:** the blob page of a PDF (`render/pdf.ts`) holds an |
| 27 | `<object type="application/pdf">` on the media URL, with an open link and a | |
| 28 | download link inside it for a browser that cannot draw it. That page, and no | |
| 27 | `<object type="application/pdf">` on the media URL, with an "Open the PDF" link and a | |
| 28 | Download button inside it for a browser that cannot draw it. That page, and no | |
| 29 | 29 | other, passes `objectSrc: true` to `page()`, which adds `object-src 'self'` |
| 30 | 30 | to its policy; it is sent only when the page really draws the PDF, never for |
| 31 | 31 | `?view=source`, and never site-wide. The PDF is drawn for a repository on |
src/web/render/pdf.ts +9 −10
| @@ -1,27 +1,26 @@ | ||
| 1 | 1 | /** |
| 2 | 2 | * A PDF drawn by the browser's own viewer, from the media route. The |
| 3 | * `<object>` holds a link, so with the viewer missing or JavaScript off the | |
| 4 | * file is still one tap away; the frame has a fixed height, so nothing shifts | |
| 5 | * when the viewer arrives. The page that holds it sends `object-src 'self'` | |
| 6 | * (`layout.ts` `objectSrc`). | |
| 3 | * `<object>` holds the fallback, so with the viewer missing (iOS Safari | |
| 4 | * shows one page, or none) the file is still one tap away to open or | |
| 5 | * download; the frame has a fixed height, so nothing shifts when the viewer | |
| 6 | * arrives. The page that holds it sends `object-src 'self'` (`layout.ts` | |
| 7 | * `objectSrc`). | |
| 7 | 8 | */ |
| 8 | 9 | |
| 9 | 10 | import { html } from "../html.ts"; |
| 11 | import { fileSize } from "../ui.ts"; | |
| 10 | 12 | import type { Rendered, RenderInput } from "./renderers.ts"; |
| 11 | 13 | |
| 12 | 14 | export function pdf(input: RenderInput): Rendered { |
| 13 | 15 | return { |
| 14 | 16 | lines: 0, |
| 17 | summary: `${fileSize(input.size)} · PDF`, | |
| 15 | 18 | content: html`<div class="card pdf-view"> |
| 16 | <p class="pdf-links"> | |
| 17 | <a href="${input.media}">Open</a> · | |
| 18 | <a href="${input.media}?download=1">Download</a> | |
| 19 | </p> | |
| 20 | 19 | <object class="pdf-frame" data="${input.media}" type="application/pdf"> |
| 21 | 20 | <p class="pdf-fallback"> |
| 22 | 21 | This browser cannot show the PDF here. |
| 23 | <a href="${input.media}">Open it</a> or | |
| 24 | <a href="${input.media}?download=1">download it</a>. | |
| 22 | <a href="${input.media}">Open the PDF</a> | |
| 23 | <a class="btn" href="${input.media}?download=1">Download</a> | |
| 25 | 24 | </p> |
| 26 | 25 | </object> |
| 27 | 26 | </div>`, |
src/web/style/content.ts +1 −2
| @@ -308,8 +308,7 @@ form.inline { display: inline; margin-left: auto; } | ||
| 308 | 308 | /* A PDF in the browser's own viewer: a fixed frame, so nothing shifts. */ |
| 309 | 309 | .pdf-view { overflow: hidden; } |
| 310 | 310 | .pdf-frame { display: block; width: 100%; height: 80vh; min-height: 480px; border: 0; } |
| 311 | .pdf-fallback { padding: 16px 20px; } | |
| 312 | .pdf-links { padding: 8px 20px; border-bottom: 1px solid var(--border); font-size: 13px; } | |
| 311 | .pdf-fallback { padding: 16px 20px; display: flex; flex-wrap: wrap; gap: 12px; align-items: center; } | |
| 313 | 312 | /* A phone: a shorter frame, so a thumb can scroll the page past it. */ |
| 314 | 313 | @media (max-width: 600px) { .pdf-frame { height: 60vh; min-height: 360px; } } |
| 315 | 314 |
test/media.test.ts −1
| @@ -354,7 +354,6 @@ describe("the blob page of a PDF", () => { | ||
| 354 | 354 | const pdf = await policy("/alice/gallery/blob/main/doc.pdf"); |
| 355 | 355 | expect(pdf.html).toContain('<object class="pdf-frame" data="/alice/gallery/media/main/doc.pdf" type="application/pdf">'); |
| 356 | 356 | expect(pdf.html).toContain('href="/alice/gallery/media/main/doc.pdf?download=1"'); |
| 357 | expect(pdf.html).toContain('<p class="pdf-links">'); | |
| 358 | 357 | expect(pdf.csp).toContain("object-src 'self'; "); |
| 359 | 358 | |
| 360 | 359 | // Every other policy is exactly what it was, with object-src left out. |
test/render-pdf.test.ts added +68
| @@ -0,0 +1,68 @@ | ||
| 1 | /** A PDF on the blob page: the browser's viewer in an <object>, with a fallback inside it. */ | |
| 2 | ||
| 3 | import { afterAll, beforeAll, describe, expect, test } from "bun:test"; | |
| 4 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 5 | import { tmpdir } from "node:os"; | |
| 6 | import { join } from "node:path"; | |
| 7 | import { createApp, type App } from "../src/app.ts"; | |
| 8 | import { commitToBranch, createRepo } from "../src/actions/repos.ts"; | |
| 9 | import { serve, type Servers } from "../src/http/server.ts"; | |
| 10 | import { seedAdmin } from "./helpers/admin.ts"; | |
| 11 | import { quietLogs } from "./helpers/globals.ts"; | |
| 12 | ||
| 13 | quietLogs(); | |
| 14 | ||
| 15 | const SMALL = new TextEncoder().encode("%PDF-1.7\n1 0 obj<<>>endobj\n%%EOF\n"); | |
| 16 | const BIG = new Uint8Array(1_500_000); | |
| 17 | BIG.set(SMALL); | |
| 18 | ||
| 19 | let dataDir = ""; | |
| 20 | let app: App; | |
| 21 | let servers: Servers; | |
| 22 | ||
| 23 | beforeAll(async () => { | |
| 24 | dataDir = await mkdtemp(join(tmpdir(), "forge-pdf-")); | |
| 25 | app = createApp({ dataDir, port: 0 }); | |
| 26 | servers = await serve(app); | |
| 27 | const a = await seedAdmin(app, { username: "alice", email: "[email protected]", password: "a-long-pdf-passphrase" }); | |
| 28 | if (!a.ok) throw new Error("signup failed"); | |
| 29 | const repo = await createRepo(app, a.value, { name: "docs", visibility: "public" }); | |
| 30 | if (!repo.ok) throw new Error("repo failed"); | |
| 31 | const made = await commitToBranch(app, a.value, repo.value, { | |
| 32 | message: "pdfs", | |
| 33 | files: [ | |
| 34 | { path: "a.pdf", content: SMALL }, | |
| 35 | { path: "big.pdf", content: BIG }, | |
| 36 | ], | |
| 37 | }); | |
| 38 | if (!made.ok) throw new Error("commit failed"); | |
| 39 | }); | |
| 40 | ||
| 41 | afterAll(async () => { | |
| 42 | await servers.stop(); | |
| 43 | app.close(); | |
| 44 | await rm(dataDir, { recursive: true, force: true }); | |
| 45 | }); | |
| 46 | ||
| 47 | const page = async (path: string, query = ""): Promise<string> => | |
| 48 | (await (await fetch(`${servers.url}/alice/docs/blob/main/${path}${query}`)).text()).replace(/\s+/g, " "); | |
| 49 | ||
| 50 | describe("PDF blob pages", () => { | |
| 51 | for (const file of ["a.pdf", "big.pdf"]) { | |
| 52 | test(`${file} is an <object> on the media URL with both fallback links`, async () => { | |
| 53 | const html = await page(file); | |
| 54 | const media = `/alice/docs/media/main/${file}`; | |
| 55 | expect(html).toContain(`<object class="pdf-frame" data="${media}" type="application/pdf">`); | |
| 56 | expect(html).toContain(`<a href="${media}">Open the PDF</a>`); | |
| 57 | expect(html).toContain(`href="${media}?download=1"`); | |
| 58 | expect(html).toContain("Download</a>"); | |
| 59 | expect(html).toContain("PDF</span>"); | |
| 60 | }); | |
| 61 | } | |
| 62 | ||
| 63 | test("the source view has no object", async () => { | |
| 64 | const html = await page("a.pdf", "?view=source"); | |
| 65 | expect(html).not.toContain("<object"); | |
| 66 | expect(html).not.toContain("application/pdf"); | |
| 67 | }); | |
| 68 | }); |
new above
offline