PageSourceSearch

https://morfoz.ai/public/i18n.js

js morfoz.ai collected 2026-10-05 15:12:17 UTC 6,313 bytes, 162 lines download raw bytes

1// Morfoz i18n — multi-language UI from day one.
2//
3// Design:
4//   1. Each language has a JSON dictionary at /public/i18n/<lang>.json.
5//   2. `t(key, vars?)` looks up the key in the current language; falls back to
6//      English, then Turkish, then the key itself.
7//   3. Elements with `data-i18n="key"` are auto-translated on boot and on
8//      language change. For attribute translation: `data-i18n-attr="aria-label:nav.dashboard"`.
9//   4. Dynamic JS-rendered content uses `t('key')` directly.
10//   5. Language is persisted to localStorage AND to the backend
11//      (`PATCH /api/auth/language`) so the user's choice follows them across
12//      devices.
13//
14// Order of resolution at boot:
15//   1. user.preferred_language from /api/auth/me  (cross-device)
16//   2. localStorage.morfoz_lang                   (this device, signed-out)
17//   3. navigator.language → 'tr' or 'en'          (first visit)
18//   4. fallback to 'tr'
19
20const SUPPORTED = ['tr', 'en'];
21const DEFAULT_LANG = 'tr';
22const LS_KEY = 'morfoz_lang';
23
24let _current = DEFAULT_LANG;
25let _dicts = {};        // { tr: {...}, en: {...} }
26const _listeners = new Set();
27
28// ---------- Public API ----------
29export function currentLang() { return _current; }
30export function supportedLangs() { return [...SUPPORTED]; }
31
32/** Translate a key. Optional `vars` for placeholder substitution (`{name}`). */
33export function t(key, vars) {
34  const dict = _dicts[_current] || {};
35  const fallback = _dicts.en || _dicts.tr || {};
36  let str = dict[key] ?? fallback[key] ?? key;
37  if (vars && typeof str === 'string') {
38    for (const [k, v] of Object.entries(vars)) {
39      str = str.replace(new RegExp(`\\{${k}\\}`, 'g'), v);
40    }
41  }
42  return str;
43}
44
45/**
46 * Anahtarın YÜKLÜ BÜTÜN sözlüklerdeki karşılıkları — tekilleştirilmiş, yalnız dize.
47 *
48 * `t()` o anki dili verir; oysa kullanıcının DAHA ÖNCE gönderdiği bir metni
49 * tanımak gerektiğinde (onay kartındaki "evet"/"yes" cevabı) metin hangi dil
50 * açıkken yazıldıysa o dildedir ve dil sonradan değişmiş olabilir. initI18n
51 * tr + en'i önden yüklediği için çağrı anında iki sözlük de burada.
52 */
53export function tHepsi(key) {
54  const out = [];
55  for (const dict of Object.values(_dicts)) {
56    const v = dict ? dict[key] : undefined;
57    if (typeof v === 'string' && !out.includes(v)) out.push(v);
58  }
59  return out;
60}
61
62/** Change the active language. Re-translates the DOM and persists. */
63export async function setLanguage(lang, { silent = false } = {}) {
64  if (!SUPPORTED.includes(lang)) return false;
65  if (!_dicts[lang]) await _load(lang);
66  _current = lang;
67  try { localStorage.setItem(LS_KEY, lang); } catch {}
68  document.documentElement.setAttribute('lang', lang);
69  applyTranslations();
70  _listeners.forEach(fn => { try { fn(lang); } catch {} });
71  // Sync to backend (don't block UI)
72  if (!silent) {
73    try {
74      const { api } = await import('./api-client.js');
75      api.patch('/api/auth/language', { language: lang }).catch(() => {});
76    } catch {}
77  }
78  return true;
79}
80
81/** Subscribe to language change (re-render dynamic content). */
82export function onLanguageChange(fn) {
83  _listeners.add(fn);
84  return () => _listeners.delete(fn);
85}
86
87/**
88 * Sözlüklerin hiçbirinde karşılığı olmayan anahtar.
89 *
90 * `t()` böyle bir durumda ANAHTARIN KENDİSİNİ döndürüyor — bir metin kutusuna
91 * yazmak için uygun bir değer değil. Ayrım burada yapılıyor ki `t()`nin
92 * sözleşmesi (her zaman bir dize döner) bozulmasın.
93 */
94function _cevirisiYok(key) {
95  return !(key in (_dicts[_current] || {})) && !(key in (_dicts.en || _dicts.tr || {}));
96}
97
98/** Translate every element under `root` (default: document). Idempotent. */
99export function applyTranslations(root = document) {
100  // textContent translation
101  root.querySelectorAll('[data-i18n]').forEach(el => {
102    const key = el.getAttribute('data-i18n');
103    if (!key) return;
104    // ÇEVİRİSİ YOKSA ELEMENTE DOKUNMA — HTML'deki Türkçe metin kalsın.
105    //
106    // Eskiden koşulsuz atanıyordu: `t()` bulamadığı anahtar için anahtarın
107    // kendisini döndürdüğünden, sözlüğe henüz girmemiş her etiket ekranda
108    // "ui.acct.email" gibi görünüyordu. Panel HTML'i data-i18n ile
109    // işaretlenip sözlükler doldurulmadan yayına çıksa TÜM panel ham anahtar
110    // yığınına dönerdi (1018 etiket).
111    //
112    // Doğru davranış iOS'ta zaten yazılı (Dil.swift): "Çeviri EKSİKSE ekranda
113    // Türkçe görünür; boş kutu ya da anahtar SIZMAZ. Yarım çeviri, bozuk
114    // arayüzden iyidir." Web de artık aynı sözleşmeye uyuyor — böylece
115    // sözlüğe taşıma işi parça parça, güvenle ilerleyebiliyor.
116    if (_cevirisiYok(key)) return;
117    el.textContent = t(key);
118  });
119  // attribute translation: data-i18n-attr="aria-label:nav.dashboard,title:nav.dashboard"
120  root.querySelectorAll('[data-i18n-attr]').forEach(el => {
121    const spec = el.getAttribute('data-i18n-attr') || '';
122    spec.split(',').forEach(pair => {
123      const [attr, key] = pair.split(':').map(s => s.trim());
124      // Nitelikte de aynı kural: çevirisi yoksa var olan değer korunur
125      // (aria-label="ui.a11y.more" ekran okuyucuya anlamsız gelirdi).
126      if (attr && key && !_cevirisiYok(key)) el.setAttribute(attr, t(key));
127    });
128  });
129}
130
131// ---------- Internal ----------
132async function _load(lang) {
133  try {
134    const res = await fetch(`/public/i18n/${lang}.json`, { cache: 'no-cache' });
135    _dicts[lang] = res.ok ? await res.json() : {};
136  } catch {
137    _dicts[lang] = {};
138  }
139}
140
141/** One-time bootstrap. Call once at app boot. */
142export async function initI18n({ userPreferred } = {}) {
143  // Preload Turkish + English so fallback chain always works
144  await Promise.all([_load('tr'), _load('en')]);
145  // Resolve preferred language
146  let lang = DEFAULT_LANG;
147  if (userPreferred && SUPPORTED.includes(userPreferred)) {
148    lang = userPreferred;
149  } else {
150    try {
151      const stored = localStorage.getItem(LS_KEY);
152      if (stored && SUPPORTED.includes(stored)) lang = stored;
153      else {
154        const nav = (navigator.language || '').slice(0, 2).toLowerCase();
155        if (SUPPORTED.includes(nav)) lang = nav;
156      }
157    } catch {}
158  }
159  _current = lang;
160  document.documentElement.setAttribute('lang', lang);
161  applyTranslations();
162}

Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.