Angular'da i18n: $localize, i18n Attribute'u, ICU Mesajları, ng extract-i18n ve Locale Başına Build
Angular'ın build-time i18n'i metni i18n attribute'u ve $localize ile işaretler, ng extract-i18n ile XLIFF dosyasına çıkarır ve her dil için ayrı bir uygulama build eder. Bookstore arayüzünü TR/EN yapıyor, ICU plural/select, LOCALE_ID, ASP.NET Core'da /tr ve /en yönlendirmesi ile eksik çeviriyi build'de yakalamayı anlatıyoruz.
Sürüm notu: Bu yazıdaki örnekler Angular 22.2, @angular/localize 22.2, TypeScript 6.0 ve .NET 10 (ASP.NET Core) ile denenmiştir.
Önce Tahmin Et
Angular'ın i18n (internationalization; arayüzü birden fazla dile hazırlamak) mesajlarında ICU (International Components for Unicode; çoğul ve seçim kurallarını tek mesajda yazmaya yarayan standart format) syntax'ı kullanılıyor. Bu syntax'ı başka araçlardan biliyorsan şu satır tanıdık gelir:
<p i18n>{book.stock, plural, =0 {Out of stock} other {# in stock}}</p>
Stokta 12 kitap varken ekranda ne yazar? "12 in stock" mı? Cevabı bu yazıda, tarayıcıdan okuduğumuz gerçek metinle göreceğiz.
Örnek yine sitedeki serilerin ortak örnek uygulaması Bookstore'un (bir online kitapçı) Angular istemcisi bookstore-web. 32. ve 33. yazılarda kurduğumuz checkout dahil arayüzün bütün metinleri İngilizce. Bu yazıda katalog, üst menü ve checkout'u Türkçe ve İngilizce yapıyoruz. Angular'ın kendi yolu build-time i18n: çeviriler build sırasında koda gömülüyor ve her dil için ayrı bir uygulama çıkıyor.
@angular/localize'ı Eklemek
ng add @angular/localize
✔ Searching for compatible package version
...
UPDATE src/main.ts (1701 bytes)
UPDATE tsconfig.app.json (489 bytes)
UPDATE tsconfig.spec.json (307 bytes)
UPDATE angular.json (3233 bytes)
Komut paketi kurdu, angular.json'daki polyfill listesine @angular/localize/init'i ekledi, iki tsconfig dosyasının types listesine @angular/localize'ı yazdı ve main.ts'in başına bir /// <reference types="@angular/localize" /> satırı koydu. Bu tip tanımları kodda $localize'ı kullanabilmemizi sağlıyor.
Metni İşaretlemek: i18n, i18n-* ve $localize
Template'te çevrilecek bir element'e i18n attribute'u ekliyorsun. Değer opsiyonel ama üç parçası var: meaning|description@@id. Katalogdaki kitap kartı:
<div class="book">
<h4><a [routerLink]="['/books', book.isbn]">{{ book.title }}</a></h4>
<p>{{ book.price | currency: 'TRY' : 'symbol-narrow' }}</p>
<p i18n="Stock line on a book card@@catalog.stock">{book.stock, plural, =0 {Out of stock} =1 {Last copy} other {{{ book.stock }} in stock}}</p>
<button (click)="onAdd()" i18n="Button on a book card|Adds the book to the shopping cart@@catalog.addToCart">Add to cart</button>
</div>
|'den önceki kısım meaning: aynı İngilizce metnin farklı bağlamlarda farklı çevrilmesi gerektiğinde ayırt etmeye yarıyor. Sonraki kısım description, çevirmene not. @@'den sonraki kısım ise mesajın kalıcı ID'si; ID vermezsen Angular metinden bir ID hesaplıyor. Bunun neden önemli olduğunu "Sık Düşülen Tuzaklar"da göreceğiz.
Element'in içeriği değil de bir attribute'u çevrilecekse i18n- önekini kullanıyorsun. Arama kutusunda:
<input (input)="query.set($any($event.target).value)" placeholder="Search books..." i18n-placeholder="@@catalog.searchPlaceholder">
Üst menüdeki linkler, footer ve "Show cart" butonu da aynı şekilde işaretlendi. Footer'da para miktarı metnin içinde geçiyor; onu string'e gömmek yerine pipe'la bıraktık:
<span i18n="Footer promotion@@footer.freeShipping">Free shipping on orders over {{ freeShippingLimit | currency: 'TRY' : 'symbol-narrow' }}.</span>
Template dışında kalan metinler de var: 32. yazıdaki checkout schema'sının hata mesajları TypeScript'te. Onlar için $localize tagged template'i kullanılıyor; ID ve metin :@@id: şeklinde başa yazılıyor:
required(order.fullName, { message: $localize`:@@checkout.fullNameRequired:Full name is required` });
// ...
? { kind: 'minInstitutionalBooks', message: $localize`:@@checkout.minInstitutionalBooks:Institutional orders need at least ${MIN_INSTITUTIONAL_BOOKS}:min: books (now ${books}:books:)` }
İkinci satırdaki ${books}:books: kısmı, interpolation'a çeviri dosyasında görünecek bir ad veriyor. Hata mesajlarını 32. yazıda schema'da topladığımız için bu iş tek bir dosyada bitti.
ICU Mesajları: plural ve select
Yazının başındaki soruya dönelim. Stok satırını önce other {# in stock} olarak yazdık. İngilizce ve Türkçe build'lerde tarayıcıdan okunan metin:
[en] stock lines=["# in stock","# in stock"]
[tr] stock lines=["Stokta # adet","Stokta # adet"]
Başka ICU uygulamalarında # sayının yerini tutar; Angular'ın ICU desteğinde tutmuyor ve karakter olduğu gibi yazılıyor. Sayıyı göstermek için kategorinin içinde normal interpolation kullanıyorsun: other {{{ book.stock }} in stock}. Düzelttikten sonra:
[en] first card=["₺450.00","12 in stock"] button=Add to cart
[tr] first card=["₺450,00","Stokta 12 adet"] button=Sepete ekle
Katalog sayfasındaki sayaç da bir plural mesaj: {itemCount(), plural, =0 {No books to show} =1 {Showing 1 book} other {Showing {{ itemCount() }} books}}. Türkçe çeviride =1 dalına gerek yok, çünkü Türkçede sayıdan sonra isim tekil kalıyor: "1 kitap", "2 kitap". Çevirmen kaynak mesajdaki kategorileri aynen kopyalamak zorunda değil; dilin ihtiyacı olanları yazıyor.
select ise bir değere göre metin seçiyor. Checkout'a seçilen teslimat türünü yazan bir satır ekledik:
<p i18n="@@checkout.delivery">Delivery: {checkoutForm.shipping().value(), select, standard {standard} express {express, next business day} pickup {pick up at the store}}</p>
Express seçilince İngilizce build "Delivery: express, next business day", Türkçe build "Teslimat: ekspres, ertesi iş günü" gösterdi.
ng extract-i18n ve XLIFF 2
ng extract-i18n --format xlf2 --output-path src/locale
Extraction Complete. (Messages: 27)
Komut uygulamayı build edip işaretli bütün mesajları src/locale/messages.xlf dosyasına yazdı. --format xlf2 ile XLIFF (çeviri araçlarının ortak kullandığı XML tabanlı dosya formatı) 2.0 istedik; çeviri ekipleri ve çeviri platformları bu dosyayı doğrudan açabiliyor. Dosyayı messages.tr.xlf adıyla kopyalayıp kök element'e trgLang="tr" ekledik ve her <source>'un altına bir <target> yazdık:
<unit id="catalog.addToCart">
<notes>
<note category="location">src/app/catalog/book-item/book-item.component.html:7,9</note>
<note category="description">Adds the book to the shopping cart</note>
<note category="meaning">Button on a book card</note>
</notes>
<segment>
<source>Add to cart</source>
<target>Sepete ekle</target>
</segment>
</unit>
Meaning ve description çevirmen için notlara dönüştü. Interpolation'lar <ph> element'i olarak geliyor; çeviride onları silmeden istediğin yere taşıyabilirsin. Footer'da <ph id="0" .../> üzeri siparişlerde kargo ücretsiz. yazdık; Türkçede tutar cümlenin başına geçti.

angular.json ve Locale Başına Build
"i18n": {
"sourceLocale": {
"code": "en-US",
"subPath": "en"
},
"locales": {
"tr": {
"translation": "src/locale/messages.tr.xlf"
}
}
},
i18n bloğu project seviyesinde, architect'in yanında duruyor. Kaynak dil en-US; subPath vermeseydik çıktı klasörünün adı en-US olurdu, ilk build'imizde öyle oldu. Build seçeneklerine iki satır ekledik:
"localize": true,
"i18nMissingTranslation": "error"
ng build artık iki uygulama üretiyor:
dist/bookstore-web/browser/en/index.html:<html lang="en-US" dir="ltr" data-beasties-container>
dist/bookstore-web/browser/en/index.html:<base href="/en/">
dist/bookstore-web/browser/tr/index.html:<html lang="tr" dir="ltr" data-beasties-container>
dist/bookstore-web/browser/tr/index.html:<base href="/tr/">
CLI her dilin base href'ini ve <html lang>'ini kendisi ayarladı. Aynı şey LOCALE_ID için de geçerli. 09. yazıda main.ts'e registerLocaleData(localeTr) ve { provide: LOCALE_ID, useValue: 'tr' } eklemiştik. Localize edilmiş build'de bu satırlar sorun çıkarıyor: onları bırakıp denediğimizde İngilizce build'in footer'ı "Free shipping on orders over ₺500,00." oldu; metin İngilizce, sayı Türkçe biçimde. İkisini de sildik; her build kendi locale verisini ve LOCALE_ID'sini kendisi getiriyor. Sonuç yukarıdaki kartlarda: aynı currency pipe'ı İngilizcede ₺450.00, Türkçede ₺450,00 yazdı.
currency: 'TRY''yi önce sembol seçmeden kullanmıştık; en-US build'i footer'a TRY500.00 yazdı. 'symbol-narrow' ikinci parametresiyle iki dilde de ₺ işareti geldi.
Sunucuda /tr ve /en
İki klasörü yayınlamak için Bookstore'un .NET tarafına küçük bir ASP.NET Core host ekledik (dotnet new web):
WebApplicationBuilder builder = WebApplication.CreateBuilder(args);
WebApplication app = builder.Build();
string[] locales = ["tr", "en"];
// "/" → ziyaretçinin dili, varsayılan Türkçe
app.MapGet("/", (HttpRequest request) =>
{
string first = request.Headers.AcceptLanguage.ToString().Split(',')[0];
string locale = first.StartsWith("en", StringComparison.OrdinalIgnoreCase) ? "en" : "tr";
return Results.Redirect($"/{locale}/");
});
app.UseStaticFiles();
// /tr/books/9780441172719 → tr/index.html; sayfa yenilenince 404 olmasın
foreach (string locale in locales)
{
app.MapFallbackToFile($"/{locale}/{{**path:nonfile}}", $"{locale}/index.html");
}
app.Run();
dotnet run -- --webroot ../web/dist/bookstore-web/browser --urls http://127.0.0.1:52234
19. yazıda Nginx için çözdüğümüz "sayfa yenilenince 404" sorunu burada dil başına bir fallback. curl ile denediğimizde Accept-Language: en-GB gönderen istemci /'den /en/'e, tr-TR gönderen /tr/'ye 302 ile gitti; /tr/books/9780441172719 200 ile tr/index.html'i döndü. Playwright ile iki dilin sayfalarını açtığımızda:
[en] lang=en-US nav=["Home","Books","About"]
[en] count="Showing 2 books"
[en] checkout errors=["Full name is required","Email is required","City is required","Street is required"]
[tr] lang=tr nav=["Ana sayfa","Kitaplar","Hakkında"]
[tr] count="2 kitap gösteriliyor"
[tr] checkout errors=["Ad soyad zorunlu","E-posta zorunlu","Şehir zorunlu","Sokak zorunlu"]
Schema'daki $localize mesajları da çevrildi. Dil değiştirmek için bir link koyduğunda kullanıcı aslında başka bir uygulamaya gidiyor ve sayfa yeniden yükleniyor.
Build-time i18n mı, Runtime Çeviri mi?
Bu bölümde resmî belgelerden yararlanılmıştır. Transloco (@jsverse/transloco) gibi kütüphaneler tek bir build içinde bütün dilleri taşıyor; çevirileri genellikle JSON dosyalarından yüklüyor ve dili sayfayı yenilemeden değiştirebiliyor. SSR (sayfanın sunucuda render edilmesi) ve SEO (arama motorlarında görünürlük) tarafında da iki yaklaşım farklı davranıyor. Karar verirken şu tablo işini görür:
| Konu | @angular/localize | Runtime kütüphane (Transloco) |
|---|---|---|
| Dil değiştirme | Başka klasöre gidilir, sayfa yenilenir | Sayfa yenilenmeden |
| Build çıktısı | Dil başına bir uygulama | Tek uygulama + çeviri dosyaları |
| Çalışma anı maliyeti | Yok, metinler koda gömülü | Çeviri yükleme ve arama |
| SSR ve SEO | Her dil kendi URL'inde, kendi lang attribute'uyla | Dil URL'e ayrıca taşınmalı |
| Eksik çeviri | Build'de yakalanır | Çalışırken fark edilir |
Bookstore gibi dili bir kez seçilen ve arama motorlarında iki dilde görünmesi istenen bir mağaza için build-time yol uygun. Kullanıcının oturum içinde sık dil değiştirdiği bir yönetim paneli ya da çevirileri yayından sonra değiştirmek istediğin bir uygulama için runtime kütüphane daha rahat.
Üretimde Dikkat
ng serve aynı anda tek dil sunabiliyor. localize: true açıkken düz ng serve şu satırı yazıp localize'ı kapattı:
Localization (`localize` option) has been disabled. The development server only supports localizing a single locale per build.
Bunun için build'e "tr": { "localize": ["tr"] }, serve'e "tr": { "buildTarget": "bookstore-web:build:development,tr" } configuration'ı ekledik; ng serve -c tr ile açılan sayfada menü "Ana sayfa, Kitaplar, Hakkında" oldu.
Fallback route'unu ilk yazdığımızda :nonfile kısıtı yoktu. O hâliyle var olmayan bir dosya istendiğinde, örneğin /en/main-nonexistent.js, sunucu 200 ve text/html ile index.html'i döndürdü. Tarayıcı bu HTML'i JavaScript diye çalıştırmaya çalışınca hata çok uzak bir yerde görünür. Kısıtı ekleyince aynı istek 404 oldu.
İlk yüklenen paket 449.20 kB'tan 467.02 kB'a çıktı; $localize runtime'ı ve locale verisi her dilin paketine giriyor. Unit testler ise kaynak dilde çalışmaya devam ediyor: ng test 61 dosyada 75 testi geçirdi, çünkü test build'i localize edilmiyor ve $localize mesajları İngilizce metni döndürüyor. Yeni bir mesaj eklediğinde ng extract-i18n'i tekrar çalıştırıp yeni unit'leri messages.tr.xlf'e taşımayı unutursan bunu testler değil, i18nMissingTranslation: error ayarlı build yakalıyor.
Sık Düşülen Tuzaklar
ID vermediğin bir mesajın metnini değiştirmek. Katalogdaki "Sort by price" linkini önce yalnızca i18n ile işaretleyip çevirdik. Sonra metni "Sort by lowest price" yaptık ve i18nMissingTranslation varsayılan değerindeyken build aldık:
▲ [WARNING] No translation found for "1746357590359649354" ("Sort by lowest price").
Metin değişince hesaplanan ID de değişti ve eski çeviri artık hiçbir mesaja bağlı değil. Build bitti, Türkçe uygulamada link İngilizce kaldı. Aynı build "i18nMissingTranslation": "error" ile durdu:
✘ [ERROR] No translation found for "1746357590359649354" ("Sort by lowest price").
Çözüm iki parçalı: mesaja @@catalog.sortByPrice gibi kalıcı bir ID ver ve CI'da bu ayarı error tut. Böylece metin değişse de ID aynı kalıyor, çeviri eksikse build kırılıyor.
Tarihi ya da parayı string birleştirerek yazmak. 'Free shipping on orders over ' + limit + ' TRY' gibi bir ifade ne çevrilebiliyor ne de locale'e göre biçimleniyor; Türkçede tutar cümlenin başına geçmek zorunda ve ondalık ayıracı virgül. Mesajın içinde pipe'lı bir interpolation bırakmak ikisini de çözdü.
Son olarak #: Angular ICU mesajlarında sayıyı yazdırmıyor, {{ }} kullan.
Küçük Bir Deneme
★☆☆ Kitap detay sayfasındaki "Add to cart" butonunu i18n ile işaretle, ama katalogdakiyle aynı ID'yi (@@catalog.addToCart) ver. ng extract-i18n sonrası dosyada kaç unit olduğuna ve location notlarına bak.
★★☆ Sipariş listesindeki durumları (pending, shipped, delivered) bir ICU select ile Türkçeleştir. Bilinmeyen bir durum geldiğinde ne yazacağını other dalıyla belirle.
★★★ messages.tr.xlf'den bir <target>'ı sil ve ng build çalıştır. Hata mesajını oku, sonra aynı kontrolü bir CI pipeline'ına ekle.
Arayüz artık iki dilli ama hâlâ hareketsiz: sepete eklenen bir ürün satırı birden beliriyor, silinen satır bir anda yok oluyor. Serinin son yazısında Angular 20.2 ile gelen animate.enter ve animate.leave'i, saf CSS geçişlerini ve eski @angular/animations kodundan çıkışı anlatıyoruz.