/posts/ and /zh/posts/ become hybrid: three fixed-slug flagship cards above the complete year/month archive, with the flagship block collapsing (inert, h=0) as soon as the reader filters. /categories/case-studies/ becomes a portfolio: compact two-column hero with a 21:9 deterministic topology banner, flagship ordering from lib/portfolio.ts instead of pubDate, and one measured pill per card. Also fixes what the screenshot review turned up: ZH archive rows and category chips were printing raw English slugs, the two chip strips were unlabelled and read as one list with a duplicated 'devops', card meta rows broke rhythm past four tags, the nav had no current-section indicator, and mobile title links were under the 44px tap minimum. scripts/probe-layout.mjs is the repeatable gate: 72/72 page x viewport checks and the collapse test.
125 lines
4.9 KiB
Plaintext
125 lines
4.9 KiB
Plaintext
---
|
|
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<string, string>;
|
|
}
|
|
|
|
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-<slug>.jpg while the renderer only emits /og/thumb/<slug>-<w>.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;
|
|
---
|
|
|
|
<ul class="post-list">
|
|
{shown.map((post) => (
|
|
<li class="post-row">
|
|
<a class="thumb" href={`/posts/${post.slug}/`} aria-hidden="true" tabindex="-1">
|
|
<img
|
|
src={hasThumb(post) ? thumb(post, 800) : og(post)}
|
|
srcset={hasThumb(post) ? `${thumb(post, 400)} 400w, ${thumb(post, 800)} 800w` : undefined}
|
|
sizes="(max-width: 640px) 100vw, 398px"
|
|
alt=""
|
|
loading="lazy"
|
|
decoding="async"
|
|
width={hasThumb(post) ? 800 : 1200}
|
|
height={hasThumb(post) ? 420 : 630}
|
|
/>
|
|
</a>
|
|
<div class="body">
|
|
<a href={`/posts/${post.slug}/`}><h2>{post.data.title}</h2></a>
|
|
<div class="meta">
|
|
<time datetime={post.data.pubDate.toISOString()}>{fmt(post.data.pubDate)}</time>
|
|
{post.data.updatedDate && (
|
|
<>
|
|
<span class="sep">·</span>
|
|
<span class="updated" title={`${updatedLabel} ${fmt(post.data.updatedDate)}`}>
|
|
{updatedLabel} {fmt(post.data.updatedDate)}
|
|
</span>
|
|
</>
|
|
)}
|
|
<span class="sep">·</span>
|
|
<a class="tag" href={catHref(post.data.category)}>{categoryLabel(post.data.category, lang)}</a>
|
|
{post.data.tags.slice(0, TAG_LIMIT).map((t) => {
|
|
const href = tagLink(t, counts, lang);
|
|
return href ? <a class="tag" href={href}>{t}</a> : <span class="tag">{t}</span>;
|
|
})}
|
|
{post.data.tags.length > TAG_LIMIT && (
|
|
<span class="tag tag-more" title={post.data.tags.slice(TAG_LIMIT).join(', ')}>
|
|
+{post.data.tags.length - TAG_LIMIT}
|
|
</span>
|
|
)}
|
|
</div>
|
|
<p>{post.data.description}</p>
|
|
<div class="row-foot">
|
|
{/* A second visual target for the same destination — deliberately out of the
|
|
tab order so keyboard users get one stop per card, not two. */}
|
|
<a class="row-cta" href={`/posts/${post.slug}/`} tabindex="-1" aria-hidden="true">
|
|
{ctaLabel(post.data.category)} <span aria-hidden="true">→</span>
|
|
</a>
|
|
{metricFor(post) && (
|
|
<span class="row-metric">
|
|
<span class="pulse-dot" aria-hidden="true"></span>{metricFor(post)}
|
|
</span>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</li>
|
|
))}
|
|
</ul>
|