feat(theme-home): 样式即数据 + 首页抽组件去重(5g T3/T4)

【T3 样式即数据】
- content/site/theme.yaml 九组白名单旋钮(accent 明暗两值 / radius_img /
  text_h1-h3 / prose_size_reading|tech / leading_reading|tech /
  space_3-6 / flow_p 与 flow_h2_post / dur);--measure、断点、纸墨、
  code 配色按方案 §3.6 红线不开放
- src/lib/theme.ts:getTheme() 钳制 + 单调性校验(h1>h2>h3、
  space3<4<5<6 任一不满足整组回落默认);themeVarsCss() 产出
  `:root:root{…}` 与 `[data-theme="dark"]:root:root{…}`,靠特异性压过
  tokens 的 :root,不依赖注入顺序
- BaseLayout head 内联 `<style is:inline set:html={themeStyle}>`:构建期
  静态、运行时零 JS 零请求
- scripts/build-perf/contrast-check.mjs 读产物断言正文/强调 × 亮暗四项
  ≥4.5:1,不达标 exit 1;build.sh 守卫挂载([ -f ] 守卫先行)
  实测:默认 12.40/10.53/6.30/7.06 全过;负例 accent #cccccc → 1.61:1 exit 1
  两坑入档:tokens 的 --bg 是 3 位简写需归一化;选 tokens CSS 要用
  `--bg:#` 定义断言(includes('--bg') 会误命中 var(--bg-soft) 引用)
- 后台 theme 单例 17 字段全中文 hint + 取值范围(分组约束写进 hint)

【T4 首页去重与细化】
- 新增 src/components/pages/HomePage.astro 承接全部区块(名片区富文本
  tagline/subtitle/社交、公告、文章列表、最新影像、热力图、项目墙、
  心跳),locale 一统;en 轨吃 postsEn 译题映射、图库链走 /en/gallery/,
  心跳仍仅 zh(零回归)
- 两 index.astro 缩成 4 行薄壳,双份漂移隐患消除
- home.yaml 三字段:projectsLimit(1-99 夹取,缺省 6)、announcement
  (双语 markdown 走 renderRich,空则不渲染)、postsTracks(三轨开关,
  在 HomePage 内过滤而非改 PostList,守边界)

验证: 构建 48 页 exit 0;contrast-check 四项 PASS;E2E 37/37(快照口径);
      实测 theme 变量内联、公告 markdown 粗体渲染、projectsLimit 限流、
      photo 轨关闭生效;验收数据已还原

Spec: 2026-11-02_blog-5g-pages (T3/T4)

Co-authored-by: GLM-5.3
This commit is contained in:
wait4xandGLM-5.3 committed 2026-11-02 18:58:11 +08:00
1 parent be87917443
commit 8debd99e61
12 files changed
+620 -308

No files matched your search

