diff --git a/apps/docs/app/[lang]/blog/[[...slug]]/page.tsx b/apps/docs/app/[lang]/blog/[[...slug]]/page.tsx index 5e34eb0cd5..b66ed759cf 100644 --- a/apps/docs/app/[lang]/blog/[[...slug]]/page.tsx +++ b/apps/docs/app/[lang]/blog/[[...slug]]/page.tsx @@ -1,3 +1,4 @@ +import type { Metadata } from 'next'; import { notFound } from 'next/navigation'; import { blog } from '@/lib/source'; import { getMDXComponents } from '@/mdx-components'; @@ -181,33 +182,86 @@ export async function generateStaticParams() { })); } +/** + * The blog's social card. + * + * ⚠️ The card generator at `app/og/docs/[...slug]/route.tsx` is docs-only: it + * renders from `source` (the `content/docs` loader) and has no branch for `blog`, + * so there is no per-post card to reference and this card is shared by the index + * and every post. Giving posts their own generated cards is a separate decision, + * not a gap in this wiring — it would mean a second `app/og/**` route. + * + * ⚠️ Same path as `apps/docs/app/[lang]/page.tsx`'s `HOME_CARD`; that file + * carries the note on why it is spelled twice and what must change together. + */ +const BLOG_CARD = { + url: '/hero-cover-dark.png', + width: 2400, + height: 1200, + alt: 'ObjectStack — the metadata framework for AI-written apps', +}; + +const BLOG_INDEX_TITLE = 'Blog'; +const BLOG_INDEX_DESCRIPTION = + 'Insights, updates, and best practices from the ObjectStack team.'; + export async function generateMetadata({ params, }: { params: Promise<{ slug?: string[] }>; -}) { +}): Promise { const { slug } = await params; - + // If no slug, return default metadata for blog index if (!slug || slug.length === 0) { + // The index has no MDX file behind it, so its route is spelled out here — the + // same literal `app/sitemap.ts` lists it under. + const canonical = absoluteUrl('/blog'); + return { - title: 'Blog', - description: 'Insights, updates, and best practices from the ObjectStack team.', - // The index has no MDX file behind it, so its route is spelled out here — the - // same literal `app/sitemap.ts` lists it under. - alternates: { canonical: absoluteUrl('/blog') }, + title: BLOG_INDEX_TITLE, + description: BLOG_INDEX_DESCRIPTION, + alternates: { canonical }, + openGraph: { + type: 'website', + title: BLOG_INDEX_TITLE, + description: BLOG_INDEX_DESCRIPTION, + url: canonical, + images: [BLOG_CARD], + }, + twitter: { + card: 'summary_large_image', + title: BLOG_INDEX_TITLE, + description: BLOG_INDEX_DESCRIPTION, + images: [BLOG_CARD.url], + }, }; } - + const page = blog.getPage(slug); if (!page) { notFound(); } + const canonical = absoluteUrl(page.url); + return { title: page.data.title, description: page.data.description, - alternates: { canonical: absoluteUrl(page.url) }, + alternates: { canonical }, + openGraph: { + type: 'article', + title: page.data.title, + description: page.data.description, + url: canonical, + images: [BLOG_CARD], + }, + twitter: { + card: 'summary_large_image', + title: page.data.title, + description: page.data.description, + images: [BLOG_CARD.url], + }, }; } diff --git a/apps/docs/app/[lang]/docs/[[...slug]]/page.tsx b/apps/docs/app/[lang]/docs/[[...slug]]/page.tsx index ed46b9888b..cb45af3708 100644 --- a/apps/docs/app/[lang]/docs/[[...slug]]/page.tsx +++ b/apps/docs/app/[lang]/docs/[[...slug]]/page.tsx @@ -1,4 +1,4 @@ -import { source } from '@/lib/source'; +import { getPageImage, source } from '@/lib/source'; import type { Metadata } from 'next'; import { DocsBody, DocsDescription, DocsPage, DocsTitle } from 'fumadocs-ui/layouts/docs/page'; import { notFound } from 'next/navigation'; @@ -61,15 +61,53 @@ export async function generateMetadata(props: { const page = source.getPage(params.slug ?? [], params.lang); if (!page) notFound(); + /** + * `app/og/docs/[...slug]/route.tsx` already prerenders a 1200x630 card for every + * page this loader returns -- its `generateStaticParams` maps over + * `source.getPages()` through this very function. Calling `getPageImage()` here + * too is what makes the card *reachable*: the generator and the reference are + * then the same expression, so a slug shape that stops matching breaks the build + * rather than emitting an `og:image` that 404s. A 404ing card is worse than no + * card at all, because the crawler falls back to scraping whatever else it finds. + * + * The URL is left site-relative on purpose: `metadataBase` in `app/layout.tsx` + * absolutises it, so the origin stays spelled in exactly one place. + */ + const image = getPageImage(page); + /** + * `page.url` is the same locale-stripped route fumadocs uses for in-site links + * and that `app/sitemap.ts` lists, so the canonical link and the sitemap entry + * cannot drift apart. `absoluteUrl()` throws rather than emit a URL on another + * host if that ever stops being a site-relative path. + */ + const canonical = absoluteUrl(page.url); + return { title: page.data.title, description: page.data.description, - /** - * `page.url` is the same locale-stripped route fumadocs uses for in-site links - * and that `app/sitemap.ts` lists, so the canonical link and the sitemap entry - * cannot drift apart. `absoluteUrl()` throws rather than emit a URL on another - * host if that ever stops being a site-relative path. - */ - alternates: { canonical: absoluteUrl(page.url) }, + alternates: { canonical }, + openGraph: { + type: 'article', + title: page.data.title, + description: page.data.description, + // Same absolute URL as the canonical link, deliberately: `og:url` is the + // identity a social platform de-duplicates shares by, and pointing it at a + // different spelling than the canonical splits one page into two. + url: canonical, + images: [ + { + url: image.url, + width: 1200, + height: 630, + alt: page.data.title, + }, + ], + }, + twitter: { + card: 'summary_large_image', + title: page.data.title, + description: page.data.description, + images: [image.url], + }, }; } diff --git a/apps/docs/app/[lang]/page.tsx b/apps/docs/app/[lang]/page.tsx index c0b09e8dee..3b9ba7d331 100644 --- a/apps/docs/app/[lang]/page.tsx +++ b/apps/docs/app/[lang]/page.tsx @@ -22,10 +22,38 @@ const mono = IBM_Plex_Mono({ variable: '--font-l-mono', }); +const HOME_TITLE = 'Metadata framework for AI-written apps'; +const HOME_DESCRIPTION = + 'ObjectStack turns the whole app — data model, UI, workflows, permissions — into typed metadata: a complete CRM in under 150k tokens, one context window.'; + +/** + * The homepage's social card. + * + * ⚠️ Deliberately NOT the docs card generator. `app/og/docs/[...slug]/route.tsx` + * renders from a `source.getPage()` result, and the homepage has no MDX file + * behind it — there is no slug to hand it. It reuses the hero cover instead, + * which is already shipped and already the video poster on this page, so the + * shared card costs no extra bytes and no extra route. + * + * ⚠️ `apps/docs/app/[lang]/blog/[[...slug]]/page.tsx` spells this same path for + * the blog's card. Two spellings rather than one shared constant because + * `lib/site.ts` is the origin's home, not the asset manifest's; if a third page + * ever needs it, hoist it there. Anything that renames, re-encodes or deletes + * `public/hero-cover-dark.png` must update BOTH — an `og:image` that 404s is + * worse than none, because crawlers then scrape whatever else the page offers. + * + * Left site-relative: `metadataBase` in `app/layout.tsx` absolutises it. + */ +const HOME_CARD = { + url: '/hero-cover-dark.png', + width: 2400, + height: 1200, + alt: 'ObjectStack — the metadata framework for AI-written apps', +}; + export const metadata: Metadata = { - title: 'Metadata framework for AI-written apps', - description: - 'ObjectStack turns the whole app — data model, UI, workflows, permissions — into typed metadata: a complete CRM in under 150k tokens, one context window.', + title: HOME_TITLE, + description: HOME_DESCRIPTION, /** * `/` is the one indexable spelling of the homepage: `proxy.ts` rewrites `/` to * this route internally, and the prefixed form `/en` 307s back to `/`. Every @@ -33,6 +61,21 @@ export const metadata: Metadata = { * points here. */ alternates: { canonical: absoluteUrl('/') }, + openGraph: { + type: 'website', + title: HOME_TITLE, + description: HOME_DESCRIPTION, + // Same absolute URL as the canonical link — see the docs route for why the + // two must not drift. + url: absoluteUrl('/'), + images: [HOME_CARD], + }, + twitter: { + card: 'summary_large_image', + title: HOME_TITLE, + description: HOME_DESCRIPTION, + images: [HOME_CARD.url], + }, }; const VOCABULARY: { tag: string; title: string; copy: string }[] = [