--- import type { CollectionEntry } from 'astro:content'; import { getCollection } from 'astro:content'; import { postLang } from '../lib/lang'; import { tagCounts, tagLink } from '../lib/tags'; import { categoryLabel } from '../lib/categories'; interface Props { posts: CollectionEntry<'posts'>[]; limit?: number; lang?: 'en' | 'zh'; /** * Measured headline per base slug (see lib/portfolio.ts) — rendered as a pill on a * portfolio card. Omitted on the home page and tag pages, where a bare list is right. */ metrics?: Record; } const { posts, limit, lang = 'en', metrics } = Astro.props; const shown = limit ? posts.slice(0, limit) : posts; const isZh = lang === 'zh'; const fmt = (d: Date) => d.toLocaleDateString(isZh ? 'zh-CN' : 'en-US', { year: 'numeric', month: 'long', day: 'numeric', }); const updatedLabel = isZh ? '更新于' : 'Updated'; const catHref = (cat: string) => `/${isZh ? 'zh/categories' : 'categories'}/${cat}/`; const og = (post: CollectionEntry<'posts'>) => post.data.ogImage || '/og-default.jpg'; // Tag pills only link when the tag has its own page (>= TAG_MIN_POSTS in this language). const counts = tagCounts((await getCollection('posts', ({ data }) => !data.draft)) .filter((p) => postLang(p.slug) === lang)); // The row thumbnail is displayed at ~398 CSS px wide, so the list used to pull a // 1200x630 PNG (~45 KB) per row for a 398px slot. Serve 400w/800w WebP derived from // the same art instead (5.8 KB / 16.1 KB — see scripts/optimize-images.py). const hasThumb = (post: CollectionEntry<'posts'>) => { const p = og(post); return p.startsWith('/og/') && p !== '/og-default.png' && p !== '/og-default.jpg'; }; const thumb = (post: CollectionEntry<'posts'>, w: 400 | 800) => { // Thumbs are keyed by the base slug, not by the og file: a ZH post's ogImage is // /og/zh-.jpg while the renderer only emits /og/thumb/-.webp. const base = post.slug.replace(/^zh\//, ''); return `/og/thumb/${base}-${w}.webp`; }; // A case study is a portfolio artefact, so the row's action says so. The same rule is // applied in Archive.astro for the /posts/ Featured block. const ctaLabel = (category: string) => { if (category === 'case-studies') return isZh ? '阅读案例复盘' : 'Read case study'; return isZh ? '阅读文章' : 'Read article'; }; const metricFor = (post: CollectionEntry<'posts'>) => metrics?.[post.slug.replace(/^zh\//, '')]; // A card that lists every tag wraps to two metadata lines on some rows and one on // others, so the grid loses its rhythm (and the summary/CTA drift). Show four and // fold the rest into a "+N" chip whose tooltip names them. const TAG_LIMIT = 4; ---
    {shown.map((post) => (
  • {post.data.title}

    {post.data.updatedDate && ( <> · {updatedLabel} {fmt(post.data.updatedDate)} )} · {categoryLabel(post.data.category, lang)} {post.data.tags.slice(0, TAG_LIMIT).map((t) => { const href = tagLink(t, counts, lang); return href ? {t} : {t}; })} {post.data.tags.length > TAG_LIMIT && ( +{post.data.tags.length - TAG_LIMIT} )}

    {post.data.description}

    {/* A second visual target for the same destination — deliberately out of the tab order so keyboard users get one stop per card, not two. */} {metricFor(post) && ( {metricFor(post)} )}
  • ))}