多国语言支持
This commit is contained in:
1 parent
b98c0333e6
commit
04de5e5a8e
23 files changed
+7795
-1279
No files matched your search
@@ -0,0 +1,136 @@
|
||||
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.')
|
||||
Reference in new issue
Block a user