#!/usr/bin/env node // tools/check-l1-sync.mjs — L1 设计真相源跨端同源闸(纯 Node 零依赖) // // 治理依据:根 CLAUDE.md「前端设计系统治理(ds-flow)」——tokens/icons 以 // design/prototype/ 为单一真源,Web 两端(website/usercenter)只能是同步副本。 // Flutter 的 token 同源由 ci/check-codegen-drift.sh 守护(codegen 零 diff), // 本闸补 Web token 值同源 + 三端图标 ⊆ 原型 sprite + Web 硬编码色扫描。 // // 本地/CI 直接跑:node tools/check-l1-sync.mjs // // 四道检查(任一违规 exit 1): // ① website token 值同源 web/website/src/styles/tokens.gen.css 每个 token // 值 ≡ design/prototype/tokens.css(:root + dark)。 // ② usercenter token 同源 web/usercenter/public/colors_and_type.css 同上。 // ③ 图标 ⊆ 原型 sprite usercenter LUCIDE(键+路径)+ Flutter pangolin_icons // _byName(键)必须 ⊆ 原型 icons.js ICONS。 // ④ Web 硬编码色扫描 website src / usercenter app|components|lib 禁裸 hex; // 白名单 #fff/#000 + 品牌 logo 固定色;行内 `ds-allow` 豁免; // 排除生成的 token 定义文件。 import { readFileSync, readdirSync, statSync, existsSync } from 'node:fs'; import { join } from 'node:path'; import { fileURLToPath } from 'node:url'; const ROOT = fileURLToPath(new URL('..', import.meta.url)); const read = (p) => readFileSync(join(ROOT, p), 'utf8'); const problems = []; // 解析 CSS 块里的 --var: value;(压掉空白便于比较) function parseVars(cssBlock) { const out = {}; for (const [, k, v] of cssBlock.matchAll(/(--[\w-]+)\s*:\s*([^;}]+)[;}]/g)) { out[k] = v.replace(/\s+/g, ' ').trim(); } return out; } // 取 tokens.css 的 :root + [data-theme="dark"] 全量 token function protoTokens() { const css = read('design/prototype/tokens.css'); const root = css.match(/:root\s*\{([^}]*)\}/s)?.[1] ?? ''; const dark = css.match(/\[data-theme="dark"\]\s*\{([^}]*)\}/s)?.[1] ?? ''; return { ...parseVars(root), ...parseVars(dark) }; } // ── ①② Web token 值同源 ──────────────────────────────────── function checkWebTokenSync(label, webPath) { if (!existsSync(join(ROOT, webPath))) { problems.push(`[${label}] 找不到 ${webPath} —— 先跑 npm run gen:tokens`); return; } const proto = protoTokens(); const web = parseVars(read(webPath)); for (const [k, v] of Object.entries(proto)) { if (!(k in web)) { problems.push(`[${label}] 缺 token ${k} —— ${webPath} 未同步原型(重跑 gen:tokens)`); } else if (web[k] !== v) { problems.push(`[${label}] ${k} 值漂移:原型=${v} Web=${web[k]} —— ${webPath} 应由 build-tokens 从原型再生,勿手改`); } } } // ── ③ 图标 ⊆ 原型 sprite ──────────────────────────────────── { // 原型 icons.js: var ICONS = { 'name': '', ... } const protoJs = read('design/prototype/icons.js'); const protoBody = protoJs.match(/var ICONS\s*=\s*\{([\s\S]*?)\n\s*\};/)?.[1] ?? ''; const protoIcons = {}; for (const [, id, body] of protoBody.matchAll(/'([^']+)'\s*:\s*'([^']*)'/g)) protoIcons[id] = body; if (!Object.keys(protoIcons).length) { problems.push('[icons] 解析原型 icons.js ICONS 为空 —— 检查文件结构'); } // usercenter: export const LUCIDE: Record = { 'name': '', ... } const ucJs = read('web/usercenter/components/icons.tsx'); const ucBody = ucJs.match(/LUCIDE\s*:[^=]*=\s*\{([\s\S]*?)\n\};/)?.[1] ?? ucJs.match(/LUCIDE\s*=\s*\{([\s\S]*?)\n\};/)?.[1] ?? ''; // 键可能带引号('refresh-cw')或裸标识符(home)—— 两种都匹配(裸键此前被漏检)。 for (const [, id, body] of ucBody.matchAll(/['"]?([\w-]+)['"]?\s*:\s*'([^']*)'/g)) { if (!(id in protoIcons)) { problems.push(`[icons] usercenter 图标「${id}」不在原型 sprite —— 先登记 design/prototype/icons.js 再用`); } else if (protoIcons[id] !== body) { problems.push(`[icons] usercenter 图标「${id}」路径与原型 sprite 不一致 —— 以原型为准`); } } // Flutter: static const Map _byName = { 'name': ..., } const dart = read('client/lib/widgets/pangolin_icons.dart'); const dartBody = dart.match(/_byName\s*=\s*\{([\s\S]*?)\};/)?.[1] ?? ''; for (const [, id] of dartBody.matchAll(/'([^']+)'\s*:/g)) { if (!(id in protoIcons)) { problems.push(`[icons] Flutter 图标「${id}」不在原型 sprite —— 新图标先登记 design/prototype/icons.js`); } } } // ── ④ Web 硬编码色扫描 ────────────────────────────────────── { // 白名单:纯白/纯黑 + 品牌 logo 固定色(SVG 内联 fill,非业务散色) const ALLOW = new Set(['fff', 'ffffff', '000', '000000', 'b96a3d', 'faf3ed', 'f4efe8', '9e5630', '3d2213']); // 排除:生成的 token 定义文件 + 构建产物 const SKIP = new Set([ 'web/website/src/styles/tokens.gen.css', 'web/usercenter/public/colors_and_type.css', ]); const SKIP_DIR = new Set(['node_modules', 'dist', 'out', '.next', 'build', '.astro', 'public']); const ROOTS = ['web/website/src', 'web/usercenter/app', 'web/usercenter/components', 'web/usercenter/lib']; const files = []; const walk = (dir) => { if (!existsSync(join(ROOT, dir))) return; for (const name of readdirSync(join(ROOT, dir))) { if (SKIP_DIR.has(name)) continue; const rel = `${dir}/${name}`; const st = statSync(join(ROOT, rel)); if (st.isDirectory()) walk(rel); else if (/\.(astro|jsx|tsx|ts|js|css)$/.test(name) && !SKIP.has(rel)) files.push(rel); } }; ROOTS.forEach(walk); for (const f of files) { read(f).split('\n').forEach((line, i) => { if (line.includes('ds-allow')) return; for (const [hex] of line.matchAll(/#([0-9a-fA-F]{3}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})\b/g)) { const v = hex.slice(1).toLowerCase(); if (!ALLOW.has(v)) { problems.push(`[web-hex] ${f}:${i + 1} 硬编码色 ${hex} —— 改用 var(--token),确需保留(如品牌 logo)加行内 ds-allow 注释`); } } }); } } checkWebTokenSync('website-token', 'web/website/src/styles/tokens.gen.css'); checkWebTokenSync('usercenter-token', 'web/usercenter/public/colors_and_type.css'); // ── 汇总 ──────────────────────────────────────────────────── const line = '='.repeat(60); if (problems.length) { console.error(line); console.error(`✗ L1 跨端同源闸未过(${problems.length} 处):`); for (const p of problems) console.error(' · ' + p); console.error(line); process.exit(1); } console.log(line); console.log('✓ 通过:L1 跨端同源(website/usercenter token 值 · 三端图标 ⊆ 原型 sprite · Web 无硬编码色)'); console.log(line);