background blurbackground mobile blur

1/1/1970

Bagaimana Saya Melaksanakan i18n untuk 20 Bahasa dalam 3 Hari

Helo! Saya baru sahaja menyiapkan satu tugasan besar di mana saya menterjemah Foony ke dalam 20 bahasa berbeza. Ia merupakan satu usaha yang besar yang melibatkan hampir setiap fail dalam pangkalan kod, tetapi saya berjaya menyiapkan semuanya dalam masa 3 hari sahaja.

Di bawah, saya akan menerangkan bagaimana saya melakukannya, angka-angka khusus di sebalik perubahan ini, dan mengapa saya memutuskan untuk membina pustaka terjemahan sendiri (sekali lagi) dan bukannya menggunakan standard industri.

Mengapa bukan i18next?

Apabila saya mula-mula melihat untuk menambah terjemahan, saya mempertimbangkan standard industri: i18next dan react-i18next.

Sebaliknya, saya memutuskan untuk mengoptimumkan untuk kebolehselenggaraan oleh AI. i18next memang berkuasa, tetapi kepelbagaian APInya boleh menyebabkan LLM berhalusinasi atau menulis kod yang tidak konsisten. Dengan menghadkan pustaka kepada t() dan interpolate() yang ringkas, saya memastikan 10+ ejen selari boleh menulis kod yang 100% selamat jenis dengan hampir tiada campur tangan manusia.

Saya juga berhati-hati untuk terikat dengan ekosistem besar yang mungkin memperkenalkan perubahan ketara di kemudian hari. Setelah "terbakar" dengan migrasi yang menyakitkan seperti React Router v5 dan MUI v4 → v5, saya tahu bahawa pemecahan keserasian ke belakang yang pantas adalah perkara biasa di dunia JavaScript. Kos untuk menambah ciri pluralisasi kemudian adalah lebih rendah daripada kos untuk memigrasikan 139k baris kod secara manual sekarang.

Saya mahukan sesuatu yang sangat ringkas, sangat ringan, dan disesuaikan tepat dengan keperluan pasukan saya.

Jadi saya menulisnya sendiri.

Saya membina subset terhad sebanyak 3 KB yang direka khusus untuk membolehkan refaktor AI autonomi yang berketepatan tinggi. Ini membolehkan saya bertindak sebagai seorang jurutera tunggal yang menyelesaikan beban kerja 3 minggu untuk pasukan 5 orang dalam masa 3 hari sahaja.

Pelaksanaan Tersuai

Saya menghasilkan pustaka i18n minimum yang bersaiz sekitar 3 KB selepas dimampatkan. Ia mendedahkan dua fungsi utama: getTranslation() untuk konteks bukan React dan satu cangkuk useTranslation() untuk komponen.

Ini mengembalikan t() untuk penggantian rentetan ringkas dan interpolate() untuk apabila saya perlu memasukkan komponen React ke dalam rentetan terjemahan (seperti pautan atau ikon). Kedua-dua fungsi menyokong penggantian pemboleh ubah, contohnya "Hello {{thing}}", {thing: 'World'}.

Kunci mengikut notasi "garis miring-titik" (garis miring untuk laluan fail ke fail penyetempatan, titik untuk objek bersarang dalam fail tersebut). Untuk memastikan keunikan, kunci terjemahan dalam fail tidak boleh mempunyai garis miring ke hadapan.

Berikut ialah fungsi teras 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 cangkuk React:

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]);
}

Teras keseluruhan pustaka ini hanyalah sekitar 580 baris kod. Ia mengendalikan:

  • Pemuatan malas fail terjemahan supaya kami tidak menghantar kesemua 20 bahasa kepada setiap pengguna.
  • Pembahagian kod terjemahan mengikut "ruang nama" (contohnya common, misc, games/{gameId}).
  • Sebuah lokal "debug" yang memaparkan kunci mentah supaya saya boleh mengesahkan semuanya disambungkan dengan betul.

Untuk memastikan sistem ini mudah diselenggara, saya juga menambah dokumentasi yang menyeluruh dalam shared/src/i18n/README.md, meliputi semuanya daripada struktur fail kepada contoh penggunaan untuk kedua-dua klien dan pelayan. Memandangkan saya tidak menggunakan pustaka standard, mempunyai rujukan ini adalah penting untuk memperkenalkan ahli pasukan baharu (atau sekadar mengingatkan diri saya pada masa hadapan atau LLM tentang cara ia berfungsi).

