Skip to content

Instantly share code, notes, and snippets.

@misterburton
Created January 5, 2026 22:25
Show Gist options
  • Select an option

  • Save misterburton/449aef12cc0097e841d051307e54af4f to your computer and use it in GitHub Desktop.

Select an option

Save misterburton/449aef12cc0097e841d051307e54af4f to your computer and use it in GitHub Desktop.
AI-Powered Localization - Client-Side Translation Handler
/**
* 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