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} 个消息键,键与占位符一致,自动语言与缓存优先级正确`)