Mengikut Angka

Untuk memberi anda gambaran tentang skala kemas kini ini, berikut adalah apa yang berubah dalam pangkalan kod:

  • 20 bahasa disokong (ditambah lokal debug untuk pembangunan).
  • 360 fail lokal dicipta.
  • 139,031 baris kod terjemahan.
  • 3,938 panggilan ke t() ditambah merentasi klien.
  • 728 fail sumber diubah suai.
  • 18 fail sumber Bahasa Inggeris yang berfungsi sebagai sumber kebenaran (16 permainan + common + misc).

Mengorkestra dengan Ejen

Melakukan ini secara manual akan mengambil masa berbulan-bulan dengan kerja mekanikal yang membosankan. Sebaliknya, saya mengorkestra lebih daripada selusin ejen Cursor secara serentak untuk melakukan kerja-kerja berat.

Saya mulakan dengan memecahkan pangkalan kod kepada "bahagian" berdasarkan folder. Setiap permainan di Foony mendapat folder tersendiri dan ruang nama terjemahannya sendiri. Ini mengekalkan saiz muatan awal yang kecil kerana anda hanya memuatkan terjemahan untuk permainan yang anda sedang main.

Saya menjalankan beberapa ejen Cursor secara serentak. Saya menugaskan setiap ejen satu bahagian khusus, seperti "tukar permainan Catur untuk menggunakan terjemahan", dan ia akan melalui fail demi fail, mencari rentetan yang menghadap pengguna dan menggantikannya dengan t('games/chess/some.key').

Ejen kemudiannya akan menambah kunci tersebut ke fail lokal Bahasa Inggeris yang sesuai dengan komen JSDoc yang menerangkan "apa" dan "di mana" rentetan tersebut. Konteks ini penting apabila menjana terjemahan untuk bahasa lain, kerana ia membantu LLM memahami sama ada "Save" bermaksud "Simpan Konfigurasi Permainan" atau "Simpan Lukisan Draw & Guess Anda".

Kawalan Kualiti

Saya menyemak dengan cepat semua kod yang dijana. Ejen-ejen tersebut sangat baik secara mengejutkan, tetapi mereka memang melakukan kesilapan sekali-sekala, seperti meletakkan cangkuk useTranslation selepas pernyataan return awal.

Terjemahan dengan jenis yang kuat (strongly-typed) sangat membantu. Ini memastikan semua terjemahan untuk setiap lokal mempunyai semua kunci yang betul (dan tiada yang salah). Ia juga memastikan bahawa panggilan ke t() dan interpolate() menggunakan rentetan terjemahan sebenar yang wujud.

Sistem jenis mengekstrak semua kunci terjemahan yang mungkin 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 memberikan autolengkap TypeScript yang sempurna, dan sebarang kesilapan menaip pada kunci terjemahan akan ditangkap pada masa kompilasi. Ejen-ejen tidak boleh membuat kesilapan seperti t('games/ches/name') kerana TypeScript akan segera menandakannya.

Penyetempatan

Setelah penukaran Bahasa Inggeris selesai, saya memecahkan tugas-tugas lokal yang selebihnya. Saya menjadikan setiap ejen bertanggungjawab untuk menukar satu fail lokal Bahasa Inggeris kepada bahasa yang ditetapkan.

Sebagai contoh, saya memberi ejen-ejen tersebut arahan 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 pernah mempertimbangkan untuk meminta Cursor membuat skrip untuk menyalurkan setiap fail ini ke dalam LLM dan menjana sesuatu, tetapi saya mahu menjimatkan sedikit kos LLM. Menggunakan skrip untuk hanya mengemas kini terjemahan yang hilang adalah pendekatan yang lebih baik, dan saya mungkin akan menggunakan penyelesaian yang serupa pada masa hadapan. Saya ingin menjejaki rentetan mana yang perlu dikemas kini atau diterjemah, tetapi mahu memastikan perkara mudah. Saya mungkin akan memindahkan kerja terjemahan ke pangkalan data atau seumpamanya.

Saya juga menambah lokal "debug" yang hanya tersedia dalam pembangunan. Ini membolehkan saya melihat semua rentetan yang digantikan untuk mengesahkan semuanya berfungsi (selain itu, saya rasa ia keren). Apabila anda menggunakan lokal debug, t() mengembalikan kunci yang dibalut dalam kurungan:

if (targetLocale === 'debug') {
  return `⟦${key}⟧`;
}

Jadi, daripada melihat "Welcome to Foony!", anda akan melihat ⟦welcome⟧, memudahkan untuk mengesan sebarang terjemahan yang hilang.

Akhirnya, satu lagi ejen telah melaksanakan penghalaan /{locale}/** supaya perkara seperti /ja/games/chess akan dihalakan ke bahasa yang betul (dalam kes ini Bahasa Jepun).

Menterjemah Blog

Menterjemah rentetan UI adalah satu hal, tetapi bagaimana pula dengan catatan blog? Saya tidak mahu memulakan dan menguruskan lebih banyak ejen lagi untuk menterjemah semua catatan blog saya.

Saya menyelesaikannya dengan meminta seorang ejen mencipta skrip (scripts/src/generateBlogTranslations.ts) yang mengautomasikan keseluruhan proses.

Berikut adalah cara ia berfungsi:

  1. Ia mengimbas direktori client/src/posts/en untuk fail MDX Bahasa Inggeris.
  2. Ia menyemak terjemahan yang hilang dalam folder lokal lain (contohnya posts/ja, posts/es).
  3. Jika terjemahan hilang, ia membaca kandungan Bahasa Inggeris dan menyalurkannya ke dalam Gemini 3 Pro Preview dengan arahan khusus untuk menterjemah kandungan sambil mengekalkan format Markdown.
  4. Ia menyimpan fail baharu ke lokasi yang betul.

Pada bahagian frontend, saya menggunakan import.meta.glob untuk mengimport secara dinamik semua fail MDX ini. Komponen PostPage saya kemudiannya menyemak lokal semasa pengguna dan memuatkan fail MDX yang betul secara malas. Jika terjemahan hilang (kerana saya belum menjalankan skrip lagi), ia akan kembali ke Bahasa Inggeris secara anggun.

Hari ke-4: Penjanaan Terjemahan Automatik

Saya tahu penyelesaian asal tidak akan berskala. Jadi, sekarang setelah i18n selesai, masa untuk menjadikannya lebih kukuh dengan pendekatan yang dipacu pangkalan data.

Pendek kata: apabila teks Bahasa Inggeris atau komen JSDoc berubah, terjemahan perlu dijana semula. Penjejakan secara manual tentang apa yang perlu dikemas kini akan terdedah kepada kesilapan dan membazirkan masa pembangun.

Jadi saya membina penyelesaian yang asalnya saya rancangkan: sistem penjanaan terjemahan yang disokong oleh PostgreSQL.

Skema Pangkalan Data

Saya menambah jadual translations ke pangkalan data PostgreSQL kami dengan struktur berikut:

  • key: Kunci terjemahan dalam notasi "garis miring-titik" (contohnya, "games/yacht/nested.name", "config.timeLimit.label").
  • en_value: Nilai sumber Bahasa Inggeris
  • target_locale: Kod lokal sasaran (contohnya, "es", "fr", "zh")
  • target_value: Nilai yang diterjemah
  • context: Medan JSONB yang mengandungi JSDoc untuk kunci ini dan semua kunci moyangnya
  • created_at dan updated_at: Cap masa untuk penjejakan

Indeks unik adalah pada (key, target_locale, en_value, context). Ini sangat penting: dengan memasukkan context dalam kekangan unik, kami boleh mengesan secara automatik apabila komen JSDoc berubah dan menjana semula terjemahan. Terjemahan lama disimpan untuk rujukan sejarah.

Skrip Penjanaan

Saya mencipta scripts/src/generateLocalizations.ts yang mengautomasikan keseluruhan aliran kerja terjemahan:

  1. Mengekstrak kunci Bahasa Inggeris: Menggunakan penghuraian AST (ts-morph) untuk mengekstrak semua kunci terjemahan dari fail shared/src/i18n/locales/en/**, memproses hanya eksport lalai
  2. Mengekstrak konteks JSDoc: Menghuraikan komen JSDoc untuk setiap kunci dan semua kunci moyang (objek induk) untuk memberikan konteks yang kaya
  3. Membuat pertanyaan ke pangkalan data: Menyemak terjemahan sedia ada dalam PostgreSQL, padanan pada key, target_locale, en_value, DAN context. Jika mana-mana satu daripada ini berubah, terjemahan akan dijana semula.
  4. Mengenal pasti kunci yang hilang/berubah: Mencari kunci yang memerlukan terjemahan atau telah berubah nilai/komen Bahasa Inggerisnya
  5. Mengelompok terjemahan: Mengumpulkan mengikut lokal dan awalan ruang nama untuk panggilan LLM yang lebih cekap (juga menjadikan terjemahan lebih pantas). Walau bagaimanapun, jika kelompok terlalu besar, kualiti terjemahan akan merosot.
  6. Menjana terjemahan: Menggunakan GPT 5.1 dengan konteks menyeluruh (JSDoc, bahasa+wilayah, nada, glosari, contoh). Saya pernah membaca bahawa 5.1 lebih baik daripada 5.2 untuk penulisan (tidak berbunyi hambar), tetapi belum mengesahkannya.
  7. Pemeriksaan QA: Mengesahkan pengekalan placeholder, contohnya {{name}}, integriti kunci, format JSON
  8. Menyimpan ke pangkalan data: Menyimpan terjemahan dengan konteks penuh (JSDoc + JSDoc moyang)
  9. Menjana fail lokal: Membaca daripada pangkalan data dan menulis fail lokal TypeScript yang diformat dengan betul dengan jenis RecursivePartial

Manfaat Utama

Pendekatan ini memberi kami beberapa peningkatan DevEx:

  • Penjanaan semula automatik: Apabila teks Bahasa Inggeris ATAU komen JSDoc berubah, terjemahan dijana semula secara automatik. Jadi jika seseorang berkata sesuatu terjemahan itu tidak baik, sangat mudah untuk menjana semula terjemahan dengan menyediakan lebih banyak konteks sebagai komen.
  • Konteks yang kaya: Komen JSDoc menyediakan konteks terjemahan (contohnya, "Mesej ralat ditunjukkan kepada pemain, maksimum 15 aksara"), membantu LLM menghasilkan terjemahan yang lebih tepat
  • Konteks moyang: JSDoc objek induk menyediakan konteks ruang nama (contohnya, "Pencapaian untuk berada dalam permainan di mana semua telur dimusnahkan"), memberikan sedikit lebih kejelasan
  • Penjejakan sejarah: Terjemahan lama disimpan dalam pangkalan data. Ia tidak mengambil banyak ruang, jadi saya tidak nampak banyak sebab untuk memadamkannya buat masa ini, dan menarik untuk melihat sejarahnya.

Butiran Teknikal

Pelaksanaan ini menggunakan beberapa teknik untuk memastikan kebolehpercayaan dan kecekapan:

  • Pengekstrakan berasaskan AST untuk memastikan saya mendapat komen yang betul
  • Pemprosesan selari menggunakan Semaphore untuk terjemahan kelompok serentak
  • Logik cuba semula backoff eksponen untuk kegagalan API. Panggilan LLM terkenal sebagai tidak stabil.

Skrip ini boleh dijalankan dengan npm run generate-localizations dari direktori scripts. Ia menyambung ke PostgreSQL dan memproses semua terjemahan yang hilang atau berubah untuk semua lokal yang disokong apabila dijalankan.

Kesimpulan

Pada ketika ini, saya mempunyai laman web yang berfungsi sepenuhnya yang diterjemah ke kesemua 20 lokal!

Ini merupakan 3 hari yang gila, tetapi hasilnya ialah sebuah laman web yang diserantaukan sepenuhnya yang terasa (kebanyakannya) asli kepada pengguna di seluruh dunia. Dengan membina pustaka tersuai yang ringan dan memanfaatkan ejen AI untuk kerja refaktor yang membosankan, saya berjaya menguruskan apa yang mustahil setahun yang lalu: i18n penuh dalam 3 hari untuk laman web yang kompleks oleh 1 jurutera. Masa depan pengaturcaraan bukanlah tentang menulis kod dengan pantas. Ia adalah tentang mengorkestra ejen AI dan memiliki kepakaran domain yang mendalam untuk mengesahkan output mereka.

8 Ball Pool online multiplayer billiards icon