2026-08-25arayuz4 dk

Yerelleştirme (i18n) mimarisi: metin dışında neler değişir

Çeviri dosyası i18n işinin küçük kısmı. Sıralama, çoğul kuralı, büyük harf, saat dilimi ve CSS yönü — metne dokunmadan bugün yapılabilecek düzeltmeler.

Bir projeye i18n eklemek çoğu ekipte şöyle başlıyor: metinler bir JSON dosyasına toplanıyor, t("kaydet") çağrıları yerleştiriliyor, dosya çevirmene gönderiliyor. Bu adım gerekli ama işin küçük kısmı. Asıl kırılmalar metnin dışında kalan yerlerde çıkıyor ve genellikle üretimde, gerçek kullanıcı verisiyle görülüyor.

Not defterine, sırayla ısıran şeyleri yazıyorum.

Büyük harf dönüşümü dile bağlı

En klasik Türkçe tuzağı. toUpperCase() locale bilmez:

"istanbul".toUpperCase()              // "ISTANBUL"  — yanlış
"istanbul".toLocaleUpperCase("tr")    // "İSTANBUL"
"IĞDIR".toLowerCase()                 // "iğdir"     — yanlış, iki I da noktalı i oldu
"IĞDIR".toLocaleLowerCase("tr")       // "ığdır"

Bunun kötü tarafı görsel değil mantıksal: karşılaştırmayı normalize etmek için toLowerCase() kullanan bir arama kutusu, Türkçe girdide sessizce yanlış eşleşiyor. Karşılaştırma için toLocaleLowerCase de doğru cevap değil — orada Intl.Collator var.

const c = new Intl.Collator("tr", { sensitivity: "base" });
c.compare("ıspanak", "Ispanak") // 0  → aynı kabul
["Çilek", "Armut", "Zeytin"].sort(c.compare)

Array.prototype.sort() varsayılanı UTF-16 kod birimine göre sıralar; Ç, Ş, Ğ listenin sonuna düşer. Kullanıcıya gösterilen her listede collator kullanın.

Çoğul kuralları if bloğu değildir

count === 1 ? "ürün" : "ürünler" İngilizce ve Türkçe için çalışır, Rusça veya Arapça için çalışmaz. Rusça'nın dört, Arapça'nın altı çoğul kategorisi var:

const pr = new Intl.PluralRules("ru");
[1, 2, 5, 21].map(n => pr.select(n)) // ["one", "few", "many", "one"]

Çeviri dosyanız bu kategorileri anahtar olarak taşımıyorsa, ileride Rusça eklendiğinde dosya biçimi değişir — yani tüm çeviriler yeniden elden geçer. ICU MessageFormat'ı baştan kullanmak, sonradan geçmekten ucuz:

