-
-
Notifications
You must be signed in to change notification settings - Fork 26
Expand file tree
/
Copy pathi18n.js
More file actions
404 lines (374 loc) · 16.3 KB
/
Copy pathi18n.js
File metadata and controls
404 lines (374 loc) · 16.3 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
// three.ws runtime i18n — client-side locale swap, the LobeHub model.
//
// Copy lives in static HTML annotated with data-i18n attributes; the source
// catalog and machine translations are committed as static JSON under
// /locales/<code>.json (built offline by scripts/i18n-translate.mjs). At
// runtime this module detects the visitor's locale, fetches that catalog once,
// and rewrites the annotated DOM — no per-request API calls, no translation
// cost, fully cacheable.
//
// Annotations (see scripts/i18n-extract.mjs):
// data-i18n="key" → element.textContent
// data-i18n-html="key" → element.innerHTML (values that contain markup)
// data-i18n-attr="attr:key;attr2:key2" → element attributes (aria-label, content, …)
//
// Pure helpers (resolveKey, interpolate, applyCatalog) are exported for tests
// and run without a DOM.
const STORAGE_KEY = 'twx_lang';
const LOCALES_BASE = '/locales';
const hasDOM = typeof document !== 'undefined';
// Some in-app webviews (Twitter/X Android, privacy-sandboxed frames) expose
// `document`/`window` but set `localStorage` to null or throw on access, so a
// raw localStorage.getItem blows up with "Cannot read properties of null". Wrap
// every access so locale persistence degrades silently instead of throwing.
const safeStorage = {
get(key) {
try {
return globalThis.localStorage?.getItem(key) ?? null;
} catch {
return null;
}
},
set(key, value) {
try {
globalThis.localStorage?.setItem(key, value);
} catch {
/* storage unavailable (private mode, sandboxed webview) — ignore */
}
},
};
const state = {
manifest: null,
current: 'en',
catalog: {}, // active locale strings (nested)
fallback: {}, // entryLocale strings, so a missing translation degrades to English
};
// --- pure helpers ----------------------------------------------------------
// Dot-path lookup against a nested catalog: resolveKey({a:{b:'x'}}, 'a.b') → 'x'.
export function resolveKey(catalog, key) {
return key
.split('.')
.reduce(
(node, part) => (node && typeof node === 'object' ? node[part] : undefined),
catalog,
);
}
// {{name}} interpolation. Missing vars are left as the literal token so they're
// visible in QA rather than silently blank.
export function interpolate(str, vars) {
if (typeof str !== 'string' || !vars) return str;
return str.replace(/\{\{\s*([\w.]+)\s*\}\}/g, (m, k) => (k in vars ? String(vars[k]) : m));
}
// Translate one key with graceful fallback: active locale → entryLocale → key.
export function translate(key, vars, { catalog = state.catalog, fallback = state.fallback } = {}) {
const hit = resolveKey(catalog, key);
const value = hit !== undefined && hit !== '' ? hit : resolveKey(fallback, key);
return interpolate(value !== undefined ? value : key, vars);
}
// Apply a catalog to a DOM subtree. Exported with an injectable root so tests
// can pass a jsdom document fragment.
export function applyCatalog(root, t) {
if (!root) return;
root.querySelectorAll?.('[data-i18n]').forEach((el) => {
const key = el.getAttribute('data-i18n');
const v = t(key);
// A total miss (both catalogs) echoes the key back. The element already
// holds its English source text; keep it rather than showing "nav.c3spxn".
if (v == null || v === key) return;
// Some nav elements carry both data-i18n (a translatable label) and
// data-auth-name (their text is the signed-in visitor's display name). The
// name is not translatable copy and nav-auth owns it. Keep the translation
// as the signed-out fallback so a locale switch still localizes the label,
// but never clobber a live display name (data-auth-named === '1').
if (el.hasAttribute('data-auth-name')) {
el.dataset.authNameOriginal = v;
if (el.dataset.authNamed === '1') return;
}
el.textContent = v;
});
root.querySelectorAll?.('[data-i18n-html]').forEach((el) => {
const key = el.getAttribute('data-i18n-html');
const v = t(key);
if (v == null || v === key) return;
// Same nav-auth ownership rule as the textContent loop above: never let a
// translated markup value overwrite a live display name.
if (el.hasAttribute('data-auth-name')) {
el.dataset.authNameOriginal = v;
if (el.dataset.authNamed === '1') return;
}
el.innerHTML = v;
});
root.querySelectorAll?.('[data-i18n-attr]').forEach((el) => {
for (const pair of el.getAttribute('data-i18n-attr').split(';')) {
const [attr, key] = pair.split(':').map((s) => s && s.trim());
if (!attr || !key) continue;
const v = t(key);
if (v != null && v !== key) {
el.setAttribute(attr, v);
if (attr === 'data-i18n-title' || attr === 'title-text') document.title = v;
}
}
});
}
// --- runtime ---------------------------------------------------------------
export const t = (key, vars) => translate(key, vars);
async function fetchJSON(url) {
const res = await fetch(url, { credentials: 'same-origin' });
if (!res.ok) throw new Error(`${url} → ${res.status}`);
return res.json();
}
async function loadManifest() {
if (state.manifest) return state.manifest;
try {
state.manifest = await fetchJSON(`${LOCALES_BASE}/manifest.json`);
} catch {
state.manifest = { default: 'en', locales: [{ code: 'en', name: 'English', dir: 'ltr' }] };
}
return state.manifest;
}
function supported(code, manifest) {
return manifest.locales.find((l) => l.code === code);
}
// ?lang= → localStorage → navigator languages → manifest default.
// The URL param must outrank the stored preference: sitemap hreflang entries
// and shared links all carry ?lang=xx, and a returning visitor (whose first
// visit stored a locale) would otherwise see every one of those deep links
// silently ignored. Honoring the param also stores it, so the choice sticks.
// Pure and exported for tests; detectLocale feeds it the browser's values.
export function pickLocale({ query, stored, navLangs }, manifest) {
if (query && supported(query, manifest)) return query;
if (stored && supported(stored, manifest)) return stored;
for (const nav of navLangs || []) {
if (!nav) continue;
if (supported(nav, manifest)) return nav;
const base = nav.split('-')[0];
const byBase = manifest.locales.find(
(l) => l.code === base || l.code.split('-')[0] === base,
);
if (byBase) return byBase.code;
}
return manifest.default;
}
function detectLocale(manifest) {
if (!hasDOM) return manifest.default;
return pickLocale(
{
query: new URLSearchParams(location.search).get('lang'),
stored: safeStorage.get(STORAGE_KEY),
navLangs: navigator.languages || [navigator.language || ''],
},
manifest,
);
}
async function loadCatalog(code) {
return fetchJSON(`${LOCALES_BASE}/${code}.json`).catch(() => ({}));
}
export async function setLocale(code) {
const manifest = await loadManifest();
const entry = supported(code, manifest) ? code : manifest.default;
// The entryLocale catalog is both the fallback (so partial translations never
// leave blanks) AND what restores the original copy when switching back to
// the default language — the committed English JSON, not the live DOM, is the
// source of truth, so a default ⇄ translated round-trip is lossless.
if (!Object.keys(state.fallback).length) {
state.fallback = await loadCatalog(manifest.default);
}
state.catalog = entry === manifest.default ? state.fallback : await loadCatalog(entry);
state.current = entry;
if (hasDOM) {
safeStorage.set(STORAGE_KEY, entry);
const meta = supported(entry, manifest);
document.documentElement.lang = entry;
document.documentElement.dir = meta?.dir === 'rtl' ? 'rtl' : 'ltr';
applyCatalog(document, t);
window.dispatchEvent(new CustomEvent('i18n:change', { detail: { locale: entry } }));
}
return entry;
}
export function getLocale() {
return state.current;
}
export async function initI18n() {
const manifest = await loadManifest();
await setLocale(detectLocale(manifest));
}
// --- <lang-switcher> web component -----------------------------------------
//
// Accessible language picker for the global nav: a native <select> (keyboard +
// screen-reader friendly out of the box) styled to match the design tokens,
// with hover/focus/active states. Renders nothing until the manifest lists more
// than one locale, so it self-hides on a single-language deploy.
function registerLangSwitcher() {
if (customElements.get('lang-switcher')) return;
class LangSwitcher extends HTMLElement {
async connectedCallback() {
// connectedCallback re-fires whenever the element is moved to a new slot
// (pages re-parent the switcher into footers/overflow menus), and a second
// attachShadow on the same host throws. The sync flag also covers the
// race where two rapid reconnects both clear the manifest await before
// either has attached the shadow root.
if (this._booted) return;
this._booted = true;
const manifest = await loadManifest();
if (!manifest.locales || manifest.locales.length < 2) return;
const root = this.attachShadow({ mode: 'open' });
root.innerHTML = `
<style>
:host { display: inline-flex; }
.wrap { position: relative; display: inline-flex; align-items: center; }
svg { position: absolute; left: 8px; width: 14px; height: 14px; opacity: .6; pointer-events: none; }
select {
appearance: none; -webkit-appearance: none;
font: inherit; font-size: 13px; line-height: 1;
color: var(--text-2, #cfcfd4);
background: var(--surface-2, rgba(255,255,255,.04));
border: 1px solid var(--border, rgba(255,255,255,.12));
border-radius: 8px;
padding: 7px 26px 7px 28px;
cursor: pointer;
transition: border-color .15s ease, background .15s ease, color .15s ease;
}
select:hover { color: var(--text, #fff); border-color: var(--border-strong, rgba(255,255,255,.24)); }
select:focus-visible { outline: 2px solid var(--accent, #6d6dff); outline-offset: 2px; }
select:active { transform: translateY(1px); }
.chev { right: 8px; left: auto; }
option { color: #111; }
</style>
<span class="wrap">
<svg viewBox="0 0 24 24" fill="none" aria-hidden="true"><circle cx="12" cy="12" r="9" stroke="currentColor" stroke-width="1.6"/><path d="M3 12h18M12 3c2.5 2.5 3.8 5.7 3.8 9S14.5 18.5 12 21M12 3C9.5 5.5 8.2 8.7 8.2 12S9.5 18.5 12 21" stroke="currentColor" stroke-width="1.4"/></svg>
<select aria-label="Choose language">
${manifest.locales.map((l) => `<option value="${l.code}">${l.name}</option>`).join('')}
</select>
<svg class="chev" viewBox="0 0 24 24" fill="none" aria-hidden="true"><path d="M6 9l6 6 6-6" stroke="currentColor" stroke-width="1.6"/></svg>
</span>`;
const select = root.querySelector('select');
select.value = getLocale();
select.addEventListener('change', () => setLocale(select.value));
// Keep the control in sync if another instance or code path changes locale.
window.addEventListener('i18n:change', (e) => {
if (e.detail?.locale) select.value = e.detail.locale;
});
}
}
customElements.define('lang-switcher', LangSwitcher);
}
// Mount a compact, fixed-position language switcher on any page that ships the
// runtime but doesn't already place a <lang-switcher> itself. This is what lets
// the auto-annotator wire a page for translation without also hand-editing its
// layout to add a picker: pages that DO place their own switcher (e.g. the home
// nav) are left alone, and a page can opt out with <body data-no-lang-switcher>.
// Self-hides on single-language deploys because <lang-switcher> renders nothing
// when the manifest lists fewer than two locales.
async function mountFloatingSwitcher() {
if (!hasDOM || !document.body) return;
if (document.querySelector('lang-switcher')) return; // page placed its own
if (document.body.hasAttribute('data-no-lang-switcher')) return;
const manifest = await loadManifest();
if (!manifest.locales || manifest.locales.length < 2) return;
const host = document.createElement('div');
host.className = 'twx-i18n-fab';
host.setAttribute('data-no-i18n', ''); // never annotate/translate the control itself
const style = document.createElement('style');
style.textContent = `
.twx-i18n-fab {
position: fixed; z-index: 2147483000;
inset-block-end: max(16px, env(safe-area-inset-bottom));
inset-inline-end: max(16px, env(safe-area-inset-right));
display: inline-flex;
filter: drop-shadow(0 4px 14px rgba(0,0,0,.35));
opacity: .92; transition: opacity .15s ease;
}
.twx-i18n-fab:hover { opacity: 1; }
@media print { .twx-i18n-fab { display: none; } }`;
document.head.appendChild(style);
host.appendChild(document.createElement('lang-switcher'));
// Flow into the shared bottom-right corner stack (public/corner-stack.js) so
// the switcher never piles onto the "Getting started" pill or other corner
// cards. The stack's #id CSS overrides the fixed positioning above, which
// remains the standalone fallback on pages that don't ship the stack (embeds,
// generated shells). Priority 10 sits above the pill (30 = owns the corner).
host.setAttribute('data-corner-priority', '10');
if (window.twsCornerStack) window.twsCornerStack.mount(host);
else document.body.appendChild(host);
}
// Inject <link rel="alternate" hreflang> tags into <head> for the current path,
// one per committed locale plus x-default, so a page crawled directly (not just
// via the sitemap) advertises its translations. The default locale and
// x-default use the bare URL; others carry ?lang=xx, matching the URLs the
// sitemap emits and that detectLocale() honors. Idempotent and dependency-free.
async function injectHreflang() {
if (!hasDOM || !document.head) return;
if (document.querySelector('link[rel="alternate"][hreflang]')) return; // already present
const manifest = await loadManifest();
if (!manifest.locales || manifest.locales.length < 2) return;
const origin = location.origin;
const pathname = location.pathname;
const href = (code) =>
code === manifest.default ? `${origin}${pathname}` : `${origin}${pathname}?lang=${code}`;
const frag = document.createDocumentFragment();
const add = (hreflang, url) => {
const link = document.createElement('link');
link.rel = 'alternate';
link.hreflang = hreflang;
link.href = url;
frag.appendChild(link);
};
for (const l of manifest.locales) add(l.code, href(l.code));
add('x-default', `${origin}${pathname}`);
document.head.appendChild(frag);
}
// Re-translate DOM that is injected AFTER the initial pass — the global nav,
// the getting-started widget, and any other runtime-built shell are fetched and
// rendered asynchronously, so they miss the initI18n applyCatalog(document).
// A MutationObserver translates each newly-added subtree that carries data-i18n
// annotations, batched on an animation frame so a burst of DOM writes costs one
// pass. setLocale still re-applies to the whole document on a language switch,
// so this only has to cover the first render of injected content.
function observeInjectedContent() {
if (!hasDOM || typeof MutationObserver === 'undefined' || !document.body) return;
const pending = new Set();
let scheduled = false;
const schedule = typeof requestAnimationFrame === 'function' ? requestAnimationFrame : (fn) => setTimeout(fn, 16);
const flush = () => {
scheduled = false;
const roots = [...pending];
pending.clear();
for (const root of roots) applyCatalog(root, t);
};
const SEL = '[data-i18n],[data-i18n-html],[data-i18n-attr]';
const observer = new MutationObserver((mutations) => {
for (const m of mutations) {
for (const node of m.addedNodes) {
if (node.nodeType !== 1) continue;
if (node.matches?.(SEL) || node.querySelector?.(SEL)) pending.add(node);
}
}
if (pending.size && !scheduled) {
scheduled = true;
schedule(flush);
}
});
observer.observe(document.body, { childList: true, subtree: true });
}
// Auto-initialize on load so any page that ships this script is localized with
// zero per-page wiring.
if (hasDOM) {
registerLangSwitcher();
const boot = async () => {
await initI18n();
observeInjectedContent();
// Catch content injected during init (before the observer was attached).
applyCatalog(document, t);
mountFloatingSwitcher();
injectHreflang();
};
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', boot);
} else {
boot();
}
// `apply` lets a runtime-built component (nav.js, getting-started.js) request
// an immediate translation of the subtree it just rendered, without waiting
// for the observer's next frame.
window.threewsI18n = { t, setLocale, getLocale, initI18n, apply: (root) => applyCatalog(root || document, t) };
}