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.
72
88%
Does it follow best practices?
Run evals on this skill
Adds up to 20 points to the overall score
View guide
Passed
No findings from the security scan
SKILL.md before any frontend i18n work, including one-key fixes.t(...) key, en.json, existing UI copy, or an explicitly requested new UI string.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.add-missing-keys.mjs script, followed by bun run i18n:sync. The script is the only sanctioned way to add or change locale values.en, zh, zh-TW, fr, ja, ru, vi), leaving keys missing in some languages.newKeys object; the script only handles insertion, sorting, and writing. Do not skip the script just because the thinking happens regardless.Before editing files, treat the task as covered by this skill if it involves:
i18n, translation, locale files, language packs, missing keys, or untranslated textt('...'), useTranslation(), static-keys.ts, or locales/*.jsonDo not skip this workflow because the fix is "just one key".
web/src/i18n/locales/{en,zh,zh-TW,fr,ja,ru,vi}.json"translation" key, keys are English source stringsen.json (most keys), fallback: zh (Chinese)bun run i18n:sync (from web/)t() calls must have corresponding keys in every locale fileFor a single known missing key (still script-only, no direct JSON edits):
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."translation" object and keeps keys alphabetically sorted automatically.bun run i18n:sync to normalize file order. This step is mandatory, not optional.cd web && bun run i18n:syncRead web/src/i18n/locales/_reports/_sync-report.json to see per-locale status (missingCount, extrasCount, untranslatedCount).
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}`)
}
}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 ===`)
}
}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.
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 orderDelete temporary scripts after completion.
en value as the source text. Use the call site only to clarify meaning, tone, and constraints.| 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):
Always translate:
web/ directorynode scripts/xxx.mjs (ESM format with top-level await)bun run i18n:sync as the final step{{variable}} placeholders in keys must be preserved in all translationslocales/*.json directly. Any non-script write to a locale file (StrReplace, Write, manual JSON edit) is non-compliant, including single-key fixes.4728032
If you maintain this skill, you can claim it as your own. Once claimed, you can manage eval scenarios, bundle related skills, attach documentation or rules, and ensure cross-agent compatibility.