perf(images): WebP banners, text-only related cards, resized list thumbnails
Deploy / build (push) Successful in 56s

Measured before/after with headless Chrome + CDP (not by eye):

- article banner: <picture> WebP q78 with the PNG as fallback, because the PNG
  must stay for og:image (social scrapers do not reliably render WebP cards).
  70 banners: 6.02 MB -> 2.52 MB (42%). JPEG measured *larger* than PNG here
  (q85 = 129%) — the art is flat terminal-style with sharp text edges.
- post page related cards: dropped four 1200x630 PNGs (183 KB) to display
  526x276 where the embedded code is illegible; now text-only cards with a
  category badge, date and reading time.
- list thumbnails (home + archive): new /og/thumb/<name>-{400,800}.webp served
  via srcset/sizes. Rows display at ~398px but were pulling full 1200x630 PNGs.
  Homepage thumbnails ~450 KB -> 62 KB; archive (71 rows) ~3.2 MB -> 436 KB.
- desktop banner capped at 720px centred: 67% -> 45% of a 900px viewport,
  keeping the art intact instead of cropping it.

Post page totals: images 277 KB -> 38 KB; page height 5758 -> 5065 (desktop)
and 9423 -> 8807 (mobile). Build: 163 pages.

Script: scripts/optimize-images.py; policy: docs/design-guide.md §3; runbook
step added to docs/ops-runbook.md.
This commit is contained in:
hoelee committed 2026-10-16 17:11:27 +08:00
1 parent 9462ab2373
commit f364084284
219 files changed
+158 -15

No files matched your search

+14 -3
View File
@@ -25,6 +25,8 @@ const minutes = Math.max(1, Math.round(words / 200));
const fmt = (d: Date) =>
d.toLocaleDateString(lang === 'zh' ? 'zh-CN' : 'en-US', { year: 'numeric', month: 'long', day: 'numeric' });
const minutesOf = (body?: string) => Math.max(1, Math.round((body?.trim().split(/\s+/).length ?? 0) / 200));
// Related posts: same language, same category or overlapping tags, excluding self
const all = await getCollection('posts', ({ data }) => !data.draft);
const related = all
@@ -101,7 +103,12 @@ const jsonLd = {
{post.data.banner && (
<figure class="article-banner">
<img src={post.data.banner} alt="" loading="lazy" width="1600" height="900" />
{/* WebP for the page (42% of the PNG bytes); the PNG stays for og:image
because social scrapers do not reliably render WebP share cards. */}
<picture>
<source srcset={post.data.banner.replace(/\.png$/, '.webp')} type="image/webp" />
<img src={post.data.banner} alt="" loading="lazy" decoding="async" width="1600" height="900" />
</picture>
</figure>
)}
@@ -116,10 +123,14 @@ const jsonLd = {
<div class="related-grid">
{related.map((p) => (
<a class="related-card" href={`/posts/${p.slug}/`}>
<img class="related-thumb" src={p.data.ogImage || '/og-default.png'} alt="" loading="lazy" width="1200" height="630" />
{/* Text-only card on purpose: the OG art is illegible at this size
(displayed ~526x276) and cost ~46 KB per card. */}
<div class="related-body">
<span class="related-badge">{p.data.category}</span>
<h3>{p.data.title}</h3>
<time datetime={p.data.pubDate.toISOString()}>{fmt(p.data.pubDate)}</time>
<time datetime={p.data.pubDate.toISOString()}>
{fmt(p.data.pubDate)} · {minutesOf(p.body)} {lang === 'zh' ? '分钟' : 'min read'}
</time>
</div>
</a>
))}