轻松聘
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 

99 lines
4.0 KiB

/**
* 图标名校验:找出传给 Ionicons 的**无效字形名**。
*
* 背景(2026-09-18 发现的真实缺陷):`src/api/api.ts` 的文书模板里写了一个
* `icon: 'gavel-outline'`,而 Ionicons 全库 1357 个字形里**根本没有 gavel**
* (法槌不在 Ionicons 里)。后果有两个,都逃过了既有全部门槛:
*
* ① 运行时 JSON 里渲染为空白 —— 「劳动仲裁申请书」这一条没有图标;
* ② Expo 打印 `"gavel-outline" is not a valid icon name for family ...`。
*
* 为什么 `tsc` 0 错误也拦不住:页面写的是
* `name={template.icon as keyof typeof Ionicons.glyphMap}`
* 这个 `as` 断言把「任意字符串」骗成了合法字形名,编译器就此闭嘴。
* 图标名来自数据层(api.ts 的 mock),本就是 `string`,静态类型无从校验 ——
* 所以要靠这个脚本,在数据这一侧把它查出来。
*
* 检测范围(只查**静态字面量**,运行时拼出来的名字查不了,属已知边界):
* - `<Ionicons name="xxx" />` 的字面量
* - 对象里的 `icon: 'xxx'` / `icon: "xxx"`
*
* 白名单:`MOCK_BADGES` 的 6 个勋章图标是 **emoji**(🏅🎯⭐🏆📮📚),
* 由 `<Text>{badge.icon}</Text>` 直接渲染成字符,本就不该是 Ionicons 名。
* 判据取「是否全为 emoji / 非 ASCII 字符」,而不是硬编码那 6 个值 ——
* 否则以后新增勋章还得回来改脚本。
*
* 用法:node scripts/check-icons.mjs # 列出全部
* node scripts/check-icons.mjs --error # 有命中则 exit 1(可用于 CI)
*/
import { readdirSync, readFileSync, statSync } from 'node:fs';
import { join } from 'node:path';
import { createRequire } from 'node:module';
const require = createRequire(import.meta.url);
/** 直接从装的包里读字形表,不另存一份 —— 另存一份就会与真实图标库脱节 */
const GLYPHMAP_PATH =
'@expo/vector-icons/build/vendor/react-native-vector-icons/glyphmaps/Ionicons.json';
const VALID_GLYPHS = new Set(Object.keys(require(GLYPHMAP_PATH)));
const ROOTS = ['app', 'src'];
const SKIP_DIRS = new Set(['node_modules', '__tests__']);
const PATTERNS = [
/<Ionicons[^>]*?\bname="([^"]+)"/g,
/\bicon:\s*'([^']+)'/g,
/\bicon:\s*"([^"]+)"/g,
];
/** emoji / 非 ASCII:这类图标走 <Text> 渲染,不是 Ionicons 名 */
function isEmojiOrNonAscii(s) {
return /[^\x00-\x7F]/.test(s);
}
function walk(dir, out = []) {
for (const entry of readdirSync(dir)) {
if (SKIP_DIRS.has(entry) || entry.startsWith('.')) continue;
const full = join(dir, entry);
if (statSync(full).isDirectory()) walk(full, out);
else if (/\.tsx?$/.test(entry)) out.push(full);
}
return out;
}
const hits = [];
let scanned = 0;
for (const file of ROOTS.flatMap((root) => walk(root))) {
scanned += 1;
const source = readFileSync(file, 'utf8');
const lines = source.split('\n');
for (const pattern of PATTERNS) {
pattern.lastIndex = 0;
let match;
while ((match = pattern.exec(source)) !== null) {
const name = match[1];
if (VALID_GLYPHS.has(name) || isEmojiOrNonAscii(name)) continue;
const line = source.slice(0, match.index).split('\n').length;
hits.push({ file, line, name, text: lines[line - 1]?.trim() ?? '' });
}
}
}
console.log(`扫描 ${scanned} 个文件,Ionicons 有效字形 ${VALID_GLYPHS.size} 个。`);
if (hits.length === 0) {
console.log('✅ 未发现无效 Ionicons 图标名');
} else {
console.log(`\n❌ 发现 ${hits.length} 处无效图标名(运行时会渲染为空白):\n`);
for (const hit of hits) {
console.log(` ${hit.file}:${hit.line}`);
console.log(` 名: ${hit.name}`);
console.log(` 行: ${hit.text}`);
}
console.log('\n改法:换成 Ionicons 里真实存在的名字;名来自数据层时,');
console.log(' 别用 `as keyof typeof Ionicons.glyphMap` 把类型错误咽下去。');
}
if (process.argv.includes('--error') && hits.length > 0) process.exit(1);