Frontend i18n Translation Workflow
Mandatory Preflight
- Read this entire
SKILL.md before any frontend i18n work, including one-key fixes.
- Before editing locale files, confirm the source text comes from a
t(...) key, en.json, existing UI copy, or an explicitly requested new UI string.
- Use the user conversation only to understand the task target. Do not copy conversation text, review wording, or task descriptions directly into locale values.
- Before translating each key, re-think the intended UI copy from the code and locale context instead of treating the surrounding chat as the translation source.
Hard Constraint: Locale Writes Go Through the Script
- You MUST NOT edit
web/src/i18n/locales/*.json directly with text-editing tools (StrReplace, Write, search-and-replace, manual JSON edits, etc.). This applies even to a single key.
- ALL locale writes MUST go through the
add-missing-keys.mjs script, followed by bun run i18n:sync. The script is the only sanctioned way to add or change locale values.
- Why this is mandatory, not optional:
- Hand-editing reliably drops one or more of the seven locales (
en, zh, zh-TW, fr, ja, ru, vi), leaving keys missing in some languages.
- Hand-editing breaks the required alphabetical key order and introduces JSON syntax errors (trailing commas, mismatched quotes).
- The script writes all seven files atomically with consistent sorting, so the locale set stays in sync by construction.
- The script does not do the translation for you. You still must reason out each locale's copy and populate the script's
newKeys object; the script only handles insertion, sorting, and writing. Do not skip the script just because the thinking happens regardless.
Scope Checklist
Before editing files, treat the task as covered by this skill if it involves:
i18n, translation, locale files, language packs, missing keys, or untranslated text
t('...'), useTranslation(), static-keys.ts, or locales/*.json
- UI copy in buttons, labels, toasts, dialogs, placeholders, validation messages, descriptions, or table/empty states
- A review finding about missing i18n keys
Do not skip this workflow because the fix is "just one key".
Overview
- Locale files:
web/src/i18n/locales/{en,zh,zh-TW,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/)
- All
t() calls must have corresponding keys in every locale file
Small Fix Path
For a single known missing key (still script-only, no direct JSON edits):
- Confirm the exact key at the call site and verify it is absent from all locale files.
- Add the key via
add-missing-keys.mjs, populating its newKeys object for every supported locale: en, zh, zh-TW, fr, ja, ru, vi. Even one key goes through the script; do not hand-edit the JSON.
- The script preserves the flat
"translation" object and keeps keys alphabetically sorted automatically.
- Run a targeted search for the key in code and locale files.
- Run
bun run i18n:sync to normalize file order. This step is mandatory, not optional.
Workflow
Step 1: Run sync and read report
cd web && bun run i18n:sync
Read web/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/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/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', 'zh-TW', '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
This script is the ONLY sanctioned way to write locale values. You MUST NOT bypass it by hand-filling the JSON files. Create web/scripts/add-missing-keys.mjs with this exact 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": "中文翻译" */ },
'zh-TW': { /* "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
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
Source Text Rules
- Reconsider every key's UI meaning before translating: component location, user action, placeholder variables, button/label/toast/dialog/validation context, and whether the copy is a noun, command, status, or full sentence.
- Prefer the English key or
en value as the source text. Use the call site only to clarify meaning, tone, and constraints.
- Do not copy chat messages, review comments, issue descriptions, or task wording as translation text.
- If the source text is unclear, inspect the code and locale files first. Ask the user for exact source copy only when the intended UI text remains ambiguous.
Length and Layout Awareness
- Consider whether translated text may overflow the UI before choosing final wording, especially for buttons, table headers, menu items, labels, toasts, dialog titles, tabs, badges, and empty states.
- For languages that often expand relative to English, especially French, Russian, and Vietnamese, prefer natural but compact wording.
- Do not sacrifice meaning just to shorten text. When the call site has limited space, choose the shortest clear translation that preserves the UI intent.
- For interpolated variables, counts, model names, provider names, quotas, and dates, consider the longest realistic rendered text, not only the translation string itself.
| Language |
Code |
Notes |
| English |
en |
Base locale, key = value |
| Chinese |
zh |
Fallback locale, must be complete |
| Traditional Chinese |
zh-TW |
Use natural Traditional Chinese wording |
| 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/ 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
- NEVER edit
locales/*.json directly. Any non-script write to a locale file (StrReplace, Write, manual JSON edit) is non-compliant, including single-key fixes.
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, zh-TW, fr, ja, ru, vi). Use for any task involving frontend locale files, missing translation keys, untranslated UI text, `t(...)` keys, `useTranslation()`, static i18n keys, button/label/ toast/dialog/placeholder/validation copy, or adding/fixing even a single i18n key. Use when review findings mention missing i18n, when new UI text needs translation, or when the user asks to add translations, fix i18n, or complete missing translations. Always load and follow this skill before translating, adding locale keys, or editing frontend i18n files.4---56# Frontend i18n Translation Workflow78## Mandatory Preflight910- Read this entire `SKILL.md` before any frontend i18n work, including one-key fixes.11- Before editing locale files, confirm the source text comes from a `t(...)` key, `en.json`, existing UI copy, or an explicitly requested new UI string.12- Use the user conversation only to understand the task target. Do not copy conversation text, review wording, or task descriptions directly into locale values.13- Before translating each key, re-think the intended UI copy from the code and locale context instead of treating the surrounding chat as the translation source.1415### Hard Constraint: Locale Writes Go Through the Script1617- You MUST NOT edit `web/src/i18n/locales/*.json` directly with text-editing tools (StrReplace, Write, search-and-replace, manual JSON edits, etc.). This applies even to a single key.18- ALL locale writes MUST go through the `add-missing-keys.mjs` script, followed by `bun run i18n:sync`. The script is the only sanctioned way to add or change locale values.19- Why this is mandatory, not optional:20 - Hand-editing reliably drops one or more of the seven locales (`en`, `zh`, `zh-TW`, `fr`, `ja`, `ru`, `vi`), leaving keys missing in some languages.21 - Hand-editing breaks the required alphabetical key order and introduces JSON syntax errors (trailing commas, mismatched quotes).22 - The script writes all seven files atomically with consistent sorting, so the locale set stays in sync by construction.23- The script does not do the translation for you. You still must reason out each locale's copy and populate the script's `newKeys` object; the script only handles insertion, sorting, and writing. Do not skip the script just because the thinking happens regardless.2425## Scope Checklist2627Before editing files, treat the task as covered by this skill if it involves:2829- `i18n`, translation, locale files, language packs, missing keys, or untranslated text30- `t('...')`, `useTranslation()`, `static-keys.ts`, or `locales/*.json`31- UI copy in buttons, labels, toasts, dialogs, placeholders, validation messages, descriptions, or table/empty states32- A review finding about missing i18n keys3334Do not skip this workflow because the fix is "just one key".3536## Overview3738- Locale files: `web/src/i18n/locales/{en,zh,zh-TW,fr,ja,ru,vi}.json`39- Format: flat JSON under `"translation"` key, keys are English source strings40- Base locale: `en.json` (most keys), fallback: `zh` (Chinese)41- Sync script: `bun run i18n:sync` (from `web/`)42- All `t()` calls must have corresponding keys in every locale file4344## Small Fix Path4546For a single known missing key (still script-only, no direct JSON edits):47481. Confirm the exact key at the call site and verify it is absent from all locale files.492. Add the key via `add-missing-keys.mjs`, populating its `newKeys` object for every supported locale: `en`, `zh`, `zh-TW`, `fr`, `ja`, `ru`, `vi`. Even one key goes through the script; do not hand-edit the JSON.503. The script preserves the flat `"translation"` object and keeps keys alphabetically sorted automatically.514. Run a targeted search for the key in code and locale files.525. Run `bun run i18n:sync` to normalize file order. This step is mandatory, not optional.5354## Workflow5556### Step 1: Run sync and read report5758```bash59cd web && bun run i18n:sync60```6162Read `web/src/i18n/locales/_reports/_sync-report.json` to see per-locale status (missingCount, extrasCount, untranslatedCount).6364### Step 2: Find missing keys (used in code but not in locale files)6566Create and run `web/scripts/find-missing-keys.mjs`:6768```javascript69import fs from 'node:fs/promises'70import path from 'node:path'7172const LOCALES_DIR = path.resolve('src/i18n/locales')73const SRC_DIR = path.resolve('src')7475const en = JSON.parse(await fs.readFile(path.join(LOCALES_DIR, 'en.json'), 'utf8'))76const enKeys = new Set(Object.keys(en.translation))7778const tCallRegex = /\bt\(\s*['"`]([^'"`\n]+?)['"`]\s*[,)]/g79const tCallMultilineRegex = /\bt\(\s*['"`]([^'"`]+?)['"`]\s*\)/g8081async function walkDir(dir) {82 const files = []83 const entries = await fs.readdir(dir, { withFileTypes: true })84 for (const entry of entries) {85 const fullPath = path.join(dir, entry.name)86 if (entry.isDirectory()) {87 if (['node_modules', '.git', 'locales', '_reports', '_extras'].includes(entry.name)) continue88 files.push(...(await walkDir(fullPath)))89 } else if (/\.(tsx?|jsx?)$/.test(entry.name)) {90 files.push(fullPath)91 }92 }93 return files94}9596const files = await walkDir(SRC_DIR)97const missingKeys = new Map()9899for (const file of files) {100 const content = await fs.readFile(file, 'utf8')101 const relPath = path.relative(SRC_DIR, file)102 for (const regex of [tCallRegex, tCallMultilineRegex]) {103 regex.lastIndex = 0104 let match105 while ((match = regex.exec(content)) !== null) {106 const key = match[1]107 if (key.startsWith('{{') || key.includes('${')) continue108 if (!enKeys.has(key)) {109 if (!missingKeys.has(key)) missingKeys.set(key, [])110 missingKeys.get(key).push(relPath)111 }112 }113 }114}115116if (missingKeys.size === 0) {117 console.log('All t() keys found in en.json!')118} else {119 console.log(`Found ${missingKeys.size} missing keys:\n`)120 for (const [key, files] of [...missingKeys.entries()].sort(([a], [b]) => a.localeCompare(b))) {121 console.log(` "${key}"`)122 for (const f of [...new Set(files)]) console.log(` -> ${f}`)123 }124}125```126127### Step 3: Find untranslated entries (value equals English)128129Create and run `web/scripts/find-untranslated.mjs`:130131```javascript132import fs from 'node:fs/promises'133import path from 'node:path'134135const LOCALES_DIR = path.resolve('src/i18n/locales')136const en = JSON.parse(await fs.readFile(path.join(LOCALES_DIR, 'en.json'), 'utf8'))137const enTrans = en.translation138139// Brand names, URLs, technical terms — skip these140const skipPatterns = [141 /^https?:\/\//, /^smtp\./, /^socks5:/, /^name@/, /^noreply@/,142 /^org-/, /^price_/, /^whsec_/, /^edit_this$/, /^my-status$/,143 /^_copy$/, /^gpt-/, /^checkout\./, /^footer\./, /^\[?\{/,144 /^"default/, /^\/status\//, /^\/your\//, /^example\.com/,145 /^AZURE_/, /^AccessKey/, /^OAuth/, /^Client /, /^Webhook URL/,146 /^API URL$/, /^Well-Known/, /^Worker URL$/, /^Uptime Kuma/,147 /^New API/, /^Baidu V2$/, /^Zhipu V4$/, /^Quota:$/,148]149150const brandNames = new Set([151 'AIGC2D','Anthropic','API2GPT','Claude','Cloudflare','Cohere','DeepSeek',152 'Discord','DoubaoVideo','FastGPT','Gemini','GitHub','Jimeng','JustSong',153 'LingYiWanWu','LinuxDO','Midjourney','MidjourneyPlus','MiniMax','Mistral',154 'MokaAI','Moonshot','NewAPI','OhMyGPT','Ollama','OpenAI','OpenAIMax',155 'OpenRouter','Passkey','Perplexity','QuantumNous','Replicate','SiliconFlow',156 'Stripe','Submodel','SunoAPI','Telegram','Tencent','Vertex AI','VolcEngine',157 'WeChat','Xinference','Xunfei','AI Proxy','One API',158])159160const locales = ['fr', 'ja', 'ru', 'zh', 'zh-TW', 'vi']161162for (const locale of locales) {163 const locFile = JSON.parse(await fs.readFile(path.join(LOCALES_DIR, `${locale}.json`), 'utf8'))164 const locTrans = locFile.translation165 const untranslated = {}166167 for (const [key, enVal] of Object.entries(enTrans)) {168 const locVal = locTrans[key]169 if (locVal === undefined || locVal !== enVal) continue170 if (brandNames.has(key)) continue171 if (skipPatterns.some(p => p.test(key))) continue172 if (typeof enVal === 'string' && enVal.length < 4) continue173 if (/[a-zA-Z]{3,}/.test(String(enVal))) untranslated[key] = enVal174 }175176 const count = Object.keys(untranslated).length177 if (count > 0) {178 console.log(`\n=== ${locale} (${count} untranslated) ===`)179 for (const [k, v] of Object.entries(untranslated))180 console.log(` ${JSON.stringify(k)}: ${JSON.stringify(v)}`)181 } else {182 console.log(`\n=== ${locale}: all translated ===`)183 }184}185```186187### Step 4: Add translations188189This script is the ONLY sanctioned way to write locale values. You MUST NOT bypass it by hand-filling the JSON files. Create `web/scripts/add-missing-keys.mjs` with this exact structure:190191```javascript192import fs from 'node:fs/promises'193import path from 'node:path'194195const LOCALES_DIR = path.resolve('src/i18n/locales')196197function stableStringify(obj) {198 return JSON.stringify(obj, null, 2) + '\n'199}200201const newKeys = {202 en: { /* "key": "English value" */ },203 zh: { /* "key": "中文翻译" */ },204 'zh-TW': { /* "key": "繁體中文翻譯" */ },205 fr: { /* "key": "Traduction française" */ },206 ja: { /* "key": "日本語翻訳" */ },207 ru: { /* "key": "Русский перевод" */ },208 vi: { /* "key": "Bản dịch tiếng Việt" */ },209}210211async function main() {212 let totalAdded = 0213214 for (const [locale, trans] of Object.entries(newKeys)) {215 const filePath = path.join(LOCALES_DIR, `${locale}.json`)216 const json = JSON.parse(await fs.readFile(filePath, 'utf8'))217218 let count = 0219 for (const [key, value] of Object.entries(trans)) {220 if (!Object.prototype.hasOwnProperty.call(json.translation, key)) {221 json.translation[key] = value222 count++223 } else if (json.translation[key] !== value) {224 json.translation[key] = value225 count++226 }227 }228229 if (count > 0) {230 json.translation = Object.fromEntries(231 Object.entries(json.translation).sort(([a], [b]) => a.localeCompare(b))232 )233 await fs.writeFile(filePath, stableStringify(json), 'utf8')234 }235236 console.log(`${locale}: ${count} translations applied`)237 totalAdded += count238 }239240 console.log(`\nTotal: ${totalAdded} translations applied`)241}242243main().catch((err) => { console.error(err); process.exitCode = 1 })244```245246Populate the `newKeys` object with actual translations for each locale.247248### Step 5: Verify and clean up249250```bash251cd web252node scripts/add-missing-keys.mjs # apply translations253node scripts/find-missing-keys.mjs # verify: should say "All t() keys found"254bun run i18n:sync # normalize file order255```256257Delete temporary scripts after completion.258259## Translation Guidelines260261### Source Text Rules262263- Reconsider every key's UI meaning before translating: component location, user action, placeholder variables, button/label/toast/dialog/validation context, and whether the copy is a noun, command, status, or full sentence.264- Prefer the English key or `en` value as the source text. Use the call site only to clarify meaning, tone, and constraints.265- Do not copy chat messages, review comments, issue descriptions, or task wording as translation text.266- If the source text is unclear, inspect the code and locale files first. Ask the user for exact source copy only when the intended UI text remains ambiguous.267268### Length and Layout Awareness269270- Consider whether translated text may overflow the UI before choosing final wording, especially for buttons, table headers, menu items, labels, toasts, dialog titles, tabs, badges, and empty states.271- For languages that often expand relative to English, especially French, Russian, and Vietnamese, prefer natural but compact wording.272- Do not sacrifice meaning just to shorten text. When the call site has limited space, choose the shortest clear translation that preserves the UI intent.273- For interpolated variables, counts, model names, provider names, quotas, and dates, consider the longest realistic rendered text, not only the translation string itself.274275| Language | Code | Notes |276|----------|------|-------|277| English | en | Base locale, key = value |278| Chinese | zh | Fallback locale, must be complete |279| Traditional Chinese | zh-TW | Use natural Traditional Chinese wording |280| French | fr | Many English cognates are valid (e.g., "Configuration") |281| Japanese | ja | Use katakana for technical loanwords |282| Russian | ru | Use formal register |283| Vietnamese | vi | Use standard Vietnamese |284285**Keep as English (do not translate):**286- Brand/product names (OpenAI, Claude, Gemini, etc.)287- URLs and email placeholders288- Technical identifiers (JSON keys, API paths, model names)289- Code-like strings (gpt-3.5-turbo, price_xxx, etc.)290291**Always translate:**292- UI labels, button text, error messages, descriptions293- Time units (hours, minutes, months, years)294- Action words (Move, Show, Delete, etc.)295296## Key Rules2972981. All scripts run from `web/` directory2992. Use `node scripts/xxx.mjs` (ESM format with top-level await)3003. Sort keys alphabetically when writing locale files3014. Always run `bun run i18n:sync` as the final step3025. Delete temporary scripts after completion3036. The `{{variable}}` placeholders in keys must be preserved in all translations3047. NEVER edit `locales/*.json` directly. Any non-script write to a locale file (StrReplace, Write, manual JSON edit) is non-compliant, including single-key fixes.