diff --git a/apps/docs/app/[lang]/blog/[[...slug]]/page.tsx b/apps/docs/app/[lang]/blog/[[...slug]]/page.tsx index a2fca0b2e2..6cebe119f6 100644 --- a/apps/docs/app/[lang]/blog/[[...slug]]/page.tsx +++ b/apps/docs/app/[lang]/blog/[[...slug]]/page.tsx @@ -4,7 +4,7 @@ import { blog } from '@/lib/source'; import { getMDXComponents } from '@/mdx-components'; import { HomeLayout } from 'fumadocs-ui/layouts/home'; import { baseOptions } from '@/lib/layout.shared'; -import { absoluteUrl } from '@/lib/site'; +import { absoluteUrl, HERO_COVER } from '@/lib/site'; import { compact, JsonLd, @@ -78,7 +78,7 @@ function postGraph(url: string, data: BlogPostData): JsonLdNode[] { url: canonical, mainEntityOfPage: canonical, inLanguage: 'en', - image: absoluteUrl(BLOG_CARD.url), + image: absoluteUrl(HERO_COVER.url), datePublished: isoDate(data.date), dateModified: sitemapLastModified(url), keywords: data.tags, @@ -251,29 +251,25 @@ export async function generateStaticParams() { })); } +const BLOG_INDEX_TITLE = 'Blog'; +const BLOG_INDEX_DESCRIPTION = + 'Insights, updates, and best practices from the ObjectStack team.'; + /** - * The blog's social card. + * Metadata for the blog index and for every post. + * + * The social card in both branches is the shared hero cover, `HERO_COVER` from + * `lib/site.ts` — the same image the homepage presents itself with, and the same + * one `postGraph()` above puts in the `BlogPosting` node. It used to be a literal + * spelled here and again on the homepage; one declaration now, so a re-encode + * cannot leave half the site pointing at a file that no longer exists. * * ⚠️ 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, }: { @@ -296,13 +292,13 @@ export async function generateMetadata({ title: BLOG_INDEX_TITLE, description: BLOG_INDEX_DESCRIPTION, url: canonical, - images: [BLOG_CARD], + images: [HERO_COVER], }, twitter: { card: 'summary_large_image', title: BLOG_INDEX_TITLE, description: BLOG_INDEX_DESCRIPTION, - images: [BLOG_CARD.url], + images: [HERO_COVER.url], }, }; } @@ -324,13 +320,13 @@ export async function generateMetadata({ title: page.data.title, description: page.data.description, url: canonical, - images: [BLOG_CARD], + images: [HERO_COVER], }, twitter: { card: 'summary_large_image', title: page.data.title, description: page.data.description, - images: [BLOG_CARD.url], + images: [HERO_COVER.url], }, }; } diff --git a/apps/docs/app/[lang]/page.tsx b/apps/docs/app/[lang]/page.tsx index 44c8d4d93a..6027180249 100644 --- a/apps/docs/app/[lang]/page.tsx +++ b/apps/docs/app/[lang]/page.tsx @@ -4,7 +4,7 @@ import { ArrowRight, Check } from 'lucide-react'; import { Bricolage_Grotesque, IBM_Plex_Mono } from 'next/font/google'; import { HomeLayout } from 'fumadocs-ui/layouts/home'; import { baseOptions, gitConfig } from '@/lib/layout.shared'; -import { absoluteUrl } from '@/lib/site'; +import { absoluteUrl, HERO_COVER } from '@/lib/site'; import { APACHE_2_0_URL, GITHUB_REPO_URL, @@ -36,7 +36,8 @@ 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. + * The homepage's metadata. Its social card is the shared hero cover, + * `HERO_COVER` from `lib/site.ts` — the same image the video poster below uses. * * ⚠️ 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 @@ -44,22 +45,13 @@ const HOME_DESCRIPTION = * 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. + * The path used to be spelled as a literal here, again in the blog's card, and a + * third time as the `poster` further down this file — with a note asking whoever + * added a third consumer to hoist it. That happened; it lives in `lib/site.ts` + * now, and the reasons a re-encode has to be careful live with it. * * 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: HOME_TITLE, description: HOME_DESCRIPTION, @@ -77,13 +69,13 @@ export const metadata: Metadata = { // Same absolute URL as the canonical link — see the docs route for why the // two must not drift. url: absoluteUrl('/'), - images: [HOME_CARD], + images: [HERO_COVER], }, twitter: { card: 'summary_large_image', title: HOME_TITLE, description: HOME_DESCRIPTION, - images: [HOME_CARD.url], + images: [HERO_COVER.url], }, }; @@ -104,7 +96,7 @@ export const metadata: Metadata = { * * Every field is drawn from something already on this page or in `lib/`: the * title and description are the same constants `metadata` uses, the image is - * `HOME_CARD`, the licence is the repo's own, and the two `sameAs` links are the + * `HERO_COVER`, the licence is the repo's own, and the two `sameAs` links are the * GitHub organisation and the YouTube channel this page links to in its hero. */ function homeGraph(): JsonLdNode[] { @@ -119,7 +111,7 @@ function homeGraph(): JsonLdNode[] { // itself one way to a crawler and another way to a social card. headline: HOME_TITLE, description: HOME_DESCRIPTION, - image: absoluteUrl(HOME_CARD.url), + image: absoluteUrl(HERO_COVER.url), codeRepository: GITHUB_REPO_URL, programmingLanguage: 'TypeScript', runtimePlatform: 'Node.js', @@ -279,7 +271,7 @@ export default function HomePage() {
`: nothing is requested from YouTube until the * viewer actually clicks play, so the homepage costs no third-party frame, * script or cookie on load. The cover is the same one the README links from - * (`docs/screenshots/hero-cover-dark.png`, copied into `public/`), which is 2:1 - * rather than the video's 16:9 — hence the fixed 2:1 frame with the player - * centred inside it at its own aspect ratio. Cropping the cover to 16:9 would - * cut through the logo on the left edge, and letting the frame change shape on - * click would shove the rest of the page down mid-interaction. + * (`docs/screenshots/hero-cover-dark.png`, re-encoded to WebP into `public/` — + * `HERO_COVER` in `lib/site.ts` owns the path and the re-encode recipe), which + * is 2:1 rather than the video's 16:9 — hence the fixed 2:1 frame with the + * player centred inside it at its own aspect ratio. Cropping the cover to 16:9 + * would cut through the logo on the left edge, and letting the frame change + * shape on click would shove the rest of the page down mid-interaction. */ export function YouTubeEmbed({ videoId, title, poster }: YouTubeEmbedProps) { const [playing, setPlaying] = useState(false); diff --git a/apps/docs/lib/site.ts b/apps/docs/lib/site.ts index 6b55ff4672..f90e05b85e 100644 --- a/apps/docs/lib/site.ts +++ b/apps/docs/lib/site.ts @@ -1,5 +1,6 @@ /** - * Canonical identity of the documentation site. + * Canonical identity of the documentation site — the origin it names itself by, + * and the shared cover asset it presents itself with. * * The origin is a maintainer ruling, not configuration. Every absolute URL this * site emits — sitemap entries, the `Sitemap:` line in `robots.txt`, and (as the @@ -36,3 +37,51 @@ export function absoluteUrl(path: string): string { return new URL(path, SITE_ORIGIN).toString(); } + +/** + * The hero cover — the one image this site presents itself with. + * + * Three consumers, one declaration: the homepage's click-to-play video poster + * (`app/[lang]/page.tsx`), the homepage's Open Graph / Twitter card and JSON-LD + * `image` (same file), and the blog's card for the index and every post + * (`app/[lang]/blog/[[...slug]]/page.tsx`). Before this constant the path was + * spelled as a literal in all three, and two of them do not read as image + * references at all — they read as SEO config — so a re-encode had to find them + * by memory. It is declared here, next to `SITE_ORIGIN`, because the cover is + * part of the site's identity in the same way the origin is: one asset, named + * once, that every self-description reads. + * + * ⚠️ `width` / `height` are emitted as `og:image:width` / `og:image:height`. A + * declared size that disagrees with the bytes is worse than no declaration — a + * crawler reserves the wrong box and some renderers reject the card outright — + * so anything that changes the asset's pixel dimensions must change these in the + * same commit. They are correct only because the re-encode below deliberately + * kept 2400x1200. + * + * ## Provenance — how to re-encode it + * + * The master is `docs/screenshots/hero-cover-dark.png` (2400x1200, 406,703 B), + * which the README embeds directly from the repo and which is NOT served by this + * site. This file is a lossy WebP derived from that master, and the derivation is + * the whole reason it is small enough to sit above the fold: + * + * sharp('docs/screenshots/hero-cover-dark.png') + * .webp({ quality: 80, effort: 6 }) // sharp 0.35.3 / libwebp 1.6.0 + * .toFile('apps/docs/public/hero-cover-dark.webp') + * + * 83,272 B — 20.5% of the PNG. Re-encode from the master, never from this file: + * a lossy re-encode of a lossy source compounds. And do not "restore" the PNG + * alongside it — a `public/` holding both is how the next re-encode picks the + * wrong one. + * + * Format is a constraint, not a preference. It is a plain `` with no + * `` fallback and it is also the og:image, so it must be a format every + * browser AND every unfurler decodes. WebP is; AVIF is measurably better per byte + * on this image and was rejected because it is not. + */ +export const HERO_COVER = { + url: '/hero-cover-dark.webp', + width: 2400, + height: 1200, + alt: 'ObjectStack — the metadata framework for AI-written apps', +} as const; diff --git a/apps/docs/public/hero-cover-dark.png b/apps/docs/public/hero-cover-dark.png deleted file mode 100644 index d8322c8392..0000000000 Binary files a/apps/docs/public/hero-cover-dark.png and /dev/null differ diff --git a/apps/docs/public/hero-cover-dark.webp b/apps/docs/public/hero-cover-dark.webp new file mode 100644 index 0000000000..7d9ae2f406 Binary files /dev/null and b/apps/docs/public/hero-cover-dark.webp differ