Files
dudu/design-pipeline/check-code.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

106 lines
4.9 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-code.mjs — 代码侧颜色/图标单源闸(分端扫描,零依赖,Node >= 18
*
* 各端规则(违规 exit 1;行内 `ds-ignore: 理由` 豁免):
* desktop/srcjsx/css/js):禁字面色(hex/rgb/hsl),须 var(--*)
* 禁内联 <path>(图标须经 @dudu/design 的 Icon/icons.js
* iosswift :禁 Color(red:/UIColor(/Color(0x/#colorLiteral,须 DuduTheme
* systemName 用的 SF Symbol 须 ∈ design/icon-map.json 的 ios 列
* androidkt :禁 Color(0x,须 DuduTheme(生成文件本身豁免)
* webhtml/css :禁字面色(令牌由 GENERATED tokens.css 提供,页面只允许 var(--*)
*
* 用法:node design-pipeline/check-code.mjs
*/
import { readFileSync, readdirSync, statSync, existsSync } from 'node:fs';
import { resolve, dirname, join, relative, basename } from 'node:path';
import { fileURLToPath } from 'node:url';
const __dir = dirname(fileURLToPath(import.meta.url));
const ROOT = resolve(__dir, '..');
const errors = [];
const err = (file, line, msg) => errors.push(`${relative(ROOT, file)}:${line} ${msg}`);
function walk(dir, exts, out = []) {
if (!existsSync(dir)) return out;
for (const name of readdirSync(dir)) {
if (name === 'node_modules' || name === 'dist' || name === 'target' || name.startsWith('.')) continue;
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;
}
const COLOR_RE = /#[0-9a-fA-F]{3,8}\b|(?:rgba?|hsla?)\(/g;
function scanColors(files, label) {
for (const f of files) {
readFileSync(f, 'utf8').split('\n').forEach((line, i) => {
if (line.includes('ds-ignore:')) return;
const stripped = line.replace(/var\(--[\w-]+\)/g, ''); // var() 里不含字面色,先剥掉便于报错定位
const m = stripped.match(COLOR_RE);
if (m) err(f, i + 1, `${label} 字面色 ${m[0]}(改用设计令牌,或加 ds-ignore: 理由)`);
});
}
}
// ─── desktop:字面色 + 内联 <path> ───────────────────────────────────────────
const desktopFiles = walk(join(ROOT, 'desktop/src'), ['.jsx', '.js', '.css', '.html']);
scanColors(desktopFiles, 'desktop');
for (const f of desktopFiles) {
readFileSync(f, 'utf8').split('\n').forEach((line, i) => {
if (line.includes('ds-ignore:')) return;
if (/<path[\s>]/.test(line))
err(f, i + 1, 'desktop 内联 <path>(图标须登记 design/icons.js 并经 Icon 组件渲染)');
});
}
// ─── iOS:字面色 + SF Symbol 同集 ────────────────────────────────────────────
const iconMap = JSON.parse(readFileSync(join(ROOT, 'design/icon-map.json'), 'utf8'));
const iosSymbols = new Set(
Object.entries(iconMap).filter(([k]) => k !== '$schema').map(([, v]) => v.ios).filter(Boolean)
);
for (const f of walk(join(ROOT, 'ios'), ['.swift'])) {
const isGenerated = basename(f) === 'DuduTheme.swift';
readFileSync(f, 'utf8').split('\n').forEach((line, i) => {
if (line.includes('ds-ignore:')) return;
if (!isGenerated && /Color\(red:|UIColor\(red:|Color\(\s*0x|#colorLiteral/.test(line))
err(f, i + 1, 'iOS 字面色(改用 DuduTheme.*,或加 ds-ignore: 理由)');
for (const m of line.matchAll(/systemName:\s*"([^"]+)"/g)) {
if (!iosSymbols.has(m[1]))
err(f, i + 1, `iOS SF Symbol "${m[1]}" 未登记 design/icon-map.jsonios 列)`);
}
});
}
// ─── Android:字面色 ─────────────────────────────────────────────────────────
for (const f of walk(join(ROOT, 'android/app/src'), ['.kt'])) {
if (basename(f) === 'DuduTheme.kt') continue; // codegen 产物
readFileSync(f, 'utf8').split('\n').forEach((line, i) => {
if (line.includes('ds-ignore:')) return;
if (/Color\(\s*0x/.test(line))
err(f, i + 1, 'Android 字面色(改用 DuduTheme / DuduPalette,或加 ds-ignore: 理由)');
});
}
// ─── web:字面色(tokens.css 为 GENERATED 豁免)──────────────────────────────
scanColors(
walk(join(ROOT, 'web'), ['.html', '.css']).filter((f) => basename(f) !== 'tokens.css'),
'web'
);
// ─── 汇总 ────────────────────────────────────────────────────────────────────
if (errors.length) {
console.error(`❌ check-code${errors.length} 处违规\n` + errors.map((e) => ' ' + e).join('\n'));
process.exit(1);
}
console.log('✅ check-codedesktop / iOS / Android / web 单源全绿');