|
|
|
|
|
class I18n { |
|
constructor() { |
|
this.currentLocale = 'en'; |
|
this.translations = {}; |
|
this.fallbackLocale = 'en'; |
|
this.loadedLocales = new Set(); |
|
this.cache = new Map(); |
|
this.observers = []; |
|
this.pluralRules = new Map(); |
|
this.dateTimeFormats = new Map(); |
|
this.numberFormats = new Map(); |
|
} |
|
|
|
|
|
async loadLocale(locale) { |
|
if (this.loadedLocales.has(locale)) { |
|
return this.translations[locale]; |
|
} |
|
|
|
try { |
|
const response = await fetch(`../locales/${locale}.json`); |
|
if (!response.ok) { |
|
throw new Error(`Failed to load locale ${locale}: ${response.status}`); |
|
} |
|
|
|
const translations = await response.json(); |
|
this.translations[locale] = translations; |
|
this.loadedLocales.add(locale); |
|
|
|
|
|
this.cacheTranslations(locale, translations); |
|
|
|
|
|
this.setupLocaleFormatters(locale); |
|
|
|
return translations; |
|
} catch (error) { |
|
console.warn(`Could not load locale ${locale}:`, error); |
|
if (locale !== this.fallbackLocale) { |
|
return await this.loadLocale(this.fallbackLocale); |
|
} |
|
throw error; |
|
} |
|
} |
|
|
|
|
|
cacheTranslations(locale, translations) { |
|
const commonKeys = [ |
|
'app.title', |
|
'chat.placeholder', |
|
'chat.send', |
|
'ui.loading', |
|
'ui.error', |
|
'ui.retry' |
|
]; |
|
|
|
commonKeys.forEach(key => { |
|
const value = this.getNestedValue(translations, key); |
|
if (value !== undefined) { |
|
this.cache.set(`${locale}:${key}`, value); |
|
} |
|
}); |
|
} |
|
|
|
|
|
setupLocaleFormatters(locale) { |
|
try { |
|
|
|
this.dateTimeFormats.set(locale, { |
|
short: new Intl.DateTimeFormat(locale, { |
|
timeStyle: 'short' |
|
}), |
|
medium: new Intl.DateTimeFormat(locale, { |
|
dateStyle: 'medium', |
|
timeStyle: 'short' |
|
}), |
|
relative: new Intl.RelativeTimeFormat(locale, { |
|
numeric: 'auto' |
|
}) |
|
}); |
|
|
|
|
|
this.numberFormats.set(locale, { |
|
decimal: new Intl.NumberFormat(locale), |
|
percent: new Intl.NumberFormat(locale, { |
|
style: 'percent' |
|
}), |
|
currency: new Intl.NumberFormat(locale, { |
|
style: 'currency', |
|
currency: 'USD' |
|
}) |
|
}); |
|
|
|
|
|
this.pluralRules.set(locale, new Intl.PluralRules(locale)); |
|
|
|
} catch (error) { |
|
console.warn(`Failed to setup formatters for ${locale}:`, error); |
|
} |
|
} |
|
|
|
|
|
async setLocale(locale) { |
|
const previousLocale = this.currentLocale; |
|
|
|
if (!this.translations[locale]) { |
|
await this.loadLocale(locale); |
|
} |
|
|
|
this.currentLocale = locale; |
|
|
|
|
|
document.documentElement.lang = locale; |
|
|
|
|
|
const rtlLanguages = ['ar', 'he', 'fa', 'ur']; |
|
document.documentElement.dir = rtlLanguages.includes(locale) ? 'rtl' : 'ltr'; |
|
|
|
|
|
localStorage.setItem('i18n-locale', locale); |
|
|
|
|
|
this.notifyObservers(locale, previousLocale); |
|
|
|
|
|
document.dispatchEvent(new CustomEvent('localeChanged', { |
|
detail: { |
|
locale: this.currentLocale, |
|
previousLocale |
|
} |
|
})); |
|
} |
|
|
|
|
|
t(key, params = {}) { |
|
const cacheKey = `${this.currentLocale}:${key}`; |
|
|
|
|
|
if (this.cache.has(cacheKey) && Object.keys(params).length === 0) { |
|
return this.cache.get(cacheKey); |
|
} |
|
|
|
let translation = this.getNestedValue( |
|
this.translations[this.currentLocale], |
|
key |
|
); |
|
|
|
if (translation === undefined) { |
|
|
|
translation = this.getNestedValue( |
|
this.translations[this.fallbackLocale], |
|
key |
|
); |
|
|
|
if (translation === undefined) { |
|
console.warn(`Translation missing for key: ${key}`); |
|
return key; |
|
} |
|
} |
|
|
|
|
|
if (typeof translation === 'object' && params.count !== undefined) { |
|
translation = this.handlePluralization(translation, params.count); |
|
} |
|
|
|
|
|
const result = this.interpolate(translation, params); |
|
|
|
|
|
if (Object.keys(params).length === 0) { |
|
this.cache.set(cacheKey, result); |
|
} |
|
|
|
return result; |
|
} |
|
|
|
|
|
handlePluralization(translations, count) { |
|
const rules = this.pluralRules.get(this.currentLocale); |
|
if (!rules) return translations.other || ''; |
|
|
|
const rule = rules.select(count); |
|
|
|
return translations[rule] || |
|
translations.other || |
|
translations.one || |
|
''; |
|
} |
|
|
|
|
|
interpolate(text, params) { |
|
if (typeof text !== 'string') return text; |
|
|
|
return text.replace(/\{\{(\w+)(?::(\w+))?\}\}/g, (match, key, format) => { |
|
const value = params[key]; |
|
|
|
if (value === undefined) return match; |
|
|
|
|
|
if (format) { |
|
return this.formatValue(value, format); |
|
} |
|
|
|
return value; |
|
}); |
|
} |
|
|
|
|
|
formatValue(value, format) { |
|
const formatters = this.numberFormats.get(this.currentLocale); |
|
const dateFormatters = this.dateTimeFormats.get(this.currentLocale); |
|
|
|
switch (format) { |
|
case 'number': |
|
return formatters?.decimal.format(value) || value; |
|
case 'percent': |
|
return formatters?.percent.format(value) || value; |
|
case 'currency': |
|
return formatters?.currency.format(value) || value; |
|
case 'date': |
|
return dateFormatters?.medium.format(new Date(value)) || value; |
|
case 'time': |
|
return dateFormatters?.short.format(new Date(value)) || value; |
|
case 'relative': |
|
const now = Date.now(); |
|
const diff = Math.floor((value - now) / 1000); |
|
return dateFormatters?.relative.format(diff, 'second') || value; |
|
case 'uppercase': |
|
return String(value).toUpperCase(); |
|
case 'lowercase': |
|
return String(value).toLowerCase(); |
|
case 'capitalize': |
|
return String(value).charAt(0).toUpperCase() + String(value).slice(1); |
|
default: |
|
return value; |
|
} |
|
} |
|
|
|
|
|
getNestedValue(obj, path) { |
|
return path.split('.').reduce((current, key) => { |
|
return current && current[key] !== undefined ? current[key] : undefined; |
|
}, obj); |
|
} |
|
|
|
|
|
addObserver(callback) { |
|
this.observers.push(callback); |
|
} |
|
|
|
|
|
removeObserver(callback) { |
|
const index = this.observers.indexOf(callback); |
|
if (index > -1) { |
|
this.observers.splice(index, 1); |
|
} |
|
} |
|
|
|
|
|
notifyObservers(newLocale, oldLocale) { |
|
this.observers.forEach(callback => { |
|
try { |
|
callback(newLocale, oldLocale); |
|
} catch (error) { |
|
console.error('Error in i18n observer:', error); |
|
} |
|
}); |
|
} |
|
|
|
|
|
getCurrentLocale() { |
|
return this.currentLocale; |
|
} |
|
|
|
|
|
getAvailableLocales() { |
|
return Array.from(this.loadedLocales); |
|
} |
|
|
|
|
|
getAllTranslations() { |
|
return this.translations; |
|
} |
|
|
|
|
|
async init(locale = null) { |
|
try { |
|
|
|
const detectedLocale = locale || |
|
localStorage.getItem('i18n-locale') || |
|
this.detectBrowserLocale() || |
|
this.fallbackLocale; |
|
|
|
await this.loadLocale(detectedLocale); |
|
await this.setLocale(detectedLocale); |
|
|
|
console.log(`i18n initialized with locale: ${detectedLocale}`); |
|
return true; |
|
} catch (error) { |
|
console.error('Failed to initialize i18n:', error); |
|
return false; |
|
} |
|
} |
|
|
|
|
|
detectBrowserLocale() { |
|
if (navigator.languages && navigator.languages.length) { |
|
|
|
for (const lang of navigator.languages) { |
|
const shortLang = lang.split('-')[0]; |
|
|
|
const supportedLocales = ['en', 'es', 'fr', 'de', 'cs']; |
|
if (supportedLocales.includes(shortLang)) { |
|
return shortLang; |
|
} |
|
} |
|
} |
|
|
|
return navigator.language?.split('-')[0] || 'en'; |
|
} |
|
|
|
|
|
async preloadLocales(locales) { |
|
const promises = locales.map(locale => this.loadLocale(locale)); |
|
await Promise.allSettled(promises); |
|
} |
|
|
|
|
|
clearCache() { |
|
this.cache.clear(); |
|
} |
|
|
|
|
|
getStats() { |
|
return { |
|
loadedLocales: this.loadedLocales.size, |
|
cachedTranslations: this.cache.size, |
|
observers: this.observers.length, |
|
currentLocale: this.currentLocale, |
|
memoryUsage: JSON.stringify(this.translations).length |
|
}; |
|
} |
|
} |
|
|
|
|
|
class TranslationHelpers { |
|
static createKeyExtractor(prefix = '') { |
|
return (key) => prefix ? `${prefix}.${key}` : key; |
|
} |
|
|
|
static createScopedTranslator(i18n, scope) { |
|
return (key, params) => i18n.t(`${scope}.${key}`, params); |
|
} |
|
|
|
static validateTranslations(translations, requiredKeys) { |
|
const missing = []; |
|
requiredKeys.forEach(key => { |
|
if (!this.hasKey(translations, key)) { |
|
missing.push(key); |
|
} |
|
}); |
|
return missing; |
|
} |
|
|
|
static hasKey(obj, path) { |
|
return path.split('.').reduce((current, key) => { |
|
return current && current[key] !== undefined ? current[key] : undefined; |
|
}, obj) !== undefined; |
|
} |
|
|
|
static flattenTranslations(obj, prefix = '') { |
|
const flattened = {}; |
|
for (const key in obj) { |
|
const newKey = prefix ? `${prefix}.${key}` : key; |
|
if (typeof obj[key] === 'object' && obj[key] !== null) { |
|
Object.assign(flattened, this.flattenTranslations(obj[key], newKey)); |
|
} else { |
|
flattened[newKey] = obj[key]; |
|
} |
|
} |
|
return flattened; |
|
} |
|
} |
|
|
|
|
|
const i18n = new I18n(); |
|
|
|
|
|
function t(key, params = {}) { |
|
return i18n.t(key, params); |
|
} |
|
|
|
|
|
function tc(key, count, params = {}) { |
|
return i18n.t(key, { ...params, count }); |
|
} |
|
|
|
function td(key, date, params = {}) { |
|
return i18n.t(key, { ...params, date }); |
|
} |
|
|
|
function tn(key, number, params = {}) { |
|
return i18n.t(key, { ...params, number }); |
|
} |
|
|
|
|
|
if (typeof module !== 'undefined' && module.exports) { |
|
module.exports = { |
|
I18n, |
|
i18n, |
|
t, |
|
tc, |
|
td, |
|
tn, |
|
TranslationHelpers |
|
}; |
|
} |
|
|
|
|
|
window.i18nDebug = { |
|
i18n, |
|
TranslationHelpers, |
|
getStats: () => i18n.getStats(), |
|
clearCache: () => i18n.clearCache(), |
|
getAllTranslations: () => i18n.getAllTranslations() |
|
}; |