Public: anyone can read this; the crew can change it.
A page's link preview names the thing on the page: trevato/forge is not called "lab" #11726
Check · Review · Land · Live done — Landed and live in production.
mason/forge:mason/11689-a-page-s-link-preview-names-the-thing-on → main 4 commits fork 4 threads · 4 unresolved
8 files
changed +52
−13
src/web/layout.ts +3 −1
| @@ -117,6 +117,8 @@ export interface PageOptions { | ||
| 117 | 117 | * signed-out unfurler; absent, the forge's own card (`/og/site.png`) stands in. |
| 118 | 118 | */ |
| 119 | 119 | ogImage?: string | undefined; |
| 120 | /** The name a preview gives the place: a repository page passes `owner/name`; absent, "forge". */ | |
| 121 | siteName?: string; | |
| 120 | 122 | /** This page's feed, advertised in the head so a reader can find it. */ |
| 121 | 123 | feed?: { href: string; title: string }; |
| 122 | 124 | /** This page's JSON twin, advertised in the head for agents. */ |
| @@ -801,7 +803,7 @@ export function page(options: PageOptions, status = 200): Response { | ||
| 801 | 803 | /> |
| 802 | 804 | <title>${tabTitle(options.title)}</title> |
| 803 | 805 | <meta name="description" content="${pageDescription(options)}" /> |
| 804 | <meta property="og:site_name" content="Open Platform Forge" /> | |
| 806 | <meta property="og:site_name" content="${options.siteName ?? "Open Platform Forge"}" /> | |
| 805 | 807 | <meta property="og:type" content="website" /> |
| 806 | 808 | <meta property="og:title" content="${options.title}" /> |
| 807 | 809 | <meta property="og:description" content="${pageDescription(options)}" /> |
src/web/og/path.ts +3 −3
| @@ -26,8 +26,8 @@ export function runCardPath(repo: Where, runId: string, visible: boolean): strin | ||
| 26 | 26 | return `/og/run/${repo.ownerName}~${repo.name}~${runId}.png`; |
| 27 | 27 | } |
| 28 | 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 { | |
| 29 | /** An app's card, by its repository (never the platform slug): only a public app of a public repo has one. */ | |
| 30 | export function appCardPath(repo: Where, app: { access: string }): string | undefined { | |
| 31 | 31 | if (repo.visibility !== "public" || app.access !== "public") return undefined; |
| 32 | return `/og/app/${app.slug}.png`; | |
| 32 | return `/og/app/${repo.ownerName}~${repo.name}.png`; | |
| 33 | 33 | } |
src/web/pages/og.ts +6 −3
| @@ -366,8 +366,12 @@ async function resolve(ctx: Ctx, kind: OgKind, ref: string): Promise<CardInput | | ||
| 366 | 366 | if (kind === "audit") return ref === "" ? auditCards(ctx.app).get("", () => auditCardInput(ctx)) : undefined; |
| 367 | 367 | if (kind === "site") return ref === "" ? siteCard(ctx) : undefined; |
| 368 | 368 | if (kind === "repo") return repoCard(ctx, ref); |
| 369 | if (!isSlug(ref)) return undefined; | |
| 370 | const record = getAppBySlug(db, ref); | |
| 369 | // An app's card is addressed by its repository, never by the slug the | |
| 370 | // platform runs it under; the slug form stays so cards already shared answer. | |
| 371 | const [owner, name, extra] = ref.split("~"); | |
| 372 | const byRepo = owner && name && extra === undefined && isSafeName(owner) && isSafeName(name) ? getRepo(db, owner, name) : undefined; | |
| 373 | if (!byRepo && !isSlug(ref)) return undefined; | |
| 374 | const record = byRepo ? getAppByRepo(db, byRepo.id) : getAppBySlug(db, ref); | |
| 371 | 375 | if (!record || record.access !== "public") return undefined; |
| 372 | 376 | const repo = publicRepo(ctx, getRepoById(db, record.repoId)); |
| 373 | 377 | if (!repo) return undefined; |
| @@ -377,7 +381,6 @@ async function resolve(ctx: Ctx, kind: OgKind, ref: string): Promise<CardInput | | ||
| 377 | 381 | return { |
| 378 | 382 | ...base, |
| 379 | 383 | ...(images.length ? { images } : {}), |
| 380 | title: record.slug, | |
| 381 | 384 | lines: [ |
| 382 | 385 | ...(base.lines ?? []), |
| 383 | 386 | `${live.state}${live.since ? ` · deployed ${live.since.slice(0, 10)}` : ""}`, |
src/web/pages/repo.ts +4 −2
| @@ -1420,7 +1420,8 @@ async function codeHome(ctx: Ctx, repo: Repo): Promise<Response> { | ||
| 1420 | 1420 | </div>`; |
| 1421 | 1421 | const emptyResponse = page({ |
| 1422 | 1422 | ctx, |
| 1423 | title: repo.name, | |
| 1423 | title: `${repo.ownerName}/${repo.name}`, | |
| 1424 | siteName: `${repo.ownerName}/${repo.name}`, | |
| 1424 | 1425 | description: repoLine({ private: repo.visibility === "private", name: repo.name, description: repo.description }), |
| 1425 | 1426 | ogImage: `/og/repo/${repo.ownerName}~${repo.name}.png`, |
| 1426 | 1427 | about: `${repo.ownerName}/${repo.name}`, |
| @@ -1508,7 +1509,8 @@ async function codeHome(ctx: Ctx, repo: Repo): Promise<Response> { | ||
| 1508 | 1509 | ${codeMount(scope, ref)} ${finderDialog()} ${liveMount({ repo: scope.base })}`; |
| 1509 | 1510 | const response = page({ |
| 1510 | 1511 | ctx, |
| 1511 | title: repo.name, | |
| 1512 | title: `${repo.ownerName}/${repo.name}`, | |
| 1513 | siteName: `${repo.ownerName}/${repo.name}`, | |
| 1512 | 1514 | description: repoLine({ private: repo.visibility === "private", name: repo.name, description: repo.description }), |
| 1513 | 1515 | ogImage: `/og/repo/${repo.ownerName}~${repo.name}.png`, |
| 1514 | 1516 | about: `${repo.ownerName}/${repo.name}`, |
src/web/studio/app.ts +2 −1
| @@ -554,7 +554,8 @@ export async function studioApp( | ||
| 554 | 554 | ${liveMount({ repo: base, app: true })}`; |
| 555 | 555 | const response = page({ |
| 556 | 556 | ctx, |
| 557 | title: `${repo.name}`, | |
| 557 | title: `${repo.ownerName}/${repo.name}`, | |
| 558 | siteName: `${repo.ownerName}/${repo.name}`, | |
| 558 | 559 | description: appLine({ |
| 559 | 560 | private: repo.visibility === "private", |
| 560 | 561 | name: repo.name, |
test/og-tags.test.ts +32 −1
| @@ -168,7 +168,7 @@ describe("every kind of page unfurls", () => { | ||
| 168 | 168 | expect(t.get("og:description")).toMatch(/Status: .+\.$/); |
| 169 | 169 | expect(t.get("twitter:card")).toBe("summary_large_image"); |
| 170 | 170 | const image = new URL(t.get("og:image")!).pathname; |
| 171 | expect(image).toBe("/og/app/widget.png"); | |
| 171 | expect(image).toBe("/og/app/alice~widget.png"); | |
| 172 | 172 | const card = await fetch(base + image); |
| 173 | 173 | expect(card.status).toBe(200); |
| 174 | 174 | expect(card.headers.get("content-type")).toBe("image/png"); |
| @@ -197,3 +197,34 @@ describe("every kind of page unfurls", () => { | ||
| 197 | 197 | }); |
| 198 | 198 | } |
| 199 | 199 | }); |
| 200 | ||
| 201 | describe("a preview names the repository, never the platform slug (#11689)", () => { | |
| 202 | test("repository and app pages carry owner/name in every preview tag", async () => { | |
| 203 | const dataDir = await mkdtemp(join(tmpdir(), "forge-ogname-")); | |
| 204 | const app = createForge({ dataDir, port: 0 }); | |
| 205 | const servers = await serve(app); | |
| 206 | try { | |
| 207 | const u = await createUser(app.db, { origin: "seed", trusted: true, username: "alice", email: "[email protected]", password: "alice-passphrase", adminIfFirst: true }); | |
| 208 | if (!u.ok) throw new Error(u.error); | |
| 209 | const names = ["forge", "one", "two", "three"]; | |
| 210 | for (const name of names) { | |
| 211 | const made = await createRepo(app, u.value, { name, visibility: "public", description: `The ${name} thing` }); | |
| 212 | if (!made.ok) throw new Error(String(made.error)); | |
| 213 | } | |
| 214 | for (const name of names.slice(1)) | |
| 215 | createAppRecord(app.db, { repoId: getRepo(app.db, "alice", name)!.id, slug: `lab-${name}`, access: "public" }); | |
| 216 | for (const name of names) { | |
| 217 | const html = await (await fetch(`${servers.url}/alice/${name}`)).text(); | |
| 218 | const t = tags(html); | |
| 219 | expect(t.get("og:title")).toBe(`alice/${name}`); | |
| 220 | expect(t.get("og:site_name")).toBe(`alice/${name}`); | |
| 221 | expect(html.match(/<title>([^<]*)<\/title>/)![1]).toContain(`alice/${name}`); | |
| 222 | expect(new URL(t.get("og:image")!).pathname).not.toContain("lab"); | |
| 223 | expect(html).not.toMatch(/\blab\b/); | |
| 224 | } | |
| 225 | } finally { | |
| 226 | await servers.stop(); | |
| 227 | await rm(dataDir, { recursive: true, force: true }); | |
| 228 | } | |
| 229 | }); | |
| 230 | }); |
test/standards.test.ts +1 −1
| @@ -529,7 +529,7 @@ describe("what the open web expects", () => { | ||
| 529 | 529 | |
| 530 | 530 | test("every page carries the metadata a link needs to unfurl", async () => { |
| 531 | 531 | const html = await fetch(`${base}/alice/open`).then((r) => r.text()); |
| 532 | expect(html).toMatch(/property="og:title"\s+content="open"/); | |
| 532 | expect(html).toMatch(/property="og:title"\s+content="alice\/open"/); | |
| 533 | 533 | expect(html).toContain(`content="${base}/alice/open"`); |
| 534 | 534 | expect(html).toContain('rel="manifest" href="/manifest.webmanifest"'); |
| 535 | 535 | expect(html).toContain( |
test/studio-words.test.ts +1 −1
| @@ -533,7 +533,7 @@ describe("dev's vocabulary never reaches a studio page", () => { | ||
| 533 | 533 | // tabs are words only since #8774, so the eye is simply absent. |
| 534 | 534 | word: "app", |
| 535 | 535 | path: repoPath, |
| 536 | title: "vocab", | |
| 536 | title: "alice/vocab", | |
| 537 | 537 | shows: [">Issues", ">Pulls"], |
| 538 | 538 | hides: [`#i-eye"/></svg>Look`, `>Look<`, 'class="where"'], |
| 539 | 539 | // #630: the app page is the home in every mode; its code, with dev's |
new above
offline