background blurbackground mobile blur

1/1/1970

20 Dile i18n'i 3 Günde Nasıl Hayata Geçirdim

Selamlar! Foony'yi 20 farklı dile çevirdiğim devasa bir görevi henüz tamamladım. Kod tabanındaki neredeyse her dosyaya dokunmayı gerektiren büyük bir işti, ama her şeyi sadece 3 günde halletmeyi başardım.

Aşağıda bunu nasıl yaptığımı, değişikliğin arkasındaki sayıları ve sektörün standart kütüphanesini kullanmak yerine neden (yine) kendi çeviri kütüphanemi yazmaya karar verdiğimi anlatacağım.

Neden i18next değil?

Çevirileri eklemeye ilk baktığımda, sektör standardını düşündüm: i18next ve react-i18next.

Bunun yerine, AI tarafından sürdürülebilirlik için optimize etmeye karar verdim. i18next güçlüdür, ama API çeşitliliği LLM'lerin halüsinasyon görmesine veya tutarsız kod yazmasına neden olabilir. Kütüphaneyi basit bir t() ve interpolate() ile sınırlandırarak, 10'dan fazla paralel ajanın neredeyse hiç insan müdahalesi olmadan %100 tip güvenli kod yazmasını sağladım.

Ayrıca daha sonra kırıcı değişiklikler getirebilecek büyük bir ekosisteme bağlanmak konusunda temkinliydim. React Router v5 ve MUI v4 → v5 gibi sancılı geçişlerden ders aldıktan sonra, geriye dönük uyumluluğun hızla bozulmasının JavaScript dünyasında ne kadar yaygın olduğunu biliyorum. Çoğullaştırma özelliklerini sonradan eklemenin maliyeti, şu anda 139 bin satır kodu manuel olarak taşımanın maliyetinden daha düşük.

Ölü basit, son derece hafif ve ekibimin ihtiyaçlarına tam olarak uyan bir şey istedim.

Ben de kendiminkini yazdım.

Yüksek doğruluklu, otonom AI yeniden düzenlemesini mümkün kılmak için özel olarak tasarlanmış 3 KB'lık kısıtlanmış bir alt küme oluşturdum. Bu, tek bir mühendis olarak 5 kişilik bir ekibin 3 haftalık iş yükünü sadece 3 günde başarmamı sağladı.

Özel Uygulama

Yaklaşık 3 KB gzip boyutunda minimal bir i18n kütüphanesi tasarladım. İki ana fonksiyon sunuyor: React dışı bağlamlar için getTranslation() ve bileşenler için bir useTranslation() kancası.

Bunlar basit string değişimi için t() ve bir çeviri stringine React bileşenleri (link veya ikon gibi) enjekte etmem gerektiğinde interpolate() döndürüyor. Her iki fonksiyon da değişken değişimini destekliyor, örneğin "Hello {{thing}}", {thing: 'World'}.

Anahtarlar "slash-dot" gösterimini takip ediyor (yerelleştirme dosyasının dosya yolu için eğik çizgiler, dosyadaki iç içe nesneler için noktalar). Benzersizliği sağlamak için, bir dosyadaki çeviri anahtarları eğik çizgi içeremez.

İşte temel t() fonksiyonu:

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

Ve React kancası:

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

Tüm kütüphanenin çekirdeği yalnızca yaklaşık 580 satır kod. Şunları yönetiyor:

  • Çeviri dosyalarının lazy-loading'i, böylece her kullanıcıya 20 dilin tamamını göndermiyoruz.
  • Çevirilerin "namespace" ile kod bölünmesi (örneğin common, misc, games/{gameId}).
  • Her şeyin doğru bağlandığını doğrulayabilmem için ham anahtarları gösteren bir "debug" yereli.

Sistemin kolayca sürdürülebilir kalmasını sağlamak için shared/src/i18n/README.md içinde dosya yapısından hem istemci hem sunucu için kullanım örneklerine kadar her şeyi kapsayan kapsamlı bir dokümantasyon da ekledim. Standart bir kütüphane kullanmadığım için, bu referansa sahip olmak yeni ekip üyelerini işe alıştırmak (ya da gelecekteki kendime veya LLM'lere nasıl çalıştığını hatırlatmak) için kritik önemde.

Sayılarla

Bu güncellemenin ölçeği hakkında bir fikir vermek için kod tabanında değişen şunlar:

  • 20 desteklenen dil (artı geliştirme için bir debug yereli).
  • 360 yerel dosyası oluşturuldu.
  • 139.031 satır çeviri kodu.
  • İstemci genelinde 3.938 t() çağrısı eklendi.
  • 728 kaynak dosyası değiştirildi.
  • Doğruluk kaynağı olarak hizmet eden 18 İngilizce kaynak dosyası (16 oyun + common + misc).

Ajanlarla Orkestrasyon

Bunu manuel yapmak, akıl uyuşturucu, mekanik bir işle ayları alırdı. Bunun yerine, ağır işi yapması için aynı anda bir düzineden fazla Cursor ajanını orkestrasyonla yönettim.

Kod tabanını klasörlere göre "bölümlere" ayırarak başladım. Foony'deki her oyun kendi klasörünü ve kendi çeviri namespace'ini aldı. Bu, başlangıç yükleme boyutunu küçük tutuyor çünkü yalnızca oynadığınız oyun için çevirileri yüklüyorsunuz.

Aynı anda birden fazla Cursor ajanı çalıştırdım. Her ajana belirli bir bölüm atadım, örneğin "Satranç oyununu çevirileri kullanacak şekilde dönüştür" gibi, ve dosya dosya gidip kullanıcıya görünen stringleri bulup t('games/chess/some.key') ile değiştirdi.

Ajan daha sonra bu anahtarı, stringin "neyi" ve "nerede" olduğunu açıklayan bir JSDoc yorumuyla birlikte uygun İngilizce yerel dosyasına ekliyordu. Bu bağlam, diğer diller için çeviriler oluşturulurken önemlidir, çünkü LLM'in "Save"in "Oyun Yapılandırmasını Kaydet" mi yoksa "Çiz ve Tahmin Et Çiziminizi Kaydet" mi anlamına geldiğini anlamasına yardımcı olur.

Kalite Kontrolü

Oluşturulan tüm kodu hızla gözden geçirdim. Ajanlar şaşırtıcı derecede iyiydi, ama zaman zaman hatalar yapıyorlardı, örneğin useTranslation kancasını erken bir return ifadesinden sonra koymak gibi.

Güçlü tipli çeviriler muazzam yardımcı oldu. Bu, her yerel için tüm çevirilerin tüm doğru anahtarlara sahip olmasını (ve yanlışlarına sahip olmamasını) sağladı. Ayrıca t() ve interpolate() çağrılarının var olan gerçek çeviri stringlerini kullanmasını sağladı.

Tip sistemi, tüm olası çeviri anahtarlarını İngilizce kaynak dosyalarından çıkarıyor:

/**
 * 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

Bu, mükemmel TypeScript otomatik tamamlama sağlıyor ve bir çeviri anahtarındaki herhangi bir yazım hatası derleme zamanında yakalanıyor. Ajanlar t('games/ches/name') gibi hatalar yapamıyor çünkü TypeScript bunu hemen işaretliyor.

Yerelleştirme

İngilizce dönüşümü tamamlandığında, kalan yerel görevlerini parçaladım. Her ajanı, tek bir İngilizce yerel dosyasını belirli bir dile dönüştürmekten sorumlu hale getirdim.

Örneğin, ajanlara şöyle bir prompt verdim:

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.

Cursor'ın bu dosyaların her birini bir LLM'e besleyip bunları üreten bir script oluşturmasını düşündüm, ama LLM maliyetinden biraz tasarruf etmek istedim. Yalnızca eksik çevirileri güncellemek için bir script kullanmak daha iyi bir yaklaşımdı ve gelecekte muhtemelen benzer bir çözüm kullanacağım. Hangi stringlerin güncellenmesi/çevrilmesi gerektiğini takip etmek istiyorum, ama işleri basit tutmak istiyorum. Çeviri işini bir veritabanına falan taşıyabilirim.

Ayrıca yalnızca geliştirmede kullanılabilen bir "debug" yereli ekledim. Bu, her şeyin çalıştığını doğrulamak için değiştirilen tüm stringleri görüntülememe izin veriyor (ayrıca havalı olduğunu düşünüyorum). Debug yerelini kullandığınızda, t() anahtarı parantez içinde sarılmış olarak döndürüyor:

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

Yani "Welcome to Foony!" görmek yerine ⟦welcome⟧ görüyorsunuz, bu da eksik çevirileri tespit etmeyi kolaylaştırıyor.

Son olarak, başka bir ajan /{locale}/** yönlendirmesini uyguladı, böylece /ja/games/chess gibi şeyler doğru dile (bu durumda Japonca) yönlendirilecekti.

Blogu Çevirme

UI stringlerini çevirmek bir şeydi, peki ya blog yazıları? Tüm blog yazılarımı çevirmek için daha da fazla ajan başlatmak ve yönetmek istemedim.

Bunu, bir ajana tüm süreci otomatikleştiren bir script (scripts/src/generateBlogTranslations.ts) oluşturtarak çözdüm.

İşte nasıl çalışıyor:

  1. İngilizce MDX dosyaları için client/src/posts/en dizinini tarıyor.
  2. Diğer yerel klasörlerinde eksik çevirileri kontrol ediyor (örneğin posts/ja, posts/es).
  3. Bir çeviri eksikse, İngilizce içeriği okuyor ve Markdown biçimlendirmesini koruyarak içeriği çevirmek için belirli bir promptla Gemini 3 Pro Preview'a besliyor.
  4. Yeni dosyayı doğru konuma kaydediyor.

Frontend'de, tüm bu MDX dosyalarını dinamik olarak içe aktarmak için import.meta.glob kullanıyorum. PostPage bileşenim sonra basitçe kullanıcının mevcut yerelini kontrol ediyor ve doğru MDX dosyasını lazy-load ediyor. Bir çeviri eksikse (çünkü scripti henüz çalıştırmadım), zarif bir şekilde İngilizceye geri dönüyor.

4. Gün: Otomatik Çeviri Üretimi

Orijinal çözümün ölçeklenmeyeceğini biliyordum. Yani, şimdi i18n hazır olduğuna göre, veritabanı odaklı bir yaklaşımla biraz sağlamlaştırma zamanıydı.

Kısaca: İngilizce metin veya JSDoc yorumları değiştiğinde, çevirilerin yeniden oluşturulması gerekiyordu. Neyin güncellenmesi gerektiğini manuel olarak takip etmek hata yapmaya açık ve geliştirici zamanı israfı olurdu.

Bu yüzden başlangıçta planladığım çözümü inşa ettim: PostgreSQL destekli bir çeviri üretim sistemi.

Veritabanı Şeması

PostgreSQL veritabanımıza aşağıdaki yapıya sahip bir translations tablosu ekledim:

  • key: "slash-dot" gösterimindeki çeviri anahtarı (örneğin, "games/yacht/nested.name", "config.timeLimit.label").
  • en_value: İngilizce kaynak değer
  • target_locale: Hedef yerel kodu (örneğin, "es", "fr", "zh")
  • target_value: Çevrilen değer
  • context: Bu anahtar ve tüm üst anahtarlar için JSDoc içeren bir JSONB alanı
  • created_at ve updated_at: Takip için zaman damgaları

Benzersiz indeks (key, target_locale, en_value, context) üzerinde. Bu çok önemli: context'i benzersiz kısıtlamaya dahil ederek, JSDoc yorumlarının ne zaman değiştiğini otomatik olarak tespit edebilir ve çevirileri yeniden oluşturabiliriz. Eski çeviriler tarihsel referans için saklanır.

Üretim Scripti

scripts/src/generateLocalizations.ts'i oluşturdum. Bu, tüm çeviri iş akışını otomatikleştiriyor:

  1. İngilizce anahtarları çıkarır: shared/src/i18n/locales/en/** dosyalarından tüm çeviri anahtarlarını çıkarmak için AST ayrıştırma (ts-morph) kullanır, yalnızca varsayılan dışa aktarımları işler
  2. JSDoc bağlamını çıkarır: Zengin bağlam sağlamak için her anahtar ve tüm üst anahtarlar (üst nesneler) için JSDoc yorumlarını ayrıştırır
  3. Veritabanını sorgular: PostgreSQL'deki mevcut çevirileri kontrol eder, key, target_locale, en_value VE context'e göre eşleştirir; bunlardan herhangi biri değişirse çeviri yeniden oluşturulur.
  4. Eksik/değişen anahtarları tanımlar: Çeviri gerektiren veya İngilizce değerleri/yorumları değişen anahtarları bulur
  5. Çevirileri toplu hale getirir: Daha verimli LLM çağrıları için yerele ve namespace önekine göre gruplar (ayrıca çevirileri daha hızlı yapar). Ancak toplu çok büyükse, çeviri kalitesi kötüleşecektir.
  6. Çeviriler oluşturur: Kapsamlı bağlamla (JSDoc, dil+bölge, ton, sözlük, örnekler) GPT 5.1 kullanır. 5.1'in yazma için 5.2'den daha iyi olduğunu okudum (sıkıcı gelmiyor), ama doğrulamadım.
  7. QA kontrolleri: Yer tutucu korumasını doğrular, örneğin {{name}}, anahtar bütünlüğü, JSON formatı
  8. Veritabanına kaydeder: Çevirileri tam bağlamla (JSDoc + üst JSDoc) kaydeder
  9. Yerel dosyaları oluşturur: Veritabanından okur ve RecursivePartial tipleriyle düzgün biçimlendirilmiş TypeScript yerel dosyaları yazar

Temel Faydalar

Bu yaklaşım bize çeşitli DevEx iyileştirmeleri sağlıyor:

  • Otomatik yeniden oluşturma: İngilizce metin VEYA JSDoc yorumları değiştiğinde, çeviriler otomatik olarak yeniden oluşturulur. Yani biri bir çevirinin kötü olduğunu söylerse, daha fazla bağlamı yorum olarak sağlayarak çevirileri yeniden oluşturmak gerçekten kolay.
  • Zengin bağlam: JSDoc yorumları çeviri bağlamı sağlar (örneğin, "Oyunculara gösterilen hata mesajı, maksimum 15 karakter"), LLM'in daha doğru çeviriler üretmesine yardımcı olur
  • Üst bağlam: Üst nesne JSDoc'u namespace bağlamı sağlar (örneğin, "Tüm yumurtaların yok edildiği bir oyunda olma başarımı"), biraz daha netlik kazandırır
  • Tarihsel takip: Eski çeviriler veritabanında saklanır. Çok yer kaplamıyorlar, bu yüzden şimdilik silmek için pek bir neden görmüyorum ve geçmişi görmek havalı.

Teknik Detaylar

Uygulama, güvenilirliği ve verimliliği sağlamak için çeşitli teknikler kullanıyor:

  • Doğru yorumları aldığımdan emin olmak için AST tabanlı çıkarım
  • Eşzamanlı toplu çeviri için Semaphore kullanan paralel işleme
  • API hataları için üstel geri çekilme yeniden deneme mantığı. LLM çağrıları kötü şöhretli derecede dengesiz.

Script scripts dizininden npm run generate-localizations ile çalıştırılabilir. Çalıştırıldığında PostgreSQL'e bağlanır ve desteklenen tüm yereller için tüm eksik veya değişmiş çevirileri işler.

Sonuç

Bu noktada, 20 yerelin tamamına çevrilmiş tamamen işlevsel bir siteye sahiptim!

Bu çılgın 3 gündü, ama sonuç dünya çapındaki kullanıcılara (çoğunlukla) yerel hissettiren tamamen yerelleştirilmiş bir site. Özel, hafif bir kütüphane oluşturarak ve sıkıcı yeniden düzenleme işi için AI ajanlarından yararlanarak, sadece bir yıl önce imkansız olacak bir şeyi başardım: 1 mühendis tarafından karmaşık bir web sitesi için 3 günde tam i18n. Programlamanın geleceği hızlı kod yazmakla ilgili değil. AI ajanlarını yönetmek ve çıktılarını doğrulamak için derin alan uzmanlığına sahip olmakla ilgili.

8 Ball Pool online multiplayer billiards icon