Türkçe büyük harf tuzağı: kodda i, I ve noktalı İ
Türkçede i büyürken İ, I küçülürken ı olur. Case folding, CSS text-transform ve veritabanı collation'ı bunu varsayılan olarak yanlış yapar.
Arama kutusu, veritabanında olduğu kesin olan bir ismi bulamıyor. Dosya yükleme, açıkça gif olan bir .gif dosyasını reddediyor. Büyük harfle dizilmiş bir menü, gören her Türkçe konuşana yazım hatası gibi geliyor. Üçünün kaynağı aynı: Türkçe i ile I'yı bütün diğer Latin alfabelerinden farklı eşleştirir ve hemen her stack'teki hemen her case fonksiyonunun bu konuda hiç dile getirmediği bir varsayımı vardır.
Gerçekte ne oluyor
Türkçede iki değil dört i harfi var. Noktalı çift i ve İ, noktasız çift ı ve I. i büyüyünce İ olur, I küçülünce ı olur. Diğer bütün Latin locale'lerinde i ile I tek çifttir ve kalan iki harf hiç yoktur.
Bundan iki sonuç çıkar ve ikisi de canınızı yakar.
Birincisi, case folding artık geri döndürülebilir bir işlem değildir:
'ısırgan'.toUpperCase() // 'ISIRGAN'
'ısırgan'.toUpperCase().toLowerCase() // 'isirgan' noktasız ı gitti
'title'.toLocaleUpperCase('tr') // 'TİTLE'
'gif'.toLocaleUpperCase('tr') // 'GİF'İkincisi daha kötü, çünkü görünmüyor. İ harfini Türkçe locale dışında küçültmek i vermez. Unicode noktayı ayrı bir birleşik işaret olarak saklar:
'İ'.toLowerCase().length // 2
JSON.stringify('İ'.toLowerCase()) // '"i̇"'
'İ'.toLowerCase() === 'i' // false
'İ'.toLowerCase().normalize('NFC') === 'i' // yine falseYani bir yerde küçültülmüş bir değer, başka bir yerde küçültülmüş bir değerle kimsenin göremediği bir karakter kadar farklı olabilir ve normalizasyon bunu kurtarmaz.
Kurallar diller arasında da aynı değil. JavaScript'te toUpperCase locale'den bağımsızdır, toLocaleUpperCase değildir. Java'da locale'e duyarlı olan düz toUpperCase'tir ve process'in varsayılan locale'ini kullanır. C#'ta ToUpper geçerli culture'ı izler, ToUpperInvariant izlemez. Bu hatanın diller arasında yolculuk etme biçimi, bir dildeki alışkanlığı diğerine taşımaktır.
Karşınıza çıkacağı dört yer
- Identifier'lar. Türkçe bir makinede
ext.toLocaleUpperCase() === 'GIF'karşılaştırması false döner, çünkü sol tarafGİFolmuştur. Aynısı header adları, enum değerleri, para birimi kodları ve karşılaştırmadan önce normalize ettiğiniz her şey için geçerli. - Slug'lar. Türkçe bir sunucuda küçültülen bir başlıkta
ISTANBUL,ıstanbulolur; ASCII dışı karakterleri temizleyen adım da baştaki harfi siler. Slugstanbulolur, eski link 404 verir ve değer makineden makineye bile aynı çıkmaz. - Ekran.
langverilmemiş bir elementtetext-transform: uppercasealtındailetişimmenü öğesiILETISIMdiye görünür. Türkçe okuyan biri bunu yazım hatası olarak okur. - Veritabanındaki karşılaştırma. Türkçe collation noktalıyla noktasızı bilerek ayırır, yani bir kolonda çalışan
LIKEaraması başka bir kolonda sessizce boş döner.
Nasıl görülür
Eşlemeleri gerçekten kullandığınız dilde ve gerçekten deploy ettiğiniz makinede çalıştırın:
const s = 'İstanbul ısırgan';
console.log(s.toUpperCase()); // İSTANBUL ISIRGAN
console.log(s.toLowerCase()); // i̇stanbul ısırgan (fazladan nokta)
console.log(s.toLocaleUpperCase('tr')); // İSTANBUL ISIRGAN
console.log(s.toLocaleLowerCase('tr')); // istanbul ısırgan
console.log(Intl.DateTimeFormat().resolvedOptions().locale);Unutulan satır sonuncusu. Production'da Türkçe bir locale, sizin makinenizde İngilizce bir locale yazıyorsa, argümansız her toLocaleUpperCase() çağrısı iki yerde iki farklı fonksiyondur.
Veritabanı tarafında dokümantasyon okumak yerine doğrudan sunucuya sorun:
/* MySQL: Türkçe collation iki i harfini bilerek ayırır */
SELECT 'i' = 'I' COLLATE utf8mb4_turkish_ci AS noktasiz,
'i' = 'İ' COLLATE utf8mb4_turkish_ci AS noktali;
/* PostgreSQL: upper() girdisinin collation'ını izler */
SHOW lc_ctype;
SELECT upper('i'), upper('i' COLLATE "tr-TR-x-icu");MySQL'de Türkçe collation'lı bir kolonla genel collation'lı bir kolon doğrudan karşılaştırılamaz; illegal mix of collations hatası alırsınız, ki bu hatanın kibar hali. Kaba hali, başarılı olup yanlış satırları dönen karşılaştırmadır. Üstelik indeksin kullanılmasını da engeller, bu da planı şaşırtan yavaş bir sorguya dönüşür.
Çözüm
Birbirine benzeyen ama aynı olmayan iki işlemi ayırın. Biri karşılaştırma için fold'lamaktır ve her yerde aynı sonucu vermek zorundadır. Diğeri ekran için büyütüp küçültmektir ve okuyucunun dilini izlemek zorundadır.
Karşılaştırma tarafında fold'u elinizle yazın, locale'e duyarlı bir fonksiyona bırakmayın:
// identifier'lar: Türkçe harfleri kendiniz eşleyin, sonra locale'siz küçültün
const FOLD = { 'İ': 'i', 'I': 'i', 'ı': 'i', 'Ş': 's', 'ş': 's', 'Ğ': 'g', 'ğ': 'g',
'Ü': 'u', 'ü': 'u', 'Ö': 'o', 'ö': 'o', 'Ç': 'c', 'ç': 'c' };
export const slug = (s) =>
[...s].map((c) => FOLD[c] ?? c).join('')
.toLowerCase()
.normalize('NFKD').replace(/[̀-ͯ]/g, '')
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-|-$/g, '');E-posta ve kullanıcı adı gibi tekil olması gereken alanlarda fold'lanmış hali ayrı bir kolona yazın ve unique index'i o kolona koyun. Kullanıcının yazdığı hali de saklayın, çünkü ekranda gösterilecek olan o. Böylece karşılaştırma tek bir yerde, tek bir fonksiyonla yapılır; kolonun collation'ı ne olursa olsun sonuç değişmez ve yarın sunucunun locale ayarı değişse bile eski satırlar geçerli kalır. Bu kolonu uygulama tarafında doldurun, veritabanının LOWER() fonksiyonuna bırakmayın: LOWER() sunucunun lc_ctype ayarını izler ve o ayar sizin kodunuzdan bağımsız değişebilir.
Kullanıcıya dönük metinde ise hiç fold'lamayın. Noktalı ile noktasızın ayrı harfler, büyük ile küçüğün aynı harf olduğunu bilen bir collator'a sorun:
const same = new Intl.Collator('tr', { sensitivity: 'base', usage: 'search' });
same.compare('İstanbul', 'istanbul'); // 0 aynı kelime
same.compare('ISIRGAN', 'ısırgan'); // 0 aynı kelime
same.compare('isirgan', 'ısırgan'); // 0 değil, iki ayrı kelime
same.compare('ISTANBUL', 'istanbul'); // 0 değil, çünkü Türkçe büyük hali İSTANBULEkran tarafını CSS yapsın. lang="tr" içindeki bir elementte text-transform: uppercase doğru harfleri üretir ve DOM'daki metne dokunmaz; kopyalama, sayfa içi arama ve ekran okuyucu yazdığınız şeyi görmeye devam eder. lang özniteliğini özenle yazmaya değmesinin bir sebebi de bu: aynı öznitelik tarayıcının iki yana yaslı bir sütunu heceleyip heceleyemeyeceğini de belirliyor.
Sonra linter'a bir kural koyun: toLocaleUpperCase ve toLocaleLowerCase argümansız çağrılmaz. Tek başına bu kural yüzeyin çoğunu kapatır.
Çalıştığını nasıl doğrularsınız
Test süitini bir kez locale'i sabitleyerek çalıştırın. Makineye bağlı bir hata, tekrarlanabilir bir test hatasına dönüşür:
LANG=tr_TR.UTF-8 LC_ALL=tr_TR.UTF-8 npm test
grep -rn "toLocaleUpperCase()\|toLocaleLowerCase()" src/ | wc -l
# 0Dört fixture ekleyin ve hiç silmeyin: İstanbul, ısırgan, ILIK, iyi. İki çifti iki yönde de kapsarlar. Assertion'ı slug çıktısına ve karşılaştırma sonucuna yazın, aradaki fold'lanmış stringe değil; ara değerin değişme hakkı vardır, davranışın yoktur.
Nelere dikkat etmeli
- Küçültülmüş değere göre tekilleştirme, mükerrer kaydı içeri alır:
iartı birleşik noktayla kaydedilmiş satır ile düziile kaydedilmiş satır, kolonda unique index olsa bile iki ayrı stringdir. - Container image'ları genelde POSIX locale ile gelir, bu yüzden hata
LANG'ı hangi taraf set ediyorsa ya yalnızca geliştirici makinesinde ya da yalnızca production'da görünür. İkisinde de sabitleyin. - Aynı collation adı her yerde aynı sonucu vermez. Veritabanı libc'nin locale'ini mi yoksa ICU'yu mu kullanıyor, hangi sürümüyle kullanıyor: bunlar sıralamayı ve büyük küçük harf eşlemesini değiştirebilir. Sunucu sürümünü yükselttikten sonra Türkçe kolonlardaki indeksleri yeniden oluşturmak gereken durumlar buradan çıkar.
lang="tr"altındakitext-transform: uppercaseo elementteki İngilizce kelimelere de uygulanır. MenüdekiBlogveiletişimdoğru çıkar amatitlegibi bir kelimeTİTLEolur. Bu tek tük kelimelerilang="en"ile işaretleyin.- Arama motorları ve analitik araçları URL'leri kendi taraflarında küçültür. İçinde
ıgeçen bir slug bazılarından başka bir şey olarak geri döner; slug'ları ASCII tutmanın bir sebebi daha. - Aynı refleks Türkçeye özgü diğer kodlama sorularında da geçerli, mesela tek bir Türkçe karakterin bir SMS'in boyutuna ne yaptığı konusunda.
Büyük küçük harf, stringin özelliği gibi görünür ama aslında dilin özelliğidir ve her platformun getirdiği varsayılan, tarafsız bir palto giymiş İngilizcedir. Sürekli geri döndüğüm pratik kural şu: makinenin karşılaştırdığı her değer benim okuyabildiğim bir kodla fold'lanmalı, insanın okuduğu her değer ise dili yanında yazılı halde motor tarafından büyütülmeli. Bu iki yol ayrıldığı anda Türkçe i özel bir durum olmaktan çıkar ve baştan beri olduğu şeye döner: bir dilin harfleri, o dili bilen katman tarafından işleniyor.
Sorular ve cevaplar
- JavaScript'te toUpperCase ile toLocaleUpperCase farkı ne?
- toUpperCase, Unicode'un locale'den bağımsız varsayılan eşlemesini uygular, yani i her zaman I olur. toLocaleUpperCase dile özel kuralları uygular ve argüman verilmezse makinenin varsayılan locale'ini kullanır, bu da aynı kodun Türkçe bir makinede farklı sonuç vermesi demektir. Dile duyarlı davranış istiyorsanız locale'i açıkça yazın, sabit sonuç istiyorsanız düz metodu kullanın.
- İ harfini küçültünce neden iki karakterlik bir string çıkıyor?
- Unicode, U+0130'un varsayılan küçük harf karşılığını i artı U+0307 birleşik üst nokta olarak tanımlar, yani taban harf değişse bile nokta ayrı bir karakter olarak kalır. Türkçe locale'de ise düz bir i'ye eşlenir. İki kod noktalı biçim normalize edilince tek karaktere dönmez, dolayısıyla böyle kaydedilmiş bir değer Türkçe locale'de küçültülmüş bir değerle asla eşleşmez.
- Türkçe metin için hangi collation'ı seçmeliyim?
- Türkçe collation'ı yalnızca insan dili gibi sıraladığınız veya aradığınız kolonlarda kullanın, çünkü noktalı ve noktasız i'yi bilerek ayırır. Slug, kod ve e-posta gibi identifier davranan kolonlarda binary ya da düz bir collation kullanın. İkisini tek karşılaştırmada karıştırmak zaten illegal mix of collations hatasını üreten şeydir.
- text-transform: uppercase dili dikkate alıyor mu?
- Evet, element veya üstündeki bir element lang="tr" taşıyorsa tarayıcı Türkçe eşlemeyi uygular. Büyük harf göstermenin en güvenli yolu budur, çünkü metnin kendisi yazıldığı gibi kalır; kopyalama, sayfa içi arama ve ekran okuyucu gerçek stringi görmeye devam eder. lang olmadan İLETİŞİM yerine ILETISIM çıkar.