{count, plural, one {# item} other {# items}} in cart

Türkçe'de one ve other metni aynı olduğu için bu yapı gereksiz görünür; gereksiz olmayan şey, Rusça eklendiğinde aynı anahtara few ve many satırlarının eklenebilmesidir.

Sayı ve tarih biçimi elle yazılmaz

price.toFixed(2) + " ₺" iki yerde bozulur: ondalık ayracı ve sembol konumu.

new Intl.NumberFormat("tr-TR", { style: "currency", currency: "TRY" }).format(1234.5)
// "₺1.234,50"
new Intl.NumberFormat("de-DE", { style: "currency", currency: "EUR" }).format(1234.5)
// "1.234,50 €"

Tarih tarafında asıl mesele biçim değil, saat dilimi. Sunucuda new Date() ile üretilip istemciye string olarak gönderilen tarih, kullanıcı hangi dilimde olursa olsun aynı görünür — ki bu bazen istenen şeydir (fatura tarihi), bazen felakettir (randevu saati). Kararı bilinçli verin, veritabanında UTC saklayın, biçimlendirmeyi görüntüleme anına bırakın:

new Intl.DateTimeFormat("tr-TR", {
  dateStyle: "medium", timeStyle: "short", timeZone: "Europe/Istanbul",
}).format(new Date(iso));

Karakter sayma ile grafem sayma farklı

"👨‍👩‍👧".length 8 döner. Türkçe için de benzer bir sorun var: birleşik aksanlı karakterler NFC ve NFD olarak iki farklı biçimde gelebilir, ikisi de aynı görünür ama === ile eşit değildir. Kullanıcı girdisini kaydetmeden önce normalize edin:

input.normalize("NFC")

Karakter sınırı uygulayacaksanız (SMS, veritabanı kolonu, kart üstü metin) Intl.Segmenter kullanın:

const seg = new Intl.Segmenter("tr", { granularity: "grapheme" });
[...seg.segment("👨‍👩‍👧 aile")].length   // 6

Yerleşim, metinden önce kırılır

Almanca kelimeler Türkçe karşılıklarından uzun, Japonca daha kısa. Sabit genişlikli butonlar ilk çeviride patlar. Geliştirirken kontrol etmenin ucuz yolu, sözde-yerelleştirme (pseudolocale): her metni %40 uzatan ve aksan ekleyen sahte bir dil.

const pseudo = s => "[" + s.replace(/[aeiou]/g, m => ({a:"á",e:"é",i:"í",o:"ó",u:"ú"}[m])) + "~~~]";

Bu dili geliştirme ortamında açık tutmak, taşan kutuları çeviri gelmeden görmenizi sağlar.

RTL (Arapça, İbranice) desteği düşünüyorsanız, CSS'te yönlü değil mantıksal özellik kullanın. Bu, sonradan yapılacak en pahalı düzeltmelerden biri:

/* sonradan RTL'de ters döner */
margin-left: 1rem;  padding-right: 2rem;  text-align: left;
/* yön bilinçli */
margin-inline-start: 1rem;  padding-inline-end: 2rem;  text-align: start;

Veri katmanı da yerele bağlı

Arayüzü çeviren ekiplerin çoğu şunları atlıyor:

  • Adres biçimi. Posta kodunun şehirden önce mi sonra mı geldiği ülkeye göre değişir. Adresi tek serbest metin alanı olarak saklamak, sonradan yapılandırmaktan kolaydır.
  • İsim alanları. "Ad" ve "Soyad" ayrımı evrensel değil. Zorunlu iki alan, bazı kullanıcıları alan doldurmaya zorlar.
  • Telefon doğrulama. Regex ile ülke kodu doğrulamak sürdürülemez; kütüphane kullanın.
  • Veritabanı sıralaması. PostgreSQL'de ORDER BY ad sonucu kolonun collation'ına bağlıdır. Uygulama katmanında Intl.Collator, veritabanında C collation kullanıyorsanız iki farklı sıra elde edersiniz — sayfalama bozulur.

Anahtar isimlendirme

Küçük ama uzun vadede en çok kazandıran karar: çeviri anahtarını metnin kendisi değil, konumu yapın.

t("Kaydet")              // metin değişince anahtar değişir, tüm diller düşer
t("urun.form.kaydet")    // metin değişir, anahtar durur

İkinci biçimde kaynak dilin metni de bir çeviri dosyasında durur; İngilizce artık "varsayılan" değil, diğerleri gibi bir dil olur. Bu, ileride kaynak dili değiştirmeyi mümkün kılan tek yapı.

Bugün yapılacak üç şey

Projede i18n henüz yoksa ve yakında gerekecekse, çeviri altyapısını kurmadan önce şu üçü yapılırsa sonraki iş yarıya iner:

  1. Kullanıcıya gösterilen tüm sıralamaları Intl.Collator üzerinden geçirin.
  2. Elle biçimlendirilmiş sayı, para ve tarih çıktılarını Intl API'lerine taşıyın; bunlar dil dosyası olmadan da çalışır.
  3. CSS'teki left/right tabanlı boşlukları mantıksal özelliklere çevirin.

Üçü de tek bir çeviri dosyası yazmadan yapılabilir ve hiçbiri geri alınmaz bir bağımlılık getirmez. Çeviri, bu temel oturduktan sonra yalnızca metin işi olarak kalır — ki asıl istenen budur.