#!/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)`);