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

+19
View File
@@ -223,9 +223,21 @@ export interface HomeSections {
projects: boolean;
}
export interface HomeTracks {
reading: boolean;
tech: boolean;
photo: boolean;
}
export interface HomeConfig {
/** 副标题 markdown 原文;空串 = 不渲染 */
subtitle: string;
/** 公告 markdown 原文;空串 = 不渲染 */
announcement: string;
/** 首页项目墙条数(1-99,缺省 6) */
projectsLimit: number;
/** 轨细分开关(缺省全开) */
postsTracks: HomeTracks;
/** 最新影像张数:0 = 隐藏区块 */
latestPhotos: number;
/** 每轨文章条数 */
@@ -244,6 +256,13 @@ export async function homeConfig(locale: Locale): Promise<HomeConfig> {
const d = entry?.data;
return {
subtitle: pickBi(d?.subtitle, { zh: '', en: '' }, locale),
announcement: pickBi(d?.announcement, { zh: '', en: '' }, locale),
projectsLimit: clampInt(d?.projectsLimit, 1, 99, 6),
postsTracks: {
reading: d?.postsTracks?.reading ?? true,
tech: d?.postsTracks?.tech ?? true,
photo: d?.postsTracks?.photo ?? true,
},
latestPhotos: clampInt(d?.latestPhotos, 0, 6, 4),
perTrackLimit: clampInt(d?.perTrackLimit, 4, 8, 6),
sections: {
+143
View File
@@ -0,0 +1,143 @@
/**
* 样式即数据(层 4):把 content/site/theme.yaml 的白名单旋钮编译成 CSS 变量注入串。
*
* 三条红线(docs/research/10 方案 §3.6):
* ① 只认 9 组白名单旋钮;--measure / 断点 / 纸墨 / code 配色一律不可配(牵动版面结构与分轨身份)。
* ② 注入用 `:root:root{…}`(0,2,0)与 `[data-theme="dark"]:root:root{…}`(0,3,0)——
* 必须靠特异性压过 tokens.css 的 `:root`(0,1,0),**不能靠 DOM 顺序**(link 与内联 style 的相对次序不可控)。
* ③ 值全部钳制 + 单调性校验:越界只回落默认,绝不让构建失败(主题配错不该炸站)。
*
* 构建期静态:BaseLayout 直接把返回串内联进 <style>,运行时零 JS、零请求。
*/
import { getEntry } from 'astro:content';
/** 与 tokens.css 完全一致的默认值(零配置 = 现状) */
const DEFAULTS = {
accentLight: '#3454d1',
accentDark: '#8ea2ff',
radiusImg: 10,
textH1: 27,
textH2: 22,
textH3: 19,
proseReading: 20,
proseTech: 19,
leadingReading: 1.75,
leadingTech: 1.7,
space3: 16,
space4: 24,
space5: 40,
space6: 64,
flowP: 0.9,
flowH2Post: 0.6,
dur: 350,
} as const;
const clamp = (v: unknown, min: number, max: number, fallback: number): number =>
typeof v === 'number' && Number.isFinite(v) ? Math.min(max, Math.max(min, v)) : fallback;
const isHex = (v: unknown): v is string =>
typeof v === 'string' && /^#[0-9a-fA-F]{6}$/.test(v.trim());
export interface Theme {
accentLight: string;
accentDark: string;
radiusImg: number;
textH1: number;
textH2: number;
textH3: number;
proseReading: number;
proseTech: number;
leadingReading: number;
leadingTech: number;
space3: number;
space4: number;
space5: number;
space6: number;
flowP: number;
flowH2Post: number;
dur: number;
}
/** 读 theme 单例并做钳制/单调校验(任一约束不满足即整组回落默认) */
export async function getTheme(): Promise<Theme> {
const entry = await getEntry('siteTheme', 'theme');
const d = (entry?.data ?? {}) as Record<string, unknown>;
const num = (k: string) => (typeof d[k] === 'number' ? (d[k] as number) : undefined);
// 标题三级必须单调递减,否则整组回落(避免 h2 比 h1 大的畸形版式)
let textH1 = clamp(num('text_h1'), 22, 32, DEFAULTS.textH1);
let textH2 = clamp(num('text_h2'), 22, 32, DEFAULTS.textH2);
let textH3 = clamp(num('text_h3'), 16, 32, DEFAULTS.textH3);
if (!(textH1 > textH2 && textH2 > textH3)) {
textH1 = DEFAULTS.textH1;
textH2 = DEFAULTS.textH2;
textH3 = DEFAULTS.textH3;
}
// 间距梯度必须严格递增,否则整组回落
let space3 = clamp(num('space_3'), 8, 80, DEFAULTS.space3);
let space4 = clamp(num('space_4'), 8, 80, DEFAULTS.space4);
let space5 = clamp(num('space_5'), 8, 80, DEFAULTS.space5);
let space6 = clamp(num('space_6'), 8, 80, DEFAULTS.space6);
if (!(space3 < space4 && space4 < space5 && space5 < space6)) {
space3 = DEFAULTS.space3;
space4 = DEFAULTS.space4;
space5 = DEFAULTS.space5;
space6 = DEFAULTS.space6;
}
return {
accentLight: isHex(d.accent_light) ? d.accent_light.trim() : DEFAULTS.accentLight,
accentDark: isHex(d.accent_dark) ? d.accent_dark.trim() : DEFAULTS.accentDark,
radiusImg: clamp(num('radius_img'), 4, 16, DEFAULTS.radiusImg),
textH1,
textH2,
textH3,
proseReading: clamp(num('prose_size_reading'), 16, 22, DEFAULTS.proseReading),
proseTech: clamp(num('prose_size_tech'), 16, 22, DEFAULTS.proseTech),
leadingReading: clamp(num('leading_reading'), 1.5, 2.0, DEFAULTS.leadingReading),
leadingTech: clamp(num('leading_tech'), 1.5, 2.0, DEFAULTS.leadingTech),
space3,
space4,
space5,
space6,
flowP: clamp(num('flow_p'), 0, 2, DEFAULTS.flowP),
flowH2Post: clamp(num('flow_h2_post'), 0, 2, DEFAULTS.flowH2Post),
dur: clamp(num('dur'), 150, 600, DEFAULTS.dur),
};
}
/** 亮态变量块(不含 accent,accent 分态) */
function lightVars(t: Theme): string {
return [
`--accent:${t.accentLight}`,
`--radius-img:${t.radiusImg}px`,
`--text-h1:${t.textH1}px`,
`--text-h2:${t.textH2}px`,
`--text-h3:${t.textH3}px`,
`--prose-size-reading:${t.proseReading}px`,
`--prose-size-tech:${t.proseTech}px`,
`--leading-reading:${t.leadingReading}`,
`--leading-tech:${t.leadingTech}`,
`--space-3:${t.space3}px`,
`--space-4:${t.space4}px`,
`--space-5:${t.space5}px`,
`--space-6:${t.space6}px`,
`--flow-p:${t.flowP}em`,
`--flow-h2-post:${t.flowH2Post}em`,
`--dur:${t.dur}ms`,
].join(';');
}
/**
* 生成内联样式串:`:root:root{…}` 压 tokens.css 的 `:root`(0,1,0),
* `[data-theme="dark"]:root:root{…}` 压其暗态覆盖——不依赖注入顺序。
*/
export function themeVarsCss(t: Theme): string {
return `:root:root{${lightVars(t)}}[data-theme="dark"]:root:root{--accent:${t.accentDark}}`;
}
/** 一次取全(BaseLayout 用):返回内联 CSS 串 */
export async function themeCss(): Promise<string> {
return themeVarsCss(await getTheme());
}