Skip to content

Commit c48d46d

Browse files
os-zhuangclaude
andauthored
fix(docs): reference the per-page Open Graph cards that were already being built (#12325)
`app/og/docs/[...slug]/route.tsx` prerenders a 1200x630 card for every one of the 403 doc pages, and until now nothing linked any of them: `getPageImage()` was called only by the route that *produces* the image, and no `openGraph` or `twitter` key existed anywhere in the app. Every page shared as a blank card. Wire the cards into the metadata of all five URL shapes: - docs pages and `/docs` (the empty-slug case) reference their own generated card through the same `getPageImage()` the generator maps over, so the reference and the generator cannot disagree about the slug shape; - the homepage and the blog reference `public/hero-cover-dark.png`, which is already shipped and already this page's video poster -- the card generator is docs-only and has no slug to render either of them from. Image URLs stay site-relative so `metadataBase` remains the single place the origin is spelled, and every `og:url` is the same absolute URL as the page's canonical link. Co-authored-by: Jack Zhuang <277994282+os-zhuang@users.noreply.github.com> Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
1 parent 10bd486 commit c48d46d

3 files changed

Lines changed: 155 additions & 20 deletions

File tree

apps/docs/app/[lang]/blog/[[...slug]]/page.tsx

Lines changed: 63 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import type { Metadata } from 'next';
12
import { notFound } from 'next/navigation';
23
import { blog } from '@/lib/source';
34
import { getMDXComponents } from '@/mdx-components';
@@ -181,33 +182,86 @@ export async function generateStaticParams() {
181182
}));
182183
}
183184

185+
/**
186+
* The blog's social card.
187+
*
188+
* ⚠️ The card generator at `app/og/docs/[...slug]/route.tsx` is docs-only: it
189+
* renders from `source` (the `content/docs` loader) and has no branch for `blog`,
190+
* so there is no per-post card to reference and this card is shared by the index
191+
* and every post. Giving posts their own generated cards is a separate decision,
192+
* not a gap in this wiring — it would mean a second `app/og/**` route.
193+
*
194+
* ⚠️ Same path as `apps/docs/app/[lang]/page.tsx`'s `HOME_CARD`; that file
195+
* carries the note on why it is spelled twice and what must change together.
196+
*/
197+
const BLOG_CARD = {
198+
url: '/hero-cover-dark.png',
199+
width: 2400,
200+
height: 1200,
201+
alt: 'ObjectStack — the metadata framework for AI-written apps',
202+
};
203+
204+
const BLOG_INDEX_TITLE = 'Blog';
205+
const BLOG_INDEX_DESCRIPTION =
206+
'Insights, updates, and best practices from the ObjectStack team.';
207+
184208
export async function generateMetadata({
185209
params,
186210
}: {
187211
params: Promise<{ slug?: string[] }>;
188-
}) {
212+
}): Promise<Metadata> {
189213
const { slug } = await params;
190-
214+
191215
// If no slug, return default metadata for blog index
192216
if (!slug || slug.length === 0) {
217+
// The index has no MDX file behind it, so its route is spelled out here — the
218+
// same literal `app/sitemap.ts` lists it under.
219+
const canonical = absoluteUrl('/blog');
220+
193221
return {
194-
title: 'Blog',
195-
description: 'Insights, updates, and best practices from the ObjectStack team.',
196-
// The index has no MDX file behind it, so its route is spelled out here — the
197-
// same literal `app/sitemap.ts` lists it under.
198-
alternates: { canonical: absoluteUrl('/blog') },
222+
title: BLOG_INDEX_TITLE,
223+
description: BLOG_INDEX_DESCRIPTION,
224+
alternates: { canonical },
225+
openGraph: {
226+
type: 'website',
227+
title: BLOG_INDEX_TITLE,
228+
description: BLOG_INDEX_DESCRIPTION,
229+
url: canonical,
230+
images: [BLOG_CARD],
231+
},
232+
twitter: {
233+
card: 'summary_large_image',
234+
title: BLOG_INDEX_TITLE,
235+
description: BLOG_INDEX_DESCRIPTION,
236+
images: [BLOG_CARD.url],
237+
},
199238
};
200239
}
201-
240+
202241
const page = blog.getPage(slug);
203242

204243
if (!page) {
205244
notFound();
206245
}
207246

247+
const canonical = absoluteUrl(page.url);
248+
208249
return {
209250
title: page.data.title,
210251
description: page.data.description,
211-
alternates: { canonical: absoluteUrl(page.url) },
252+
alternates: { canonical },
253+
openGraph: {
254+
type: 'article',
255+
title: page.data.title,
256+
description: page.data.description,
257+
url: canonical,
258+
images: [BLOG_CARD],
259+
},
260+
twitter: {
261+
card: 'summary_large_image',
262+
title: page.data.title,
263+
description: page.data.description,
264+
images: [BLOG_CARD.url],
265+
},
212266
};
213267
}

apps/docs/app/[lang]/docs/[[...slug]]/page.tsx

