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

137 lines
4.6 KiB
JavaScript

import { readFile, writeFile } from 'node:fs/promises'
const source = JSON.parse(await readFile('src/i18n/locales/zh-CN.json', 'utf8'))
const localeConfigs = {
'zh-TW': { title: 'Spine粒子編輯器-Yeomai' },
ja: { title: 'Spineパーティクルエディター-Yeomai' },
ko: { title: 'Spine 파티클 에디터-Yeomai' },
fr: { title: 'Spine Particle Editor-Yeomai' },
de: { title: 'Spine Particle Editor-Yeomai' },
'pt-BR': { title: 'Spine Particle Editor-Yeomai' },
es: { title: 'Spine Particle Editor-Yeomai' },
}
const nativeLanguageLabels = {
zhCN: '中文',
zhTW: '繁體中文',
en: 'English',
ja: '日本語',
ko: '한국어',
fr: 'Français',
de: 'Deutsch',
ptBR: 'Português',
es: 'Español',
}
const protectedTerms = [
'SpineParticle', 'Spine', 'Yeomai', 'JSON', 'FPS', 'ZIP', 'PNG', 'JPEG',
'WebP', 'WebGL', 'Bézier', 'Bezier', 'Mesh', 'Atlas', 'Ctrl', 'macOS',
'Windows', 'Universal', 'A/B', 'AB',
]
function flatten(value, path = '', result = []) {
for (const [key, child] of Object.entries(value)) {
const nextPath = path ? `${path}.${key}` : key
if (child && typeof child === 'object' && !Array.isArray(child)) flatten(child, nextPath, result)
else result.push({ path: nextPath, value: child })
}
return result
}
function setPath(target, path, value) {
const parts = path.split('.')
let current = target
for (let index = 0; index < parts.length - 1; index++) {
current = current[parts[index]] ||= {}
}
current[parts.at(-1)] = value
}
function protectText(text) {
const values = []
const escapedTerms = protectedTerms
.sort((a, b) => b.length - a.length)
.map((term) => term.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'))
const pattern = new RegExp(`\\{[a-zA-Z0-9_]+\\}|\\b(?:${escapedTerms.join('|')})\\b`, 'g')
const protectedText = String(text).replace(pattern, (match) => {
const token = `ZXQV${values.length}QXZ`
values.push(match)
return token
})
return { protectedText, values }
}
function restoreText(text, values) {
return String(text).replace(/ZXQV(\d+)QXZ/g, (_match, index) => values[Number(index)] ?? '')
}
async function translateBatch(texts, target) {
const params = new URLSearchParams({
client: 'dict-chrome-ex',
sl: 'zh-CN',
tl: target,
})
for (const text of texts) params.append('q', text)
const response = await fetch(`https://clients5.google.com/translate_a/t?${params}`, {
signal: AbortSignal.timeout(30000),
})
if (!response.ok) throw new Error(`Translation HTTP ${response.status}`)
const body = await response.json()
if (!Array.isArray(body)) throw new Error('Unexpected translation response')
if (body.length !== texts.length) {
throw new Error(`Translation count mismatch: expected ${texts.length}, got ${body.length}`)
}
return body.map((value) => typeof value === 'string' ? value : JSON.stringify(value))
}
async function translateLocale(code, config) {
const entries = flatten(source).filter(({ path }) => !path.startsWith('language.') && path !== 'app.title')
const translated = {}
const batches = []
let batch = []
let encodedLength = 0
for (const entry of entries) {
const protectedEntry = protectText(entry.value)
const item = { ...entry, ...protectedEntry }
const itemLength = encodeURIComponent(item.protectedText).length + 3
if (batch.length && encodedLength + itemLength > 7000) {
batches.push(batch)
batch = []
encodedLength = 0
}
batch.push(item)
encodedLength += itemLength
}
if (batch.length) batches.push(batch)
for (let index = 0; index < batches.length; index++) {
const current = batches[index]
let values
for (let attempt = 0; attempt < 4; attempt++) {
try {
values = await translateBatch(current.map((entry) => entry.protectedText), code)
break
} catch (error) {
if (attempt === 3) throw error
await new Promise((resolve) => setTimeout(resolve, 1800 * (attempt + 1)))
}
}
current.forEach((entry, itemIndex) => {
setPath(translated, entry.path, restoreText(values[itemIndex], entry.values))
})
process.stdout.write(`${code}: ${index + 1}/${batches.length}\n`)
await new Promise((resolve) => setTimeout(resolve, 500))
}
translated.app.title = config.title
translated.language = nativeLanguageLabels
await writeFile(`src/i18n/locales/${code}.json`, `${JSON.stringify(translated, null, 2)}\n`)
}
for (const [code, config] of Object.entries(localeConfigs)) {
await translateLocale(code, config)
}
console.log('Generated locale drafts. Review and override key terminology before release.')