Files

226 lines
9.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
/**
* 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);
}