PageSourceSearch

https://www.signalpilot.io/education/assets/lang-switcher.js?v=9

js signalpilot.io collected 2026-10-05 23:10:47 UTC 13,550 bytes, 373 lines download raw bytes

1/**
2 * Language Switcher
3 *
4 * Two tiers:
5 *   1. Signal Pilot translations — the twelve languages the curriculum is
6 *      actually written in. Picking one navigates to the real translated page
7 *      (/de/education/..., /ja/education/..., and so on) and clears any Google
8 *      Translate cookie so nothing gets translated a second time on top.
9 *   2. Google Translate — every other language Google offers, applied to the
10 *      English original.
11 */
12
13(function() {
14  'use strict';
15
16  // Locales we have hand-translated. Order matters: this is the menu order.
17  const NATIVE = [
18    { code: 'en', name: 'English',    flagCode: 'us' },
19    { code: 'de', name: 'Deutsch',    flagCode: 'de' },
20    { code: 'es', name: 'Español',    flagCode: 'es' },
21    { code: 'fr', name: 'Français',   flagCode: 'fr' },
22    { code: 'it', name: 'Italiano',   flagCode: 'it' },
23    { code: 'pt', name: 'Português',  flagCode: 'pt' },
24    { code: 'nl', name: 'Nederlands', flagCode: 'nl' },
25    { code: 'ru', name: 'Русский',    flagCode: 'ru' },
26    { code: 'ja', name: '日本語',      flagCode: 'jp' },
27    { code: 'tr', name: 'Türkçe',     flagCode: 'tr' },
28    { code: 'hu', name: 'Magyar',     flagCode: 'hu' },
29    { code: 'ar', name: 'العربية',     flagCode: 'sa' }
30  ];
31
32  // Everything else Google Translate can reach, applied to the English page.
33  const MACHINE = [
34    { code: 'zh-CN', name: '中文 (简体)', flagCode: 'cn' },
35    { code: 'zh-TW', name: '中文 (繁體)', flagCode: 'tw' },
36    { code: 'ko', name: '한국어', flagCode: 'kr' },
37    { code: 'hi', name: 'हिन्दी', flagCode: 'in' },
38    { code: 'pl', name: 'Polski', flagCode: 'pl' },
39    { code: 'uk', name: 'Українська', flagCode: 'ua' },
40    { code: 'sv', name: 'Svenska', flagCode: 'se' },
41    { code: 'no', name: 'Norsk', flagCode: 'no' },
42    { code: 'da', name: 'Dansk', flagCode: 'dk' },
43    { code: 'fi', name: 'Suomi', flagCode: 'fi' },
44    { code: 'cs', name: 'Čeština', flagCode: 'cz' },
45    { code: 'ro', name: 'Română', flagCode: 'ro' },
46    { code: 'bg', name: 'Български', flagCode: 'bg' },
47    { code: 'el', name: 'Ελληνικά', flagCode: 'gr' },
48    { code: 'iw', name: 'עברית', flagCode: 'il' },
49    { code: 'id', name: 'Bahasa Indonesia', flagCode: 'id' },
50    { code: 'th', name: 'ไทย', flagCode: 'th' },
51    { code: 'vi', name: 'Tiếng Việt', flagCode: 'vn' }
52  ];
53
54  const LOCALE_RE = /^\/(de|es|fr|it|pt|nl|ru|ja|tr|hu|ar)(?=\/|$)/;
55
56  // Paths that exist in every locale directory. Anything else has no native
57  // translation, so those pages fall back to Google Translate.
58  const NATIVE_PATHS = [
59    /^\/education\/curriculum\//,
60    /^\/education\/(index\.html)?$/,
61    /^\/chronicle\//,
62    /^\/(index\.html)?$/,
63    /^\/(affiliates|faq|manage-subscription|privacy|refund|roadmap|terms|trial-thanks)\.html$/
64  ];
65
66  function getFlagImg(flagCode, size = 40) {
67    return `<img src="https://flagcdn.com/w${size}/${flagCode}.png" width="26" height="17" alt="${flagCode.toUpperCase()}" loading="lazy" style="vertical-align:middle;border-radius:2px;flex-shrink:0">`;
68  }
69
70  // --- path helpers -------------------------------------------------------
71
72  // '/de/education/index.html' -> 'de'   ;  '/education/index.html' -> 'en'
73  function pathLocale() {
74    const m = location.pathname.match(LOCALE_RE);
75    return m ? m[1] : 'en';
76  }
77
78  // '/de/education/index.html' -> '/education/index.html'
79  function basePath() {
80    const p = location.pathname;
81    const m = p.match(LOCALE_RE);
82    return m ? (p.slice(m[0].length) || '/') : p;
83  }
84
85  function hasNativeVersion(path) {
86    return NATIVE_PATHS.some(re => re.test(path));
87  }
88
89  function nativeUrl(langCode) {
90    const base = basePath();
91    const prefix = langCode === 'en' ? '' : '/' + langCode;
92    return prefix + base + location.search + location.hash;
93  }
94
95  // --- cookies ------------------------------------------------------------
96
97  function baseDomain(host) {
98    const p = host.split('.');
99    return p.length > 2 ? p.slice(-2).join('.') : host;
100  }
101
102  function setCookie(name, value, days, domain) {
103    let expires = '';
104    if (days) {
105      const d = new Date();
106      d.setTime(d.getTime() + days * 864e5);
107      expires = '; expires=' + d.toUTCString();
108    }
109    const dom = domain ? '; domain=' + domain : '';
110    document.cookie = name + '=' + value + expires + '; path=/' + dom;
111  }
112
113  function rootDomain() {
114    return '.' + baseDomain(location.hostname.replace(/^www\./, ''));
115  }
116
117  function setGoogTrans(langCode) {
118    const val = '/en/' + (langCode === 'zh' ? 'zh-CN' : langCode);
119    setCookie('googtrans', val, 365);
120    setCookie('googtrans', val, 365, rootDomain());
121  }
122
123  function clearGoogTrans() {
124    setCookie('googtrans', '', -1);
125    setCookie('googtrans', '', -1, rootDomain());
126    try { localStorage.removeItem('sp_lang'); } catch (e) {}
127  }
128
129  function googTransCookie() {
130    const match = document.cookie.match(/googtrans=\/en\/([^;]+)/);
131    return match && match[1] ? match[1] : null;
132  }
133
134  function applyDirLang(langCode) {
135    document.documentElement.lang = (langCode === 'zh') ? 'zh-CN' : langCode;
136    document.documentElement.dir = (langCode === 'ar' || langCode === 'iw') ? 'rtl' : 'ltr';
137  }
138
139  // Which entry should be highlighted. A locale in the URL always wins: we are
140  // reading a real translation, whatever a stale cookie says.
141  function activeCode() {
142    const loc = pathLocale();
143    if (loc !== 'en') return loc;
144    return googTransCookie() || 'en';
145  }
146
147  // --- navigation ---------------------------------------------------------
148
149  function goNative(langCode) {
150    clearGoogTrans();
151    const target = nativeUrl(langCode);
152    if (target === location.pathname + location.search + location.hash) {
153      location.reload();
154    } else {
155      location.href = target;
156    }
157  }
158
159  function goMachine(langCode) {
160    try { localStorage.setItem('sp_lang', langCode); } catch (e) {}
161    setGoogTrans(langCode);
162    applyDirLang(langCode);
163    // Always machine-translate the English original, never one of our own
164    // translations — layering Google on top of hand-written German is worse
165    // than either on its own.
166    const english = basePath() + location.search + location.hash;
167    if (english !== location.pathname + location.search + location.hash) {
168      location.href = english;
169    } else {
170      location.reload();
171    }
172  }
173
174  function changeLanguage(langCode, isNative) {
175    if (isNative && hasNativeVersion(basePath())) {
176      goNative(langCode);
177      return;
178    }
179    if (isNative && langCode === 'en') {
180      clearGoogTrans();
181      document.documentElement.lang = 'en';
182      document.documentElement.dir = 'ltr';
183      location.href = basePath() + location.search + location.hash;
184      return;
185    }
186    // No hand-translated version of this particular page: fall back to Google.
187    goMachine(langCode);
188  }
189
190  // --- menu ---------------------------------------------------------------
191
192  function makeHeading(text) {
193    const h = document.createElement('div');
194    h.className = 'lang-group-label';
195    h.textContent = text;
196    return h;
197  }
198
199  function makeButton(lang, isNative, current, translated) {
200    const button = document.createElement('button');
201    button.type = 'button';
202    button.setAttribute('role', 'menuitem');
203    button.setAttribute('data-lang', lang.code);
204    button.innerHTML = `${getFlagImg(lang.flagCode)}<span>${lang.name}</span>`;
205    if (isNative && !translated && lang.code !== 'en') {
206      button.classList.add('lang-approx');
207      button.title = 'This page has no hand-written translation yet — Google Translate will be used.';
208    }
209    if (lang.code === current) {
210      button.classList.add('active');
211      button.setAttribute('aria-current', 'true');
212    }
213    button.addEventListener('click', () => changeLanguage(lang.code, isNative));
214    return button;
215  }
216
217  function createDropdownMenu() {
218    const menu = document.createElement('div');
219    menu.className = 'lang-dropdown-menu';
220    menu.setAttribute('role', 'menu');
221    menu.setAttribute('aria-label', 'Language selection');
222
223    const current = activeCode();
224    const translated = hasNativeVersion(basePath());
225
226    menu.appendChild(makeHeading('Signal Pilot translations'));
227    NATIVE.forEach(lang => menu.appendChild(makeButton(lang, true, current, translated)));
228
229    menu.appendChild(makeHeading('More languages · Google Translate'));
230    MACHINE.forEach(lang => menu.appendChild(makeButton(lang, false, current, translated)));
231
232    document.addEventListener('click', (e) => {
233      if (!e.target.closest('.lang-dropdown') && menu.classList.contains('active')) {
234        menu.classList.remove('active');
235      }
236    });
237
238    return menu;
239  }
240
241  // --- init ---------------------------------------------------------------
242
243  function init() {
244    const container = document.getElementById('google_translate_element');
245    if (!container) return;
246
247    const current = activeCode();
248    const all = NATIVE.concat(MACHINE);
249    const currentLangObj = all.find(l => l.code === current) || NATIVE[0];
250
251    container.id = 'langToggle';
252    container.setAttribute('role', 'button');
253    container.setAttribute('tabindex', '0');
254    container.setAttribute('aria-label', `Select language (current: ${currentLangObj.name})`);
255    container.setAttribute('aria-haspopup', 'true');
256    container.setAttribute('aria-expanded', 'false');
257    container.innerHTML = getFlagImg(currentLangObj.flagCode);
258
259    const menu = createDropdownMenu();
260    document.body.appendChild(menu);
261
262    // The menu used to sit at a hard-coded top:70px, which lands underneath the
263    // header whenever anything above it (the translation notice, a banner)
264    // pushes the header down — and the part hidden behind the header cannot be
265    // scrolled to. Anchor it to the button instead, every time it opens.
266    function position() {
267      const r = container.getBoundingClientRect();
268      const gap = 10;
269      const margin = 12;
270      menu.style.top = (r.bottom + gap) + 'px';
271      menu.style.right = Math.max(margin, window.innerWidth - r.right) + 'px';
272      menu.style.bottom = 'auto';
273      menu.style.maxHeight = Math.max(180, window.innerHeight - r.bottom - gap - margin) + 'px';
274    }
275
276    container.addEventListener('click', (e) => {
277      e.stopPropagation();
278      const isActive = menu.classList.contains('active');
279      if (!isActive) {
280        position();
281        menu.scrollTop = 0;
282      }
283      menu.classList.toggle('active');
284      container.setAttribute('aria-expanded', String(!isActive));
285    });
286
287    window.addEventListener('resize', () => {
288      if (menu.classList.contains('active')) position();
289    });
290    window.addEventListener('scroll', () => {
291      if (menu.classList.contains('active')) position();
292    }, { passive: true });
293    document.addEventListener('keydown', (e) => {
294      if (e.key === 'Escape' && menu.classList.contains('active')) {
295        menu.classList.remove('active');
296        container.setAttribute('aria-expanded', 'false');
297        container.focus();
298      }
299    });
300    container.addEventListener('keydown', (e) => {
301      if (e.key === 'Enter' || e.key === ' ') {
302        e.preventDefault();
303        container.click();
304      }
305    });
306
307    if (pathLocale() !== 'en') {
308      // We are on one of our own translations. Make sure a leftover cookie
309      // cannot machine-translate it a second time.
310      if (googTransCookie()) clearGoogTrans();
311      return;
312    }
313
314    if (current !== 'en') {
315      applyDirLang(current);
316      loadGoogleTranslate(current);
317    }
318  }
319
320  function loadGoogleTranslate(lang) {
321    if (!lang || lang === 'en') return;
322
323    const hiddenDiv = document.createElement('div');
324    hiddenDiv.id = 'google_translate_container';
325    hiddenDiv.style.position = 'absolute';
326    hiddenDiv.style.left = '-9999px';
327    hiddenDiv.setAttribute('aria-hidden', 'true');
328    document.body.appendChild(hiddenDiv);
329
330    window.googleTranslateElementInit = function() {
331      new google.translate.TranslateElement({
332        pageLanguage: 'en',
333        includedLanguages: NATIVE.concat(MACHINE).map(l => l.code).join(','),
334        layout: google.translate.TranslateElement.InlineLayout.SIMPLE,
335        autoDisplay: false
336      }, 'google_translate_container');
337    };
338
339    const script = document.createElement('script');
340    script.src = 'https://translate.google.com/translate_a/element.js?cb=googleTranslateElementInit';
341    script.async = true;
342    script.onerror = function() {
343      console.warn('Failed to load Google Translate');
344    };
345    document.head.appendChild(script);
346  }
347
348  function cleanupGoogleUI() {
349    const frames = document.querySelectorAll('.goog-te-banner-frame, iframe.goog-te-banner-frame, .skiptranslate');
350    frames.forEach(f => { if (f.parentNode) f.parentNode.removeChild(f); });
351    document.body.style.top = '0';
352    document.body.style.position = 'relative';
353    if (document.body.hasAttribute('style')) {
354      const style = document.body.getAttribute('style');
355      if (style && style.includes('top:')) {
356        document.body.setAttribute('style', style.replace(/top:\s*[^;]+;?/g, ''));
357      }
358    }
359  }
360
361  window.addEventListener('load', () => {
362    cleanupGoogleUI();
363    setTimeout(cleanupGoogleUI, 500);
364    setTimeout(cleanupGoogleUI, 1000);
365    setTimeout(cleanupGoogleUI, 2000);
366  });
367
368  if (document.readyState === 'loading') {
369    document.addEventListener('DOMContentLoaded', init);
370  } else {
371    init();
372  }
373})();

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.