PageSourceSearch

https://learn.iriedu.com/static/tools/lang/lang.js

js iriedu.com collected 2026-10-10 01:59:13 UTC 14,010 bytes, 425 lines download raw bytes

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.