Created
January 5, 2026 22:25
-
-
Save misterburton/449aef12cc0097e841d051307e54af4f to your computer and use it in GitHub Desktop.
AI-Powered Localization - Client-Side Translation Handler
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| /** | |
| * LocalizationManager - Client-Side Translation Handler | |
| * | |
| * Manages real-time language switching with translation caching, DOM updates, | |
| * and narration integration. Works with a server-side translation API that | |
| * uses AI (like Gemini Flash) to translate content. | |
| * | |
| * REQUIREMENTS: | |
| * - Server-side /api/translate endpoint (see pre-translate.js gist) | |
| * - HTML elements with data-l10n-id attributes | |
| * - Optional: content-hashes.json for cache validation | |
| * - Optional: toast-manager.js for user feedback | |
| * | |
| * USAGE: | |
| * 1. Add data-l10n-id attributes to translatable elements: | |
| * <p data-l10n-id="intro-1">This text will be translated.</p> | |
| * <input placeholder="Search..." data-l10n-id="search-placeholder"> | |
| * | |
| * 2. Import and instantiate: | |
| * import { LocalizationManager } from './localization-manager.js'; | |
| * new LocalizationManager(); | |
| * | |
| * 3. The manager will: | |
| * - Inject a language selector UI | |
| * - Handle language switching with smooth transitions | |
| * - Cache translations via your API | |
| * - Update URLs for shareable deep links | |
| * | |
| * DEEP LINKING: | |
| * - URLs like yoursite.com/page?lang=es will auto-translate to Spanish | |
| * - Language preference is stored in localStorage | |
| * | |
| * @license MIT | |
| */ | |
| // ============================================ | |
| // LANGUAGE CONFIGURATION | |
| // Add or modify languages as needed | |
| // Languages with hasNarration: true should have | |
| // corresponding audio files generated | |
| // ============================================ | |
| export const LANGUAGES = [ | |
| // === Languages with Audio Narration (shown first) === | |
| { code: 'en', name: 'English', native: 'English', flag: '🇺🇸', hasNarration: true }, | |
| { code: 'es', name: 'Spanish', native: 'Español', flag: '🇪🇸', hasNarration: true }, | |
| { code: 'zh', name: 'Chinese', native: '中文', flag: '🇨🇳', hasNarration: true }, | |
| { code: 'hi', name: 'Hindi', native: 'हिन्दी', flag: '🇮🇳', hasNarration: true }, | |
| { code: 'ar', name: 'Arabic', native: 'العربية', flag: '🇸🇦', hasNarration: true }, | |
| { code: 'fr', name: 'French', native: 'Français', flag: '🇫🇷', hasNarration: true }, | |
| // === All Other Languages (Alphabetical) === | |
| // Add more languages as needed - these are translation-only (no audio) | |
| { code: 'de', name: 'German', native: 'Deutsch', flag: '🇩🇪' }, | |
| { code: 'it', name: 'Italian', native: 'Italiano', flag: '🇮🇹' }, | |
| { code: 'ja', name: 'Japanese', native: '日本語', flag: '🇯🇵' }, | |
| { code: 'ko', name: 'Korean', native: '한국어', flag: '🇰🇷' }, | |
| { code: 'pt', name: 'Portuguese', native: 'Português', flag: '🇵🇹' }, | |
| { code: 'ru', name: 'Russian', native: 'Русский', flag: '🇷🇺' }, | |
| // ... add more languages as needed | |
| ]; | |
| // Languages that have audio narration support | |
| export const NARRATION_ENABLED_LANGUAGES = ['en', 'es', 'zh', 'hi', 'ar', 'fr']; | |
| export class LocalizationManager { | |
| constructor() { | |
| // ============================================ | |
| // SINGLETON PATTERN | |
| // ============================================ | |
| if (window.localizationManager) { | |
| return window.localizationManager; | |
| } | |
| window.localizationManager = this; | |
| // ============================================ | |
| // LANGUAGE DETECTION | |
| // Priority: URL param > localStorage > default 'en' | |
| // ============================================ | |
| const urlParams = new URLSearchParams(window.location.search); | |
| const urlLang = urlParams.get('lang'); | |
| if (urlLang && LANGUAGES.some(l => l.code === urlLang)) { | |
| this.currentLanguage = urlLang; | |
| localStorage.setItem('currentLanguage', urlLang); | |
| } else { | |
| this.currentLanguage = localStorage.getItem('currentLanguage') || 'en'; | |
| } | |
| // ============================================ | |
| // STATE INITIALIZATION | |
| // ============================================ | |
| this.pageId = this.getPageId(); | |
| this.isDev = window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1'; | |
| this.originalContent = {}; // Original English text content | |
| this.originalHTML = {}; // Original HTML (preserves formatting) | |
| this.isTranslating = false; | |
| this.contentHashes = null; | |
| this.init(); | |
| } | |
| /** | |
| * Extracts page ID from URL path | |
| * Used for translation caching | |
| */ | |
| getPageId() { | |
| const path = window.location.pathname; | |
| if (path === '/' || path === '/index.html') return 'home'; | |
| const parts = path.split('/').filter(Boolean); | |
| if (parts.length === 0) return 'home'; | |
| const lastPart = parts[parts.length - 1]; | |
| if (lastPart === 'index.html') { | |
| return parts.length > 1 ? parts[parts.length - 2] : 'home'; | |
| } | |
| return lastPart.replace('.html', ''); | |
| } | |
| /** | |
| * Initializes the localization system | |
| */ | |
| async init() { | |
| // Extract original content before any translation | |
| this.extractOriginalContent(); | |
| // Inject language selector UI | |
| this.injectUI(); | |
| // Load content hashes for cache validation | |
| try { | |
| const response = await fetch('/content-hashes.json'); | |
| if (response.ok) { | |
| this.contentHashes = await response.json(); | |
| } | |
| } catch (e) { | |
| console.warn('Failed to load content-hashes.json', e); | |
| } | |
| // Set initial HTML class based on language | |
| this.updateHtmlLangClass(this.currentLanguage); | |
| // Apply stored language if not English | |
| if (this.currentLanguage !== 'en') { | |
| await this.translatePage(this.currentLanguage); | |
| } | |
| // Update URL and links for deep linking | |
| this.updateUrlLanguage(this.currentLanguage); | |
| this.updateInternalLinks(this.currentLanguage); | |
| // Remove loading state (reveal content) | |
| document.documentElement.classList.remove('translation-loading'); | |
| // Update narration UI if available | |
| this.updateNarrationUI(); | |
| } | |
| /** | |
| * Updates HTML classes based on language | |
| * @param {string} langCode - Language code | |
| */ | |
| updateHtmlLangClass(langCode) { | |
| const html = document.documentElement; | |
| html.classList.remove('lang-en', 'lang-intl', 'lang-narration'); | |
| html.classList.add(langCode === 'en' ? 'lang-en' : 'lang-intl'); | |
| if (NARRATION_ENABLED_LANGUAGES.includes(langCode)) { | |
| html.classList.add('lang-narration'); | |
| } | |
| } | |
| /** | |
| * Extracts and stores original content from all translatable elements | |
| */ | |
| extractOriginalContent() { | |
| const translatableElements = Array.from(document.querySelectorAll('[data-l10n-id]')); | |
| translatableElements.forEach(el => { | |
| const id = el.dataset.l10nId; | |
| let content = ''; | |
| // Handle different element types | |
| if (el.tagName === 'INPUT' || el.tagName === 'TEXTAREA') { | |
| content = el.placeholder || ''; | |
| } else if (el.tagName === 'META') { | |
| content = el.getAttribute('content') || ''; | |
| } else { | |
| content = el.innerHTML.trim(); | |
| } | |
| if (id && content) { | |
| this.originalContent[id] = content; | |
| this.originalHTML[id] = content; | |
| } | |
| }); | |
| } | |
| /** | |
| * Computes a hash of current content for cache validation | |
| * Must match the server-side hash computation exactly | |
| */ | |
| async computeNormalizedHash() { | |
| const allIds = Object.keys(this.originalContent).sort(); | |
| const normalizedForHash = {}; | |
| allIds.forEach(id => { | |
| const el = document.querySelector(`[data-l10n-id="${id}"]`); | |
| if (!el) return; | |
| let textForHash = ''; | |
| if (el.tagName === 'INPUT' || el.tagName === 'TEXTAREA') { | |
| textForHash = el.placeholder || ''; | |
| } else if (el.tagName === 'META') { | |
| textForHash = el.getAttribute('content') || ''; | |
| } else { | |
| // Clone element, strip dynamic content, get textContent | |
| const clone = el.cloneNode(true); | |
| clone.querySelectorAll('.footer-year, .copy-button, .line-number').forEach(e => e.remove()); | |
| textForHash = clone.textContent; | |
| } | |
| // Normalize: strip zero-width chars, collapse whitespace, trim | |
| normalizedForHash[id] = textForHash | |
| .replace(/[\u200B-\u200D\uFEFF]/g, '') | |
| .replace(/\s+/g, ' ') | |
| .trim(); | |
| }); | |
| return this.hashString(JSON.stringify(normalizedForHash)); | |
| } | |
| /** | |
| * Injects the language selector UI into the page | |
| * Customize this for your site's design | |
| */ | |
| injectUI() { | |
| // ============================================ | |
| // FIND INJECTION POINT | |
| // Adjust selector to match your navigation structure | |
| // ============================================ | |
| const nav = document.getElementById('section-nav') || document.querySelector('nav'); | |
| if (!nav) return; | |
| // Remove any existing selector | |
| nav.querySelectorAll('.language-selector').forEach(el => el.remove()); | |
| // ============================================ | |
| // CREATE LANGUAGE SELECTOR | |
| // ============================================ | |
| this.selectorContainer = document.createElement('div'); | |
| this.selectorContainer.className = 'language-selector'; | |
| // Flag toggle button | |
| const currentLangObj = LANGUAGES.find(l => l.code === this.currentLanguage) || LANGUAGES[0]; | |
| this.flagToggle = document.createElement('button'); | |
| this.flagToggle.className = 'flag-toggle'; | |
| this.flagToggle.setAttribute('aria-label', 'Change language'); | |
| this.flagToggle.innerHTML = `<span class="language-flag">${currentLangObj.flag}</span>`; | |
| this.selectorContainer.appendChild(this.flagToggle); | |
| nav.appendChild(this.selectorContainer); | |
| // ============================================ | |
| // CREATE DROPDOWN | |
| // ============================================ | |
| this.dropdown = document.createElement('div'); | |
| this.dropdown.className = 'language-dropdown'; | |
| this.dropdown.innerHTML = ` | |
| <div class="language-search-container"> | |
| <input type="text" class="language-search" placeholder="Search language..." aria-label="Search languages"> | |
| </div> | |
| <div class="language-list"> | |
| ${LANGUAGES.map(lang => ` | |
| <button class="language-item ${lang.code === this.currentLanguage ? 'selected' : ''}${lang.hasNarration ? ' has-narration' : ''}" data-code="${lang.code}"> | |
| <span class="language-flag">${lang.flag}</span> | |
| <div class="language-name-container"> | |
| <span class="language-name">${lang.name}</span> | |
| <span class="language-name-native">${lang.native}</span> | |
| </div> | |
| ${lang.hasNarration ? '<span class="narration-indicator" title="Audio narration available">🔊</span>' : ''} | |
| </button> | |
| `).join('')} | |
| </div> | |
| `; | |
| document.body.appendChild(this.dropdown); | |
| this.setupEventListeners(); | |
| } | |
| /** | |
| * Sets up event listeners for the language selector | |
| */ | |
| setupEventListeners() { | |
| // Toggle dropdown on flag click | |
| this.flagToggle.addEventListener('click', (e) => { | |
| e.stopPropagation(); | |
| this.toggleDropdown(); | |
| }); | |
| // Search filter | |
| const searchInput = this.dropdown.querySelector('.language-search'); | |
| searchInput.addEventListener('input', (e) => { | |
| const term = e.target.value.toLowerCase(); | |
| const items = this.dropdown.querySelectorAll('.language-item'); | |
| items.forEach(item => { | |
| const text = item.innerText.toLowerCase(); | |
| item.style.display = text.includes(term) ? 'flex' : 'none'; | |
| }); | |
| }); | |
| // Language selection | |
| this.dropdown.addEventListener('click', async (e) => { | |
| const item = e.target.closest('.language-item'); | |
| if (item) { | |
| const code = item.dataset.code; | |
| await this.selectLanguage(code); | |
| this.toggleDropdown(false); | |
| } | |
| }); | |
| // Close dropdown on outside click | |
| document.addEventListener('click', (e) => { | |
| if (this.dropdown.classList.contains('active') && | |
| !this.dropdown.contains(e.target) && | |
| !this.flagToggle.contains(e.target)) { | |
| this.toggleDropdown(false); | |
| } | |
| }); | |
| // Close on Escape | |
| document.addEventListener('keydown', (e) => { | |
| if (e.key === 'Escape' && this.dropdown.classList.contains('active')) { | |
| this.toggleDropdown(false); | |
| } | |
| }); | |
| // Sync language across tabs | |
| window.addEventListener('storage', (e) => { | |
| if (e.key === 'currentLanguage' && e.newValue !== this.currentLanguage) { | |
| this.syncLanguage(e.newValue || 'en'); | |
| } | |
| }); | |
| } | |
| /** | |
| * Syncs language when changed in another tab | |
| * @param {string} code - Language code | |
| */ | |
| async syncLanguage(code) { | |
| if (this.isTranslating || code === this.currentLanguage) return; | |
| this.currentLanguage = code; | |
| this.updateUrlLanguage(code); | |
| this.updateInternalLinks(code); | |
| this.updateHtmlLangClass(code); | |
| // Update flag | |
| const langObj = LANGUAGES.find(l => l.code === code) || LANGUAGES[0]; | |
| this.flagToggle.innerHTML = `<span class="language-flag">${langObj.flag}</span>`; | |
| this.dropdown.querySelectorAll('.language-item').forEach(item => { | |
| item.classList.toggle('selected', item.dataset.code === code); | |
| }); | |
| this.updateNarrationUI(); | |
| await this.translatePage(code); | |
| } | |
| /** | |
| * Toggles the language dropdown visibility | |
| * @param {boolean} show - Force show/hide | |
| */ | |
| toggleDropdown(show) { | |
| const isActive = show !== undefined ? show : !this.dropdown.classList.contains('active'); | |
| this.dropdown.classList.toggle('active', isActive); | |
| if (isActive) { | |
| const searchInput = this.dropdown.querySelector('.language-search'); | |
| searchInput.value = ''; | |
| searchInput.dispatchEvent(new Event('input')); | |
| setTimeout(() => searchInput.focus(), 100); | |
| document.body.style.overflow = 'hidden'; | |
| // Position dropdown | |
| const rect = this.flagToggle.getBoundingClientRect(); | |
| this.dropdown.style.top = `${rect.bottom + 10}px`; | |
| this.dropdown.style.maxHeight = `calc(100svh - ${rect.bottom + 30}px)`; | |
| } else { | |
| document.body.style.overflow = ''; | |
| } | |
| } | |
| /** | |
| * Handles language selection | |
| * @param {string} code - Language code | |
| */ | |
| async selectLanguage(code) { | |
| if (this.isTranslating || code === this.currentLanguage) return; | |
| this.currentLanguage = code; | |
| localStorage.setItem('currentLanguage', code); | |
| this.updateUrlLanguage(code); | |
| this.updateInternalLinks(code); | |
| this.updateHtmlLangClass(code); | |
| // Update flag immediately | |
| const langObj = LANGUAGES.find(l => l.code === code); | |
| this.flagToggle.innerHTML = `<span class="language-flag">${langObj.flag}</span>`; | |
| this.dropdown.querySelectorAll('.language-item').forEach(item => { | |
| item.classList.toggle('selected', item.dataset.code === code); | |
| }); | |
| this.toggleDropdown(false); | |
| this.updateNarrationUI(); | |
| await this.translatePage(code); | |
| } | |
| /** | |
| * Translates the page to the specified language | |
| * @param {string} code - Language code | |
| */ | |
| async translatePage(code) { | |
| if (this.isTranslating && code === this.currentLanguage) return; | |
| // Revert to English | |
| if (code === 'en') { | |
| document.body.classList.remove('font-fallback'); | |
| this.updateHtmlLangClass('en'); | |
| await this.applyTranslation(this.originalHTML, true); | |
| return; | |
| } | |
| const langName = LANGUAGES.find(l => l.code === code)?.name || code; | |
| // ============================================ | |
| // FONT FALLBACK | |
| // Load system fonts for non-Latin scripts | |
| // ============================================ | |
| const requiresFallback = !['en', 'es', 'fr', 'de', 'it', 'pt', 'nl', 'sv', 'da', 'no'].includes(code); | |
| if (requiresFallback) { | |
| // Preload fallback fonts | |
| document.fonts.load('400 1em "Noto Sans"'); | |
| } | |
| const contentHash = await this.computeNormalizedHash(); | |
| // Check cache | |
| const isCached = this.contentHashes && | |
| this.contentHashes[this.pageId] && | |
| this.contentHashes[this.pageId]._translationHash === contentHash; | |
| this.isTranslating = true; | |
| // ============================================ | |
| // VISUAL FEEDBACK | |
| // Show loading state during translation | |
| // ============================================ | |
| const contentWrapper = document.querySelector('.content-wrapper'); | |
| if (!isCached && contentWrapper) { | |
| contentWrapper.style.opacity = '0.15'; | |
| } | |
| const controller = new AbortController(); | |
| const timeoutId = setTimeout(() => controller.abort(), 90000); | |
| try { | |
| // ============================================ | |
| // TRANSLATION API CALL | |
| // Your server endpoint should handle caching | |
| // ============================================ | |
| const response = await fetch('/api/translate', { | |
| method: 'POST', | |
| headers: { 'Content-Type': 'application/json' }, | |
| body: JSON.stringify({ | |
| pageId: this.pageId, | |
| content: this.originalContent, | |
| targetLanguage: langName, | |
| contentHash: contentHash | |
| }), | |
| signal: controller.signal | |
| }); | |
| clearTimeout(timeoutId); | |
| if (!response.ok) throw new Error(`Translation API returned ${response.status}`); | |
| const { translatedContent } = await response.json(); | |
| document.body.classList.toggle('font-fallback', requiresFallback); | |
| await this.applyTranslation(translatedContent, false); | |
| if (contentWrapper) contentWrapper.style.opacity = '1'; | |
| console.log(`Translation to ${langName} applied.`); | |
| } catch (error) { | |
| console.error('Translation error:', error); | |
| if (contentWrapper) contentWrapper.style.opacity = '1'; | |
| // Revert to English on error | |
| this.currentLanguage = 'en'; | |
| localStorage.setItem('currentLanguage', 'en'); | |
| this.flagToggle.innerHTML = `<span class="language-flag">🇺🇸</span>`; | |
| document.body.classList.remove('font-fallback'); | |
| await this.applyTranslation(this.originalHTML, true); | |
| } finally { | |
| this.isTranslating = false; | |
| clearTimeout(timeoutId); | |
| } | |
| } | |
| /** | |
| * Applies translation to DOM with optional animation | |
| * @param {Object} content - Map of l10n-id to translated content | |
| * @param {boolean} immediate - Skip animation | |
| */ | |
| async applyTranslation(content, immediate = false) { | |
| const elements = []; | |
| Object.entries(content).forEach(([id, value]) => { | |
| const el = document.querySelector(`[data-l10n-id="${id}"]`); | |
| if (el) elements.push({ el, value }); | |
| }); | |
| if (immediate) { | |
| elements.forEach(({ el, value }) => this.updateElement(el, value)); | |
| return; | |
| } | |
| // Fade out | |
| elements.forEach(({ el }) => { | |
| el.style.transition = 'opacity 0.2s ease-in-out'; | |
| el.style.opacity = '0'; | |
| }); | |
| await new Promise(resolve => setTimeout(resolve, 200)); | |
| // Update content | |
| elements.forEach(({ el, value }) => this.updateElement(el, value)); | |
| // Force reflow | |
| void document.body.offsetHeight; | |
| // Fade in | |
| elements.forEach(({ el }) => { | |
| el.style.opacity = '1'; | |
| }); | |
| await new Promise(resolve => setTimeout(resolve, 200)); | |
| } | |
| /** | |
| * Updates a single element's content | |
| * @param {HTMLElement} el - Element to update | |
| * @param {string} value - New content | |
| */ | |
| updateElement(el, value) { | |
| if (el.tagName === 'INPUT' || el.tagName === 'TEXTAREA') { | |
| el.placeholder = value; | |
| } else if (el.tagName === 'META') { | |
| el.setAttribute('content', value); | |
| } else if (el.tagName === 'TITLE') { | |
| document.title = value; | |
| } else { | |
| el.innerHTML = value; | |
| } | |
| } | |
| /** | |
| * Updates narration system when language changes | |
| */ | |
| updateNarrationUI() { | |
| const hasNarration = NARRATION_ENABLED_LANGUAGES.includes(this.currentLanguage); | |
| // Stop current narration | |
| if (window.articleSpeech?.audioFiles) { | |
| window.articleSpeech.stop(true); | |
| } | |
| // Update audio paths for new language | |
| if (hasNarration && window.articleSpeech) { | |
| window.articleSpeech.updateLanguage(this.currentLanguage); | |
| } | |
| } | |
| /** | |
| * Updates URL to include language parameter | |
| * @param {string} code - Language code | |
| */ | |
| updateUrlLanguage(code) { | |
| const url = new URL(window.location.href); | |
| if (code === 'en') { | |
| url.searchParams.delete('lang'); | |
| } else { | |
| url.searchParams.set('lang', code); | |
| } | |
| history.replaceState(null, '', url.toString()); | |
| } | |
| /** | |
| * Updates internal links to include language parameter | |
| * @param {string} code - Language code | |
| */ | |
| updateInternalLinks(code) { | |
| const links = document.querySelectorAll('a[href]'); | |
| const currentOrigin = window.location.origin; | |
| links.forEach(link => { | |
| try { | |
| const href = link.getAttribute('href'); | |
| // Skip external links, anchors, special protocols | |
| if (!href || | |
| href.startsWith('#') || | |
| href.startsWith('javascript:') || | |
| href.startsWith('mailto:') || | |
| href.startsWith('tel:')) { | |
| return; | |
| } | |
| // Handle absolute URLs | |
| if (href.startsWith('http://') || href.startsWith('https://')) { | |
| const linkUrl = new URL(href); | |
| if (linkUrl.origin !== currentOrigin) return; | |
| if (code === 'en') { | |
| linkUrl.searchParams.delete('lang'); | |
| } else { | |
| linkUrl.searchParams.set('lang', code); | |
| } | |
| link.setAttribute('href', linkUrl.toString()); | |
| return; | |
| } | |
| // Handle relative URLs | |
| const linkUrl = new URL(href, currentOrigin); | |
| if (code === 'en') { | |
| linkUrl.searchParams.delete('lang'); | |
| } else { | |
| linkUrl.searchParams.set('lang', code); | |
| } | |
| const newPath = linkUrl.pathname + linkUrl.search + linkUrl.hash; | |
| link.setAttribute('href', newPath); | |
| } catch (e) { | |
| // Skip malformed URLs | |
| } | |
| }); | |
| } | |
| /** | |
| * Computes SHA-256 hash of a string | |
| * Falls back to simple hash in non-secure contexts | |
| * @param {string} str - String to hash | |
| * @returns {Promise<string>} Hash string | |
| */ | |
| async hashString(str) { | |
| // Fallback for non-secure contexts | |
| if (!window.isSecureContext || !crypto.subtle) { | |
| let hash = 0; | |
| for (let i = 0; i < str.length; i++) { | |
| const char = str.charCodeAt(i); | |
| hash = ((hash << 5) - hash) + char; | |
| hash = hash & hash; | |
| } | |
| return 'dev-' + Math.abs(hash).toString(16); | |
| } | |
| const msgUint8 = new TextEncoder().encode(str); | |
| const hashBuffer = await crypto.subtle.digest('SHA-256', msgUint8); | |
| const hashArray = Array.from(new Uint8Array(hashBuffer)); | |
| return hashArray.map(b => b.toString(16).padStart(2, '0')).join(''); | |
| } | |
| } | |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment