Public: anyone can read this; the crew can change it.
Run and app pages carry og:title, og:description, og:image and twitter:card #2472
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/2448-run-and-app-pages-carry-og-title-og-desc → main 1 commit fork 2 threads · 2 unresolved
4 files
changed +77
−0
src/web/og/path.ts +12
| @@ -19,3 +19,15 @@ export function cardPath( | ||
| 19 | 19 | if (repo.visibility !== "public" || thread.sealed) return undefined; |
| 20 | 20 | return `/og/${kind}/${repo.ownerName}~${repo.name}~${thread.number}.png`; |
| 21 | 21 | } |
| 22 | ||
| 23 | /** A run's card, when the repo is public and the run's thread is not sealed (`visible`). */ | |
| 24 | export function runCardPath(repo: Where, runId: string, visible: boolean): string | undefined { | |
| 25 | if (repo.visibility !== "public" || !visible) return undefined; | |
| 26 | return `/og/run/${repo.ownerName}~${repo.name}~${runId}.png`; | |
| 27 | } | |
| 28 | ||
| 29 | /** An app's card, by slug: only a public app of a public repo has one. */ | |
| 30 | export function appCardPath(repo: Where, app: { slug: string; access: string }): string | undefined { | |
| 31 | if (repo.visibility !== "public" || app.access !== "public") return undefined; | |
| 32 | return `/og/app/${app.slug}.png`; | |
| 33 | } |
src/web/studio/app.ts +2
| @@ -9,6 +9,7 @@ | ||
| 9 | 9 | * for a viewer who can write here; a stranger sees the app. |
| 10 | 10 | */ |
| 11 | 11 | |
| 12 | import { appCardPath } from "../og/path.ts"; | |
| 12 | 13 | import { sealedScope } from "../../core/sealed.ts"; |
| 13 | 14 | import { egressSetting } from "../../core/capacity.ts"; |
| 14 | 15 | import { egressNote } from "./egress.ts"; |
| @@ -501,6 +502,7 @@ export async function studioApp( | ||
| 501 | 502 | ), |
| 502 | 503 | ).word, |
| 503 | 504 | }), |
| 505 | ogImage: appCardPath(repo, record), | |
| 504 | 506 | crumbs: crumbsFor(repo), |
| 505 | 507 | palette: repoPalette(ctx, repo), |
| 506 | 508 | body, |
src/web/studio/run.ts +3
| @@ -15,6 +15,8 @@ import { carriedBy } from "../../core/carried.ts"; | ||
| 15 | 15 | import { getIssueById } from "../../core/issues.ts"; |
| 16 | 16 | import { getPull, openPullsClosing } from "../../core/pulls.ts"; |
| 17 | 17 | import { canSeeOwnerFacts, type Repo } from "../../core/repos.ts"; |
| 18 | import { getRepoRun } from "../../actions/runs.ts"; | |
| 19 | import { runCardPath } from "../og/path.ts"; | |
| 18 | 20 | import { isTerminal, type Run } from "../../core/runs.ts"; |
| 19 | 21 | import { listHeldSecrets } from "../../core/run_secret.ts"; |
| 20 | 22 | import { getUserById } from "../../core/users.ts"; |
| @@ -323,6 +325,7 @@ export async function runView( | ||
| 323 | 325 | commitOid: run.kind === "agent" ? "" : run.commitOid, |
| 324 | 326 | summary: run.summary, |
| 325 | 327 | }), |
| 328 | ogImage: runCardPath(repo, run.id, getRepoRun(ctx.app, undefined, repo, run.id).ok), | |
Correct to pass actor undefined: the card has to be fetchable by a stranger, not by the viewer. The same visibility rule now lives here and in pages/og.ts, so keep them together. |
||
| 326 | 329 | crumbs: crumbsFor(repo), |
| 327 | 330 | palette: repoPalette(ctx, repo), |
| 328 | 331 | body, |
test/og-tags.test.ts +60
| @@ -6,6 +6,8 @@ import { createApp as createForge, type App } from "../src/app.ts"; | ||
| 6 | 6 | import { createRepo } from "../src/actions/repos.ts"; |
| 7 | 7 | import { getRepo } from "../src/core/repos.ts"; |
| 8 | 8 | import { createIssue } from "../src/core/issues.ts"; |
| 9 | import { createApp as createAppRecord } from "../src/core/apps.ts"; | |
| 10 | import { claimRun, createRun, finishRun } from "../src/core/runs.ts"; | |
| 9 | 11 | import { createSession } from "../src/core/sessions.ts"; |
| 10 | 12 | import { createUser } from "../src/core/users.ts"; |
| 11 | 13 | import { serve, type Servers } from "../src/http/server.ts"; |
| @@ -24,6 +26,8 @@ describe("every kind of page unfurls", () => { | ||
| 24 | 26 | let servers: Servers; |
| 25 | 27 | let base: string; |
| 26 | 28 | let cookie = ""; |
| 29 | let runPath = ""; | |
| 30 | let sealedRunPath = ""; | |
| 27 | 31 | |
| 28 | 32 | beforeAll(async () => { |
| 29 | 33 | dataDir = await mkdtemp(join(tmpdir(), "forge-ogtags-")); |
| @@ -67,6 +71,26 @@ describe("every kind of page unfurls", () => { | ||
| 67 | 71 | ) |
| 68 | 72 | .run(i.value.id, repoId); |
| 69 | 73 | } |
| 74 | // An app (a public one), a run that finished, and a run on a sealed report. | |
| 75 | const widget = await createRepo(app, u.value, { name: "widget", visibility: "public" }); | |
| 76 | if (!widget.ok) throw new Error(String(widget.error)); | |
| 77 | const widgetId = getRepo(app.db, "alice", "widget")!.id; | |
| 78 | const record = createAppRecord(app.db, { repoId: widgetId, slug: "widget", access: "public" }); | |
| 79 | if (!record.ok) throw new Error(record.error); | |
| 80 | const deploy = createRun(app.db, { repoId: widgetId, kind: "deploy", env: "production" }); | |
| 81 | claimRun(app.db, deploy.id); | |
| 82 | finishRun(app.db, deploy.id, { state: "ok", summary: "deployed" }); | |
| 83 | runPath = `/alice/widget/runs/${deploy.id}`; | |
| 84 | const report = createIssue(app.db, { | |
| 85 | repoId: widgetId, | |
| 86 | authorId: u.value.id, | |
| 87 | title: "Sealed", | |
| 88 | kind: "issue", | |
| 89 | sealed: true, | |
| 90 | }); | |
| 91 | if (!report.ok) throw new Error(String(report.error)); | |
| 92 | const sealed = createRun(app.db, { repoId: widgetId, kind: "agent", issueId: report.value.id }); | |
| 93 | sealedRunPath = `/alice/widget/runs/${sealed.id}`; | |
| 70 | 94 | }); |
| 71 | 95 | |
| 72 | 96 | afterAll(async () => { |
| @@ -120,6 +144,42 @@ describe("every kind of page unfurls", () => { | ||
| 120 | 144 | }); |
| 121 | 145 | } |
| 122 | 146 | |
| 147 | test("a run page's description is its outcome and its card answers", async () => { | |
| 148 | const res = await fetch(base + runPath); | |
| 149 | expect(res.status).toBe(200); | |
| 150 | const t = tags(await res.text()); | |
| 151 | expect(t.get("og:title")).toBeTruthy(); | |
| 152 | expect(t.get("og:description")).toMatch(/deployed/i); | |
| 153 | expect(t.get("twitter:card")).toBe("summary_large_image"); | |
| 154 | const image = new URL(t.get("og:image")!).pathname; | |
| 155 | expect(image).toBe(`/og/run/alice~widget~${runPath.split("/").pop()}.png`); | |
| 156 | const card = await fetch(base + image); | |
| 157 | expect(card.status).toBe(200); | |
| 158 | expect(card.headers.get("content-type")).toBe("image/png"); | |
| 159 | await card.arrayBuffer(); | |
| 160 | }); | |
| 161 | ||
| 162 | test("an app page's description is its status and its card answers", async () => { | |
| 163 | const res = await fetch(base + "/alice/widget"); | |
| 164 | expect(res.status).toBe(200); | |
| 165 | const t = tags(await res.text()); | |
| 166 | expect(t.get("og:title")).toBeTruthy(); | |
| 167 | expect(t.get("og:description")).toMatch(/·.*\.$/); | |
| 168 | expect(t.get("twitter:card")).toBe("summary_large_image"); | |
| 169 | const image = new URL(t.get("og:image")!).pathname; | |
| 170 | expect(image).toBe("/og/app/widget.png"); | |
| 171 | const card = await fetch(base + image); | |
| 172 | expect(card.status).toBe(200); | |
| 173 | expect(card.headers.get("content-type")).toBe("image/png"); | |
| 174 | await card.arrayBuffer(); | |
| 175 | }); | |
| 176 | ||
| 177 | test("a run on a sealed report names the site card, not one that 404s", async () => { | |
| 178 | const res = await fetch(base + sealedRunPath, { headers: { cookie } }); | |
| 179 | expect(res.status).toBe(200); | |
| 180 | expect(new URL(tags(await res.text()).get("og:image")!).pathname).toBe("/og/site.png"); | |
| 181 | }); | |
| 182 | ||
| 123 | 183 | // A card an unfurler cannot fetch is never named: the site card stands in, |
| 124 | 184 | // even when the page is viewed by a signed-in writer who can see the thing. |
| 125 | 185 | for (const path of ["/alice/hidden/issues/1", "/alice/shown/issues/3", "/alice/shown/pulls/4"]) { |
new above
offline
The access gate is right but untested: a public repo with a non-public app should fall back to the site card, and no test pins that.