Files
dudu/design-pipeline/check-ds.mjs
T
wangjia 208d0cda12 feat(design): design/ 真相源 + 防漂移静态闸(#25)
真相源整固:
- design/index.html 登记簿:token 色板(var() 实时渲染)/ 组件登记卡 /
  图标与跨端映射 / guidelines / ui_kits 单一入口,check-ds 强制登记
- design/icons.js 图标单源(Lucide 线条)+ icon-map.json 跨端映射表
  (web/iOS/Android 三列,iOS 12 个 SF Symbol 全部收编登记)
- components/core/Icon.jsx 单源图标渲染器;MicBar/RecognitionOverlay 接入
- tokens 新增 --brand-wechat(-press)(微信官方绿)与 --overlay-wave
- fonts.css 弃 Google Fonts CDN,Outfit 可变字体自托管
  (assets/outfit-latin.woff2,40KB;中国网络下 CDN 不可用会卡字体)
- design/serve.mjs 零依赖预览服务器(登记簿与原型评审入口)

codegen 强化(export-tokens.mjs):
- 产物直写消费位置:ios/dudu/Shared/DuduTheme.swift、
  android/.../design/DuduTheme.kt——删除 design-pipeline/generated/
  中间拷贝层(此前 app 内是手工拷贝,--check 守不到真实消费文件已漂移)
- 新增 web/tokens.css 产物(custom props 透传 + Outfit data URI 内嵌)
- tokens 文件遍历排序,产物确定性

存量收编:
- desktop:三个窗口内联 SVG → Icon 组件;tray 裸 rgba → token+opacity
- Android:LoginScreen 微信绿常量 → DuduPalette.brandWechat(Press),
  Color.White → textOnAccent
- 官网 web/index.html:接入 tokens.css,34 处硬编码 hex 全部 var() 化;
  深色板块(metrics/cta/footer)改挂 data-theme="dark" 复用 dark 令牌;
  移除 Google Fonts CDN
- design 包内 8 处裸色:微信绿 token 化、装饰色 ds-ignore 白名单、
  同值色改 var() 引用

防漂移闸(零依赖 Node):
- design-pipeline/check-ds.mjs:真相源自检 7 道(dark⊆light / var 链 /
  裸色∈tokens 值集 / 组件登记 / icons 唯一 / icon-map 双向同集 / 禁 emoji)
- design-pipeline/check-code.mjs:代码侧单源(desktop 禁字面色+内联 path /
  iOS 禁字面色+SF Symbol 同集 / Android 禁 Color(0x / web 禁字面色),
  ds-ignore 行级豁免制
- .githooks/pre-commit(启用:git config core.hooksPath .githooks)
- CI design job 扩展为三道闸全绿
- 反向验证通过:注入裸色/孤儿 token/未登记组件/未登记 symbol 均被拦截

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-11 00:05:22 +08:00

176 lines
7.6 KiB
JavaScript
Raw 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
/**
* check-ds.mjs — 真相源自检闸(扫 design/ 自身,零依赖,Node >= 18
*
* 七道检查(违规 exit 1,逐条打印文件:行):
* ① tokens dark 键 ⊆ light 键(防孤儿 dark token
* ② token 值内 var(--x) 引用链可解析;styles.css @import 的文件存在
* ③ design/ 内(guidelines/components/ui_kits/index.html/styles.css)裸色
* 必须 ∈ tokens 值集,或行内带 `ds-ignore:` 豁免注释
* ④ components/**\/*.jsx 导出的组件必须在登记簿 design/index.html 出现
* ⑤ icons.jskey 唯一、path 为描边风格(禁 fill="#..."
* ⑥ icon-map.jsonweb 列 ↔ icons.js key 双向同集;值为 string|null
* ⑦ 组件/ui_kits 界面文案禁 emoji(键帽符号 ⌘⇧⌫↵⌥ 不在 emoji 区,天然放行)
*
* 用法:node design-pipeline/check-ds.mjs
*/
import { readFileSync, readdirSync, existsSync, statSync } from 'node:fs';
import { resolve, dirname, join, relative } from 'node:path';
import { fileURLToPath } from 'node:url';
const __dir = dirname(fileURLToPath(import.meta.url));
const DESIGN = resolve(__dir, '../design');
const errors = [];
const err = (file, line, msg) =>
errors.push(`${relative(resolve(__dir, '..'), file)}:${line} ${msg}`);
// ─── 工具 ────────────────────────────────────────────────────────────────────
function walk(dir, exts, out = []) {
for (const name of readdirSync(dir)) {
const p = join(dir, name);
if (statSync(p).isDirectory()) walk(p, exts, out);
else if (exts.some((e) => name.endsWith(e))) out.push(p);
}
return out;
}
function parseBlock(css, selector) {
const out = {};
const re = new RegExp(`${selector.replace(/[[\]"=]/g, (m) => '\\' + m)}\\s*\\{([\\s\\S]*?)\\}`, 'g');
let m;
while ((m = re.exec(css))) {
const body = m[1].replace(/\/\*[\s\S]*?\*\//g, '');
for (const line of body.split(';')) {
const mm = line.match(/--([\w-]+)\s*:\s*(.+)/);
if (mm) out[mm[1].trim()] = mm[2].trim();
}
}
return out;
}
// ─── 解析 tokens ─────────────────────────────────────────────────────────────
const tokenFiles = readdirSync(join(DESIGN, 'tokens')).filter((f) => f.endsWith('.css')).sort();
const light = {}, dark = {};
for (const f of tokenFiles) {
const css = readFileSync(join(DESIGN, 'tokens', f), 'utf8');
Object.assign(light, parseBlock(css, ':root'));
Object.assign(dark, parseBlock(css, '[data-theme="dark"]'));
}
// tokens 合法值集(hex 大写 + rgba 去空格),供 ③ 比对
const tokenValues = new Set();
for (const v of [...Object.values(light), ...Object.values(dark)]) {
for (const hex of v.matchAll(/#[0-9a-fA-F]{3,8}\b/g)) tokenValues.add(hex[0].toUpperCase());
for (const fn of v.matchAll(/(?:rgba?|hsla?)\([^)]*\)/g)) tokenValues.add(fn[0].replace(/\s+/g, ''));
}
// ① dark ⊆ light
for (const k of Object.keys(dark)) {
if (!(k in light)) err(join(DESIGN, 'tokens'), 0, `① 孤儿 dark token--${k} 未在 :root 定义`);
}
// ② var() 链可解析 + @import 存在
for (const [scopeName, scope] of [['light', light], ['dark', dark]]) {
for (const [k, v] of Object.entries(scope)) {
for (const ref of v.matchAll(/var\(--([\w-]+)\)/g)) {
if (!(ref[1] in light) && !(ref[1] in dark))
err(join(DESIGN, 'tokens'), 0, `② --${k}${scopeName})引用了不存在的 var(--${ref[1]})`);
}
}
}
{
const styles = readFileSync(join(DESIGN, 'styles.css'), 'utf8');
for (const im of styles.matchAll(/@import\s+(?:url\()?['"]([^'"]+)['"]/g)) {
if (!existsSync(join(DESIGN, im[1]))) err(join(DESIGN, 'styles.css'), 0, `② @import 目标不存在:${im[1]}`);
}
}
// ③ design/ 裸色须 ∈ tokens 值集(ds-ignore 豁免;tokens/ 与资产 svg 不扫)
const scanFiles = [
...walk(join(DESIGN, 'guidelines'), ['.html', '.css']),
...walk(join(DESIGN, 'components'), ['.jsx', '.css', '.html']),
...walk(join(DESIGN, 'ui_kits'), ['.jsx', '.css', '.html']),
join(DESIGN, 'index.html'),
join(DESIGN, 'styles.css'),
];
for (const f of scanFiles) {
const lines = readFileSync(f, 'utf8').split('\n');
lines.forEach((line, i) => {
if (line.includes('ds-ignore:')) return;
for (const m of line.matchAll(/#[0-9a-fA-F]{3,8}\b|(?:rgba?|hsla?)\([^)]*\)/g)) {
const val = m[0].startsWith('#') ? m[0].toUpperCase() : m[0].replace(/\s+/g, '');
// #fff/#FFF 简写归一到 6 位再比对
const norm = /^#[0-9A-F]{3}$/.test(val)
? '#' + [...val.slice(1)].map((c) => c + c).join('')
: val;
if (!tokenValues.has(norm))
err(f, i + 1, `③ 裸色 ${m[0]} 不在 tokens 值集(改用 var(--*) 或加 ds-ignore: 理由)`);
}
});
}
// ④ 组件登记:components/**/*.jsx 导出名必须现于 index.html
const registry = readFileSync(join(DESIGN, 'index.html'), 'utf8');
for (const f of walk(join(DESIGN, 'components'), ['.jsx'])) {
const src = readFileSync(f, 'utf8');
for (const m of src.matchAll(/export\s+(?:function|const)\s+([A-Z]\w+)/g)) {
if (!registry.includes(`<b>${m[1]}</b>`) && !registry.includes(`<b>${m[1]} `) && !new RegExp(`<b>[^<]*\\b${m[1]}\\b`).test(registry))
err(f, 0, `④ 组件 ${m[1]} 未在登记簿 design/index.html 登记`);
}
}
// ⑤ icons.jskey 唯一 + 描边风格
{
const f = join(DESIGN, 'icons.js');
const src = readFileSync(f, 'utf8');
const keys = [...src.matchAll(/^\s{2}(\w+):/gm)].map((m) => m[1]);
const seen = new Set();
for (const k of keys) {
if (seen.has(k)) err(f, 0, `⑤ icons.js 重复 key${k}`);
seen.add(k);
}
if (/fill="#/.test(src)) err(f, 0, '⑤ icons.js 含填充色(应为 stroke/currentColor 线条风格)');
globalThis.__iconKeys = seen;
}
// ⑥ icon-map.json ↔ icons.js 双向同集
{
const f = join(DESIGN, 'icon-map.json');
const map = JSON.parse(readFileSync(f, 'utf8'));
const webUsed = new Set();
for (const [name, v] of Object.entries(map)) {
if (name === '$schema') continue;
for (const col of ['web', 'ios', 'android']) {
if (!(col in v)) err(f, 0, `${name}${col} 列(未使用请置 null`);
else if (v[col] !== null && typeof v[col] !== 'string') err(f, 0, `${name}.${col} 应为 string|null`);
}
if (v.web) {
if (!globalThis.__iconKeys.has(v.web)) err(f, 0, `${name}.web=${v.web} 不存在于 icons.js`);
webUsed.add(v.web);
}
}
for (const k of globalThis.__iconKeys) {
if (!webUsed.has(k)) err(f, 0, `⑥ icons.js 的 ${k} 未登记进 icon-map.jsonweb 列)`);
}
}
// ⑦ 界面文案禁 emojicomponents + ui_kits 的 jsx/card.html
const EMOJI = /[\u{1F000}-\u{1FAFF}\u{2600}-\u{27BF}\u{FE0F}]/u;
for (const f of [...walk(join(DESIGN, 'components'), ['.jsx', '.html']), ...walk(join(DESIGN, 'ui_kits'), ['.jsx', '.html'])]) {
readFileSync(f, 'utf8').split('\n').forEach((line, i) => {
if (line.includes('ds-ignore:')) return;
if (EMOJI.test(line)) err(f, i + 1, '⑦ 界面文案/原型含 emoji(品牌规范禁用;确属注释示例加 ds-ignore: 理由)');
});
}
// ─── 汇总 ────────────────────────────────────────────────────────────────────
if (errors.length) {
console.error(`❌ check-ds${errors.length} 处违规\n` + errors.map((e) => ' ' + e).join('\n'));
process.exit(1);
}
console.log('✅ check-ds:真相源自检 7 道全绿');