

1/1/1970
Cara Aku Implement i18n Untuk 20 Bahasa Dalam 3 Hari
Howdy! Aku baru sahaja habis satu tugasan gergasi: terjemahkan Foony ke 20 bahasa berbeza. Kerja ni betul-betul besar sebab hampir setiap fail dalam codebase kena sentuh, tapi aku berjaya siapkan semuanya dalam masa 3 hari sahaja.
Di bawah ni aku pecahkan macam mana aku buat, nombor-nombor di sebalik perubahan ni, dan kenapa aku sekali lagi pilih untuk bina sendiri library terjemahan, bukannya guna standard industri.
Kenapa bukan i18next?
Masa aku mula-mula fikir nak tambah terjemahan, aku memang tengok dulu standard industri: i18next dan react-i18next.
Tapi akhirnya aku pilih untuk optimasikan maintainability oleh AI. i18next memang power, tapi variasi API dia boleh buat LLM terhalusinasi atau tulis kod yang tak konsisten. Dengan mengehadkan library kepada t() dan interpolate() yang ringkas, aku pastikan lebih 10 ejen selari boleh tulis kod yang 100% type-safe dengan hampir tiada campur tangan manusia.
Aku juga agak berhati-hati nak komit kepada ekosistem besar yang mungkin perkenalkan breaking changes pada masa depan. Lepas pernah kena dengan migrasi perit macam React Router v5 dan MUI v4 → v5, aku dah tahu betapa biasa backward compatibility dipecahkan dengan cepat dalam dunia JavaScript. Kos untuk tambah ciri pluralization nanti lebih rendah daripada kos migrate manual 139k baris kod sekarang.
Aku nak sesuatu yang sangat ringkas, betul-betul ringan, dan betul-betul ikut keperluan team aku.
Jadi aku bina sendiri.
Aku buat subset 3 KB yang terhad, direka khusus untuk benarkan AI buat refactor autonomi dengan ketepatan tinggi. Ini yang membolehkan aku, sebagai seorang engineer tunggal, selesaikan kerja 5 orang untuk 3 minggu dalam masa 3 hari.
Implementasi Custom
Aku reka satu library i18n minimal yang saiznya sekitar 3 KB gzipped. Ia dedahkan dua fungsi utama: getTranslation() untuk konteks bukan React dan hook useTranslation() untuk komponen.
Dua-dua ni pulangkan t() untuk penggantian string mudah dan interpolate() bila aku perlu suntik komponen React ke dalam string terjemahan (macam link atau ikon). Kedua-dua fungsi sokong penggantian pembolehubah, contoh "Hello {{thing}}", {thing: 'World'}.
Ini fungsi t() teras:
export function t(key: TranslationKeys, values?: Record<string, string | number>, locale?: SupportedLocale): string {
let namespace: string = '';
let translationKey: string = key;
// Check if key contains '/' - this indicates a namespace
const slashIndex = key.indexOf('/');
if (slashIndex !== -1) {
const parts = key.split('/');
namespace = parts.slice(0, -1).join('/');
translationKey = parts[parts.length - 1];
}
const targetLocale = locale ?? currentLocale;
const text = getTranslationValue(targetLocale, namespace, translationKey);
if (values) {
return interpolateString(text, values);
}
return text;
}
Dan hook React:
export function useTranslation() {
const [language] = useLanguage();
return useMemo(() => ({
t: (key: TranslationKeys, values?: Record<string, string | number>) =>
t(key, values, language),
interpolate: (key: TranslationKeys, components: Record<string, ReactNode>) =>
interpolate(key, components, language),
}), [language, version]);
}
Teras keseluruhan library ni cuma sekitar 580 baris kod. Ia uruskan:
- Lazy-loading fail terjemahan supaya kita tak hantar semua 20 bahasa kepada setiap pengguna.
- Code-splitting terjemahan ikut "namespace" (contoh
common,misc,games/{gameId}). - Satu locale "debug" yang tunjuk key mentah supaya aku boleh semak semua benda dah disambung dengan betul.
Untuk pastikan sistem ni kekal senang diselenggara, aku juga tambah dokumentasi lengkap dalam shared/src/i18n/README.md, dari struktur fail sampai contoh penggunaan untuk client dan server. Sebab aku tak guna library standard, rujukan ni sangat penting untuk onboard ahli team baru (atau ingatkan diri aku pada masa depan, atau LLM) macam mana sistem ni berfungsi.
Ikut Nombor
Untuk bagi gambaran skala update ni, ini yang berubah dalam codebase:
- 20 bahasa disokong (ditambah satu locale debug untuk dev).
- 360 fail locale dicipta.
- 139,031 baris kod terjemahan.
- 3,938 panggilan
t()ditambah merata-rata dalam client. - 728 fail sumber diubah.
- 18 fail sumber bahasa Inggeris yang jadi "source of truth" (16 game + common + misc).
Mengatur Dengan Ejen
Kalau buat semua ni manual, mungkin makan masa berbulan-bulan dan sangat membosankan. Sebaliknya, aku uruskan lebih sedozen ejen Cursor serentak untuk buat kerja berat ni.
Aku mula dengan pecahkan codebase kepada "seksyen" ikut folder. Setiap game di Foony dapat folder sendiri dan namespace terjemahan sendiri. Ini kekalkan saiz initial load kecil, sebab kau hanya load terjemahan untuk game yang kau main.
Aku jalankan banyak ejen Cursor serentak. Aku bagi setiap ejen satu seksyen khusus, contohnya "tukar game Chess untuk guna terjemahan", dan ejen tu akan lalui fail demi fail, cari string yang nampak pada pengguna dan ganti dengan t('games/chess/some.key').
Lepas tu ejen akan tambah key tu ke fail locale bahasa Inggeris yang sesuai dengan komen JSDoc yang jelaskan "apa" dan "di mana" string tu digunakan. Konteks ni penting bila nak jana terjemahan untuk bahasa lain, sebab ia bantu LLM faham sama ada "Save" bermaksud "Simpan Tetapan Game" atau "Simpan Lukisan Draw & Guess Anda".
Kawalan Kualiti
Aku semak dengan cepat semua kod yang dijana. Ejen-ejen tu sebenarnya agak bagus, tapi sekali-sekala tetap ada silap, macam letak hook useTranslation selepas return awal.
Terjemahan yang strongly-typed sangat membantu. Ini pastikan semua terjemahan untuk setiap locale ada semua key yang betul (dan tiada key yang salah). Ia juga pastikan panggilan t() dan interpolate() guna string terjemahan sebenar yang wujud.
Sistem type tu ekstrak semua kemungkinan translation key daripada fail sumber bahasa Inggeris:
/**
* Extracts all possible paths from a nested object type, creating dot-notation keys.
* Example: {a: string, b: {c: string, d: {e: string}}} → 'a' | 'b.c' | 'b.d.e'
*/
type ExtractPaths<T, Prefix extends string = ''> = T extends string
? Prefix extends '' ? never : Prefix
: T extends object
? {
[K in keyof T]: K extends string | number
? T[K] extends string
? Prefix extends '' ? `${K}` : `${Prefix}.${K}`
: ExtractPaths<T[K], Prefix extends '' ? `${K}` : `${Prefix}.${K}`>
: never
}[keyof T]
: never;
export type TranslationKeys =
| ExtractPaths<typeof import('./locales/en/index').default>
| `misc/${ExtractPaths<typeof import('./locales/en/misc').default>}`
| `games/chess/${ExtractPaths<typeof import('./locales/en/games/chess').default>}`
| `games/pool/${ExtractPaths<typeof import('./locales/en/games/pool').default>}`
// ... and so on for all games
Ini bagi autocomplete TypeScript yang sempurna, dan sebarang typo dalam translation key akan ditangkap masa compile. Ejen tak boleh buat silap macam t('games/ches/name') sebab TypeScript terus bising.
Penterjemahan (Localization)
Bila penukaran bahasa Inggeris dah siap, aku pecahkan baki tugas locale. Aku jadikan setiap ejen bertanggungjawab untuk tukar satu fail locale Inggeris kepada satu bahasa tertentu.
Contohnya, aku bagi ejen prompt macam ni:
Please ensure that ar/games/dinomight.ts has all the translations from en/games/dinomight.ts.
Use `export const account: DinomightTranslations = {`.
Iterate until there are no more type errors for your translation file (if you see errors for other files, ignore them--you are running in parallel with other agents that are responsible for those other files).
Your translations must be excellent and correct for the jsdoc context provided in en.
You must do this manually and without writing "helper" scripts, and with no shortcuts.
Aku sempat terfikir nak suruh Cursor buat satu skrip yang feed setiap fail ni ke LLM dan biar LLM jana terjemahan, tapi aku nak jimat sedikit kos LLM. Guna skrip yang hanya update terjemahan yang masih hilang adalah pendekatan yang lebih baik, dan mungkin aku akan guna solusi serupa pada masa depan. Aku nak jejak string mana yang perlu dikemas kini / diterjemah, tapi aku juga nak kekalkan benda ni simple. Mungkin nanti aku alih kerja terjemahan ke database atau sesuatu.
Aku juga tambah satu locale "debug" yang hanya wujud dalam development. Ni benarkan aku lihat semua string yang diganti untuk sahkan semuanya berjalan (dan nampak agak cool juga). Bila kau guna locale debug, t() akan pulangkan key yang dibalut dalam bracket:
if (targetLocale === 'debug') {
return `⟦${key}⟧`;
}
Jadi, bukannya nampak "Welcome to Foony!", kau akan nampak ⟦welcome⟧, dan ini mudahkan aku kesan mana-mana terjemahan yang hilang.
Akhir sekali, satu lagi ejen implement routing /{locale}/** supaya URL macam /ja/games/chess terus route ke bahasa yang betul (untuk contoh ni, Jepun).
Menterjemah Blog
Terjemah string UI satu hal, tapi macam mana dengan blog post? Aku tak nak pula kena hidupkan dan urus lagi banyak ejen hanya untuk terjemahkan semua blog post aku.
Aku selesaikan ini dengan suruh satu ejen buat skrip (scripts/src/generateBlogTranslations.ts) yang automasikan keseluruhan proses.
Macam ni cara ia berfungsi:
- Ia imbas direktori
client/src/posts/enuntuk fail MDX bahasa Inggeris. - Ia semak terjemahan yang hilang dalam folder locale lain (contoh
posts/ja,posts/es). - Kalau terjemahan tak wujud, ia baca kandungan bahasa Inggeris dan hantar ke Gemini 3 Pro Preview dengan prompt khas untuk terjemahkan kandungan sambil kekalkan format Markdown.
- Ia simpan fail baru di lokasi yang betul.
Di frontend, aku guna import.meta.glob untuk import semua fail MDX ni secara dinamik. Komponen PostPage aku kemudian akan semak locale semasa pengguna dan lazy-load fail MDX yang betul. Kalau terjemahan hilang (sebab aku belum jalankan skrip lagi), ia akan fallback dengan baik ke bahasa Inggeris.
Kesimpulan
Pada tahap ni, aku dah ada satu laman penuh berfungsi yang diterjemah ke semua 20 locale!
3 hari ni memang agak gila, tapi hasilnya adalah laman yang dilokalize sepenuhnya dan terasa (hampir) native untuk pengguna di seluruh dunia. Dengan bina library custom yang ringan dan guna ejen AI untuk kerja refactor yang membosankan, aku berjaya buat sesuatu yang mungkin mustahil setahun lepas: full i18n dalam 3 hari untuk laman web kompleks oleh seorang engineer saja.
Masa depan programming bukan tentang tulis kod secepat mungkin. Ia tentang mengatur ejen AI dan ada kepakaran domain yang cukup dalam untuk menyemak dan mengesahkan hasil kerja mereka.