import { readFile, stat } from 'node:fs/promises' import { build } from 'esbuild' const localeCodes = ['zh-CN', 'zh-TW', 'en', 'ja', 'ko', 'ru', '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 || '').trim() !== String(messages.welcome?.documentTitle || '').trim()) { throw new Error(`${code} welcome and editor document titles must match`) } } 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, ['ru-RU'], 'ru'], [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'], ['ru', ['en-US'], 'ru'], ] 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} 个消息键,预设索引兼容,自动语言与缓存优先级正确`)