157 lines
6.0 KiB
Bash
Executable File
157 lines
6.0 KiB
Bash
Executable File
#!/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<string, string>;');
|
||
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
|