Skip to content

Commit fd332dd

Browse files
os-zhuangclaude
andauthored
perf(docs): re-encode the hero cover to WebP and hoist its path into one constant (#12377)
The homepage video poster was a 2400x1200 PNG served at 974 CSS px. Measured over the wire against a local `next build && next start`: 406,703 B, no transport compression (image/png, Content-Length == file size). It is the heaviest single element the homepage loads. Re-encoded from the master `docs/screenshots/hero-cover-dark.png` to WebP at quality 80 / effort 6 (sharp 0.35.3, libwebp 1.6.0), keeping 2400x1200 so the declared `og:image:width` / `og:image:height` stay true and 2x displays stay oversampled. 83,272 B on the wire -- 20.5% of the PNG, and 83,572 B including response headers as Chrome's Resource Timing reports it. Fidelity, measured at the exact device-pixel size Chrome paints (1948x974, read off the running page): SSIM 0.994713, PSNR 47.10 dB against the master, max per-pixel delta 24/255, 0.739% of pixels differing by more than 5/255. The same harness scores the master against itself at SSIM 1.0 / PSNR infinity, so it is not fabricating agreement. Crops of the four regions most at risk -- code panel, smallest dashboard labels, gradient headline, and the two worst-scoring tiles -- are indistinguishable at 2x magnification. WebP rather than AVIF deliberately: this is a plain `<img>` with no `<picture>` fallback and it doubles as the og:image, so it must decode everywhere. AVIF is measurably better per byte on this image (q60 reaches SSIM 0.998 at 65 KB) and was rejected because unfurler support is not there. The path was spelled three times -- the poster, the homepage card, the blog card -- and two of those read as SEO config rather than image references. It is now `HERO_COVER` in `lib/site.ts`, next to `SITE_ORIGIN`, carrying the re-encode recipe and the reason the dimensions and the format are constrained. `docs/screenshots/hero-cover-dark.png` -- the README's copy, a separate file for a separate consumer -- is untouched: same blob sha before and after. Co-authored-by: Jack Zhuang <277994282+os-zhuang@users.noreply.github.com> Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
1 parent e5ce2ed commit fd332dd

6 files changed

Lines changed: 85 additions & 47 deletions

File tree

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

Lines changed: 17 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import { blog } from '@/lib/source';
44
import { getMDXComponents } from '@/mdx-components';
55
import { HomeLayout } from 'fumadocs-ui/layouts/home';
66
import { baseOptions } from '@/lib/layout.shared';
7-
import { absoluteUrl } from '@/lib/site';
7+
import { absoluteUrl, HERO_COVER } from '@/lib/site';
88
import {
99
compact,
1010
JsonLd,
@@ -78,7 +78,7 @@ function postGraph(url: string, data: BlogPostData): JsonLdNode[] {
7878
url: canonical,
7979
mainEntityOfPage: canonical,
8080
inLanguage: 'en',
81-
image: absoluteUrl(BLOG_CARD.url),
81+
image: absoluteUrl(HERO_COVER.url),
8282
datePublished: isoDate(data.date),
8383
dateModified: sitemapLastModified(url),
8484
keywords: data.tags,
@@ -251,29 +251,25 @@ export async function generateStaticParams() {
251251
}));
252252
}
253253

