226 lines
9.2 KiB
JavaScript
226 lines
9.2 KiB
JavaScript
#!/usr/bin/env node
|
||
/**
|
||
* ui-token-audit.mjs — 视觉秩序(token 收敛度)审计
|
||
*
|
||
* 以 src/styles/base.css 的 :root 自定义属性与设计系统内置类为唯一 palette 来源,
|
||
* 扫描 src 下全部 .vue/.css/.scss(base.css 自身除外),统计:
|
||
* 1. 颜色:var() 引用 / palette 内字面值 / palette 外字面值(违规)
|
||
* 2. 字号:分布 + 不在 base.css 派生字阶内的"孤儿"值
|
||
* 3. 间距:margin/padding/gap 分布 + 非 2px 网格值
|
||
* 4. 圆角:分布 + 不在 token 半径集合内的值(0/百分比/胶囊值除外)
|
||
*
|
||
* 输出控制台摘要 + output/ui-ux/token-audit-latest.{json,md}。
|
||
* 默认只报告不失败;--strict 时存在 palette 外颜色则以 1 退出。
|
||
*/
|
||
import { readdirSync, readFileSync, mkdirSync, writeFileSync } from 'node:fs';
|
||
import { join, relative } from 'node:path';
|
||
import {
|
||
mobileRoot,
|
||
tokenFile,
|
||
normalizeHex,
|
||
normalizeRgba,
|
||
NEAR_MISS_MAX_DIST,
|
||
loadTokens,
|
||
} from './lib/ui-tokens.mjs';
|
||
|
||
const root = mobileRoot;
|
||
const srcDir = join(root, 'src');
|
||
const strict = process.argv.includes('--strict');
|
||
|
||
// ---------- 1. 从 base.css 提取 palette 与派生字阶(共享 lib,与渲染审计同源) ----------
|
||
const { palette, fontScale, radiusTokens, nearestPalette } = loadTokens();
|
||
|
||
// ---------- 2. 收集源文件 ----------
|
||
const exts = new Set(['.vue', '.css', '.scss']);
|
||
function walk(dir) {
|
||
const out = [];
|
||
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
||
const p = join(dir, entry.name);
|
||
if (entry.isDirectory()) out.push(...walk(p));
|
||
else if (exts.has(entry.name.slice(entry.name.lastIndexOf('.')))) out.push(p);
|
||
}
|
||
return out;
|
||
}
|
||
const files = walk(srcDir).filter((f) => f !== tokenFile);
|
||
|
||
// ---------- 3. 扫描 ----------
|
||
const colors = { varRefs: 0, paletteLiteral: new Map(), offPalette: new Map() };
|
||
const fontSizes = new Map();
|
||
const fontSizesOffScale = new Map();
|
||
const spacings = new Map();
|
||
const spacingsOffGrid = new Map();
|
||
const radii = new Map();
|
||
const radiiOffToken = new Map();
|
||
|
||
function bump(map, key, loc) {
|
||
const cur = map.get(key) || { count: 0, locs: [] };
|
||
cur.count += 1;
|
||
if (cur.locs.length < 5) cur.locs.push(loc);
|
||
map.set(key, cur);
|
||
}
|
||
|
||
for (const file of files) {
|
||
const rel = relative(root, file).replaceAll('\\', '/');
|
||
const text = readFileSync(file, 'utf8');
|
||
const lineOf = (idx) => rel + ':' + (text.slice(0, idx).split('\n').length);
|
||
|
||
for (const m of text.matchAll(/var\(--[\w-]+\)/g)) colors.varRefs += 1;
|
||
|
||
for (const m of text.matchAll(/#[0-9a-fA-F]{3,8}\b/g)) {
|
||
const v = normalizeHex(m[0]);
|
||
if (palette.has(v)) bump(colors.paletteLiteral, v, lineOf(m.index));
|
||
else bump(colors.offPalette, v, lineOf(m.index));
|
||
}
|
||
for (const m of text.matchAll(/rgba?\([^)]*\)/g)) {
|
||
const v = normalizeRgba(m[0]);
|
||
if (palette.has(v)) bump(colors.paletteLiteral, v, lineOf(m.index));
|
||
else bump(colors.offPalette, v, lineOf(m.index));
|
||
}
|
||
|
||
for (const m of text.matchAll(/font-size:\s*([\d.]+)px/g)) {
|
||
const v = Number(m[1]);
|
||
bump(fontSizes, v, lineOf(m.index));
|
||
if (!fontScale.has(v)) bump(fontSizesOffScale, v, lineOf(m.index));
|
||
}
|
||
|
||
for (const m of text.matchAll(/(?:margin|padding|gap)(?:-[\w]+)?:\s*([^;}]+)/g)) {
|
||
for (const n of m[1].matchAll(/(-?[\d.]+)px/g)) {
|
||
const v = Number(n[1]);
|
||
if (v === 0) continue;
|
||
bump(spacings, v, lineOf(m.index));
|
||
if (v % 2 !== 0) bump(spacingsOffGrid, v, lineOf(m.index));
|
||
}
|
||
}
|
||
|
||
for (const m of text.matchAll(/border-radius:\s*([^;}]+)/g)) {
|
||
const raw = m[1].trim();
|
||
if (raw.includes('var(') || raw.includes('%')) continue;
|
||
for (const n of raw.matchAll(/([\d.]+)px/g)) {
|
||
const v = Number(n[1]);
|
||
if (v === 0 || v >= 999) continue; // 直角与胶囊/圆形属合法手段
|
||
bump(radii, v, lineOf(m.index));
|
||
if (!radiusTokens.has(v)) bump(radiiOffToken, v, lineOf(m.index));
|
||
}
|
||
}
|
||
}
|
||
|
||
// ---------- 4. 汇总 ----------
|
||
const sum = (map) => [...map.values()].reduce((a, b) => a + b.count, 0);
|
||
const top = (map, n = 12) =>
|
||
[...map.entries()].sort((a, b) => b[1].count - a[1].count).slice(0, n);
|
||
|
||
const offPaletteDetailed = {};
|
||
let nearMissCount = 0;
|
||
for (const [v, d] of top(colors.offPalette, colors.offPalette.size)) {
|
||
const nearest = v.startsWith('#') && v.length === 7 ? nearestPalette(v) : null;
|
||
if (nearest && nearest.dist > 0 && nearest.dist <= NEAR_MISS_MAX_DIST) nearMissCount += d.count;
|
||
offPaletteDetailed[v] = { ...d, nearest };
|
||
}
|
||
|
||
const result = {
|
||
generatedAt: new Date().toISOString(),
|
||
scope: 'mobile-uni/src (excluding styles/base.css as token source)',
|
||
filesScanned: files.length,
|
||
tokenSource: {
|
||
paletteSize: palette.size,
|
||
fontScale: [...fontScale].sort((a, b) => a - b),
|
||
radiusTokens: [...radiusTokens].sort((a, b) => a - b),
|
||
},
|
||
colors: {
|
||
varRefs: colors.varRefs,
|
||
paletteLiteralCount: sum(colors.paletteLiteral),
|
||
offPaletteCount: sum(colors.offPalette),
|
||
offPaletteDistinct: colors.offPalette.size,
|
||
nearMissCount,
|
||
offPalette: Object.fromEntries(Object.entries(offPaletteDetailed).slice(0, 30)),
|
||
},
|
||
fontSizes: {
|
||
distinct: fontSizes.size,
|
||
total: sum(fontSizes),
|
||
offScaleDistinct: fontSizesOffScale.size,
|
||
offScaleCount: sum(fontSizesOffScale),
|
||
distribution: Object.fromEntries(top(fontSizes, 20)),
|
||
offScale: Object.fromEntries(top(fontSizesOffScale, 20)),
|
||
},
|
||
spacings: {
|
||
distinct: spacings.size,
|
||
total: sum(spacings),
|
||
offGridDistinct: spacingsOffGrid.size,
|
||
offGridCount: sum(spacingsOffGrid),
|
||
distribution: Object.fromEntries(top(spacings, 20)),
|
||
offGrid: Object.fromEntries(top(spacingsOffGrid, 20)),
|
||
},
|
||
radii: {
|
||
distribution: Object.fromEntries(top(radii, 20)),
|
||
offToken: Object.fromEntries(top(radiiOffToken, 20)),
|
||
},
|
||
};
|
||
|
||
// ---------- 5. 输出 ----------
|
||
const literalTotal = result.colors.paletteLiteralCount + result.colors.offPaletteCount;
|
||
const colorTotal = colors.varRefs + literalTotal;
|
||
const varRatio = colorTotal ? ((colors.varRefs / colorTotal) * 100).toFixed(1) : '0.0';
|
||
|
||
const lines = [];
|
||
lines.push('# mobile-uni UI token 审计');
|
||
lines.push('');
|
||
lines.push(`- 生成时间:${result.generatedAt}`);
|
||
lines.push(`- 扫描文件:${files.length} 个(token 源:src/styles/base.css)`);
|
||
lines.push(`- palette:${palette.size} 个颜色;字阶:${result.tokenSource.fontScale.join('/')}px;半径 token:${result.tokenSource.radiusTokens.join('/')}px`);
|
||
lines.push('');
|
||
lines.push('## 颜色');
|
||
lines.push(`- var() 引用 ${colors.varRefs} 次;字面值 ${literalTotal} 次(palette 内 ${result.colors.paletteLiteralCount},**palette 外 ${result.colors.offPaletteCount} 次 / ${colors.offPalette.size} 种**,其中近色漂移 ${nearMissCount} 次)`);
|
||
lines.push(`- token 引用占比(var / 全部颜色引用):${varRatio}%`);
|
||
if (colors.offPalette.size) {
|
||
lines.push('');
|
||
lines.push('| palette 外颜色 | 次数 | 最近 palette 色 (Δ) | 示例位置 |');
|
||
lines.push('|---|---:|---|---|');
|
||
for (const [v, d] of Object.entries(offPaletteDetailed).slice(0, 30)) {
|
||
const hint = d.nearest ? `${d.nearest.value} (Δ${d.nearest.dist})` : '—';
|
||
lines.push(`| ${v} | ${d.count} | ${hint} | ${d.locs.join('、')} |`);
|
||
}
|
||
}
|
||
lines.push('');
|
||
lines.push('## 字号');
|
||
lines.push(`- 共 ${result.fontSizes.total} 处、${fontSizes.size} 种;**字阶外 ${result.fontSizes.offScaleCount} 处 / ${fontSizesOffScale.size} 种**`);
|
||
lines.push(`- 分布:${[...top(fontSizes, 20)].map(([v, d]) => `${v}px×${d.count}`).join(',')}`);
|
||
if (fontSizesOffScale.size) {
|
||
lines.push('');
|
||
lines.push('| 字阶外字号 | 次数 | 示例位置 |');
|
||
lines.push('|---|---:|---|');
|
||
for (const [v, d] of top(fontSizesOffScale, 20)) lines.push(`| ${v}px | ${d.count} | ${d.locs.join('、')} |`);
|
||
}
|
||
lines.push('');
|
||
lines.push('## 间距(margin/padding/gap)');
|
||
lines.push(`- 共 ${result.spacings.total} 处、${spacings.size} 种;**非 2px 网格 ${result.spacings.offGridCount} 处 / ${spacingsOffGrid.size} 种**`);
|
||
lines.push(`- 分布:${[...top(spacings, 20)].map(([v, d]) => `${v}px×${d.count}`).join(',')}`);
|
||
if (spacingsOffGrid.size) {
|
||
lines.push('');
|
||
lines.push('| 非网格间距 | 次数 | 示例位置 |');
|
||
lines.push('|---|---:|---|');
|
||
for (const [v, d] of top(spacingsOffGrid, 20)) lines.push(`| ${v}px | ${d.count} | ${d.locs.join('、')} |`);
|
||
}
|
||
lines.push('');
|
||
lines.push('## 圆角');
|
||
lines.push(`- 分布:${[...top(radii, 20)].map(([v, d]) => `${v}px×${d.count}`).join(',') || '(无字面值)'}`);
|
||
if (radiiOffToken.size) {
|
||
lines.push('');
|
||
lines.push('| token 外圆角 | 次数 | 示例位置 |');
|
||
lines.push('|---|---:|---|');
|
||
for (const [v, d] of top(radiiOffToken, 20)) lines.push(`| ${v}px | ${d.count} | ${d.locs.join('、')} |`);
|
||
}
|
||
|
||
const outDir = join(root, '..', 'output', 'ui-ux');
|
||
mkdirSync(outDir, { recursive: true });
|
||
writeFileSync(join(outDir, 'token-audit-latest.json'), JSON.stringify(result, null, 2));
|
||
writeFileSync(join(outDir, 'token-audit-latest.md'), lines.join('\n') + '\n');
|
||
|
||
console.log(lines.join('\n'));
|
||
console.log('');
|
||
console.log(`报告已写入 output/ui-ux/token-audit-latest.{md,json}`);
|
||
|
||
if (strict && result.colors.offPaletteCount > 0) {
|
||
console.error(`--strict:存在 ${result.colors.offPaletteCount} 处 palette 外颜色`);
|
||
process.exit(1);
|
||
}
|