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

Merged opened this in answer to

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

Merged

mason/forge:mason/11689-a-page-s-link-preview-names-the-thing-on → main 4 commits fork 4 threads · 4 unresolved

  • Merged. mason/11689-a-page-s-link-preview-names-the-thing-on was merged into main as 3a626de.

Review

Sign in to review this change.
8 files changed +52 −13
src/web/layout.ts +3 −1
@@ -117,6 +117,8 @@ export interface PageOptions {
117117 * signed-out unfurler; absent, the forge's own card (`/og/site.png`) stands in.
118118 */
119119 ogImage?: string | undefined;
120 /** The name a preview gives the place: a repository page passes `owner/name`; absent, "forge". */
121 siteName?: string;
120122 /** This page's feed, advertised in the head so a reader can find it. */
121123 feed?: { href: string; title: string };
122124 /** This page's JSON twin, advertised in the head for agents. */
@@ -801,7 +803,7 @@ export function page(options: PageOptions, status = 200): Response {
801803 />
802804 <title>${tabTitle(options.title)}</title>
803805 <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"}" />
805807 <meta property="og:type" content="website" />
806808 <meta property="og:title" content="${options.title}" />
807809 <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
2626 return `/og/run/${repo.ownerName}~${repo.name}~${runId}.png`;
2727 }
2828
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 {
3131 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`;
3333 }
src/web/pages/og.ts +6 −3
@@ -366,8 +366,12 @@ async function resolve(ctx: Ctx, kind: OgKind, ref: string): Promise<CardInput |
366366 if (kind === "audit") return ref === "" ? auditCards(ctx.app).get("", () => auditCardInput(ctx)) : undefined;
367367 if (kind === "site") return ref === "" ? siteCard(ctx) : undefined;
368368 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);
371375 if (!record || record.access !== "public") return undefined;
372376 const repo = publicRepo(ctx, getRepoById(db, record.repoId));
373377 if (!repo) return undefined;
@@ -377,7 +381,6 @@ async function resolve(ctx: Ctx, kind: OgKind, ref: string): Promise<CardInput |
377381 return {
378382 ...base,
379383 ...(images.length ? { images } : {}),
380 title: record.slug,
381384 lines: [
382385 ...(base.lines ?? []),
383386 `${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> {
14201420 </div>`;
14211421 const emptyResponse = page({
14221422 ctx,
1423 title: repo.name,
1423 title: `${repo.ownerName}/${repo.name}`,
1424 siteName: `${repo.ownerName}/${repo.name}`,
14241425 description: repoLine({ private: repo.visibility === "private", name: repo.name, description: repo.description }),
14251426 ogImage: `/og/repo/${repo.ownerName}~${repo.name}.png`,
14261427 about: `${repo.ownerName}/${repo.name}`,
@@ -1508,7 +1509,8 @@ async function codeHome(ctx: Ctx, repo: Repo): Promise<Response> {
15081509 ${codeMount(scope, ref)} ${finderDialog()} ${liveMount({ repo: scope.base })}`;
15091510 const response = page({
15101511 ctx,
1511 title: repo.name,
1512 title: `${repo.ownerName}/${repo.name}`,
1513 siteName: `${repo.ownerName}/${repo.name}`,
15121514 description: repoLine({ private: repo.visibility === "private", name: repo.name, description: repo.description }),
15131515 ogImage: `/og/repo/${repo.ownerName}~${repo.name}.png`,
15141516 about: `${repo.ownerName}/${repo.name}`,
src/web/studio/app.ts +2 −1
@@ -554,7 +554,8 @@ export async function studioApp(
554554 ${liveMount({ repo: base, app: true })}`;
555555 const response = page({
556556 ctx,
557 title: `${repo.name}`,
557 title: `${repo.ownerName}/${repo.name}`,
558 siteName: `${repo.ownerName}/${repo.name}`,
558559 description: appLine({
559560 private: repo.visibility === "private",
560561 name: repo.name,
test/og-tags.test.ts +32 −1
@@ -168,7 +168,7 @@ describe("every kind of page unfurls", () => {
168168 expect(t.get("og:description")).toMatch(/Status: .+\.$/);
169169 expect(t.get("twitter:card")).toBe("summary_large_image");
170170 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");
172172 const card = await fetch(base + image);
173173 expect(card.status).toBe(200);
174174 expect(card.headers.get("content-type")).toBe("image/png");
@@ -197,3 +197,34 @@ describe("every kind of page unfurls", () => {
197197 });
198198 }
199199 });
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", () => {
529529
530530 test("every page carries the metadata a link needs to unfurl", async () => {
531531 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"/);
533533 expect(html).toContain(`content="${base}/alice/open"`);
534534 expect(html).toContain('rel="manifest" href="/manifest.webmanifest"');
535535 expect(html).toContain(
test/studio-words.test.ts +1 −1
@@ -533,7 +533,7 @@ describe("dev's vocabulary never reaches a studio page", () => {
533533 // tabs are words only since #8774, so the eye is simply absent.
534534 word: "app",
535535 path: repoPath,
536 title: "vocab",
536 title: "alice/vocab",
537537 shows: [">Issues", ">Pulls"],
538538 hides: [`#i-eye"/></svg>Look`, `>Look<`, 'class="where"'],
539539 // #630: the app page is the home in every mode; its code, with dev's

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs