

1/1/1970
Bagaimana Saya Menerapkan i18n ke 20 Bahasa dalam 3 Hari
Halo! Saya baru saja menyelesaikan tugas besar yaitu menerjemahkan Foony ke 20 bahasa berbeda. Ini adalah pekerjaan raksasa yang melibatkan hampir setiap file di codebase, tapi saya berhasil menyelesaikannya hanya dalam 3 hari.
Di bawah ini saya akan menjelaskan bagaimana saya melakukannya, angka-angka spesifik di balik perubahan ini, dan mengapa saya memutuskan untuk membuat library terjemahan sendiri (sekali lagi) alih-alih menggunakan standar industri.
Mengapa bukan i18next?
Ketika saya pertama kali mempertimbangkan untuk menambahkan terjemahan, saya melirik standar industri: i18next dan react-i18next.
Namun, saya memutuskan untuk mengoptimalkan kemudahan pemeliharaan oleh AI. i18next memang powerful, tapi variasi API-nya bisa membuat LLM berhalusinasi atau menulis kode yang tidak konsisten. Dengan membatasi library ke t() dan interpolate() yang sederhana, saya memastikan 10+ agen paralel dapat menulis kode yang 100% type-safe dengan hampir tanpa intervensi manusia.
Saya juga waspada terhadap ekosistem besar yang mungkin memperkenalkan breaking changes di kemudian hari. Setelah pernah tersakiti oleh migrasi yang menyiksa seperti React Router v5 dan MUI v4 → v5, saya tahu bahwa pemutusan kompatibilitas mundur yang cepat sangat umum di dunia JavaScript. Biaya menambahkan fitur pluralisasi nanti lebih rendah daripada biaya migrasi manual 139 ribu baris kode sekarang.
Saya ingin sesuatu yang sangat sederhana, sangat ringan, dan dirancang khusus untuk kebutuhan tim saya.
Jadi saya menulisnya sendiri.
Saya membangun subset yang dibatasi sebesar 3 KB, dirancang khusus untuk memungkinkan refactoring AI otonom dengan akurasi tinggi. Ini memungkinkan saya bertindak sebagai engineer tunggal yang menyelesaikan beban kerja tim 5 orang selama 3 minggu hanya dalam 3 hari.
Implementasi Custom
Saya membuat library i18n minimal yang berukuran sekitar 3 KB gzipped. Library ini mengekspos dua fungsi utama: getTranslation() untuk konteks non-React dan hook useTranslation() untuk komponen.
Keduanya mengembalikan t() untuk penggantian string sederhana dan interpolate() ketika saya perlu menyuntikkan komponen React ke dalam string terjemahan (seperti link atau ikon). Kedua fungsi mendukung penggantian variabel, misalnya "Hello {{thing}}", {thing: 'World'}.
Key mengikuti notasi "slash-dot" (slash untuk path file ke file lokalisasi, dot untuk objek bersarang dalam file). Untuk memastikan keunikan, key terjemahan dalam sebuah file tidak boleh mengandung forward-slash.
Ini fungsi inti t():
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-nya:
export function useTranslation() {
const [language] = useLanguage();
// Subscribe to locale loading events to trigger re-renders when translations are loaded
const version = useSyncExternalStore(
(callback) => LocaleQueryer.onLoad(callback),
() => LocaleQueryer.getVersion(),
() => LocaleQueryer.getVersion()
);
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]);
}
Inti dari seluruh library hanya sekitar 580 baris kode. Library ini menangani:
- Lazy-loading file terjemahan agar kami tidak mengirim seluruh 20 bahasa ke setiap pengguna.
- Code-splitting terjemahan berdasarkan "namespace" (misalnya
common,misc,games/{gameId}). - Locale "debug" yang menampilkan key mentah sehingga saya bisa memverifikasi semuanya terhubung dengan benar.
Untuk memastikan sistem tetap mudah dipelihara, saya juga menambahkan dokumentasi komprehensif di shared/src/i18n/README.md, mencakup segalanya mulai dari struktur file hingga contoh penggunaan untuk client maupun server. Karena saya tidak menggunakan library standar, memiliki referensi ini sangat penting untuk onboarding anggota tim baru (atau sekadar mengingatkan diri saya di masa depan atau LLM tentang cara kerjanya).
Menurut Angka
Untuk memberikan gambaran skala pembaruan ini, berikut yang berubah di codebase:
- 20 bahasa didukung (plus locale debug untuk pengembangan).
- 360 file locale dibuat.
- 139.031 baris kode terjemahan.
- 3.938 pemanggilan
t()ditambahkan di seluruh client. - 728 file sumber dimodifikasi.
- 18 file sumber bahasa Inggris yang berfungsi sebagai sumber kebenaran (16 game + common + misc).
Mengorkestrasi dengan Agen
Melakukan ini secara manual akan memakan waktu berbulan-bulan kerja mekanis yang membosankan. Sebagai gantinya, saya mengorkestrasi lebih dari selusin agen Cursor secara bersamaan untuk melakukan pekerjaan berat.
Saya mulai dengan memecah codebase menjadi "bagian" berdasarkan folder. Setiap game di Foony mendapatkan folder dan namespace terjemahannya sendiri. Ini menjaga ukuran muatan awal tetap kecil karena Anda hanya memuat terjemahan untuk game yang sedang dimainkan.
Saya menjalankan beberapa agen Cursor secara bersamaan. Saya menugaskan setiap agen ke bagian tertentu, seperti "konversi game Chess untuk menggunakan terjemahan", dan agen tersebut akan menelusuri file demi file, mencari string yang ditampilkan ke pengguna dan menggantinya dengan t('games/chess/some.key').
Agen tersebut kemudian akan menambahkan key tersebut ke file locale Inggris yang sesuai dengan komentar JSDoc yang menjelaskan "apa" dan "di mana" string tersebut. Konteks ini penting saat membuat terjemahan untuk bahasa lain, karena membantu LLM memahami apakah "Save" berarti "Save Game Configuration" atau "Save Your Draw & Guess Drawing".
Kontrol Kualitas
Saya dengan cepat meninjau semua kode yang dihasilkan. Para agen ternyata cukup baik, tetapi mereka kadang-kadang membuat kesalahan, seperti meletakkan hook useTranslation setelah pernyataan return lebih awal.
Terjemahan dengan tipe yang ketat sangat membantu. Ini memastikan semua terjemahan untuk setiap locale memiliki semua key yang benar (dan tidak ada yang salah). Ini juga memastikan bahwa pemanggilan t() dan interpolate() menggunakan string terjemahan yang benar-benar ada.
Sistem tipe mengekstrak semua kemungkinan key terjemahan dari file sumber bahasa Inggris:
/**
* 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 memberikan autocomplete TypeScript yang sempurna, dan setiap salah ketik pada key terjemahan akan tertangkap saat compile time. Para agen tidak bisa membuat kesalahan seperti t('games/ches/name') karena TypeScript langsung menandainya.
Lokalisasi
Setelah konversi bahasa Inggris selesai, saya membagi tugas locale yang tersisa. Saya membuat setiap agen bertanggung jawab untuk mengonversi satu file locale Inggris ke bahasa tertentu.
Misalnya, saya memberi para agen prompt seperti ini:
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.
Saya mempertimbangkan untuk meminta Cursor membuat skrip yang memasukkan setiap file ini ke dalam LLM dan membiarkannya menghasilkan, tetapi saya ingin sedikit menghemat biaya LLM. Menggunakan skrip untuk hanya memperbarui terjemahan yang hilang adalah pendekatan yang lebih baik, dan saya mungkin akan menggunakan solusi serupa di masa depan. Saya ingin melacak string mana yang perlu diperbarui / diterjemahkan, tetapi ingin menjaga kesederhanaan. Saya mungkin akan memindahkan pekerjaan terjemahan ke database atau sejenisnya.
Saya juga menambahkan locale "debug" yang hanya tersedia di pengembangan. Ini memungkinkan saya melihat semua string yang diganti untuk memverifikasi bahwa semuanya berfungsi (selain itu menurut saya keren). Saat Anda menggunakan locale debug, t() mengembalikan key yang dibungkus dalam tanda kurung:
if (targetLocale === 'debug') {
return `⟦${key}⟧`;
}
Jadi alih-alih melihat "Welcome to Foony!", Anda akan melihat ⟦welcome⟧, sehingga mudah untuk menemukan terjemahan yang hilang.
Akhirnya, agen lain mengimplementasikan routing /{locale}/** sehingga hal-hal seperti /ja/games/chess akan mengarah ke bahasa yang benar (dalam hal ini bahasa Jepang).
Menerjemahkan Blog
Menerjemahkan string UI adalah satu hal, tetapi bagaimana dengan postingan blog? Saya tidak ingin menjalankan dan mengelola lebih banyak agen lagi untuk menerjemahkan semua postingan blog saya.
Saya menyelesaikan ini dengan meminta agen membuat skrip (scripts/src/generateBlogTranslations.ts) yang mengotomatisasi seluruh proses.
Begini cara kerjanya:
- Skrip memindai direktori
client/src/posts/enuntuk file MDX bahasa Inggris. - Skrip memeriksa terjemahan yang hilang di folder locale lain (misalnya
posts/ja,posts/es). - Jika terjemahan hilang, skrip membaca konten bahasa Inggris dan memasukkannya ke Gemini 3 Pro Preview dengan prompt khusus untuk menerjemahkan konten sambil mempertahankan format Markdown.
- Skrip menyimpan file baru ke lokasi yang benar.
Di frontend, saya menggunakan import.meta.glob untuk mengimpor semua file MDX ini secara dinamis. Komponen PostPage saya kemudian hanya memeriksa locale pengguna saat ini dan melakukan lazy-load file MDX yang benar. Jika terjemahan hilang (karena saya belum menjalankan skrip), komponen ini dengan mulus akan kembali ke bahasa Inggris.
Hari ke-4: Pembuatan Terjemahan Otomatis
Saya tahu solusi awal tidak akan bisa diskalakan. Jadi, sekarang setelah i18n keluar, saatnya membuatnya lebih kokoh dengan pendekatan berbasis database.
Singkatnya: ketika teks bahasa Inggris atau komentar JSDoc berubah, terjemahan perlu dibuat ulang. Pelacakan manual tentang apa yang perlu diperbarui akan rentan kesalahan dan membuang waktu developer.
Jadi saya membangun solusi yang sebelumnya saya rencanakan: sistem pembuatan terjemahan yang didukung PostgreSQL.
Skema Database
Saya menambahkan tabel translations ke database PostgreSQL kami dengan struktur berikut:
key: Key terjemahan dalam notasi "slash-dot" (misalnya,"games/yacht/nested.name","config.timeLimit.label").en_value: Nilai sumber bahasa Inggristarget_locale: Kode locale target (misalnya,"es","fr","zh")target_value: Nilai terjemahancontext: Field JSONB yang berisi JSDoc untuk key ini dan semua key indukcreated_atdanupdated_at: Timestamp untuk pelacakan
Indeks unik berada pada (key, target_locale, en_value, context). Ini sangat penting: dengan menyertakan context dalam constraint unik, kami dapat secara otomatis mendeteksi ketika komentar JSDoc berubah dan membuat ulang terjemahan. Terjemahan lama disimpan untuk referensi historis.
Skrip Pembuatan
Saya membuat scripts/src/generateLocalizations.ts yang mengotomatisasi seluruh alur kerja terjemahan:
- Mengekstrak key bahasa Inggris: Menggunakan parsing AST (ts-morph) untuk mengekstrak semua key terjemahan dari file
shared/src/i18n/locales/en/**, hanya memproses default exports - Mengekstrak konteks JSDoc: Mengurai komentar JSDoc untuk setiap key dan semua key induk (objek induk) untuk memberikan konteks yang kaya
- Query database: Memeriksa terjemahan yang ada di PostgreSQL, mencocokkan pada
key,target_locale,en_value, DANcontext. Jika salah satu dari ini berubah, terjemahan dibuat ulang. - Mengidentifikasi key yang hilang/berubah: Menemukan key yang perlu diterjemahkan atau memiliki nilai/komentar bahasa Inggris yang berubah
- Mengelompokkan terjemahan: Mengelompokkan berdasarkan locale dan prefix namespace untuk panggilan LLM yang lebih efisien (juga membuat terjemahan lebih cepat). Namun, jika batch terlalu besar, kualitas terjemahan akan menurun.
- Menghasilkan terjemahan: Menggunakan GPT 5.1 dengan konteks lengkap (JSDoc, bahasa+wilayah, nada, glosarium, contoh). Saya pernah membaca bahwa 5.1 lebih baik daripada 5.2 untuk menulis (tidak terdengar hambar), tetapi belum mengonfirmasinya.
- Pemeriksaan QA: Memvalidasi pelestarian placeholder, misalnya
{{name}}, integritas key, format JSON - Menyimpan di database: Menyimpan terjemahan dengan konteks lengkap (JSDoc + JSDoc induk)
- Menghasilkan file locale: Membaca dari database dan menulis file locale TypeScript yang diformat dengan benar dengan tipe
RecursivePartial
Manfaat Utama
Pendekatan ini memberi kami beberapa peningkatan DevEx:
- Pembuatan ulang otomatis: Ketika teks bahasa Inggris ATAU komentar JSDoc berubah, terjemahan dibuat ulang secara otomatis. Jadi jika seseorang mengatakan terjemahan kurang bagus, sangat mudah untuk membuat ulang terjemahan dengan memberikan lebih banyak konteks sebagai komentar.
- Konteks yang kaya: Komentar JSDoc memberikan konteks terjemahan (misalnya, "Pesan kesalahan yang ditampilkan ke pemain, maksimal 15 karakter"), membantu LLM menghasilkan terjemahan yang lebih akurat
- Konteks induk: JSDoc objek induk memberikan konteks namespace (misalnya, "Pencapaian karena berada di game di mana semua telur dihancurkan"), memberikan sedikit lebih banyak kejelasan
- Pelacakan historis: Terjemahan lama disimpan di database. Mereka tidak memakan banyak ruang, jadi saya tidak melihat banyak alasan untuk menghapusnya untuk saat ini, dan keren melihat riwayatnya.
Detail Teknis
Implementasinya menggunakan beberapa teknik untuk memastikan keandalan dan efisiensi:
- Ekstraksi berbasis AST untuk memastikan saya mendapatkan komentar yang benar
- Pemrosesan paralel menggunakan Semaphore untuk terjemahan batch konkuren
- Logika retry exponential backoff untuk kegagalan API. Panggilan LLM terkenal tidak stabil.
Skrip ini dapat dijalankan dengan npm run generate-localizations dari direktori scripts. Skrip ini terhubung ke PostgreSQL dan memproses semua terjemahan yang hilang atau berubah untuk semua locale yang didukung saat dijalankan.
Kesimpulan
Pada titik ini, saya memiliki situs yang berfungsi penuh diterjemahkan ke semua 20 locale!
Ini adalah 3 hari yang gila, tetapi hasilnya adalah situs yang sepenuhnya terlokalisasi yang terasa (sebagian besar) native bagi pengguna di seluruh dunia. Dengan membangun library custom yang ringan dan memanfaatkan agen AI untuk pekerjaan refactoring yang membosankan, saya berhasil melakukan apa yang tidak mungkin hanya setahun yang lalu: i18n penuh dalam 3 hari untuk situs web kompleks oleh 1 engineer. Masa depan pemrograman bukan tentang menulis kode dengan cepat. Ini tentang mengorkestrasi agen AI dan memiliki keahlian domain yang mendalam untuk memverifikasi output mereka.