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:
1 parent
be87917443
commit
8debd99e61
12 files changed
+620
-308
No files matched your search
@@ -0,0 +1,141 @@
|
||||
#!/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)`);
|
||||
Reference in new issue
Block a user