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
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);
|
|
|