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

Merged opened this

Check · Review · Land · Live done — Landed and live in production.

Merged

scout/forge:scout/2448-run-and-app-pages-carry-og-title-og-desc → main 1 commit fork 2 threads · 2 unresolved

  • Merged. scout/2448-run-and-app-pages-carry-og-title-og-desc was merged into main as 79b5635.

Review

Sign in to review this change.
4 files changed +77 −0
src/web/og/path.ts +12
@@ -19,3 +19,15 @@ export function cardPath(
1919 if (repo.visibility !== "public" || thread.sealed) return undefined;
2020 return `/og/${kind}/${repo.ownerName}~${repo.name}~${thread.number}.png`;
2121 }
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`;

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.

33 }
src/web/studio/app.ts +2
@@ -9,6 +9,7 @@
99 * for a viewer who can write here; a stranger sees the app.
1010 */
1111
12 import { appCardPath } from "../og/path.ts";
1213 import { sealedScope } from "../../core/sealed.ts";
1314 import { egressSetting } from "../../core/capacity.ts";
1415 import { egressNote } from "./egress.ts";
@@ -501,6 +502,7 @@ export async function studioApp(
501502 ),
502503 ).word,
503504 }),
505 ogImage: appCardPath(repo, record),
504506 crumbs: crumbsFor(repo),
505507 palette: repoPalette(ctx, repo),
506508 body,
src/web/studio/run.ts +3
@@ -15,6 +15,8 @@ import { carriedBy } from "../../core/carried.ts";
1515 import { getIssueById } from "../../core/issues.ts";
1616 import { getPull, openPullsClosing } from "../../core/pulls.ts";
1717 import { canSeeOwnerFacts, type Repo } from "../../core/repos.ts";
18 import { getRepoRun } from "../../actions/runs.ts";
19 import { runCardPath } from "../og/path.ts";
1820 import { isTerminal, type Run } from "../../core/runs.ts";
1921 import { listHeldSecrets } from "../../core/run_secret.ts";
2022 import { getUserById } from "../../core/users.ts";
@@ -323,6 +325,7 @@ export async function runView(
323325 commitOid: run.kind === "agent" ? "" : run.commitOid,
324326 summary: run.summary,
325327 }),
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.

326329 crumbs: crumbsFor(repo),
327330 palette: repoPalette(ctx, repo),
328331 body,
test/og-tags.test.ts +60
@@ -6,6 +6,8 @@ import { createApp as createForge, type App } from "../src/app.ts";
66 import { createRepo } from "../src/actions/repos.ts";
77 import { getRepo } from "../src/core/repos.ts";
88 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";
911 import { createSession } from "../src/core/sessions.ts";
1012 import { createUser } from "../src/core/users.ts";
1113 import { serve, type Servers } from "../src/http/server.ts";
@@ -24,6 +26,8 @@ describe("every kind of page unfurls", () => {
2426 let servers: Servers;
2527 let base: string;
2628 let cookie = "";
29 let runPath = "";
30 let sealedRunPath = "";
2731
2832 beforeAll(async () => {
2933 dataDir = await mkdtemp(join(tmpdir(), "forge-ogtags-"));
@@ -67,6 +71,26 @@ describe("every kind of page unfurls", () => {
6771 )
6872 .run(i.value.id, repoId);
6973 }
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}`;
7094 });
7195
7296 afterAll(async () => {
@@ -120,6 +144,42 @@ describe("every kind of page unfurls", () => {
120144 });
121145 }
122146
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
123183 // A card an unfurler cannot fetch is never named: the site card stands in,
124184 // even when the page is viewed by a signed-in writer who can see the thing.
125185 for (const path of ["/alice/hidden/issues/1", "/alice/shown/issues/3", "/alice/shown/pulls/4"]) {

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs