/** * 图标名校验:找出传给 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`,静态类型无从校验 —— * 所以要靠这个脚本,在数据这一侧把它查出来。 * * 检测范围(只查**静态字面量**,运行时拼出来的名字查不了,属已知边界): * - `` 的字面量 * - 对象里的 `icon: 'xxx'` / `icon: "xxx"` * * 白名单:`MOCK_BADGES` 的 6 个勋章图标是 **emoji**(🏅🎯⭐🏆📮📚), * 由 `{badge.icon}` 直接渲染成字符,本就不该是 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 = [ /]*?\bname="([^"]+)"/g, /\bicon:\s*'([^']+)'/g, /\bicon:\s*"([^"]+)"/g, ]; /** emoji / 非 ASCII:这类图标走 渲染,不是 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);