Lines changed: 46 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { source } from '@/lib/source';
1+
import { getPageImage, source } from '@/lib/source';
22
import type { Metadata } from 'next';
33
import { DocsBody, DocsDescription, DocsPage, DocsTitle } from 'fumadocs-ui/layouts/docs/page';
44
import { notFound } from 'next/navigation';
@@ -61,15 +61,53 @@ export async function generateMetadata(props: {
6161
const page = source.getPage(params.slug ?? [], params.lang);
6262
if (!page) notFound();
6363

64+
/**
65+
* `app/og/docs/[...slug]/route.tsx` already prerenders a 1200x630 card for every
66+
* page this loader returns -- its `generateStaticParams` maps over
67+
* `source.getPages()` through this very function. Calling `getPageImage()` here
68+
* too is what makes the card *reachable*: the generator and the reference are
69+
* then the same expression, so a slug shape that stops matching breaks the build
70+
* rather than emitting an `og:image` that 404s. A 404ing card is worse than no
71+
* card at all, because the crawler falls back to scraping whatever else it finds.
72+
*
73+
* The URL is left site-relative on purpose: `metadataBase` in `app/layout.tsx`
74+
* absolutises it, so the origin stays spelled in exactly one place.
75+
*/
76+
const image = getPageImage(page);
77+
/**
78+
* `page.url` is the same locale-stripped route fumadocs uses for in-site links
79+
* and that `app/sitemap.ts` lists, so the canonical link and the sitemap entry
80+
* cannot drift apart. `absoluteUrl()` throws rather than emit a URL on another
81+
* host if that ever stops being a site-relative path.
82+
*/
83+
const canonical = absoluteUrl(page.url);
84+
6485
return {
6586
title: page.data.title,
6687
description: page.data.description,
67-
/**
68-
* `page.url` is the same locale-stripped route fumadocs uses for in-site links
69-
* and that `app/sitemap.ts` lists, so the canonical link and the sitemap entry
70-
* cannot drift apart. `absoluteUrl()` throws rather than emit a URL on another
71-
* host if that ever stops being a site-relative path.
72-
*/
73-
alternates: { canonical: absoluteUrl(page.url) },
88+
alternates: { canonical },
89+
openGraph: {
90+
type: 'article',
91+
title: page.data.title,
92+
description: page.data.description,
93+
// Same absolute URL as the canonical link, deliberately: `og:url` is the
94+
// identity a social platform de-duplicates shares by, and pointing it at a
95+
// different spelling than the canonical splits one page into two.
96+
url: canonical,
97+
images: [
98+
{
99+
url: image.url,
100+
width: 1200,
101+
height: 630,
102+
alt: page.data.title,
103+
},
104+
],
105+
},
106+
twitter: {
107+
card: 'summary_large_image',
108+
title: page.data.title,
109+
description: page.data.description,
110+
images: [image.url],
111+
},
74112
};
75113
}

apps/docs/app/[lang]/page.tsx

Lines changed: 46 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -22,17 +22,60 @@ const mono = IBM_Plex_Mono({
2222
variable: '--font-l-mono',
2323
});
2424

25+
const HOME_TITLE = 'Metadata framework for AI-written apps';
26+
const HOME_DESCRIPTION =
27+
'ObjectStack turns the whole app — data model, UI, workflows, permissions — into typed metadata: a complete CRM in under 150k tokens, one context window.';
28+
29+
/**
30+
* The homepage's social card.
31+
*
32+
* ⚠️ Deliberately NOT the docs card generator. `app/og/docs/[...slug]/route.tsx`
33+
* renders from a `source.getPage()` result, and the homepage has no MDX file
34+
* behind it — there is no slug to hand it. It reuses the hero cover instead,
35+
* which is already shipped and already the video poster on this page, so the
36+
* shared card costs no extra bytes and no extra route.
37+
*
38+
* ⚠️ `apps/docs/app/[lang]/blog/[[...slug]]/page.tsx` spells this same path for
39+
* the blog's card. Two spellings rather than one shared constant because
40+
* `lib/site.ts` is the origin's home, not the asset manifest's; if a third page
41+
* ever needs it, hoist it there. Anything that renames, re-encodes or deletes
42+
* `public/hero-cover-dark.png` must update BOTH — an `og:image` that 404s is
43+
* worse than none, because crawlers then scrape whatever else the page offers.
44+
*
45+
* Left site-relative: `metadataBase` in `app/layout.tsx` absolutises it.
46+
*/
47+
const HOME_CARD = {
48+
url: '/hero-cover-dark.png',
49+
width: 2400,
50+
height: 1200,
51+
alt: 'ObjectStack — the metadata framework for AI-written apps',
52+
};
53+
2554
export const metadata: Metadata = {
26-
title: 'Metadata framework for AI-written apps',
27-
description:
28-
'ObjectStack turns the whole app — data model, UI, workflows, permissions — into typed metadata: a complete CRM in under 150k tokens, one context window.',
55+
title: HOME_TITLE,
56+
description: HOME_DESCRIPTION,
2957
/**
3058
* `/` is the one indexable spelling of the homepage: `proxy.ts` rewrites `/` to
3159
* this route internally, and the prefixed form `/en` 307s back to `/`. Every
3260
* other spelling a crawler reaches it by — query strings, tracking parameters —
3361
* points here.
3462
*/
3563
alternates: { canonical: absoluteUrl('/') },
64+
openGraph: {
65+
type: 'website',
66+
title: HOME_TITLE,
67+
description: HOME_DESCRIPTION,
68+
// Same absolute URL as the canonical link — see the docs route for why the
69+
// two must not drift.
70+
url: absoluteUrl('/'),
71+
images: [HOME_CARD],
72+
},
73+
twitter: {
74+
card: 'summary_large_image',
75+
title: HOME_TITLE,
76+
description: HOME_DESCRIPTION,
77+
images: [HOME_CARD.url],
78+
},
3679
};
3780

3881
const VOCABULARY: { tag: string; title: string; copy: string }[] = [

0 commit comments

Comments
 (0)