254+
const BLOG_INDEX_TITLE = 'Blog';
255+
const BLOG_INDEX_DESCRIPTION =
256+
'Insights, updates, and best practices from the ObjectStack team.';
257+
254258
/**
255-
* The blog's social card.
259+
* Metadata for the blog index and for every post.
260+
*
261+
* The social card in both branches is the shared hero cover, `HERO_COVER` from
262+
* `lib/site.ts` — the same image the homepage presents itself with, and the same
263+
* one `postGraph()` above puts in the `BlogPosting` node. It used to be a literal
264+
* spelled here and again on the homepage; one declaration now, so a re-encode
265+
* cannot leave half the site pointing at a file that no longer exists.
256266
*
257267
* ⚠️ The card generator at `app/og/docs/[...slug]/route.tsx` is docs-only: it
258268
* renders from `source` (the `content/docs` loader) and has no branch for `blog`,
259269
* so there is no per-post card to reference and this card is shared by the index
260270
* and every post. Giving posts their own generated cards is a separate decision,
261271
* not a gap in this wiring — it would mean a second `app/og/**` route.
262-
*
263-
* ⚠️ Same path as `apps/docs/app/[lang]/page.tsx`'s `HOME_CARD`; that file
264-
* carries the note on why it is spelled twice and what must change together.
265272
*/
266-
const BLOG_CARD = {
267-
url: '/hero-cover-dark.png',
268-
width: 2400,
269-
height: 1200,
270-
alt: 'ObjectStack — the metadata framework for AI-written apps',
271-
};
272-
273-
const BLOG_INDEX_TITLE = 'Blog';
274-
const BLOG_INDEX_DESCRIPTION =
275-
'Insights, updates, and best practices from the ObjectStack team.';
276-
277273
export async function generateMetadata({
278274
params,
279275
}: {
@@ -296,13 +292,13 @@ export async function generateMetadata({
296292
title: BLOG_INDEX_TITLE,
297293
description: BLOG_INDEX_DESCRIPTION,
298294
url: canonical,
299-
images: [BLOG_CARD],
295+
images: [HERO_COVER],
300296
},
301297
twitter: {
302298
card: 'summary_large_image',
303299
title: BLOG_INDEX_TITLE,
304300
description: BLOG_INDEX_DESCRIPTION,
305-
images: [BLOG_CARD.url],
301+
images: [HERO_COVER.url],
306302
},
307303
};
308304
}
@@ -324,13 +320,13 @@ export async function generateMetadata({
324320
title: page.data.title,
325321
description: page.data.description,
326322
url: canonical,
327-
images: [BLOG_CARD],
323+
images: [HERO_COVER],
328324
},
329325
twitter: {
330326
card: 'summary_large_image',
331327
title: page.data.title,
332328
description: page.data.description,
333-
images: [BLOG_CARD.url],
329+
images: [HERO_COVER.url],
334330
},
335331
};
336332
}

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

