Files
dudu/design-pipeline/export-tokens.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

225 lines
8.5 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
/**
* export-tokens.mjs — 设计令牌导出管线(零依赖,Node >= 18
*
* 解析 design/tokens/*.css 的 CSS 自定义属性(:root = light[data-theme="dark"] = dark),
* 生成各端主题产物并直写消费位置(无中间拷贝层,--check 守的即真实消费文件):
* ios/dudu/Shared/DuduTheme.swift — Color(light/dark 动态) + 尺寸常量
* android/app/src/main/java/app/dudu/design/DuduTheme.kt — Compose Color/Dp 常量(Light/Dark
* web/tokens.css — 官网令牌层(custom props 原样透传 +
* Outfit 字体 data URI 内嵌,单文件自包含)
*
* 用法:node design-pipeline/export-tokens.mjs [--check]
* --check CI 校验模式:重新生成并与已提交产物比对,不一致退出 1
*/
import { readFileSync, writeFileSync, mkdirSync, readdirSync, existsSync } from 'node:fs';
import { resolve, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
const __dir = dirname(fileURLToPath(import.meta.url));
const TOKENS_DIR = resolve(__dir, '../design/tokens');
const OUT_IOS = resolve(__dir, '../ios/dudu/Shared/DuduTheme.swift');
const OUT_ANDROID = resolve(__dir, '../android/app/src/main/java/app/dudu/design/DuduTheme.kt');
const OUT_WEB = resolve(__dir, '../web/tokens.css');
const FONT_WOFF2 = resolve(__dir, '../design/assets/outfit-latin.woff2');
// ─── 解析 ────────────────────────────────────────────────────────────────────
/** 提取一个 CSS 块(selector { ... })里的全部 --var: value */
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;
}
const files = readdirSync(TOKENS_DIR).filter((f) => f.endsWith('.css')).sort();
let light = {}, dark = {};
for (const f of files) {
const css = readFileSync(resolve(TOKENS_DIR, f), 'utf8');
Object.assign(light, parseBlock(css, ':root'));
Object.assign(dark, parseBlock(css, '[data-theme="dark"]'));
}
/** 解析 var() 引用链到最终值 */
function resolveVar(val, scope) {
let v = val, guard = 0;
while (v && v.includes('var(') && guard++ < 10) {
v = v.replace(/var\(--([\w-]+)\)/g, (_, name) => scope[name] ?? light[name] ?? '');
}
return v.trim();
}
// ─── 颜色转换 ─────────────────────────────────────────────────────────────────
/** #RGB/#RRGGBB/rgba() → {r,g,b,a} 0-1 浮点;无法解析返回 null */
function parseColor(v) {
if (!v) return null;
v = v.trim();
let m = v.match(/^#([0-9a-f]{6})$/i);
if (m) {
const n = parseInt(m[1], 16);
return { r: (n >> 16 & 255) / 255, g: (n >> 8 & 255) / 255, b: (n & 255) / 255, a: 1 };
}
m = v.match(/^rgba?\(\s*([\d.]+)\s*,\s*([\d.]+)\s*,\s*([\d.]+)\s*(?:,\s*([\d.]+)\s*)?\)$/);
if (m) return { r: +m[1] / 255, g: +m[2] / 255, b: +m[3] / 255, a: m[4] === undefined ? 1 : +m[4] };
return null;
}
const hex2 = (f) => Math.round(f * 255).toString(16).padStart(2, '0').toUpperCase();
// ─── 分类 ────────────────────────────────────────────────────────────────────
const colorTokens = []; // {name, light:{...}, dark:{...}}
const dimenTokens = []; // {name, value} px → pt/dp
const otherSkipped = [];
const camel = (s) => s.replace(/-(\w)/g, (_, c) => c.toUpperCase());
for (const [name, raw] of Object.entries(light)) {
const lv = resolveVar(raw, light);
const dvRaw = dark[name];
const dv = dvRaw ? resolveVar(dvRaw, dark) : lv;
const lc = parseColor(lv);
if (lc) {
colorTokens.push({ name: camel(name), light: lc, dark: parseColor(dv) ?? lc });
continue;
}
const px = lv.match(/^(-?[\d.]+)px$/);
if (px) {
dimenTokens.push({ name: camel(name), value: parseFloat(px[1]) });
continue;
}
otherSkipped.push(name);
}
colorTokens.sort((a, b) => a.name.localeCompare(b.name));
dimenTokens.sort((a, b) => a.name.localeCompare(b.name));
// ─── 生成 Swift ───────────────────────────────────────────────────────────────
const HEADER = `// 自动生成 — 请勿手改。来源 design/tokens/*.css
// 重新生成:node design-pipeline/export-tokens.mjs
`;
function swift() {
const colors = colorTokens.map((t) => {
const l = t.light, d = t.dark;
return ` public static let ${t.name} = dynamic(
light: Color(.sRGB, red: ${l.r.toFixed(4)}, green: ${l.g.toFixed(4)}, blue: ${l.b.toFixed(4)}, opacity: ${l.a.toFixed(2)}),
dark: Color(.sRGB, red: ${d.r.toFixed(4)}, green: ${d.g.toFixed(4)}, blue: ${d.b.toFixed(4)}, opacity: ${d.a.toFixed(2)}))`;
}).join('\n');
const dims = dimenTokens.map((t) => ` public static let ${t.name}: CGFloat = ${t.value}`).join('\n');
return `${HEADER}
import SwiftUI
public enum DuduTheme {
/// light/dark 动态色(跟随系统外观)
static func dynamic(light: Color, dark: Color) -> Color {
Color(UIColor { trait in
trait.userInterfaceStyle == .dark ? UIColor(dark) : UIColor(light)
})
}
// MARK: - Colors
${colors}
// MARK: - Dimensions (pt)
${dims}
}
`;
}
// ─── 生成 Kotlin ─────────────────────────────────────────────────────────────
function kotlin() {
const group = (scope) => colorTokens.map((t) => {
const c = scope === 'light' ? t.light : t.dark;
const argb = `0x${hex2(c.a)}${hex2(c.r)}${hex2(c.g)}${hex2(c.b)}`;
return ` val ${t.name} = Color(${argb})`;
}).join('\n');
const dims = dimenTokens.map((t) =>
Number.isInteger(t.value) ? ` val ${t.name} = ${t.value}.dp` : ` val ${t.name} = ${t.value}f.dp`
).join('\n');
return `${HEADER}
package app.dudu.design
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
object DuduTheme {
object Light {
${group('light')}
}
object Dark {
${group('dark')}
}
// Dimensions (dp)
${dims}
}
`;
}
// ─── 生成 Web(官网令牌层)───────────────────────────────────────────────────
function web() {
const props = (scope) =>
Object.entries(scope).map(([k, v]) => ` --${k}: ${v};`).join('\n');
const fontB64 = readFileSync(FONT_WOFF2).toString('base64');
return `/* 自动生成 — 请勿手改。来源 design/tokens/*.css + assets/outfit-latin.woff2
重新生成:node design-pipeline/export-tokens.mjs */
@font-face {
font-family: 'Outfit';
font-style: normal;
font-weight: 400 700;
font-display: swap;
src: url(data:font/woff2;base64,${fontB64}) format('woff2');
}
:root {
${props(light)}
}
[data-theme="dark"] {
${props(dark)}
}
`;
}
// ─── 输出 / 校验 ─────────────────────────────────────────────────────────────
const outputs = [
[OUT_IOS, swift()],
[OUT_ANDROID, kotlin()],
[OUT_WEB, web()],
];
const check = process.argv.includes('--check');
let dirty = false;
for (const [path, content] of outputs) {
if (check) {
const cur = existsSync(path) ? readFileSync(path, 'utf8') : '';
if (cur !== content) {
console.error(`❌ 过期:${path}(请重新运行导出脚本并提交)`);
dirty = true;
}
} else {
mkdirSync(dirname(path), { recursive: true });
writeFileSync(path, content, 'utf8');
console.log(`${path}`);
}
}
if (check && dirty) process.exit(1);
if (check) console.log('✅ 令牌产物与源一致');
console.log(`颜色 ${colorTokens.length} · 尺寸 ${dimenTokens.length} · 跳过(非色非px) ${otherSkipped.length}: ${otherSkipped.join(', ')}`);