feat(related): photo cards — 6 on desktop, 4 on phones, each post's own share card
Deploy / build (push) Successful in 33s
Deploy / build (push) Successful in 33s
Related posts were 4 text-only cards. They now carry the post's language-specific ogImage (/og/<slug>.jpg for EN, /og/zh-<slug>.jpg for ZH), so an English page never shows Chinese art. Grid is 3 across above 900px, 2 across below, and phones show the first 4 only; read-time is dropped under 600px so the date fits. Verified in the built HTML + headless Chrome at 1264/884/500px: 3/2/2 columns, 6/6/4 visible cards, no horizontal overflow, all six images decoded.
This commit is contained in:
1 parent
c8cb2a25f4
commit
7f2680ea92
5 files changed
+61
-15
No files matched your search
@@ -22,7 +22,7 @@ Read this **before** touching theme, layout, typography, color, or SEO markup. P
|
||||
- **Sticky header** — name/logo + nav (Blog / Categories / About / RSS).
|
||||
- **Article header** — title, pub date, reading time, category + tags, author card (photo, 1–2 line bio, LinkedIn + GitHub + git.hoelee.com links).
|
||||
- **Author box / ProfilePage schema** on every post (see SEO below).
|
||||
- **Related posts** (3 cards) — internal linking + keeps readers on-site.
|
||||
- **Related posts** (6 photo cards: 3 across on desktop, 2 across ≤900px, the first 4 only on ≤600px) — internal linking + keeps readers on-site. Each card shows the post's own language-specific share card, so an EN page never shows ZH art.
|
||||
- **RSS link** in footer — how the technical audience follows me.
|
||||
- **Custom og:image per post** — 1200×630, never the reused logo.
|
||||
|
||||
@@ -38,7 +38,8 @@ Read this **before** touching theme, layout, typography, color, or SEO markup. P
|
||||
|---|---|---|
|
||||
| `og:image` / `twitter:image` | **PNG** (1200×630) | Social scrapers (WhatsApp / LinkedIn / FB / Telegram) do not reliably render WebP share cards. Compatibility beats bytes here. |
|
||||
| Article banner in-page | **WebP q78** + PNG fallback via `<picture>` | 6.02 MB → 2.52 MB across 70 banners (42%); JPEG would be *larger* (q85 = 129% of PNG) because the art is flat terminal-style with sharp text edges. |
|
||||
| List/related thumbnails | **WebP q72 at 400w + 800w** (`/og/thumb/<name>-400.webp`) | Rows display at ~398px; they used to fetch the 1200×630 PNG (~45 KB). 400w is 5.8 KB, 800w is 16.1 KB. The homepage went from ~450 KB of thumbnails to ~62 KB. |
|
||||
| List thumbnails | **WebP q72 at 400w + 800w** (`/og/thumb/<name>-400.webp`) | Rows display at ~398px; they used to fetch the 1200×630 PNG (~45 KB). 400w is 5.8 KB, 800w is 16.1 KB. The homepage went from ~450 KB of thumbnails to ~62 KB. |
|
||||
| Related-post cards | The post's **own share card** (`/og/<slug>.jpg`, ZH `/og/zh-<slug>.jpg`), `loading="lazy"` | The card must match the page language, and the thumb WebPs are keyed by base slug — using them would print EN art on ZH pages. 6 cards ≈ 390 KB, deferred below the fold. |
|
||||
| Real photos (author, hardware) | **WebP q82** + JPEG fallback | Continuous-tone images are the opposite case from the flat art above. |
|
||||
|
||||
Regenerate all derivatives after adding a post: `uv run --with pillow python scripts/optimize-images.py`.
|
||||
|
||||
@@ -252,6 +252,14 @@ No topics were killed by the user in this sweep.
|
||||
- Open, and **not** engineering: the round-5 consultant questions ("no text" acceptance line, banner framing, dark art on the light theme) — see the phase log.
|
||||
- ⚠ Overwriting art in place keeps the same URLs, so a returning reader sees the old picture from their own cache; hard-refresh before judging a restyle batch.
|
||||
|
||||
### Phase G — Related-posts photo grid (2026-10-20)
|
||||
- ✅ `Related Posts` / `相关文章` went from 4 text-only cards to **6 cards carrying each post's own share card**: `ogImage` per post means the EN page shows `/og/<slug>.jpg` and the ZH page `/og/zh-<slug>.jpg` — no cross-language art. Measured in the built HTML: 6 `<img class="related-thumb">`, all 6 files 200, ZH page resolves all 6 to `zh-` files.
|
||||
- Layout: 3 across (>900px), 2 across (≤900px), and on ≤600px the same 2 columns show only the **first 4** (`.related-extra` hidden, `i >= 4` in `[...slug].astro`). Read-time span (`.related-read`) is dropped on phones so the date fits a 160px card.
|
||||
- Verified with headless Chrome by injecting the built `<section class="related">` + built CSS into a probe at 1264 / 884 / 500 px: `gridTemplateColumns` = 3 / 2 / 2 columns, visible cards 6 / 6 / 4, `scrollWidth == clientWidth` everywhere (no overflow), 6/6 images decoded.
|
||||
- ⚠ Headless `--window-size=390` is silently clamped to **500** on Windows — the right-hand column looks clipped in the screenshot, but that is the capture, not the layout. Verify <500px by putting the probe in a 390px `<iframe>` and screenshotting that.
|
||||
- ⚠ `--virtual-time-budget` can expire before lazy images paint (5 of 6 cards came out as empty `var(--border)` rectangles at budget 6000). Use ≥20000 before declaring an image broken.
|
||||
- Bytes: 6 × ~65 KB JPEG ≈ 390 KB, `loading="lazy"` and below the fold. The `/og/thumb/` WebPs (5.8 / 16.1 KB) are keyed by base slug with **no ZH variant**, so they cannot be used here without first emitting `/og/thumb/zh-<slug>-{400,800}.webp`.
|
||||
|
||||
### Phase A — Identity (DONE)
|
||||
- ✅ Real headshot in the About hero, the author card on every post, the home hero (EN + ZH) and JSON-LD `Person.image`; no 404 for any derivative.
|
||||
- ✅ One branding line ("Full-Stack Developer & DevOps Engineer") aligned across `SITE.title`, meta description, JSON-LD and the homepage hero.
|
||||
|
||||
@@ -42,7 +42,7 @@ The entire blog strategy is a **name-search play**, so identity must be airtight
|
||||
- [ ] `sitemap.xml` + `robots.txt` + RSS — auto-generated by Astro
|
||||
- [ ] Core Web Vitals 90+ (verify Lighthouse on 390px **and** 1280px)
|
||||
- [ ] Question-shaped H2/H3 (the phrasing a learner actually searches)
|
||||
- [ ] Related-posts module (3 cards) on every post
|
||||
- [ ] Related-posts module (6 cards) on every post
|
||||
- [ ] No orphaned/thin pages (avoid the hoelee.com ~400-attachment mistake)
|
||||
|
||||
### Crawler / sitemap mechanics (as built)
|
||||
|
||||
@@ -28,7 +28,8 @@ const minutes = readingMinutes(post.body);
|
||||
const fmt = (d: Date) =>
|
||||
d.toLocaleDateString(lang === 'zh' ? 'zh-CN' : 'en-US', { year: 'numeric', month: 'long', day: 'numeric' });
|
||||
|
||||
// Related posts: same language, same category or overlapping tags, excluding self
|
||||
// Related posts: same language, same category or overlapping tags, excluding self.
|
||||
// Six max — the card grid shows all six on desktop and the first four on mobile.
|
||||
const all = await getCollection('posts', ({ data }) => !data.draft);
|
||||
// Tag pills: a tag with its own page becomes a link, a one-off tag stays plain text.
|
||||
const tagCount = tagCounts(all.filter((p) => postLang(p.slug) === lang));
|
||||
@@ -41,7 +42,7 @@ const related = all
|
||||
})
|
||||
.filter((r) => r.score > 0)
|
||||
.sort((a, b) => b.score - a.score)
|
||||
.slice(0, 4)
|
||||
.slice(0, 6)
|
||||
.map((r) => r.post);
|
||||
|
||||
// Translation link for the language switch:
|
||||
@@ -127,15 +128,26 @@ const jsonLd = {
|
||||
<section class="related">
|
||||
<h2>{lang === 'zh' ? '相关文章' : 'Related Posts'}</h2>
|
||||
<div class="related-grid">
|
||||
{related.map((p) => (
|
||||
<a class="related-card" href={`/posts/${p.slug}/`}>
|
||||
{/* Text-only card on purpose: the OG art is illegible at this size
|
||||
(displayed ~526x276) and cost ~46 KB per card. */}
|
||||
{related.map((p, i) => (
|
||||
<a class:list={['related-card', { 'related-extra': i >= 4 }]} href={`/posts/${p.slug}/`}>
|
||||
{/* Each post ships its own language-specific share card (ogImage:
|
||||
/og/<slug>.jpg for EN, /og/zh-<slug>.jpg for ZH), so the related
|
||||
grid never shows the other language's art. */}
|
||||
<img
|
||||
class="related-thumb"
|
||||
src={p.data.ogImage ?? '/og-default.jpg'}
|
||||
alt={p.data.title}
|
||||
width="1200"
|
||||
height="630"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
/>
|
||||
<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)} · {readingMinutes(p.body)} {lang === 'zh' ? '分钟' : 'min read'}
|
||||
{fmt(p.data.pubDate)}
|
||||
<span class="related-read"> · {readingMinutes(p.body)} {lang === 'zh' ? '分钟' : 'min read'}</span>
|
||||
</time>
|
||||
</div>
|
||||
</a>
|
||||
|
||||
+30
-5
@@ -426,7 +426,7 @@ pre code {
|
||||
.related { margin-top: 3rem; padding-top: 1.5rem; border-top: 1px solid var(--border); }
|
||||
.related-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 1rem;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
@@ -440,8 +440,19 @@ pre code {
|
||||
color: var(--ink);
|
||||
text-decoration: none;
|
||||
}
|
||||
/* The post's own OG share card doubles as the card image, so the art matches
|
||||
the language of the page (see the ogImage note in posts/[...slug].astro). */
|
||||
.related-thumb {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: auto;
|
||||
aspect-ratio: 1200 / 630;
|
||||
object-fit: cover;
|
||||
background: var(--border);
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.related-card:hover { border-color: var(--brand); }
|
||||
.related-body { padding: 0.9rem 1rem; display: flex; flex-direction: column; flex: 1; }
|
||||
.related-body { padding: 0.8rem 0.9rem; display: flex; flex-direction: column; flex: 1; }
|
||||
.related-badge {
|
||||
align-self: flex-start;
|
||||
display: inline-block;
|
||||
@@ -457,8 +468,8 @@ pre code {
|
||||
}
|
||||
.related-card h3 {
|
||||
margin: 0 0 0.3rem;
|
||||
font-size: 0.95rem;
|
||||
/* keep card heights comparable without thumbnails */
|
||||
font-size: 0.92rem;
|
||||
/* keep card heights comparable across a row */
|
||||
display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical; overflow: hidden;
|
||||
}
|
||||
.related-card time { font-size: 0.78rem; margin-top: auto; padding-top: 0.4rem; }
|
||||
@@ -510,6 +521,12 @@ pre code {
|
||||
margin: 1rem 0 2.5rem;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
/* Three photo cards per row stops working below ~900px — the OG art is
|
||||
1200x630 with baked-in type, so it needs width to stay readable. */
|
||||
.related-grid { grid-template-columns: repeat(2, 1fr); }
|
||||
}
|
||||
|
||||
@media (max-width: 680px) {
|
||||
.featured-bento-grid {
|
||||
grid-template-columns: 1fr;
|
||||
@@ -898,7 +915,15 @@ a.category-card:hover { border-color: var(--brand); }
|
||||
.nav { padding: 0.8rem 1.25rem; gap: 0.85rem; }
|
||||
h1 { font-size: 1.7rem; }
|
||||
h2 { font-size: 1.25rem; }
|
||||
.related-grid { grid-template-columns: 1fr; }
|
||||
/* Related grid: four photo cards on phones (the last two are hidden), and
|
||||
keep the type small enough that a 160px-wide card still reads. */
|
||||
.related-grid { grid-template-columns: repeat(2, 1fr); gap: 0.75rem; }
|
||||
.related-extra { display: none; }
|
||||
.related-card h3 { font-size: 0.82rem; }
|
||||
.related-card time { font-size: 0.7rem; }
|
||||
.related-read { display: none; } /* date alone fits a 160px card */
|
||||
.related-body { padding: 0.6rem 0.65rem; }
|
||||
.related-badge { font-size: 0.6rem; margin-bottom: 0.35rem; }
|
||||
.featured-bento-grid { grid-template-columns: 1fr; }
|
||||
.proof-grid { grid-template-columns: repeat(2, 1fr); }
|
||||
.stack-grid, .contact-split { grid-template-columns: 1fr; }
|
||||
|
||||
Reference in new issue
Block a user