Lines changed: 12 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import { ArrowRight, Check } from 'lucide-react';
44
import { Bricolage_Grotesque, IBM_Plex_Mono } from 'next/font/google';
55
import { HomeLayout } from 'fumadocs-ui/layouts/home';
66
import { baseOptions, gitConfig } from '@/lib/layout.shared';
7-
import { absoluteUrl } from '@/lib/site';
7+
import { absoluteUrl, HERO_COVER } from '@/lib/site';
88
import {
99
APACHE_2_0_URL,
1010
GITHUB_REPO_URL,
@@ -36,30 +36,22 @@ const HOME_DESCRIPTION =
3636
'ObjectStack turns the whole app — data model, UI, workflows, permissions — into typed metadata: a complete CRM in under 150k tokens, one context window.';
3737

3838
/**
39-
* The homepage's social card.
39+
* The homepage's metadata. Its social card is the shared hero cover,
40+
* `HERO_COVER` from `lib/site.ts` — the same image the video poster below uses.
4041
*
4142
* ⚠️ Deliberately NOT the docs card generator. `app/og/docs/[...slug]/route.tsx`
4243
* renders from a `source.getPage()` result, and the homepage has no MDX file
4344
* behind it — there is no slug to hand it. It reuses the hero cover instead,
4445
* which is already shipped and already the video poster on this page, so the
4546
* shared card costs no extra bytes and no extra route.
4647
*
47-
* ⚠️ `apps/docs/app/[lang]/blog/[[...slug]]/page.tsx` spells this same path for
48-
* the blog's card. Two spellings rather than one shared constant because
49-
* `lib/site.ts` is the origin's home, not the asset manifest's; if a third page
50-
* ever needs it, hoist it there. Anything that renames, re-encodes or deletes
51-
* `public/hero-cover-dark.png` must update BOTH — an `og:image` that 404s is
52-
* worse than none, because crawlers then scrape whatever else the page offers.
48+
* The path used to be spelled as a literal here, again in the blog's card, and a
49+
* third time as the `poster` further down this file — with a note asking whoever
50+
* added a third consumer to hoist it. That happened; it lives in `lib/site.ts`
51+
* now, and the reasons a re-encode has to be careful live with it.
5352
*
5453
* Left site-relative: `metadataBase` in `app/layout.tsx` absolutises it.
5554
*/
56-
const HOME_CARD = {
57-
url: '/hero-cover-dark.png',
58-
width: 2400,
59-
height: 1200,
60-
alt: 'ObjectStack — the metadata framework for AI-written apps',
61-
};
62-
6355
export const metadata: Metadata = {
6456
title: HOME_TITLE,
6557
description: HOME_DESCRIPTION,
@@ -77,13 +69,13 @@ export const metadata: Metadata = {
7769
// Same absolute URL as the canonical link — see the docs route for why the
7870
// two must not drift.
7971
url: absoluteUrl('/'),
80-
images: [HOME_CARD],
72+
images: [HERO_COVER],
8173
},
8274
twitter: {
8375
card: 'summary_large_image',
8476
title: HOME_TITLE,
8577
description: HOME_DESCRIPTION,
86-
images: [HOME_CARD.url],
78+
images: [HERO_COVER.url],
8779
},
8880
};
8981

@@ -104,7 +96,7 @@ export const metadata: Metadata = {
10496
*
10597
* Every field is drawn from something already on this page or in `lib/`: the
10698
* title and description are the same constants `metadata` uses, the image is
107-
* `HOME_CARD`, the licence is the repo's own, and the two `sameAs` links are the
99+
* `HERO_COVER`, the licence is the repo's own, and the two `sameAs` links are the
108100
* GitHub organisation and the YouTube channel this page links to in its hero.
109101
*/
110102
function homeGraph(): JsonLdNode[] {
@@ -119,7 +111,7 @@ function homeGraph(): JsonLdNode[] {
119111
// itself one way to a crawler and another way to a social card.
120112
headline: HOME_TITLE,
121113
description: HOME_DESCRIPTION,
122-
image: absoluteUrl(HOME_CARD.url),
114+
image: absoluteUrl(HERO_COVER.url),
123115
codeRepository: GITHUB_REPO_URL,
124116
programmingLanguage: 'TypeScript',
125117
runtimePlatform: 'Node.js',
@@ -279,7 +271,7 @@ export default function HomePage() {
279271
<YouTubeEmbed
280272
videoId={OVERVIEW_VIDEO_ID}
281273
title="ObjectStack in 90 Seconds"
282-
poster="/hero-cover-dark.png"
274+
poster={HERO_COVER.url}
283275
/>
284276
<figcaption
285277
className="mt-3 flex flex-wrap items-center justify-center gap-x-3 gap-y-1 text-[12px] text-fd-muted-foreground"

apps/docs/components/youtube-embed.tsx

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -18,11 +18,12 @@ type YouTubeEmbedProps = {
1818
* A facade, not a bare `<iframe>`: nothing is requested from YouTube until the
1919
* viewer actually clicks play, so the homepage costs no third-party frame,
2020
* script or cookie on load. The cover is the same one the README links from
21-
* (`docs/screenshots/hero-cover-dark.png`, copied into `public/`), which is 2:1
22-
* rather than the video's 16:9 — hence the fixed 2:1 frame with the player
23-
* centred inside it at its own aspect ratio. Cropping the cover to 16:9 would
24-
* cut through the logo on the left edge, and letting the frame change shape on
25-
* click would shove the rest of the page down mid-interaction.
21+
* (`docs/screenshots/hero-cover-dark.png`, re-encoded to WebP into `public/` —
22+
* `HERO_COVER` in `lib/site.ts` owns the path and the re-encode recipe), which
23+
* is 2:1 rather than the video's 16:9 — hence the fixed 2:1 frame with the
24+
* player centred inside it at its own aspect ratio. Cropping the cover to 16:9
25+
* would cut through the logo on the left edge, and letting the frame change
26+
* shape on click would shove the rest of the page down mid-interaction.
2627
*/
2728
export function YouTubeEmbed({ videoId, title, poster }: YouTubeEmbedProps) {
2829
const [playing, setPlaying] = useState(false);

apps/docs/lib/site.ts

Lines changed: 50 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
/**
2-
* Canonical identity of the documentation site.
2+
* Canonical identity of the documentation site — the origin it names itself by,
3+
* and the shared cover asset it presents itself with.
34
*
45
* The origin is a maintainer ruling, not configuration. Every absolute URL this
56
* site emits — sitemap entries, the `Sitemap:` line in `robots.txt`, and (as the
@@ -36,3 +37,51 @@ export function absoluteUrl(path: string): string {
3637

3738
return new URL(path, SITE_ORIGIN).toString();
3839
}
40+
41+
/**
42+
* The hero cover — the one image this site presents itself with.
43+
*
44+
* Three consumers, one declaration: the homepage's click-to-play video poster
45+
* (`app/[lang]/page.tsx`), the homepage's Open Graph / Twitter card and JSON-LD
46+
* `image` (same file), and the blog's card for the index and every post
47+
* (`app/[lang]/blog/[[...slug]]/page.tsx`). Before this constant the path was
48+
* spelled as a literal in all three, and two of them do not read as image
49+
* references at all — they read as SEO config — so a re-encode had to find them
50+
* by memory. It is declared here, next to `SITE_ORIGIN`, because the cover is
51+
* part of the site's identity in the same way the origin is: one asset, named
52+
* once, that every self-description reads.
53+
*
54+
* ⚠️ `width` / `height` are emitted as `og:image:width` / `og:image:height`. A
55+
* declared size that disagrees with the bytes is worse than no declaration — a
56+
* crawler reserves the wrong box and some renderers reject the card outright —
57+
* so anything that changes the asset's pixel dimensions must change these in the
58+
* same commit. They are correct only because the re-encode below deliberately
59+
* kept 2400x1200.
60+
*
61+
* ## Provenance — how to re-encode it
62+
*
63+
* The master is `docs/screenshots/hero-cover-dark.png` (2400x1200, 406,703 B),
64+
* which the README embeds directly from the repo and which is NOT served by this
65+
* site. This file is a lossy WebP derived from that master, and the derivation is
66+
* the whole reason it is small enough to sit above the fold:
67+
*
68+
* sharp('docs/screenshots/hero-cover-dark.png')
69+
* .webp({ quality: 80, effort: 6 }) // sharp 0.35.3 / libwebp 1.6.0
70+
* .toFile('apps/docs/public/hero-cover-dark.webp')
71+
*
72+
* 83,272 B — 20.5% of the PNG. Re-encode from the master, never from this file:
73+
* a lossy re-encode of a lossy source compounds. And do not "restore" the PNG
74+
* alongside it — a `public/` holding both is how the next re-encode picks the
75+
* wrong one.
76+
*
77+
* Format is a constraint, not a preference. It is a plain `<img>` with no
78+
* `<picture>` fallback and it is also the og:image, so it must be a format every
79+
* browser AND every unfurler decodes. WebP is; AVIF is measurably better per byte
80+
* on this image and was rejected because it is not.
81+
*/
82+
export const HERO_COVER = {
83+
url: '/hero-cover-dark.webp',
84+
width: 2400,
85+
height: 1200,
86+
alt: 'ObjectStack — the metadata framework for AI-written apps',
87+
} as const;
-397 KB
Binary file not shown.
81.3 KB
Loading

0 commit comments

Comments
 (0)