1/** 2 * IRI Shared Language Engine 3 * Unified i18n for learning platform + all DryIQ field tools. 4 * Load in <head> or early <body>. 5 * 6 * localStorage key : dryiqLangV1 (compat: hopeLangV1, mm_lang) 7 * Default : auto-detect from browser, else 'en' 8 * 9 * Supports 12 languages chosen by restoration workforce demographics: 10 * en English â baseline 11 * es Español â Latin America, US Southwest/Southeast 12 * pl Polski â Northeast US restoration crews 13 * pt Português â Brazilian crews, Northeast US 14 * ru Ð ÑÑÑкий â NYC, Chicago, Pacific NW 15 * vi Tiếng Viá»t â Gulf Coast (Houston, NOLA) 16 * tl Tagalog â California, Hawaii 17 * ar Ø§ÙØ¹Ø±Ø¨ÙØ© â Dearborn, NYC, DMV 18 * fr Français â Louisiana, Quebec-border 19 * ko íêµì´ â LA, NYC metro 20 * ht Kreyòl Ayisyen â South Florida, NYC 21 * zh 䏿ï¼ç®ä½ï¼ â West Coast, NYC 22 */ 23(() => { 24 const STORAGE_KEY = 'dryiqLangV1'; 25 const COMPAT_KEYS = ['hopeLangV1', 'mm_lang']; 26 const DEFAULT_LANG = 'en'; 27 28 const LANGUAGES = [ 29 { code: 'en', label: 'English', dir: 'ltr' }, 30 { code: 'es', label: 'Español', dir: 'ltr' }, 31 { code: 'pl', label: 'Polski', dir: 'ltr' }, 32 { code: 'pt', label: 'Português', dir: 'ltr' }, 33 { code: 'ru', label: 'Ð ÑÑÑкий', dir: 'ltr' }, 34 { code: 'vi', label: 'Tiếng Viá»t', dir: 'ltr' }, 35 { code: 'tl', label: 'Tagalog', dir: 'ltr' }, 36 { code: 'ar', label: 'Ø§ÙØ¹Ø±Ø¨ÙØ©', dir: 'rtl' }, 37 { code: 'fr', label: 'Français', dir: 'ltr' }, 38 { code: 'ko', label: 'íêµì´', dir: 'ltr' }, 39 { code: 'ht', label: 'Kreyòl Ayisyen', dir: 'ltr' }, 40 { code: 'zh', label: '䏿ï¼ç®ä½ï¼', dir: 'ltr' }, 41 ]; 42 43 const LANG_CODES = new Set(LANGUAGES.map(l => l.code)); 44 45 // Normalize raw browser / stored language codes 46 function normalize(raw) { 47 if (!raw) return null; 48 const l = String(raw).trim().toLowerCase(); 49 if (l.startsWith('zh')) return 'zh'; 50 if (l.startsWith('pt')) return 'pt'; 51 if (l.startsWith('es')) return 'es'; 52 if (l.startsWith('pl')) return 'pl'; 53 if (l.startsWith('ru')) return 'ru'; 54 if (l.startsWith('vi')) return 'vi'; 55 if (l.startsWith('tl') || l.startsWith('fil')) return 'tl'; 56 if (l.startsWith('ar')) return 'ar'; 57 if (l.startsWith('fr')) return 'fr'; 58 if (l.startsWith('ko')) return 'ko'; 59 if (l.startsWith('ht')) return 'ht'; 60 if (l.startsWith('en')) return 'en'; 61 return LANG_CODES.has(l) ? l : null; 62 } 63 64 // Detect language: URL param > localStorage > compat keys > browser > default 65 function detect() { 66 // 1. URL param ?lang=xx 67 try { 68 const p = new URLSearchParams(window.location.search).get('lang'); 69 const n = normalize(p); 70 if (n && LANG_CODES.has(n)) return n; 71 } catch (e) { /* ignore */ } 72 73 // 2. Primary storage key 74 const stored = normalize(localStorage.getItem(STORAGE_KEY)); 75 if (stored && LANG_CODES.has(stored)) return stored; 76 77 // 3. Compat keys (hopeLangV1, mm_lang) 78 for (const key of COMPAT_KEYS) { 79 const v = normalize(localStorage.getItem(key)); 80 if (v && LANG_CODES.has(v)) return v; 81 } 82 83 // 4. Browser language 84 const candidates = (navigator.languages && navigator.languages.length) 85 ? navigator.languages : [navigator.language]; 86 for (const c of candidates) { 87 const n = normalize(c); 88 if (n && LANG_CODES.has(n)) return n; 89 } 90 91 return DEFAULT_LANG; 92 } 93 94 // State 95 let currentLang = detect(); 96 let dict = {}; // merged EN + target translations 97 let scope = null; // set by init() or data-i18n-scope 98 let basePath = null; // set by init() or meta tag 99 100 // Learn pages render course/quiz/exam/exercise content on the server, so 101 // their URL language must match the stored selector language. 102 const LEARN_LANG_PATHS = [ 103 /^\/me\/?$/, 104 /^\/account\/?$/, 105 /^\/course(?:\/|$)/, 106 /^\/exercise(?:\/|$)/, 107 /^\/exercises(?:\/|$)/, 108 /^\/exam(?:\/|$)/, 109 /^\/exams\/?$/, 110 /^\/resources(?:\/|$)/, 111 /^\/messages(?:\/|$)/, 112 ]; 113 let learnLinkBridgeWired = false;
114 115 function isLearnScope() { 116 return document.documentElement.getAttribute('data-i18n-scope') === 'learn'; 117 } 118 119 function isLearnLangPath(pathname) { 120 const path = pathname || '/'; 121 return LEARN_LANG_PATHS.some(re => re.test(path)); 122 } 123 124 function isLearnServerPage() { 125 return isLearnScope() && isLearnLangPath(window.location.pathname); 126 } 127 128 function canonicalLearnUrl(lang, href) { 129 const n = normalize(lang) || DEFAULT_LANG; 130 const url = new URL(href || window.location.href, window.location.href); 131 if (n === DEFAULT_LANG) { 132 url.searchParams.delete('lang'); 133 } else { 134 url.searchParams.set('lang', n); 135 } 136 return url; 137 } 138 139 function currentUrlMatchesLang(lang) { 140 try { 141 const expected = canonicalLearnUrl(lang); 142 return expected.href === window.location.href; 143 } catch (e) { 144 return true; 145 } 146 } 147 148 function navigateLearnToLang(lang, replace) { 149 if (!isLearnServerPage() || currentUrlMatchesLang(lang)) return false; 150 const url = canonicalLearnUrl(lang); 151 if (replace) { 152 window.location.replace(url.toString()); 153 } else { 154 window.location.assign(url.toString()); 155 } 156 return true; 157 } 158 159 function shouldCarryLangToUrl(url) { 160 if (url.origin !== window.location.origin) return false; 161 if (!/^https?:$/.test(url.protocol)) return false; 162 if (!isLearnLangPath(url.pathname)) return false; 163 if (/^\/(?:admin|logout|certificate|certificates|specialists)(?:\/|$)/.test(url.pathname)) return false; 164 if (/^\/(?:static|tools)(?:\/|$)/.test(url.pathname)) return false; 165 return true; 166 } 167 168 function localizedLearnHref(rawHref) { 169 if (!rawHref) return null; 170 if (/^(?:mailto:|tel:|sms:|javascript:)/i.test(rawHref)) return null; 171 try { 172 const url = new URL(rawHref, window.location.href); 173 if (!shouldCarryLangToUrl(url)) return null; 174 return canonicalLearnUrl(currentLang, url.href).toString(); 175 } catch (e) { 176 return null; 177 } 178 } 179 180 function wireLearnLinkBridge() { 181 if (learnLinkBridgeWired || !isLearnScope()) return; 182 learnLinkBridgeWired = true; 183 document.addEventListener('click', event => { 184 if (event.defaultPrevented || event.button !== 0) return; 185 if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return; 186 const anchor = event.target && event.target.closest 187 ? event.target.closest('a[href]') 188 : null; 189 if (!anchor) return; 190 if (anchor.target && anchor.target !== '_self') return; 191 if (anchor.hasAttribute('download')) return; 192 const nextHref = localizedLearnHref(anchor.getAttribute('href')); 193 if (nextHref && nextHref !== anchor.href) { 194 anchor.href = nextHref; 195 } 196 }, true); 197 } 198 199 function persistLang(lang) { 200 localStorage.setItem(STORAGE_KEY, lang); 201 for (const key of COMPAT_KEYS) { 202 localStorage.setItem(key, lang); 203 } 204 } 205 206 function syncSelectors(lang) { 207 document.querySelectorAll('[data-i18n-select]').forEach(sel => { 208 sel.value = lang; 209 }); 210 } 211 212 // ââ DOM helpers ââ 213 214 function setText(el, text) { 215 // Preserve child elements (icons, badges) â only set first text node 216 if (el.childNodes.length && el.childNodes[0].nodeType === Node.TEXT_NODE) { 217 el.childNodes[0].nodeValue = text; 218 return; 219 } 220 el.textContent = text; 221 } 222 223 function applyDict() { 224 document.querySelectorAll('[data-i18n]').forEach(el => { 225 const key = el.getAttribute('data-i18n'); 226 if (dict[key]) setText(el, dict[key]); 227 }); 228 document.querySelectorAll('[data-i18n-placeholder]').forEach(el => { 229 const key = el.getAttribute('data-i18n-placeholder'); 230 if (dict[key]) el.setAttribute('placeholder', dict[key]); 231 }); 232 document.querySelectorAll('[data-i18n-title]').forEach(el => { 233 const key = el.getAttribute('data-i18n-title'); 234 if (dict[key]) el.setAttribute('title', dict[key]); 235 }); 236 document.querySelectorAll('[data-i18n-aria]').forEach(el => { 237 const key = el.getAttribute('data-i18n-aria'); 238 if (dict[key]) el.setAttribute('aria-label', dict[key]); 239 }); 240 // RTL support 241 const langDef = LANGUAGES.find(l => l.code === currentLang); 242 if (langDef) { 243 document.documentElement.setAttribute('dir', langDef.dir); 244 } 245 document.documentElement.setAttribute('lang', currentLang); 246 } 247 248 // ââ Fetching translations ââ 249 250 async function fetchJson(lang) { 251 if (!basePath || !scope) return null; 252 const url = `${basePath}/${scope}/${lang}.json`; 253 try { 254 const res = await fetch(url, { cache: 'no-store' }); 255 if (!res.ok) return null; 256 return await res.json(); 257 } catch (e) { 258 return null; 259 } 260 } 261 262 async function loadLang(lang) { 263 currentLang = lang; 264 const enDict = await fetchJson(DEFAULT_LANG); 265 const targetDict = (lang === DEFAULT_LANG) ? null : await fetchJson(lang); 266 dict = Object.assign({}, enDict || {}, targetDict || {}); 267 applyDict(); 268 return dict; 269 } 270 271 // ââ Public API ââ 272 273 async function setLang(lang) { 274 const n = normalize(lang) || DEFAULT_LANG; 275 // Persist to all keys for cross-tool consistency 276 persistLang(n); 277 syncSelectors(n); 278 if (navigateLearnToLang(n, false)) return; 279 await loadLang(n); 280 // Update URL param 281 try { 282 const url = new URL(window.location.href); 283 if (n === DEFAULT_LANG) { 284 url.searchParams.delete('lang'); 285 } else { 286 url.searchParams.set('lang', n); 287 } 288 window.history.replaceState({}, '', url.toString()); 289 } catch (e) { /* ignore */ } 290 // Dispatch event for other scripts 291 window.dispatchEvent(new CustomEvent('dryiq:langchange', { detail: { lang: n } })); 292 // Update any lang selects on page 293 syncSelectors(n); 294 } 295 296 function t(key, fallback, vars) { 297 let text = dict[key] || fallback || key; 298 if (vars && typeof vars === 'object') {
299 Object.entries(vars).forEach(([k, v]) => { 300 text = text.replace(new RegExp(`\\{${k}\\}`, 'g'), v); 301 }); 302 } 303 return text; 304 } 305 306 // ââ Inject language selector ââ 307 308 function injectSelector() { 309 // Wire up any existing data-i18n-select elements 310 document.querySelectorAll('[data-i18n-select]').forEach(sel => { 311 // Populate if empty 312 if (sel.options.length === 0) { 313 LANGUAGES.forEach(l => { 314 const opt = document.createElement('option'); 315 opt.value = l.code; 316 opt.textContent = l.label; 317 sel.appendChild(opt); 318 }); 319 } 320 sel.value = currentLang; 321 sel.addEventListener('change', () => setLang(sel.value)); 322 }); 323 324 // If no selector exists, inject one into header 325 if (document.querySelector('[data-i18n-select]')) return; 326 327 const sel = document.createElement('select'); 328 sel.setAttribute('data-i18n-select', ''); 329 sel.setAttribute('aria-label', 'Language'); 330 sel.style.cssText = 'padding:4px 8px;border-radius:6px;border:1px solid rgba(128,128,128,.3);background:transparent;color:inherit;font-size:0.75rem;cursor:pointer;font-family:inherit;'; 331 sel.classList.add('no-print'); 332 LANGUAGES.forEach(l => { 333 const opt = document.createElement('option'); 334 opt.value = l.code; 335 opt.textContent = l.label; 336 sel.appendChild(opt); 337 }); 338 sel.value = currentLang; 339 sel.addEventListener('change', () => setLang(sel.value)); 340 341 // Find a place â try common header patterns 342 const targets = [ 343 '.top-bar nav', 344 '.header-actions', 345 '.print-bar', 346 '.app-header nav', 347 'header .lang-select', 348 'header', 349 ]; 350 for (const s of targets) { 351 const el = document.querySelector(s); 352 if (el) { 353 // Insert before theme toggle if present 354 const themeBtn = el.querySelector('#theme-toggle, #dryiq-theme-toggle'); 355 if (themeBtn) { 356 el.insertBefore(sel, themeBtn); 357 } else { 358 el.appendChild(sel); 359 } 360 return; 361 } 362 } 363 // Fallback 364 (document.querySelector('header') || document.body).appendChild(sel); 365 } 366 367 // ââ Initialization ââ 368 369 function init(opts) { 370 if (opts) { 371 if (opts.scope) scope = opts.scope; 372 if (opts.base) basePath = opts.base; 373 if (opts.lang) currentLang = normalize(opts.lang) || currentLang; 374 } 375 // Auto-detect from DOM if not passed 376 if (!scope) { 377 scope = document.documentElement.getAttribute('data-i18n-scope') || 'common'; 378 } 379 if (!basePath) { 380 basePath = document.querySelector('meta[name="i18n-base"]')?.content || '/tools/lang'; 381 } 382 383 // Persist detected lang 384 persistLang(currentLang); 385 wireLearnLinkBridge(); 386 if (navigateLearnToLang(currentLang, true)) { 387 return Promise.resolve(); 388 } 389 390 return loadLang(currentLang).then(() => { 391 injectSelector(); 392 window.dispatchEvent(new CustomEvent('dryiq:langchange', { detail: { lang: currentLang } })); 393 }).catch(err => { 394 console.warn('[iri-lang] load failed:', err); 395 injectSelector(); // still inject selector even if translations fail 396 }); 397 } 398 399 // Expose globally 400 window.iriLang = { 401 init, 402 setLang, 403 t, 404 get lang() { return currentLang; }, 405 get languages() { return LANGUAGES; }, 406 normalize, 407 }; 408 409 // Also expose as dryiqI18n for backward compat with tools i18n 410 window.dryiqI18n = { 411 get ready() { return Promise.resolve(); }, 412 setLang, 413 t, 414 get lang() { return currentLang; }, 415 }; 416 417 // Auto-init on DOMContentLoaded if scope is set on <html> 418 if (document.documentElement.hasAttribute('data-i18n-scope')) { 419 if (document.readyState === 'loading') { 420 document.addEventListener('DOMContentLoaded', () => init()); 421 } else { 422 init(); 423 } 424 } 425})();
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.