// 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 的编译器);解析不到时只告警不拦截(避免门禁误伤)。 const fs = require('fs'); const path = require('path'); let baseCompile; try { ({ baseCompile } = require('@intlify/message-compiler')); } catch { console.warn('[i18n-check] 未找到 @intlify/message-compiler,跳过消息编译预检(需 npm 依赖)'); process.exit(0); } const DIRS = ['src/i18n/zh-CN', 'src/i18n/en']; // key: 'value' / key: "value"(带转义),仅取字符串字面量值 const KV_RE = /(?:^|[\s,{])([A-Za-z0-9_$]+)\s*:\s*(['"])((?:\\.|(?!\2).)*)\2/gm; let checked = 0; let failed = 0; for (const dir of DIRS) { let files; try { files = fs.readdirSync(dir); } catch { continue; } for (const f of files) { if (!f.endsWith('.ts')) continue; const txt = fs.readFileSync(path.join(dir, f), 'utf8'); KV_RE.lastIndex = 0; let m; while ((m = KV_RE.exec(txt))) { const key = m[1]; const val = m[3]; // 只编译含 vue-i18n 特殊字符的值(@ { |),其余不可能因语法报错 if (!/[@{|]/.test(val)) continue; checked++; const errs = []; try { baseCompile(val, { onError: (e) => errs.push(e), onWarn: () => {} }); } catch (e) { errs.push(e); } if (errs.length) { failed++; console.error(`✗ ${dir}/${f} :: ${key} => ${errs[0].message}`); console.error(` 值: ${JSON.stringify(val)}`); console.error(` 提示: 字面 @ 须转义为 {'@'}(同理 | 与裸 {})`); } } } } if (failed > 0) { console.error(`[i18n-check] 失败 ${failed} 处(共编译 ${checked} 个含特殊字符的值)——prod 运行时会抛 Invalid linked format`); process.exit(1); } console.log(`[i18n-check] 通过(编译 ${checked} 个含特殊字符的值,0 失败)`);