// vue-i18n 消息预检:揪出只在 **prod runtime** 抛错的未转义特殊字符(字面 @ / | / 裸 {})。 // // 背景:vue-i18n 9 把 message 里的字面 `@` 当 linked-message 语法、`|` 当 plural 分隔符, // 未转义会编译报 "Invalid linked format"。该错误 dev 宽松回退、`vue-tsc` 捕获不到(message 是 // 字符串值非类型),只在打包装出的应用里炸——已两次踩坑(2026-06-15 白屏 / 2026-10-10 输入框消失), // 且两次静态检查全绿。故前置到提交门禁 + 可手动跑:`node scripts/check-i18n.cjs` // // 修法:字面量用 vue-i18n 的插值转义,如 `{'@'}`(渲染仍显示 @)。 // // 依赖 @intlify/message-compiler(vue-i18n 的编译器);解析不到时只告警不拦截(避免门禁误伤)。 // // 扫描/编译逻辑抽为可导出函数(scanText / scanDirectory / compileMessage),供 // scripts/check-i18n.test.cjs 自测;CLI 入口行为保持不变。 const fs = require('fs'); const path = require('path'); let baseCompile = null; try { ({ baseCompile } = require('@intlify/message-compiler')); } catch { baseCompile = null; } const DIRS = ['src/i18n/zh-CN', 'src/i18n/en']; // key: 'value' / "value" / `value`(带转义),三种字面量都取—— // 反引号模板字面量同样可能含未转义 @/|/裸{},原先只匹配引号 → 门禁假阴性(2026-10-10 对抗审查发现)。 const KV_RE = /(?:^|[\s,{])([A-Za-z0-9_$]+)\s*:\s*([`'"])((?:\\.|(?!\2).)*)\2/gm; // 编译单个消息值,返回错误数组(@intlify 风格:{ message }) function compileMessage(val) { const errs = []; try { baseCompile(val, { onError: (e) => errs.push(e), onWarn: () => {} }); } catch (e) { errs.push(e); } return errs; } // 扫描一段源码文本,返回 { checked, violations } // - checked: 含 vue-i18n 特殊字符(@ { |)并已送编译的值个数 // - violations: 编译报错的条目 [{ key, value, message }] function scanText(txt) { const violations = []; let checked = 0; KV_RE.lastIndex = 0; let m; while ((m = KV_RE.exec(txt))) { const key = m[1]; // 模板字面量里的 ${...} 插值对 vue-i18n 无意义,编译前替换为占位符避免假报错 const val = m[2] === '`' ? m[3].replace(/\$\{[^}]*\}/g, 'X') : m[3]; // 只编译含 vue-i18n 特殊字符的值(@ { |),其余不可能因语法报错 if (!/[@{|]/.test(val)) continue; checked++; const errs = compileMessage(val); if (errs.length) violations.push({ key, value: val, message: errs[0].message }); } return { checked, violations }; } // 扫描目录下所有 .ts,返回 { checked, violations }(violations 带 file 字段) function scanDirectory(dir) { const violations = []; let checked = 0; let files; try { files = fs.readdirSync(dir); } catch { return { checked, violations }; } for (const f of files) { if (!f.endsWith('.ts')) continue; const res = scanText(fs.readFileSync(path.join(dir, f), 'utf8')); checked += res.checked; for (const v of res.violations) violations.push({ file: f, ...v }); } return { checked, violations }; } // CLI 主流程:返回进程退出码(0 通过 / 1 失败) function run() { if (!baseCompile) { console.warn('[i18n-check] 未找到 @intlify/message-compiler,跳过消息编译预检(需 npm 依赖)'); return 0; } let checked = 0; let failed = 0; for (const dir of DIRS) { const res = scanDirectory(dir); checked += res.checked; for (const v of res.violations) { failed++; console.error(`✗ ${dir}/${v.file} :: ${v.key} => ${v.message}`); console.error(` 值: ${JSON.stringify(v.value)}`); console.error(` 提示: 字面 @ 须转义为 {'@'}(同理 | 与裸 {})`); } } if (failed > 0) { console.error(`[i18n-check] 失败 ${failed} 处(共编译 ${checked} 个含特殊字符的值)——prod 运行时会抛 Invalid linked format`); return 1; } console.log(`[i18n-check] 通过(编译 ${checked} 个含特殊字符的值,0 失败)`); return 0; } if (require.main === module) { process.exit(run()); } module.exports = { compileMessage, scanText, scanDirectory, run, KV_RE, DIRS };