// Localized Wrapper – declarative i18n support for any slotted/child content // Usage: // //
[[localization.welcome]]
//
// Provides translate(), setLocale(), addTranslations(), etc. import { MixinBase } from '../common/mixin-base.js'; class LocalizedMixin extends MixinBase() { [key: string]: any; static get observedAttributes() { return ['locale', 'translations']; } constructor() { super(); this._locale = 'en'; this._translations = {}; this._defaultTranslations = {}; this._originalTemplate = null; } /* -------------------------------------------------- * Validation helpers * -------------------------------------------------- */ _isValidLocale(loc: string | null) { return typeof loc === 'string' && loc.trim().length > 0; } _isValidTranslations(obj: unknown) { return typeof obj === 'object' && obj !== null; } /* -------------------------------------------------- * Translation lookup helpers * -------------------------------------------------- */ _findTranslation(key: string, locale: string = this._locale) { const tryLocale = (store: Record>, loc: string): unknown => { const parts = key.split('.'); let val: unknown = store[loc]; for (const part of parts) { if (val && typeof val === 'object') { val = (val as Record)[part]; } else { return null; } } return val ?? null; }; // 1) exact locale let val = tryLocale(this._translations, locale) ?? tryLocale(this._defaultTranslations, locale); if (val !== null) return val; // 2) base locale (e.g. en for en-US) const base = locale.split('-')[0]; if (base !== locale) { val = tryLocale(this._translations, base) ?? tryLocale(this._defaultTranslations, base); if (val !== null) return val; } // 3) english fallback return tryLocale(this._translations, 'en') ?? tryLocale(this._defaultTranslations, 'en'); } _interpolate(str: string, params: Record) { if (!str || typeof str !== 'string' || !params) return str; return str.replace(/\{(\w+)\}/g, (m, k) => { const v = params[k]; return v !== undefined ? String(v) : m; }); } /* -------------------------------------------------- * Public API * -------------------------------------------------- */ setLocale(loc: string | null) { if(!loc || !this._isValidLocale(loc)) return; this._locale = loc; this._loadTranslations(); this._render(); } getLocale(){ return this._locale; } addTranslations(locale: string, obj: Record){ if(!this._isValidLocale(locale)||!this._isValidTranslations(obj)) return false; this._translations[locale] = { ...(this._translations[locale]||{}), ...obj }; return true; } setDefaultTranslations(locale: string,obj: Record){ if(this._isValidLocale(locale)&&this._isValidTranslations(obj)) this._defaultTranslations[locale]={...obj}; } // @ts-ignore translate(key: any, params?: any): any { const t=this._findTranslation(key); return t?this._interpolate(t as string,params):key; } // Method to reload translations from attribute or global object reloadTranslations() { this._loadTranslations(); this._render(); return true; } _loadTranslations() { this._translations = {}; const translationsAttr = this.getAttribute('translations'); if (translationsAttr) { try { const parsedTranslations = JSON.parse(translationsAttr); Object.entries(parsedTranslations).forEach(([l, t]) => this.addTranslations(l, t as Record)); return; } catch (e) { console.error('Invalid translations JSON in attribute', e); } } if((window as any).globalTranslations && this._isValidTranslations((window as any).globalTranslations)){ Object.entries((window as any).globalTranslations).forEach(([l,t]) => this.addTranslations(l, t as Record)); } } /* -------------------------------------------------- * Internal render – replace [[localization.key]] in light DOM content * -------------------------------------------------- */ _render(){ const target = this.firstElementChild; if(!target) return; if(!this._originalTemplate) { this._originalTemplate = target.innerHTML; } let html = this._originalTemplate; html = html.replace(/\[\[localization\.([^\]]+)\]\]/g,(m: string,k: string)=> { return this.translate(k); }); target.innerHTML = html; // --- Attribute-based localization using data-localize-map --- if (target.hasAttribute && target.hasAttribute('data-localize-map')) { const mapAttr = target.getAttribute('data-localize-map'); if (!mapAttr) return; let map; try { map = JSON.parse(mapAttr); } catch (e) { console.error('Invalid data-localize-map JSON', e); return; } Object.entries(map).forEach(([attr, keys]) => { let value; if (Array.isArray(keys)) { value = keys.map(k => this.translate(k)); target.setAttribute(attr, JSON.stringify(value)); } else if (typeof keys === 'string') { value = this.translate(keys); target.setAttribute(attr, value); } }); } } /* -------------------------------------------------- * Lifecycle * -------------------------------------------------- */ connectedCallback(){ super.connectedCallback?.(); // Read attributes const locAttr = this.getAttribute('locale'); if(this._isValidLocale(locAttr)) this.setLocale(locAttr as string); // Load translations from attribute or global this._loadTranslations(); // Initialize target once a child exists const initTarget = () => { // async render after children ready setTimeout(() => this._render()); }; if (this.firstElementChild) { initTarget(); } else { // Wait until a child is slotted/added const mo = new MutationObserver(() => { if (this.firstElementChild) { mo.disconnect(); initTarget(); } }); mo.observe(this, { childList: true }); } } disconnectedCallback() { super.disconnectedCallback?.(); } attributeChangedCallback(name: string, oldVal: string | null, newVal: string | null){ super.attributeChangedCallback?.(name, oldVal, newVal); if(name === 'locale' && newVal) this.setLocale(newVal); if(name === 'translations') { this._loadTranslations(); this._render(); } if(name === 'translations' && newVal){ try{ const obj = JSON.parse(newVal); Object.entries(obj).forEach(([l,t]) => this.addTranslations(l, t as Record)); } catch(e){ console.error('Invalid translations JSON', e); } } } } // Avoid redefining during HMR / demos export { LocalizedMixin };