+214
View File
@@ -0,0 +1,214 @@
---
// 首页(zh/en 共用):名片区 / 文章列表 / 最新影像 / 热力图 / 项目墙 / 心跳
// 两个 index.astro 只是薄壳(传 locale),区块逻辑与样式集中在这里,避免双份漂移。
import { Image } from 'astro:assets';
import { getCollection } from 'astro:content';
import BaseLayout from '../../layouts/BaseLayout.astro';
import IdCard from '../IdCard.astro';
import PostList from '../PostList.astro';
import ProjectWall from '../geek/ProjectWall.astro';
import Heatmap from '../geek/Heatmap.astro';
import SocialLinks from '../SocialLinks.astro';
import { t } from '../../i18n/ui';
import type { Locale } from '../../lib/locale';
import {
homeConfig,
homeTagline,
pageTypography,
renderMarkdown,
renderRich,
} from '../../lib/site-content';
import { fetchHeartbeat, agoZh } from '../../lib/heartbeat';
interface Props {
locale?: Locale;
}
const { locale = 'zh' } = Astro.props;
// 首页可配项(后台主页设置):副标题/影像张数/区块开关/每轨条数/项目墙条数/公告/轨细分
const cfg = await homeConfig(locale);
const subtitleHtml = cfg.subtitle ? await renderMarkdown(cfg.subtitle) : '';
const announcementHtml = cfg.announcement ? await renderRich(cfg.announcement) : '';
const posts = (
await getCollection('posts', ({ data }) => !data.draft && !data.tags.includes('hidden'))
).sort((a, b) => b.data.pubDate.getTime() - a.data.pubDate.getTime());
// en 轨列表标题吃 postsEn 译题(缺译回退 zh,与 /en/archives 同策略)
const enTitle = locale === 'en'
? Object.fromEntries((await getCollection('postsEn')).map((e) => [e.id, e.data.title]))
: undefined;
// 最新影像取集合前 N 张;照片时间序由照片管线治理,此处只裁切
const photos = (await getCollection('photos')).slice(0, Math.max(1, cfg.latestPhotos));
const tagline = await homeTagline(locale);
const taglineHtml = await renderRich(tagline, { inline: true });
const typo = await pageTypography('siteHome', 'home');
// 心跳只出现在中文首页(en 轨历史形态如此,保持零回归)
const heartbeat = locale === 'zh' ? await fetchHeartbeat() : null;
const galleryHref = locale === 'en' ? '/en/gallery/' : '/gallery/';
// 轨细分开关:只保留开启的轨道(全开时等价于不过滤,零回归)
const activeTracks = Object.entries(cfg.postsTracks)
.filter(([, on]) => on)
.map(([k]) => k);
const allTracksOn = activeTracks.length === Object.keys(cfg.postsTracks).length;
const visiblePosts = allTracksOn
? posts
: posts.filter((p) => activeTracks.includes(p.data.category));
---
<BaseLayout
title="wait4x"
description={locale === 'en' ? "wait4x's blog: reading · code · photography" : undefined}
>
<div class={typo.className || undefined} style={typo.style || undefined}>
<IdCard>
<p class="tagline rich" set:html={taglineHtml} />
{subtitleHtml && <div class="subtitle" set:html={subtitleHtml} />}
<SocialLinks />
</IdCard>
{announcementHtml && (
<div class="announcement rich" set:html={announcementHtml} aria-label={locale === 'en' ? 'Announcement' : '公告'} />
)}
{cfg.sections.tracks && (
<PostList
posts={visiblePosts}
locale={locale}
titleOverrides={enTitle}
perTrack={cfg.perTrackLimit}
/>
)}
{cfg.sections.photos && cfg.latestPhotos > 0 && (
<section class="latest" aria-label={t('latest_photos', locale)}>
<div class="latest-head">
<span class="lab">{t('latest_photos', locale)}</span>
<a class="tap more" href={galleryHref}>{t('enter_gallery', locale)}</a>
</div>
<div class="thumbs">
{photos.map((photo, i) => (
<a class="thumb" href={galleryHref} aria-label={`${t('latest_photos', locale)}: ${photo.data.title}`}>
{/* 首两图 eager(Astro Image 默认 lazy 会把 LCP 压到 6s+),首图再拔 fetchpriority */}
<Image
src={photo.data.image}
alt={photo.data.alt ?? photo.data.title}
widths={[180, 360]}
sizes="(min-width: 820px) 20vw, 42vw"
loading={i < 2 ? 'eager' : 'lazy'}
fetchpriority={i === 0 ? 'high' : undefined}
/>
</a>
))}
</div>
</section>
)}
{cfg.sections.heatmap && (
<Heatmap
labels={{
title: t('heatmap_title', locale),
summary: t('heatmap_summary', locale),
less: t('heatmap_less', locale),
more: t('heatmap_more', locale),
note: t('heatmap_note', locale),
}}
/>
)}
{cfg.sections.projects && <ProjectWall limit={cfg.projectsLimit} />}
{heartbeat && (
<p class="heartbeat" aria-label="最近心跳">
<span class="beat-dot" aria-hidden="true"></span>
最近心跳:{agoZh(heartbeat.when)} · {heartbeat.msg}
</p>
)}
</div>
</BaseLayout>
<style>
.subtitle {
font-size: 13.5px;
color: var(--ink-4);
margin-top: 2px;
letter-spacing: 0.01em;
}
.subtitle :global(p) { margin: 0; }
.subtitle :global(a) { color: var(--accent); }
.tagline {
font-size: 14px;
color: var(--ink-3);
margin-top: 4px;
letter-spacing: 0.01em;
}
/* 公告块:独立于区块开关,空则不渲染(markdown 富文本,左缘细线做提示) */
.announcement {
margin-top: var(--space-4);
padding: var(--space-2) 0 var(--space-2) var(--space-3);
border-left: 2px solid var(--line);
font-size: 14px;
line-height: 1.7;
color: var(--ink-2);
}
.announcement :global(p) { margin: 0; }
.announcement :global(p + p) { margin-top: 0.6em; }
.announcement :global(a) { color: var(--accent); }
.heartbeat {
font-family: var(--font-mono);
font-size: 12.5px;
color: var(--ink-3);
margin-top: var(--space-2);
display: flex;
align-items: center;
gap: 8px;
}
.beat-dot {
width: 7px; height: 7px; border-radius: 50%;
background: var(--ink-2);
animation: beat 2.4s ease-in-out infinite;
}
@keyframes beat { 0%, 100% { opacity: 0.35; } 50% { opacity: 1; } }
@media (prefers-reduced-motion: reduce) { .beat-dot { animation: none; } }
.latest { margin-top: var(--space-6); }
.latest-head {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: var(--space-3);
}
.lab {
font-size: 12.5px;
color: var(--ink-4);
letter-spacing: 0.08em;
}
.more {
font-size: 13px;
color: var(--accent);
padding-inline: var(--space-2);
transition: color var(--dur) var(--ease);
}
.more:hover { color: var(--ink-1); }
.thumbs {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: var(--space-2);
margin-top: var(--space-3);
}
.thumb {
aspect-ratio: 1;
border-radius: 8px;
overflow: hidden;
}
.thumb :global(img) {
width: 100%;
height: 100%;
object-fit: cover;
transition: transform var(--dur) var(--ease);
}
.thumb:hover :global(img) { transform: scale(1.03); }
@media (min-width: 820px) {
.thumbs { grid-template-columns: repeat(4, 1fr); }
}
</style>