#!/usr/bin/env bash # 从平台仓 apps/web/src/skins/index.ts 生成 app 侧皮肤令牌快照(web/src/skin/tokens.ts)。 # 用法: sync-skin-tokens.sh <平台仓根> [--out <输出文件>] # 退出码: 0 成功 / 1 平台仓或源文件找不到 / 2 用法错 # # 为什么要有这个脚本:app 跑在 iframe 里,自己的 document 独立,CSS 变量不跨文档继承; # 令牌只能由 app 自己写一遍。手抄 3 套皮肤 × 40 个令牌必然漂移,所以快照一律由脚本生成。 set -euo pipefail usage() { echo "用法: sync-skin-tokens.sh <平台仓根> [--out <文件>]" >&2; exit 2; } PLATFORM="${1:-}" [ -n "$PLATFORM" ] || usage shift OUT="web/src/skin/tokens.ts" while [ $# -gt 0 ]; do case "$1" in --out) [ -n "${2:-}" ] || usage; OUT="$2"; shift 2 ;; *) usage ;; esac done SKINS="$PLATFORM/apps/web/src/skins/index.ts" if [ ! -f "$SKINS" ]; then echo "DIE 找不到 $SKINS" >&2 echo " 原因: 平台仓根参数不对,或平台把皮肤实现挪了位置。" >&2 echo " 怎么办: 传平台仓根目录(含 apps/web/src/skins/index.ts 的那层);" >&2 echo " 若平台确实改了路径,先更新本脚本第 20 行的 SKINS 变量。" >&2 exit 1 fi command -v node >/dev/null 2>&1 || { echo "DIE 需要 node(app 仓库本身就跑 node,环境里应有)" >&2; exit 1; } mkdir -p "$(dirname "$OUT")" node - "$SKINS" "$OUT" "$PLATFORM" <<'EOF' const fs = require('fs'); const [, , skinsPath, outPath, platformRoot] = process.argv; const src = fs.readFileSync(skinsPath, 'utf8'); /** 逐个抓 `tokens: { ... }` 块(手写花括号扫描,避免正则被 rgba()/cubic-bezier() 的括号干扰)。 * `export interface Skin` 里的 `tokens: Record<...>` 声明也会命中 "tokens:", * 所以用「{ 后第一个键必须是 '--sk-」判定真块;伪匹配只前进一个字符, * 否则会连带吃掉紧跟其后的第一套皮肤。 */ function tokenBlocks(s) { const blocks = []; let from = 0; for (;;) { const m = s.indexOf('tokens:', from); if (m === -1) break; const start = s.indexOf('{', m); if (start === -1) break; if (!s.slice(start + 1).trimStart().startsWith("'--sk-")) { from = m + 1; continue; } let depth = 0; let i = start; let inStr = false; let quote = ''; for (; i < s.length; i++) { const c = s[i]; if (inStr) { if (c === quote && s[i - 1] !== '\\') inStr = false; continue; } if (c === "'" || c === '"') { inStr = true; quote = c; continue; } if (c === '{') depth++; else if (c === '}') { depth--; if (depth === 0) break; } } blocks.push({ headStart: m, bodyStart: start + 1, end: i, body: s.slice(start + 1, i) }); from = i; } return blocks; } const blocks = tokenBlocks(src); if (!blocks.length) { console.error('DIE 没从 skins/index.ts 里解析出任何 tokens 块'); console.error(' 原因: 平台改写了 SKINS 结构(不再是 tokens: { ... } 字面量)。'); console.error(' 怎么办: 人工核对 skins/index.ts,同步改本脚本的解析逻辑,不要手写令牌。'); process.exit(1); } const skins = []; let prevEnd = 0; for (const b of blocks) { const header = src.slice(prevEnd, b.headStart); prevEnd = b.end; const pick = (k) => { const m = new RegExp(k + ":\\s*'([^']*)'").exec(header); return m ? m[1] : ''; }; const tokens = {}; const re = /'(--sk-[a-z0-9-]+)':\s*'([^']*)'/g; let m; while ((m = re.exec(b.body))) tokens[m[1]] = m[2]; const key = pick('key'); if (!key || !Object.keys(tokens).length) continue; skins.push({ key, label: pick('label'), description: pick('description'), tokens }); } if (!skins.length) { console.error('DIE 解析出的皮肤对象为空'); process.exit(1); } const keyCount = Object.keys(skins[0].tokens).length; const drift = skins.filter((s) => Object.keys(s.tokens).length !== keyCount); if (drift.length) { console.error('WARN 各皮肤令牌键数不一致:' + skins.map((s) => `${s.key}=${Object.keys(s.tokens).length}`).join(' ')); console.error(' 平台 skins/index.ts 自身可能漏了键(令牌键集合应三处同步)。'); } const today = new Date().toISOString().slice(0, 10); const lines = []; lines.push('// 自动生成,请勿手改——改这个文件等于和平台脱钩。'); lines.push('// 来源:平台仓 apps/web/src/skins/index.ts(唯一令牌源)'); lines.push('// 同步:bash scripts/sync-skin-tokens.sh <平台仓根>'); lines.push(`// 快照日期:${today} 皮肤:${skins.map((s) => `${s.key}(${s.label})`).join(' / ')} 令牌数:${keyCount}`); lines.push('//'); lines.push('// 为什么 app 要自带一份:micro 应用跑在同源 iframe 里,自己的 document 独立,'); lines.push('// 宿主的 --sk-* 不会跨文档继承;皮肤 key 走 localStorage[\'ts-skin\'] 共享,令牌值只能本地落一份。'); lines.push(''); lines.push('export interface Skin {'); lines.push(' key: string;'); lines.push(' label: string;'); lines.push(' description: string;'); lines.push(' tokens: Record;'); lines.push('}'); lines.push(''); lines.push('export const SKINS: Skin[] = ['); for (const s of skins) { lines.push(' {'); lines.push(` key: '${s.key}',`); lines.push(` label: '${s.label}',`); lines.push(` description: '${s.description}',`); lines.push(' tokens: {'); for (const [k, v] of Object.entries(s.tokens)) { lines.push(` '${k}': '${v}',`); } lines.push(' },'); lines.push(' },'); } lines.push('];'); lines.push(''); lines.push("export const SKIN_STORAGE_KEY = 'ts-skin';"); lines.push(`export const DEFAULT_SKIN_KEY = '${skins[0].key}';`); lines.push(`export const SKIN_TOKEN_COUNT = ${keyCount};`); lines.push(`export const SKIN_SNAPSHOT_DATE = '${today}';`); lines.push(''); fs.writeFileSync(outPath, lines.join('\n')); console.log(`ok ${outPath}:${skins.length} 套皮肤 / ${keyCount} 个令牌`); console.log(` 来源 ${platformRoot}/apps/web/src/skins/index.ts`); EOF