Files
wangjia 40760aa884
ci / server (push) Failing after 14s
ci / design-tokens (push) Failing after 11s
dudu MVP:五端语音输入法初始提交
- server:Go 网关(WS 流式识别中继/计费配额/微信登录支付 mock/反馈/埋点),gummy provider 已真实联调
- desktop:Tauri 2(全局快捷键 push-to-talk/浮层/托盘/设置/登录购买/反馈/首启引导)
- android:Compose 主 App + IME(键盘内录音直传)
- ios:App + 键盘扩展(1A spike 实证键盘内不可录音,走 deep link 听写)
- design/design-pipeline:设计系统 + token 导出 iOS/Android 主题
- doc:前后端设计文档(HTML);web:官网宣传页;todo:任务看板

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 00:38:37 +08:00

194 lines
7.2 KiB
JavaScript
Raw Permalink 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),
* 生成移动端原生主题文件,保证三端令牌单一来源:
* generated/ios/DuduTheme.swift — Color(light/dark 动态) + 尺寸/字号常量
* generated/android/DuduTheme.kt — Compose Color/Dp/Sp 常量(Light/Dark 两组)
*
* 用法: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, 'generated/ios/DuduTheme.swift');
const OUT_ANDROID = resolve(__dir, 'generated/android/DuduTheme.kt');
// ─── 解析 ────────────────────────────────────────────────────────────────────
/** 提取一个 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'));
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}
}
`;
}
// ─── 输出 / 校验 ─────────────────────────────────────────────────────────────
const outputs = [
[OUT_IOS, swift()],
[OUT_ANDROID, kotlin()],
];
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(', ')}`);