Files
SpineParticlesWeb/scripts/verify-i18n.mjs
T
2026-10-22 18:37:30 +08:00

99 lines
3.3 KiB
JavaScript

import { readFile } from 'node:fs/promises'
import { build } from 'esbuild'
const localeCodes = ['zh-CN', 'zh-TW', 'en', 'ja', 'ko', 'fr', 'de', 'pt-BR', 'es']
const localeMessages = Object.fromEntries(await Promise.all(localeCodes.map(async (code) => [
code,
JSON.parse(await readFile(`src/i18n/locales/${code}.json`, 'utf8')),
])))
const zh = localeMessages['zh-CN']
function flattenKeys(value, prefix = '') {
const keys = []
for (const [key, child] of Object.entries(value)) {
const path = prefix ? `${prefix}.${key}` : key
if (child && typeof child === 'object' && !Array.isArray(child)) {
keys.push(...flattenKeys(child, path))
} else {
keys.push(path)
}
}
return keys.sort()
}
const zhKeys = flattenKeys(zh)
function getPath(value, path) {
return path.split('.').reduce((current, key) => current?.[key], value)
}
function placeholders(value) {
return [...String(value).matchAll(/\{[a-zA-Z0-9_]+\}/g)].map((match) => match[0]).sort()
}
for (const code of localeCodes.slice(1)) {
const messages = localeMessages[code]
const keys = flattenKeys(messages)
const missing = zhKeys.filter((key) => !keys.includes(key))
const extra = keys.filter((key) => !zhKeys.includes(key))
if (missing.length || extra.length) {
throw new Error([
missing.length ? `${code} missing: ${missing.join(', ')}` : '',
extra.length ? `${code} extra: ${extra.join(', ')}` : '',
].filter(Boolean).join('\n'))
}
for (const key of zhKeys) {
if (!String(getPath(messages, key) ?? '').trim()) {
throw new Error(`${code}.${key} is empty`)
}
const expected = placeholders(getPath(zh, key))
const actual = placeholders(getPath(messages, key))
if (JSON.stringify(expected) !== JSON.stringify(actual)) {
throw new Error(`${code}.${key} placeholders differ: ${expected.join(',')} != ${actual.join(',')}`)
}
}
if (JSON.stringify(messages).includes('ZXQV') || /\uE000|\uE001|\uE002/.test(JSON.stringify(messages))) {
throw new Error(`${code} contains unresolved translation tokens`)
}
if (!String(messages.app?.title || '').includes('Yeomai')) {
throw new Error(`${code} title must keep the Yeomai brand`)
}
}
const bundled = await build({
entryPoints: ['src/i18n/index.ts'],
bundle: true,
format: 'esm',
platform: 'node',
write: false,
logLevel: 'silent',
})
const i18nModule = await import(
`data:text/javascript;base64,${Buffer.from(bundled.outputFiles[0].contents).toString('base64')}`
)
const cases = [
[null, ['zh-CN'], 'zh-CN'],
[null, ['zh-Hant-TW'], 'zh-TW'],
[null, ['zh-HK'], 'zh-TW'],
[null, ['en-US'], 'en'],
[null, ['ja-JP'], 'ja'],
[null, ['ko-KR'], 'ko'],
[null, ['fr-FR'], 'fr'],
[null, ['de-DE'], 'de'],
[null, ['pt-PT'], 'pt-BR'],
[null, ['es-MX'], 'es'],
[null, ['it-IT'], 'en'],
[null, ['it-IT', 'de-DE'], 'de'],
['en', ['zh-CN'], 'en'],
['zh-CN', ['en-US'], 'zh-CN'],
['ja', ['en-US'], 'ja'],
]
for (const [saved, browserLanguages, expected] of cases) {
const actual = i18nModule.resolveAppLocale(saved, browserLanguages)
if (actual !== expected) {
throw new Error(`Locale resolution failed for ${JSON.stringify(browserLanguages)}: expected ${expected}, got ${actual}`)
}
}
console.log(`i18n 校验通过:${localeCodes.length} 种语言、${zhKeys.length} 个消息键,键与占位符一致,自动语言与缓存优先级正确`)