Files
wait4xandGLM-5.3 8debd99e61 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
2026-11-02 18:58:11 +08:00

142 lines
4.9 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env node
/**
* 主题对比度闸门(样式即数据 · 层 4):从**构建产物**里读实际生效的 CSS 变量,
* 断言正文与强调色在两态下的对比度都 ≥4.5:1。构建期跑,失败即 exit 1 拦住发布。
*
* 为什么读产物而不是读 yaml:断言的对象必须是"用户最终看到的颜色"——
* theme.yaml 经 theme.ts 钳制/回落后的真值只存在于 dist 内联样式里。
*
* 用法:node scripts/build-perf/contrast-check.mjs [distRoot]
*/
import { readFileSync, existsSync, readdirSync } from 'node:fs';
import { join } from 'node:path';
const distRoot = process.argv[2] ?? 'dist';
const MIN = 4.5;
const read = (p) => readFileSync(p, 'utf8');
function normalize(hex) {
const h = hex.replace('#', '');
return '#' + (h.length === 3 ? h.split('').map((c) => c + c).join('') : h);
}
function lum(hex) {
const h = normalize(hex).replace('#', '');
const [r, g, b] = [0, 2, 4].map((i) => parseInt(h.slice(i, i + 2), 16) / 255);
const f = (c) => (c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4);
return 0.2126 * f(r) + 0.7152 * f(g) + 0.0722 * f(b);
}
function ratio(a, b) {
const [la, lb] = [lum(a), lum(b)];
const [hi, lo] = [Math.max(la, lb), Math.min(la, lb)];
return (hi + 0.05) / (lo + 0.05);
}
// ── 1. 从产物取主题变量(内联 style 的 :root:root / [data-theme=dark] 块) ──
let html = '';
if (!existsSync(distRoot)) {
console.error(`[contrast] dist 不存在:${distRoot}(先 build)`);
process.exit(1);
}
const htmlFile = join(distRoot, 'index.html');
if (existsSync(htmlFile)) html = read(htmlFile);
else {
// 兜底:任取一个页面
const walk = (d) => {
for (const e of readdirSync(d, { withFileTypes: true })) {
const p = join(d, e.name);
if (e.isDirectory()) {
const r = walk(p);
if (r) return r;
} else if (e.name === 'index.html') return p;
}
return null;
};
const f = walk(distRoot);
if (f) html = read(f);
}
if (!html) {
console.error('[contrast] 产物里找不到任何页面');
process.exit(1);
}
const varOf = (scope, name) => {
const re = new RegExp(`${scope.replace(/[[\]]/g, '\\$&')}[^{]*\\{([^}]*)\\}`, 'g');
let m;
while ((m = re.exec(html))) {
const hit = new RegExp(`${name}\\s*:\\s*(#[0-9a-fA-F]{3,6})`).exec(m[1]);
if (hit) return hit[1];
}
return null;
};
// tokens.css 的底色与正文色(不在主题旋钮白名单内,故从产物 CSS 取)
const cssFiles = [];
const walkCss = (d) => {
for (const e of readdirSync(d, { withFileTypes: true })) {
const p = join(d, e.name);
if (e.isDirectory()) walkCss(p);
else if (e.name.endsWith('.css')) cssFiles.push(p);
}
};
walkCss(distRoot);
let tokensCss = '';
for (const f of cssFiles) {
const c = read(f);
// 必须是**定义**(--bg:#xxx),而不是 var(--bg-soft) 这类引用——
// 后者会误命中只用到变量名、并不定义 base token 的页面级 CSS
if (/--ink-2\s*:\s*#/.test(c) && /--bg\s*:\s*#/.test(c)) { tokensCss = c; break; }
}
const tokenVar = (name) => {
const m = new RegExp(`${name}\\s*:\\s*(#[0-9a-fA-F]{3,6})`).exec(tokensCss);
return m ? m[1] : null;
};
const checks = [];
const bgLight = tokenVar('--bg');
const bgDark = (() => {
// 暗态 --bg 在 [data-theme=dark] 块里
const m = /\[data-theme=["']?dark["']?\][^{]*\{([^}]*)\}/.exec(tokensCss);
if (!m) return null;
const h = /--bg\s*:\s*(#[0-9a-fA-F]{3,6})/.exec(m[1]);
return h ? h[1] : null;
})();
const ink2Light = tokenVar('--ink-2');
const ink2Dark = (() => {
const m = /\[data-theme=["']?dark["']?\][^{]*\{([^}]*)\}/.exec(tokensCss);
if (!m) return null;
const h = /--ink-2\s*:\s*(#[0-9a-fA-F]{3,6})/.exec(m[1]);
return h ? h[1] : null;
})();
const accentLight = varOf(':root:root', '--accent');
const accentDark = (() => {
const m = /\[data-theme=["']?dark["']?\]:root:root\{([^}]*)\}/.exec(html);
if (!m) return null;
const h = /--accent\s*:\s*(#[0-9a-fA-F]{3,6})/.exec(m[1]);
return h ? h[1] : null;
})();
if (bgLight && ink2Light) checks.push(['正文(亮)', ink2Light, bgLight]);
if (bgDark && ink2Dark) checks.push(['正文(暗)', ink2Dark, bgDark]);
if (accentLight && bgLight) checks.push(['强调色(亮)', accentLight, bgLight]);
if (accentDark && bgDark) checks.push(['强调色(暗)', accentDark, bgDark]);
if (checks.length === 0) {
console.error('[contrast] 未能从产物解析出主题/正文色变量,请检查注入是否生效');
process.exit(1);
}
let failed = 0;
for (const [label, fg, bg] of checks) {
const r = ratio(fg, bg);
const ok = r >= MIN;
if (!ok) failed++;
console.log(`[contrast] ${ok ? 'PASS' : 'FAIL'} ${label} ${fg} on ${bg} = ${r.toFixed(2)}:1`);
}
if (failed > 0) {
console.error(`[contrast] ${failed} 项低于 ${MIN}:1 —— 主题配色不可读,请调亮/调暗后再构建`);
process.exit(1);
}
console.log(`[contrast] 全部通过(${checks.length} 项 ≥ ${MIN}:1)`);