208d0cda12
真相源整固: - 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>
176 lines
7.6 KiB
JavaScript
176 lines
7.6 KiB
JavaScript
#!/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.js:key 唯一、path 为描边风格(禁 fill="#...")
|
||
* ⑥ icon-map.json:web 列 ↔ 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.js:key 唯一 + 描边风格
|
||
{
|
||
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.json(web 列)`);
|
||
}
|
||
}
|
||
|
||
// ⑦ 界面文案禁 emoji(components + 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 道全绿');
|