Frontend i18n Translation Workflow
Overview
- Locale files:
web/default/src/i18n/locales/{en,zh,fr,ja,ru,vi}.json
- Format: flat JSON under
"translation" key, keys are English source strings
- Base locale:
en.json (most keys), fallback: zh (Chinese)
- Sync script:
bun run i18n:sync (from web/default/)
- All
t() calls must have corresponding keys in every locale file
Workflow
Step 1: Run sync and read report
cd web/default && bun run i18n:sync
Read web/default/src/i18n/locales/_reports/_sync-report.json to see per-locale status (missingCount, extrasCount, untranslatedCount).
Step 2: Find missing keys (used in code but not in locale files)
Create and run web/default/scripts/find-missing-keys.mjs:
import fs from 'node:fs/promises'
import path from 'node:path'
const LOCALES_DIR = path.resolve('src/i18n/locales')
const SRC_DIR = path.resolve('src')
const en = JSON.parse(await fs.readFile(path.join(LOCALES_DIR, 'en.json'), 'utf8'))
const enKeys = new Set(Object.keys(en.translation))
const tCallRegex = /\bt\(\s*['"`]([^'"`\n]+?)['"`]\s*[,)]/g
const tCallMultilineRegex = /\bt\(\s*['"`]([^'"`]+?)['"`]\s*\)/g
async function walkDir(dir) {
const files = []
const entries = await fs.readdir(dir, { withFileTypes: true })
for (const entry of entries) {
const fullPath = path.join(dir, entry.name)
if (entry.isDirectory()) {
if (['node_modules', '.git', 'locales', '_reports', '_extras'].includes(entry.name)) continue
files.push(...(await walkDir(fullPath)))
} else if (/\.(tsx?|jsx?)$/.test(entry.name)) {
files.push(fullPath)
}
}
return files
}
const files = await walkDir(SRC_DIR)
const missingKeys = new Map()
for (const file of files) {
const content = await fs.readFile(file, 'utf8')
const relPath = path.relative(SRC_DIR, file)
for (const regex of [tCallRegex, tCallMultilineRegex]) {
regex.lastIndex = 0
let match
while ((match = regex.exec(content)) !== null) {
const key = match[1]
if (key.startsWith('{{') || key.includes('${')) continue
if (!enKeys.has(key)) {
if (!missingKeys.has(key)) missingKeys.set(key, [])
missingKeys.get(key).push(relPath)
}
}
}
}
if (missingKeys.size === 0) {
console.log('All t() keys found in en.json!')
} else {
console.log(`Found ${missingKeys.size} missing keys:\n`)
for (const [key, files] of [...missingKeys.entries()].sort(([a], [b]) => a.localeCompare(b))) {
console.log(` "${key}"`)
for (const f of [...new Set(files)]) console.log(` -> ${f}`)
}
}
Step 3: Find untranslated entries (value equals English)
Create and run web/default/scripts/find-untranslated.mjs:
import fs from 'node:fs/promises'
import path from 'node:path'
const LOCALES_DIR = path.resolve('src/i18n/locales')
const en = JSON.parse(await fs.readFile(path.join(LOCALES_DIR, 'en.json'), 'utf8'))
const enTrans = en.translation
// Brand names, URLs, technical terms — skip these
const skipPatterns = [
/^https?:\/\//, /^smtp\./, /^socks5:/, /^name@/, /^noreply@/,
/^org-/, /^price_/, /^whsec_/, /^edit_this$/, /^my-status$/,
/^_copy$/, /^gpt-/, /^checkout\./, /^footer\./, /^\[?\{/,
/^"default/, /^\/status\//, /^\/your\//, /^example\.com/,
/^AZURE_/, /^AccessKey/, /^OAuth/, /^Client /, /^Webhook URL/,
/^API URL$/, /^Well-Known/, /^Worker URL$/, /^Uptime Kuma/,
/^New API/, /^Baidu V2$/, /^Zhipu V4$/, /^Quota:$/,
]
const brandNames = new Set([
'AIGC2D','Anthropic','API2GPT','Claude','Cloudflare','Cohere','DeepSeek',
'Discord','DoubaoVideo','FastGPT','Gemini','GitHub','Jimeng','JustSong',
'LingYiWanWu','LinuxDO','Midjourney','MidjourneyPlus','MiniMax','Mistral',
'MokaAI','Moonshot','NewAPI','OhMyGPT','Ollama','OpenAI','OpenAIMax',
'OpenRouter','Passkey','Perplexity','QuantumNous','Replicate','SiliconFlow',
'Stripe','Submodel','SunoAPI','Telegram','Tencent','Vertex AI','VolcEngine',
'WeChat','Xinference','Xunfei','AI Proxy','One API',
])
const locales = ['fr', 'ja', 'ru', 'zh', 'vi']
for (const locale of locales) {
const locFile = JSON.parse(await fs.readFile(path.join(LOCALES_DIR, `${locale}.json`), 'utf8'))
const locTrans = locFile.translation
const untranslated = {}
for (const [key, enVal] of Object.entries(enTrans)) {
const locVal = locTrans[key]
if (locVal === undefined || locVal !== enVal) continue
if (brandNames.has(key)) continue
if (skipPatterns.some(p => p.test(key))) continue
if (typeof enVal === 'string' && enVal.length < 4) continue
if (/[a-zA-Z]{3,}/.test(String(enVal))) untranslated[key] = enVal
}
const count = Object.keys(untranslated).length
if (count > 0) {
console.log(`\n=== ${locale} (${count} untranslated) ===`)
for (const [k, v] of Object.entries(untranslated))
console.log(` ${JSON.stringify(k)}: ${JSON.stringify(v)}`)
} else {
console.log(`\n=== ${locale}: all translated ===`)
}
}
Step 4: Add translations
Create web/default/scripts/add-missing-keys.mjs with this structure:
import fs from 'node:fs/promises'
import path from 'node:path'
const LOCALES_DIR = path.resolve('src/i18n/locales')
function stableStringify(obj) {
return JSON.stringify(obj, null, 2) + '\n'
}
const newKeys = {
en: { /* "key": "English value" */ },
zh: { /* "key": "中文翻译" */ },
fr: { /* "key": "Traduction française" */ },
ja: { /* "key": "日本語翻訳" */ },
ru: { /* "key": "Русский перевод" */ },
vi: { /* "key": "Bản dịch tiếng Việt" */ },
}
async function main() {
let totalAdded = 0
for (const [locale, trans] of Object.entries(newKeys)) {
const filePath = path.join(LOCALES_DIR, `${locale}.json`)
const json = JSON.parse(await fs.readFile(filePath, 'utf8'))
let count = 0
for (const [key, value] of Object.entries(trans)) {
if (!Object.prototype.hasOwnProperty.call(json.translation, key)) {
json.translation[key] = value
count++
} else if (json.translation[key] !== value) {
json.translation[key] = value
count++
}
}
if (count > 0) {
json.translation = Object.fromEntries(
Object.entries(json.translation).sort(([a], [b]) => a.localeCompare(b))
)
await fs.writeFile(filePath, stableStringify(json), 'utf8')
}
console.log(`${locale}: ${count} translations applied`)
totalAdded += count
}
console.log(`\nTotal: ${totalAdded} translations applied`)
}
main().catch((err) => { console.error(err); process.exitCode = 1 })
Populate the newKeys object with actual translations for each locale.
Step 5: Verify and clean up
cd web/default
node scripts/add-missing-keys.mjs # apply translations
node scripts/find-missing-keys.mjs # verify: should say "All t() keys found"
bun run i18n:sync # normalize file order
Delete temporary scripts after completion.
Translation Guidelines
| Language |
Code |
Notes |
| English |
en |
Base locale, key = value |
| Chinese |
zh |
Fallback locale, must be complete |
| French |
fr |
Many English cognates are valid (e.g., "Configuration") |
| Japanese |
ja |
Use katakana for technical loanwords |
| Russian |
ru |
Use formal register |
| Vietnamese |
vi |
Use standard Vietnamese |
Keep as English (do not translate):
- Brand/product names (OpenAI, Claude, Gemini, etc.)
- URLs and email placeholders
- Technical identifiers (JSON keys, API paths, model names)
- Code-like strings (gpt-3.5-turbo, price_xxx, etc.)
Always translate:
- UI labels, button text, error messages, descriptions
- Time units (hours, minutes, months, years)
- Action words (Move, Show, Delete, etc.)
Key Rules
- All scripts run from
web/default/ directory
- Use
node scripts/xxx.mjs (ESM format with top-level await)
- Sort keys alphabetically when writing locale files
- Always run
bun run i18n:sync as the final step
- Delete temporary scripts after completion
- The
{{variable}} placeholders in keys must be preserved in all translations
1---2name: i18n-translate3description: Complete and maintain frontend i18n translations for this project. Covers finding missing translation keys, detecting untranslated entries, and adding translations for all supported locales (en, zh, fr, ja, ru, vi). Use when the user asks to add translations, fix i18n, complete missing translations, or when new UI text needs to be internationalized.4---56# Frontend i18n Translation Workflow78## Overview910- Locale files: `web/default/src/i18n/locales/{en,zh,fr,ja,ru,vi}.json`11- Format: flat JSON under `"translation"` key, keys are English source strings12- Base locale: `en.json` (most keys), fallback: `zh` (Chinese)13- Sync script: `bun run i18n:sync` (from `web/default/`)14- All `t()` calls must have corresponding keys in every locale file1516## Workflow1718### Step 1: Run sync and read report1920```bash21cd web/default && bun run i18n:sync22```2324Read `web/default/src/i18n/locales/_reports/_sync-report.json` to see per-locale status (missingCount, extrasCount, untranslatedCount).2526### Step 2: Find missing keys (used in code but not in locale files)2728Create and run `web/default/scripts/find-missing-keys.mjs`:2930```javascript31import fs from 'node:fs/promises'32import path from 'node:path'3334const LOCALES_DIR = path.resolve('src/i18n/locales')35const SRC_DIR = path.resolve('src')3637const en = JSON.parse(await fs.readFile(path.join(LOCALES_DIR, 'en.json'), 'utf8'))38const enKeys = new Set(Object.keys(en.translation))3940const tCallRegex = /\bt\(\s*['"`]([^'"`\n]+?)['"`]\s*[,)]/g41const tCallMultilineRegex = /\bt\(\s*['"`]([^'"`]+?)['"`]\s*\)/g4243async function walkDir(dir) {44 const files = []45 const entries = await fs.readdir(dir, { withFileTypes: true })46 for (const entry of entries) {47 const fullPath = path.join(dir, entry.name)48 if (entry.isDirectory()) {49 if (['node_modules', '.git', 'locales', '_reports', '_extras'].includes(entry.name)) continue50 files.push(...(await walkDir(fullPath)))51 } else if (/\.(tsx?|jsx?)$/.test(entry.name)) {52 files.push(fullPath)53 }54 }55 return files56}5758const files = await walkDir(SRC_DIR)59const missingKeys = new Map()6061for (const file of files) {62 const content = await fs.readFile(file, 'utf8')63 const relPath = path.relative(SRC_DIR, file)64 for (const regex of [tCallRegex, tCallMultilineRegex]) {65 regex.lastIndex = 066 let match67 while ((match = regex.exec(content)) !== null) {68 const key = match[1]69 if (key.startsWith('{{') || key.includes('${')) continue70 if (!enKeys.has(key)) {71 if (!missingKeys.has(key)) missingKeys.set(key, [])72 missingKeys.get(key).push(relPath)73 }74 }75 }76}7778if (missingKeys.size === 0) {79 console.log('All t() keys found in en.json!')80} else {81 console.log(`Found ${missingKeys.size} missing keys:\n`)82 for (const [key, files] of [...missingKeys.entries()].sort(([a], [b]) => a.localeCompare(b))) {83 console.log(` "${key}"`)84 for (const f of [...new Set(files)]) console.log(` -> ${f}`)85 }86}87```8889### Step 3: Find untranslated entries (value equals English)9091Create and run `web/default/scripts/find-untranslated.mjs`:9293```javascript94import fs from 'node:fs/promises'95import path from 'node:path'9697const LOCALES_DIR = path.resolve('src/i18n/locales')98const en = JSON.parse(await fs.readFile(path.join(LOCALES_DIR, 'en.json'), 'utf8'))99const enTrans = en.translation100101// Brand names, URLs, technical terms — skip these102const skipPatterns = [103 /^https?:\/\//, /^smtp\./, /^socks5:/, /^name@/, /^noreply@/,104 /^org-/, /^price_/, /^whsec_/, /^edit_this$/, /^my-status$/,105 /^_copy$/, /^gpt-/, /^checkout\./, /^footer\./, /^\[?\{/,106 /^"default/, /^\/status\//, /^\/your\//, /^example\.com/,107 /^AZURE_/, /^AccessKey/, /^OAuth/, /^Client /, /^Webhook URL/,108 /^API URL$/, /^Well-Known/, /^Worker URL$/, /^Uptime Kuma/,109 /^New API/, /^Baidu V2$/, /^Zhipu V4$/, /^Quota:$/,110]111112const brandNames = new Set([113 'AIGC2D','Anthropic','API2GPT','Claude','Cloudflare','Cohere','DeepSeek',114 'Discord','DoubaoVideo','FastGPT','Gemini','GitHub','Jimeng','JustSong',115 'LingYiWanWu','LinuxDO','Midjourney','MidjourneyPlus','MiniMax','Mistral',116 'MokaAI','Moonshot','NewAPI','OhMyGPT','Ollama','OpenAI','OpenAIMax',117 'OpenRouter','Passkey','Perplexity','QuantumNous','Replicate','SiliconFlow',118 'Stripe','Submodel','SunoAPI','Telegram','Tencent','Vertex AI','VolcEngine',119 'WeChat','Xinference','Xunfei','AI Proxy','One API',120])121122const locales = ['fr', 'ja', 'ru', 'zh', 'vi']123124for (const locale of locales) {125 const locFile = JSON.parse(await fs.readFile(path.join(LOCALES_DIR, `${locale}.json`), 'utf8'))126 const locTrans = locFile.translation127 const untranslated = {}128129 for (const [key, enVal] of Object.entries(enTrans)) {130 const locVal = locTrans[key]131 if (locVal === undefined || locVal !== enVal) continue132 if (brandNames.has(key)) continue133 if (skipPatterns.some(p => p.test(key))) continue134 if (typeof enVal === 'string' && enVal.length < 4) continue135 if (/[a-zA-Z]{3,}/.test(String(enVal))) untranslated[key] = enVal136 }137138 const count = Object.keys(untranslated).length139 if (count > 0) {140 console.log(`\n=== ${locale} (${count} untranslated) ===`)141 for (const [k, v] of Object.entries(untranslated))142 console.log(` ${JSON.stringify(k)}: ${JSON.stringify(v)}`)143 } else {144 console.log(`\n=== ${locale}: all translated ===`)145 }146}147```148149### Step 4: Add translations150151Create `web/default/scripts/add-missing-keys.mjs` with this structure:152153```javascript154import fs from 'node:fs/promises'155import path from 'node:path'156157const LOCALES_DIR = path.resolve('src/i18n/locales')158159function stableStringify(obj) {160 return JSON.stringify(obj, null, 2) + '\n'161}162163const newKeys = {164 en: { /* "key": "English value" */ },165 zh: { /* "key": "中文翻译" */ },166 fr: { /* "key": "Traduction française" */ },167 ja: { /* "key": "日本語翻訳" */ },168 ru: { /* "key": "Русский перевод" */ },169 vi: { /* "key": "Bản dịch tiếng Việt" */ },170}171172async function main() {173 let totalAdded = 0174175 for (const [locale, trans] of Object.entries(newKeys)) {176 const filePath = path.join(LOCALES_DIR, `${locale}.json`)177 const json = JSON.parse(await fs.readFile(filePath, 'utf8'))178179 let count = 0180 for (const [key, value] of Object.entries(trans)) {181 if (!Object.prototype.hasOwnProperty.call(json.translation, key)) {182 json.translation[key] = value183 count++184 } else if (json.translation[key] !== value) {185 json.translation[key] = value186 count++187 }188 }189190 if (count > 0) {191 json.translation = Object.fromEntries(192 Object.entries(json.translation).sort(([a], [b]) => a.localeCompare(b))193 )194 await fs.writeFile(filePath, stableStringify(json), 'utf8')195 }196197 console.log(`${locale}: ${count} translations applied`)198 totalAdded += count199 }200201 console.log(`\nTotal: ${totalAdded} translations applied`)202}203204main().catch((err) => { console.error(err); process.exitCode = 1 })205```206207Populate the `newKeys` object with actual translations for each locale.208209### Step 5: Verify and clean up210211```bash212cd web/default213node scripts/add-missing-keys.mjs # apply translations214node scripts/find-missing-keys.mjs # verify: should say "All t() keys found"215bun run i18n:sync # normalize file order216```217218Delete temporary scripts after completion.219220## Translation Guidelines221222| Language | Code | Notes |223|----------|------|-------|224| English | en | Base locale, key = value |225| Chinese | zh | Fallback locale, must be complete |226| French | fr | Many English cognates are valid (e.g., "Configuration") |227| Japanese | ja | Use katakana for technical loanwords |228| Russian | ru | Use formal register |229| Vietnamese | vi | Use standard Vietnamese |230231**Keep as English (do not translate):**232- Brand/product names (OpenAI, Claude, Gemini, etc.)233- URLs and email placeholders234- Technical identifiers (JSON keys, API paths, model names)235- Code-like strings (gpt-3.5-turbo, price_xxx, etc.)236237**Always translate:**238- UI labels, button text, error messages, descriptions239- Time units (hours, minutes, months, years)240- Action words (Move, Show, Delete, etc.)241242## Key Rules2432441. All scripts run from `web/default/` directory2452. Use `node scripts/xxx.mjs` (ESM format with top-level await)2463. Sort keys alphabetically when writing locale files2474. Always run `bun run i18n:sync` as the final step2485. Delete temporary scripts after completion2496. The `{{variable}}` placeholders in keys must be preserved in all translations