Files
SpineParticlesWeb/scripts/verify-i18n.mjs
T
tianmo 46083c8d2a 完善浏览器配置库与未保存提示
- 新增 IndexedDB 配置保存、加载、删除及批量导入导出

- 配置列表支持选中、双击加载和悬浮操作提示

- 新增未保存提示设置,默认关闭并持久化到浏览器

- 优化配置管理界面并同步九种语言文案

- 更新开发文档与生产构建产物
2026-10-23 11:48:21 +08:00

173 lines
6.1 KiB
JavaScript

import { readFile, stat } 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']
const presetIndex = JSON.parse(await readFile('public/Preset/index.json', 'utf8'))
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`)
}
}
for (const preset of presetIndex) {
const file = typeof preset === 'string' ? preset : preset?.file
if (typeof file !== 'string' || !file.trim()) throw new Error('Preset index entry is missing a file')
await stat(`public/Preset/${file}`)
if (typeof preset === 'string' || preset.name == null) continue
if (typeof preset.name === 'string') continue
if (typeof preset.name !== 'object' || Array.isArray(preset.name)) {
throw new Error(`Preset ${file} has an invalid name`)
}
for (const [code, name] of Object.entries(preset.name)) {
if (!localeCodes.includes(code) || typeof name !== 'string' || !name.trim()) {
throw new Error(`Preset ${file} has an invalid ${code} display name`)
}
}
}
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 presetNamesBundle = await build({
entryPoints: ['src/views/particle-panel/presetNames.ts'],
bundle: true,
format: 'esm',
platform: 'node',
write: false,
logLevel: 'silent',
})
const presetNames = await import(
`data:text/javascript;base64,${Buffer.from(presetNamesBundle.outputFiles[0].contents).toString('base64')}`
)
const parsedPresets = presetNames.parsePresetIndex([
{ file: '8.test.json' },
'9.raw.json',
{ file: 'old.json', name: 'Old Preset' },
{ file: 'partial.json', name: { ja: '日本語プリセット' } },
])
if (parsedPresets.length !== 4) throw new Error('Preset compatibility parser rejected legacy entries')
const presetCases = [
[parsedPresets[0], 'zh-CN', '8.test'],
[parsedPresets[1], 'en', '9.raw'],
[parsedPresets[2], 'fr', 'Old Preset'],
[parsedPresets[3], 'ja', '日本語プリセット'],
[parsedPresets[3], 'fr', 'partial'],
]
for (const [preset, locale, expected] of presetCases) {
const actual = presetNames.resolvePresetDisplayName(preset, locale)
if (actual !== expected) {
throw new Error(`Preset fallback failed: expected ${expected}, got ${actual}`)
}
}
const projectLibraryBundle = await build({
entryPoints: ['src/editor/projectLibrary.ts'],
bundle: true,
format: 'esm',
platform: 'node',
write: false,
logLevel: 'silent',
})
const projectLibrary = await import(
`data:text/javascript;base64,${Buffer.from(projectLibraryBundle.outputFiles[0].contents).toString('base64')}`
)
if (projectLibrary.normalizeProjectName(' Demo.json ') !== 'Demo') {
throw new Error('Project name normalization must trim spaces and the .json suffix')
}
if (projectLibrary.projectJsonFileName('Demo') !== 'Demo.json') {
throw new Error('Project download file name is incorrect')
}
let invalidProjectNameRejected = false
try {
projectLibrary.normalizeProjectName('bad/name')
} catch {
invalidProjectNameRejected = true
}
if (!invalidProjectNameRejected) throw new Error('Invalid project names must be rejected')
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} 个消息键,预设索引兼容,自动语言与缓存优先级正确`)