#!/usr/bin/env node /** * gen_flutter_tokens.mjs — CSS token → Dart codegen * * 唯一真相源:design/prototype/tokens.css * 输出: client/lib/pangolin_tokens.gen.dart * * 生成内容(纯数据层,无 Flutter 实现逻辑): * PangolinColors — 原色阶 hex const * PangolinSpacing — 间距 double const * PangolinRadius — 圆角 double + Radius const * PangolinMotion — 时长 Duration + Cubic const * PangolinShadow — BoxShadow List(引用 PangolinColors._tint) * * 使用: * node design/codegen/gen_flutter_tokens.mjs */ import { readFileSync, writeFileSync, existsSync } from 'node:fs'; import { fileURLToPath } from 'node:url'; import { dirname, resolve } from 'node:path'; const __dirname = dirname(fileURLToPath(import.meta.url)); const SRC = resolve(__dirname, '../prototype/tokens.css'); const OUT = resolve(__dirname, '../../client/lib/pangolin_tokens.gen.dart'); if (!existsSync(SRC)) { console.error('[gen_flutter_tokens] 找不到源文件:', SRC); process.exit(1); } const css = readFileSync(SRC, 'utf8'); // ── 工具函数 ─────────────────────────────────────────────────────────── /** 从 :root { ... } 块提取 CSS 自定义属性 Map */ function extractRootVars(css) { const rootMatch = css.match(/:root\s*\{([^}]+)\}/s); if (!rootMatch) throw new Error('找不到 :root 块'); const vars = {}; for (const line of rootMatch[1].split('\n')) { const m = line.match(/--([a-zA-Z0-9-]+)\s*:\s*(.+?)\s*;/); if (m) vars[m[1]] = m[2].trim(); } return vars; } /** #RRGGBB → 0xFFRRGGBB(Dart Color hex) */ function hexToDart(hex) { const h = hex.replace('#', ''); if (h.length !== 6) throw new Error(`不支持的 hex: ${hex}`); return `0xFF${h.toUpperCase()}`; } /** rgba(r, g, b, a) → 0xAARRGGBB */ function rgbaToDart(rgba) { const m = rgba.match(/rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*(?:,\s*([\d.]+))?\s*\)/); if (!m) throw new Error(`不支持的 rgba: ${rgba}`); const r = parseInt(m[1]).toString(16).padStart(2, '0').toUpperCase(); const g = parseInt(m[2]).toString(16).padStart(2, '0').toUpperCase(); const b = parseInt(m[3]).toString(16).padStart(2, '0').toUpperCase(); const alpha = m[4] !== undefined ? Math.round(parseFloat(m[4]) * 255) : 255; const a = alpha.toString(16).padStart(2, '0').toUpperCase(); return `0x${a}${r}${g}${b}`; } /** rem → px double(root 16px) */ function remToPx(val) { if (val === '0') return 0; const m = val.match(/([\d.]+)rem/); if (!m) throw new Error(`不支持的 rem: ${val}`); return parseFloat((parseFloat(m[1]) * 16).toFixed(1)); } /** px 字符串 → double */ function pxToDouble(val) { if (val === '0') return 0; const m = val.match(/([\d.]+)px/); if (!m) throw new Error(`不支持的 px: ${val}`); return parseFloat(m[1]); } /** ms 字符串 → int */ function msToInt(val) { const m = val.match(/(\d+)ms/); if (!m) throw new Error(`不支持的 ms: ${val}`); return parseInt(m[1]); } /** cubic-bezier(a,b,c,d) → [a,b,c,d] */ function parseCubic(val) { const m = val.match(/cubic-bezier\(\s*([\d.]+)\s*,\s*([\d.]+)\s*,\s*([\d.]+)\s*,\s*([\d.]+)\s*\)/); if (!m) throw new Error(`不支持的 cubic-bezier: ${val}`); return [m[1], m[2], m[3], m[4]]; } /** 解析 shadow 值:`0 1px 2px rgba(45, 30, 20, 0.06)` */ function parseShadow(val) { // format: 0 px px rgba(r,g,b,) const m = val.match(/0\s+([\d.]+)px\s+([\d.]+)px\s+rgba\(\d+,\s*\d+,\s*\d+,\s*([\d.]+)\)/); if (!m) throw new Error(`不支持的 shadow: ${val}`); return { offsetY: parseFloat(m[1]), blur: parseFloat(m[2]), opacity: parseFloat(m[3]) }; } // ── 解析 ────────────────────────────────────────────────────────────── const vars = extractRootVars(css); // 原色阶:hex 值 const colorRamps = { clay: ['50','100','200','300','400','500','600','700','800','900'], sand: ['50','100','200','300','400','500','600','700','800','900','950'], green: ['400','500','600'], amber: ['400','500','600'], red: ['400','500','600'], }; // CSS property name → Dart identifier function cssPropToDartName(ramp, shade) { return `${ramp}${shade}`; } // ── 生成 Dart ────────────────────────────────────────────────────────── const lines = []; lines.push(`// pangolin_tokens.gen.dart`); lines.push(`// AUTO-GENERATED — 勿手改。`); lines.push(`// 源: design/prototype/tokens.css`); lines.push(`// 生成器: design/codegen/gen_flutter_tokens.mjs`); lines.push(`//`); lines.push(`// 包含:PangolinColors · PangolinSpacing · PangolinRadius · PangolinMotion · PangolinShadow`); lines.push(`// 由 pangolin_theme.dart 导入;业务代码通过 pangolin_theme.dart 的符号访问,无需直接 import 本文件。`); lines.push(``); lines.push(`import 'package:flutter/material.dart';`); lines.push(``); // ── PangolinColors ────────────────────────────────────────────────── lines.push(`/// ── Primitive color ramps ───────────────────────────────────────────`); lines.push(`/// Auto-generated from design/prototype/tokens.css :root color ramps.`); lines.push(`class PangolinColors {`); lines.push(` PangolinColors._();`); lines.push(``); for (const [ramp, shades] of Object.entries(colorRamps)) { lines.push(` // ${ramp.charAt(0).toUpperCase() + ramp.slice(1)}`); for (const shade of shades) { const cssKey = `${ramp}-${shade}`; const val = vars[cssKey]; if (!val) throw new Error(`找不到 CSS 变量 --${cssKey}`); const dartHex = hexToDart(val.split(';')[0].trim().split(' ')[0]); const paddedName = cssPropToDartName(ramp, shade).padEnd(10); // extract trailing comment from CSS const commentMatch = vars[cssKey].match(/\/\*(.+?)\*\//); const comment = commentMatch ? ` // ${commentMatch[1].trim()}` : ''; lines.push(` static const ${paddedName}= Color(${dartHex});${comment}`); } lines.push(``); } lines.push(` static const white = Color(0xFFFFFFFF);`); lines.push(`}`); lines.push(``); // ── PangolinSpacing ────────────────────────────────────────────────── lines.push(`/// ── Spacing (4px base) ──────────────────────────────────────────────`); lines.push(`class PangolinSpacing {`); lines.push(` PangolinSpacing._();`); const spacingKeys = ['space-0','space-1','space-2','space-3','space-4','space-5','space-6','space-8','space-10','space-12','space-16']; const spacingNames = { 'space-0':'x0','space-1':'x1','space-2':'x2','space-3':'x3','space-4':'x4', 'space-5':'x5','space-6':'x6','space-8':'x8','space-10':'x10','space-12':'x12','space-16':'x16' }; const spacingParts = []; for (const key of spacingKeys) { const val = vars[key]; if (!val) throw new Error(`找不到 --${key}`); const px = remToPx(val.split(';')[0].trim().split(' ')[0]); spacingParts.push(`x${key.replace('space-','')} = ${px}`); } lines.push(` static const double ${spacingParts.join(', ')};`); lines.push(`}`); lines.push(``); // ── PangolinRadius ────────────────────────────────────────────────── lines.push(`/// ── Radii ───────────────────────────────────────────────────────────`); lines.push(`class PangolinRadius {`); lines.push(` PangolinRadius._();`); const radiusMap = { 'radius-sm':'sm', 'radius-md':'md', 'radius-lg':'lg', 'radius-xl':'xl', 'radius-2xl':'xxl', 'radius-full':'full' }; const radiusParts = []; const radiusCircParts = []; for (const [cssKey, dartName] of Object.entries(radiusMap)) { const val = vars[cssKey]; if (!val) throw new Error(`找不到 --${cssKey}`); const px = pxToDouble(val.split(';')[0].trim().split(' ')[0]); radiusParts.push(`${dartName} = ${px}`); if (dartName !== 'full') { const capName = dartName.charAt(0).toUpperCase() + dartName.slice(1); radiusCircParts.push(`r${capName} = Radius.circular(${dartName})`); } } lines.push(` static const double ${radiusParts.join(', ')};`); lines.push(` static const rSm = Radius.circular(sm);`); lines.push(` static const rMd = Radius.circular(md);`); lines.push(` static const rLg = Radius.circular(lg);`); lines.push(` static const rXl = Radius.circular(xl);`); lines.push(` static const rXxl = Radius.circular(xxl);`); lines.push(`}`); lines.push(``); // ── PangolinMotion ────────────────────────────────────────────────── lines.push(`/// ── Motion ──────────────────────────────────────────────────────────`); lines.push(`class PangolinMotion {`); lines.push(` PangolinMotion._();`); const durFast = msToInt(vars['dur-fast']); const durBase = msToInt(vars['dur-base']); const durSlow = msToInt(vars['dur-slow']); lines.push(` static const fast = Duration(milliseconds: ${durFast});`); lines.push(` static const base = Duration(milliseconds: ${durBase});`); lines.push(` static const slow = Duration(milliseconds: ${durSlow});`); const easeOut = parseCubic(vars['ease-out']); const easeInOut = parseCubic(vars['ease-in-out']); lines.push(` static const easeOut = Cubic(${easeOut.join(', ')});`); lines.push(` static const easeInOut = Cubic(${easeInOut.join(', ')});`); lines.push(`}`); lines.push(``); // ── PangolinShadow ────────────────────────────────────────────────── lines.push(`/// ── Soft warm-tinted shadows (light mode) ─────────────────────────`); lines.push(`class PangolinShadow {`); lines.push(` PangolinShadow._();`); lines.push(` static const _tint = Color(0xFF2D1E14); // rgba(45, 30, 20) warm shadow tint`); for (const name of ['sm','md','lg','xl']) { const val = vars[`shadow-${name}`]; if (!val) throw new Error(`找不到 --shadow-${name}`); const s = parseShadow(val); lines.push(` static List ${name} = [BoxShadow(color: _tint.withValues(alpha: ${s.opacity}), blurRadius: ${s.blur}, offset: const Offset(0, ${s.offsetY}))];`); } lines.push(`}`); lines.push(``); // ── 写文件 ──────────────────────────────────────────────────────────── const output = lines.join('\n'); writeFileSync(OUT, output, 'utf8'); console.log(`[gen_flutter_tokens] ✅ 已生成 ${OUT} (${output.length} 字节)`);