/** * Simple i18n system for Tractatus * Supports: en (English), de (German), fr (French), mi (Te Reo Māori - coming soon) */ const I18n = { currentLang: 'en', translations: {}, supportedLanguages: ['en', 'de', 'fr'], async init() { // 1. Detect language preference this.currentLang = this.detectLanguage(); // 2. Load translations await this.loadTranslations(this.currentLang); // 3. Apply to page this.applyTranslations(); // 4. Update language selector if present this.updateLanguageSelector(); // 5. Dispatch initialization complete event window.dispatchEvent(new CustomEvent('i18nInitialized', { detail: { language: this.currentLang } })); console.log(`[i18n] Initialized with language: ${this.currentLang}`); }, detectLanguage() { // Priority order: // 1. User's manual selection (localStorage) - allows override via flag clicks // 2. Browser's language setting (automatic detection) // 3. Default to English (fallback) // 1. Check localStorage first (user override) const saved = localStorage.getItem('tractatus-lang'); if (saved && this.supportedLanguages.includes(saved)) { console.log(`[i18n] Language detected from user preference: ${saved}`); return saved; } // 2. Check browser language (automatic detection) const browserLang = (navigator.language || navigator.userLanguage).split('-')[0]; if (this.supportedLanguages.includes(browserLang)) { console.log(`[i18n] Language detected from browser: ${browserLang} (from ${navigator.language})`); return browserLang; } // 3. Default to English console.log(`[i18n] Language defaulted to: en (browser language '${navigator.language}' not supported)`); return 'en'; }, detectPageName() { // Try to get page name from data attribute first const pageAttr = document.documentElement.getAttribute('data-page'); if (pageAttr) { return pageAttr; } // Detect from URL path const path = window.location.pathname; // Map paths to translation file names const pageMap = { '/': 'homepage', '/index.html': 'homepage', '/researcher.html': 'researcher', '/leader.html': 'leader', '/implementer.html': 'implementer', '/about.html': 'about', '/about/values.html': 'values', '/about/values': 'values', '/faq.html': 'faq', '/koha.html': 'koha', '/koha/transparency.html': 'transparency', '/koha/transparency': 'transparency', '/privacy.html': 'privacy', '/privacy': 'privacy', '/gdpr.html': 'gdpr', '/gdpr': 'gdpr', '/blog.html': 'blog', '/blog.html': 'blog', '/blog': 'blog', '/architecture.html': 'architecture', '/architecture': 'architecture' }; return pageMap[path] || 'homepage'; }, async loadTranslations(lang) { try { // Cache-busting: Add timestamp to force fresh fetch and bypass stale cache const cacheBust = `?v=${Date.now()}`; // Always load common translations (footer, navbar, etc.) const commonResponse = await fetch(`/locales/${lang}/common.json${cacheBust}`); let commonTranslations = {}; if (commonResponse.ok) { commonTranslations = await commonResponse.json(); } // Load page-specific translations const pageName = this.detectPageName(); const pageResponse = await fetch(`/locales/${lang}/${pageName}.json${cacheBust}`); let pageTranslations = {}; if (pageResponse.ok) { pageTranslations = await pageResponse.json(); } else if (pageName !== 'homepage') { // If page-specific translations don't exist, that's okay for some pages console.warn(`[i18n] No translations found for ${lang}/${pageName}, using common only`); } else { throw new Error(`Failed to load translations for ${lang}/${pageName}`); } // Deep merge common and page-specific translations (page-specific takes precedence) // Uses deep merge to preserve nested objects like footer in common.json this.translations = this.deepMerge(commonTranslations, pageTranslations); // Expose translations globally for components like interactive-diagram window.i18nTranslations = this.translations; console.log(`[i18n] Loaded translations: common + ${pageName}`); } catch (error) { console.error(`[i18n] Error loading translations:`, error); // Fallback to English if loading fails if (lang !== 'en') { this.currentLang = 'en'; await this.loadTranslations('en'); } } }, t(key) { const keys = key.split('.'); let value = this.translations; for (const k of keys) { if (value && typeof value === 'object') { value = value[k]; } else { return key; // Return key if translation not found } } return value || key; }, applyTranslations() { // Find all elements with data-i18n attribute // Using innerHTML to preserve formatting like , , tags in translations document.querySelectorAll('[data-i18n]').forEach(el => { const key = el.dataset.i18n; const translation = this.t(key); if (typeof translation === 'string') { el.innerHTML = translation; } }); // Handle data-i18n-html for HTML content (kept for backward compatibility) document.querySelectorAll('[data-i18n-html]').forEach(el => { const key = el.dataset.i18nHtml; const translation = this.t(key); if (typeof translation === 'string') { el.innerHTML = translation; } }); // Handle data-i18n-placeholder for input placeholders document.querySelectorAll('[data-i18n-placeholder]').forEach(el => { const key = el.dataset.i18nPlaceholder; const translation = this.t(key); if (typeof translation === 'string') { el.placeholder = translation; } }); }, async setLanguage(lang) { if (!this.supportedLanguages.includes(lang)) { console.error(`[i18n] Unsupported language: ${lang}`); return; } // Save preference (overrides browser language detection) localStorage.setItem('tractatus-lang', lang); console.log(`[i18n] User manually selected language: ${lang} (saved to localStorage)`); // Update current language this.currentLang = lang; // Reload translations await this.loadTranslations(lang); // Reapply to page this.applyTranslations(); // Update selector this.updateLanguageSelector(); // Update HTML lang attribute document.documentElement.lang = lang; // Dispatch event for language change window.dispatchEvent(new CustomEvent('languageChanged', { detail: { language: lang } })); console.log(`[i18n] Language changed to: ${lang} (will persist across pages)`); }, updateLanguageSelector() { const selector = document.getElementById('language-selector'); if (selector) { selector.value = this.currentLang; } }, getLanguageName(code) { const names = { 'en': 'English', 'de': 'Deutsch', 'fr': 'Français', 'mi': 'Te Reo Māori' }; return names[code] || code; }, /** * Deep merge utility function * Recursively merges source into target without overwriting nested objects * @param {Object} target - Base object (e.g., commonTranslations) * @param {Object} source - Override object (e.g., pageTranslations) * @returns {Object} - Deeply merged result */ deepMerge(target, source) { const output = { ...target }; for (const key in source) { if (source.hasOwnProperty(key)) { // If both values are objects (and not arrays), merge recursively if (source[key] && typeof source[key] === 'object' && !Array.isArray(source[key]) && target[key] && typeof target[key] === 'object' && !Array.isArray(target[key])) { output[key] = this.deepMerge(target[key], source[key]); } else { // Otherwise, source value takes precedence output[key] = source[key]; } } } return output; } }; // Auto-initialize when DOM is ready if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', () => I18n.init()); } else { I18n.init(); } // Make available globally window.I18n = I18n;