Oregami
Repositories/oxedyne/daimond

oxedyne/daimond/www/js/i18n.js

28.9 KiB, 1 run

created by r2519314175:1381, which is this file's identity for as long as the history lasts, whatever it is later renamed to

download · who wrote it · its history

1/* i18n.js — what the app says, and what money looks like.
2 *
3 * Two settings live here and nowhere else: the language the interface speaks,
4 * and the currency figures are shown in. Both are preferences of the device,
5 * kept beside the theme rather than inside an account, so a browser shared by
6 * two people does not ask each of them again.
7 *
8 * Money is DISPLAY ONLY. Daimond bills in US dollars. A figure converted into
9 * another currency is a courtesy and always carries a ≈; at every point where
10 * a charge actually happens the US dollar amount is printed explicitly beside
11 * it, with one sentence saying so. The rates are a static table stamped with
12 * the date they were taken — nothing here calls a rate service, because a
13 * price panel that phones home is not a local-first app.
14 *
15 * Strings live in `i18n/<locale>.js`, each of which calls `register()` with a
16 * flat object of dot-namespaced keys. `en` is the baseline and the fallback: a
17 * key missing from a translation falls back to English rather than to a blank,
18 * and says so once in the console.
19 *
20 * THREE WAYS A STRING GETS INTO THE NEW LANGUAGE, and a surface needs whichever
21 * fits how it was built:
22 *
23 * `data-i18n` in the markup — `apply()` repaints it on every change. Free.
24 * `bind(node, attr, key)` — sets the string AND stamps the key, for a
25 * widget built in script that knows the key.
26 * `mark(node, attr, text)` — for a string a widget was HANDED rather than
27 * looked up: it recovers the key from the string
28 * and stamps it, after which the first case
29 * applies. Use `bind` where the key is to hand.
30 * `surface(host, draw)` — for a surface that builds itself and is only
31 * sometimes on screen. Redrawn where it stands.
32 *
33 * `onChange` remains for what is on screen the whole time. Anything that opens
34 * and closes should use `surface`: the bug it exists to stop is a panel left in
35 * the language it was opened in, and that came back once already because each
36 * hook decided for itself whether it was open.
37 */
38(function () {
39 'use strict';
40
41 var LS_LOCALE = 'daimond-locale';
42 var LS_CCY = 'daimond-currency';
43
44 // ── Locales ────────────────────────────────────────────────
45 // Native names, because a language picker that names languages in a
46 // language you cannot read is no use to the person who needs it.
47 var LOCALES = [
48 { code: 'en', name: 'English' },
49 { code: 'es', name: 'Español' },
50 { code: 'de', name: 'Deutsch' },
51 { code: 'fr', name: 'Français' },
52 { code: 'pt-BR', name: 'Português (Brasil)' },
53 { code: 'zh-Hans', name: '简体中文' },
54 { code: 'ja', name: '日本語' },
55 { code: 'ko', name: '한국어' },
56 ];
57
58 // ── Currencies ─────────────────────────────────────────────
59 // Approximate, and labelled as such wherever they are used. One number per
60 // currency: units of it per US dollar.
61 var RATES_AS_OF = '2026-07-01';
62 var CURRENCIES = [
63 { code: 'USD', name: 'US dollar', rate: 1 },
64 { code: 'EUR', name: 'Euro', rate: 0.92 },
65 { code: 'GBP', name: 'Pound sterling', rate: 0.79 },
66 { code: 'JPY', name: 'Japanese yen', rate: 151 },
67 { code: 'CNY', name: 'Chinese yuan', rate: 7.2 },
68 { code: 'KRW', name: 'Korean won', rate: 1360 },
69 { code: 'INR', name: 'Indian rupee', rate: 84 },
70 { code: 'BRL', name: 'Brazilian real', rate: 5.4 },
71 { code: 'AUD', name: 'Australian dollar', rate: 1.52 },
72 { code: 'CAD', name: 'Canadian dollar', rate: 1.37 },
73 ];
74
75 var RATE = {};
76 CURRENCIES.forEach(function (c) { RATE[c.code] = c.rate; });
77
78 // ── State ──────────────────────────────────────────────────
79 var TABLES = {}; // locale code -> { key: string }
80 var missing = {}; // keys already complained about, so one warning each
81 var loading = {}; // locale code -> Promise, so a file loads once
82 var present = {}; // locale code -> true|false once probed
83 var hooks = []; // functions to run after a locale change
84 var live = []; // { host, draw } for surfaces that build their own strings
85 var booted = false;
86 var landed = false; // the active non-English table has arrived
87 var revMap = null; // the table in force, value -> every key that produces it
88 var revFor = null; // which locale revMap was built from
89 var origin = Object.create(null); // string -> the key `t` last produced it from, this locale
90
91 var curLocale = null; // the chosen locale, or null while nothing is chosen
92 var curCcy = null; // the chosen currency, or null for US dollars
93
94 function supported(code) {
95 for (var i = 0; i < LOCALES.length; i++) if (LOCALES[i].code === code) return true;
96 return false;
97 }
98
99 /// Map whatever the browser reports onto a locale we ship. `pt-PT` lands on
100 /// Brazilian Portuguese and `zh-Hant` lands on nothing, which is honest:
101 /// half a translation is worse than English.
102 function mapBrowser(tag) {
103 tag = String(tag || '');
104 if (!tag) return 'en';
105 if (supported(tag)) return tag;
106 var base = tag.split(/[-_]/)[0].toLowerCase();
107 var reg = (tag.split(/[-_]/)[1] || '').toLowerCase();
108 if (base === 'pt') return 'pt-BR';
109 if (base === 'zh') return (reg === 'tw' || reg === 'hk' || reg === 'mo' || /hant/i.test(tag)) ? 'en' : 'zh-Hans';
110 if (supported(base)) return base;
111 return 'en';
112 }
113
114 function readStored() {
115 try {
116 var l = localStorage.getItem(LS_LOCALE);
117 if (l && supported(l)) curLocale = l;
118 var c = localStorage.getItem(LS_CCY);
119 if (c && RATE[c]) curCcy = c;
120 } catch (e) { /* private mode: the defaults stand. */ }
121 }
122 readStored();
123
124 /// The locale in force: the choice if there is one, else the browser's.
125 function locale() { return curLocale || mapBrowser(navigator.language || 'en'); }
126 /// The currency in force. Absent a choice, US dollars.
127 function currency() { return curCcy || 'USD'; }
128
129 /// Whether figures are being converted. A caller that marks its own
130 /// estimates with a ≈ asks this first, so a converted estimate carries one
131 /// ≈ rather than two.
132 function converted() { return currency() !== 'USD'; }
133
134 /// What to hand `Intl` and `toLocaleString`. Until the user has chosen a
135 /// language, this is `undefined` — the browser's own idea of how to write a
136 /// number, which is what every figure in the app used before i18n existed.
137 function intlLocale() { return curLocale || undefined; }
138
139 // ── Lookup ─────────────────────────────────────────────────
140
141 /// Fill `{name}` placeholders from `vars`. An unknown placeholder is left
142 /// standing rather than blanked, so a mistake is visible instead of silent.
143 function interp(s, vars) {
144 return s.replace(/\{(\w+)\}/g, function (whole, k) {
145 return (vars && vars[k] != null) ? String(vars[k]) : whole;
146 });
147 }
148
149 /// The string for `key` in the current locale, falling back to English.
150 function t(key, vars) {
151 var s = TABLES[locale()] ? TABLES[locale()][key] : undefined;
152 if (s == null && TABLES.en) s = TABLES.en[key];
153 if (s == null) {
154 if (!missing[key]) {
155 missing[key] = 1;
156 console.warn('i18n: no string for "' + key + '"');
157 }
158 return key;
159 }
160 if (vars) return interp(s, vars);
161 // Which key these words came from, so `mark` is told rather than left to
162 // guess. Only the table's own string is recorded: one with a `{n}` filled
163 // in is not a string the table produces, and marking it would repaint the
164 // placeholder back over the number.
165 origin[s] = key;
166 return s;
167 }
168
169 /// Plural form: looks up `<key>.one` or `<key>.other`, with `{n}` bound to
170 /// the count. English has two forms; a language with more registers the
171 /// extra forms under the same key and overrides `plural()` for itself.
172 function tn(key, n, vars) {
173 var v = {};
174 if (vars) for (var k in vars) if (Object.prototype.hasOwnProperty.call(vars, k)) v[k] = vars[k];
175 v.n = n;
176 return t(key + (n === 1 ? '.one' : '.other'), v);
177 }
178
179 /// Whether the current table (not the fallback) carries this key. The
180 /// picker uses it to tell a real translation from an English stand-in.
181 function has(key) {
182 var tbl = TABLES[locale()];
183 return !!(tbl && tbl[key] != null);
184 }
185
186 // ── Money ──────────────────────────────────────────────────
187
188 /// Decimal places for a figure, by size. Two cascades, because the app has
189 /// always had two: `calm` is the per-turn cost beside a message, `fine` is
190 /// the spending panel, which shows a sub-cent turn honestly.
191 function digits(v, mode) {
192 if (mode === 'fine') {
193 if (v > 0 && v < 0.0995) return 4;
194 if (v > 0 && v < 0.995) return 3;
195 return 2;
196 }
197 if (v < 0.01) return 4;
198 if (v < 1) return 3;
199 return 2;
200 }
201
202 /// The dollar string the app printed before display currencies existed.
203 /// Reproduced exactly rather than routed through `Intl`, because `Intl`
204 /// groups thousands and this never did.
205 function plainUsd(u, mode) {
206 if (mode === 'fine') return '$' + u.toFixed(digits(u, 'fine'));
207 if (u <= 0) return '$0';
208 return '$' + u.toFixed(digits(u, 'calm'));
209 }
210
211 /// Format an amount already in `ccy`, using the locale's conventions. Below
212 /// a unit the natural fraction digits are overridden, so a fifth of a cent
213 /// reads as a fifth of a cent instead of rounding to nothing.
214 function intlMoney(v, ccy, mode) {
215 var opt = { style: 'currency', currency: ccy };
216 var d = digits(v, mode);
217 if (d > 2) { opt.minimumFractionDigits = d; opt.maximumFractionDigits = d; }
218 try {
219 return v.toLocaleString(intlLocale(), opt);
220 } catch (e) {
221 return ccy + ' ' + v.toFixed(Math.min(d, 4));
222 }
223 }
224
225 /// A US dollar figure as the user has asked to see it. In US dollars this
226 /// is byte-for-byte what the app always printed; in anything else it is the
227 /// converted figure behind a ≈.
228 function money(usd, mode) {
229 usd = +usd || 0;
230 var ccy = currency();
231 if (ccy === 'USD') return plainUsd(usd, mode);
232 // Nothing converts to nothing exactly, so zero carries no ≈ and none of
233 // the sub-cent digits a real figure would earn.
234 if (usd === 0) return intlFixed(0, ccy, mode === 'fine' ? 2 : 0);
235 return '≈' + intlMoney(usd * RATE[ccy], ccy, mode);
236 }
237
238 /// The same, for the gateway's minor units. `src` is the currency the
239 /// gateway quoted, which is US dollars unless it says otherwise; a figure
240 /// already quoted in another currency is shown as quoted, not converted
241 /// twice.
242 function moneyMinor(minor, src) {
243 var v = (minor || 0) / 100;
244 src = String(src || 'usd').toUpperCase();
245 var ccy = currency();
246 if (src !== 'USD' || ccy === 'USD') {
247 try {
248 return v.toLocaleString(intlLocale(), { style: 'currency', currency: src });
249 } catch (e) {
250 return '$' + v.toFixed(2);
251 }
252 }
253 return '≈' + intlMoney(v * RATE[ccy], ccy);
254 }
255
256 /// A price the user will actually be CHARGED. Always says US dollars out
257 /// loud, and hangs the converted figure off it when there is one, because
258 /// the card statement will read in dollars whatever this panel shows.
259 function billed(usd, mode) {
260 usd = +usd || 0;
261 var ccy = currency();
262 // Reading in dollars already: "$" is not ambiguous, and this is what the
263 // app has always printed.
264 if (ccy === 'USD') return plainUsd(usd, mode);
265 return 'US' + plainUsd(usd, mode) + ' ≈ ' + intlMoney(usd * RATE[ccy], ccy, mode);
266 }
267
268 /// `billed`, for the gateway's minor units.
269 function billedMinor(minor, src) {
270 src = String(src || 'usd').toUpperCase();
271 if (src !== 'USD' || currency() === 'USD') return moneyMinor(minor, src);
272 return 'US' + ((minor || 0) / 100).toLocaleString('en-US', { style: 'currency', currency: 'USD' })
273 + ' ≈ ' + intlMoney(((minor || 0) / 100) * RATE[currency()], currency());
274 }
275
276 // ── Round prices in a currency that is not the billing one ─
277 //
278 // A shop offers €10, not €9.26. A dollar tier converted straight through
279 // gives the second, which reads as a rounding error rather than a price —
280 // so the tier is snapped to the ladder every shop in the world uses, and
281 // the dollar figure that will actually be charged is printed beside it.
282
283 // Currencies quoted whole. Asked of `Intl` first, because it knows; the
284 // list is the answer for a browser that does not.
285 var ZERO_DEC = { JPY: 1, KRW: 1 };
286 function decimalsOf(ccy) {
287 try {
288 var r = new Intl.NumberFormat('en', { style: 'currency', currency: ccy }).resolvedOptions();
289 if (typeof r.maximumFractionDigits === 'number') return r.maximumFractionDigits;
290 } catch (e) {}
291 return ZERO_DEC[ccy] ? 0 : 2;
292 }
293
294 var LADDER = [1, 2, 2.5, 5];
295
296 /// The nearest rung of the 1 / 2 / 2.5 / 5 × 10ᵏ ladder, by ratio — so a
297 /// price is snapped by how far off it is proportionally, not absolutely.
298 function snap(v) {
299 if (!(v > 0)) return 0;
300 var k = Math.floor(Math.log(v) / Math.LN10);
301 var best = null, bestErr = Infinity;
302 // One decade either side, because the nearest rung to 9.2 is 10, which
303 // belongs to the decade above.
304 for (var d = k - 1; d <= k + 1; d++) {
305 for (var i = 0; i < LADDER.length; i++) {
306 var c = LADDER[i] * Math.pow(10, d);
307 var err = c > v ? c / v : v / c;
308 if (err < bestErr - 1e-12) { bestErr = err; best = c; }
309 }
310 }
311 return best;
312 }
313
314 /// The next rung strictly above `v`. Used only to break a tie when two
315 /// tiers snap to the same price.
316 function nextRung(v) {
317 var k = Math.floor(Math.log(v) / Math.LN10) - 1;
318 for (var d = k; d <= k + 3; d++) {
319 for (var i = 0; i < LADDER.length; i++) {
320 var c = LADDER[i] * Math.pow(10, d);
321 if (c > v * (1 + 1e-9)) return c;
322 }
323 }
324 return v * 2;
325 }
326
327 /// Turn a list of US dollar tiers (minor units) into round prices in the
328 /// display currency, each paired with the dollar amount that will actually
329 /// be charged for it.
330 ///
331 /// Returns `[{ local, localText, billedMinor, billedText, ccy }]`. With US
332 /// dollars selected the tiers come back untouched, which is what keeps the
333 /// dollar case byte-for-byte what it always was.
334 function niceTiers(usdMinors) {
335 var ccy = currency();
336 var list = (usdMinors || []).map(function (m) { return +m || 0; });
337 if (ccy === 'USD') {
338 return list.map(function (m) {
339 return {
340 local: m / 100, localText: moneyMinor(m, 'usd'),
341 billedMinor: m, billedText: 'US$' + (m / 100).toFixed(2), ccy: 'USD',
342 };
343 });
344 }
345 var rate = RATE[ccy];
346 var dec = decimalsOf(ccy);
347 var out = [], prev = 0;
348 list.forEach(function (m) {
349 var v = snap((m / 100) * rate);
350 // Whole-quoted currencies cannot carry a rung below the unit.
351 if (dec === 0 && v < 1) v = 1;
352 while (v <= prev) v = nextRung(v);
353 prev = v;
354 var minor = Math.round((v / rate) * 100);
355 out.push({
356 local: v,
357 localText: intlFixed(v, ccy, (dec === 0 || v === Math.round(v)) ? 0 : 2),
358 billedMinor: minor,
359 billedText: 'US$' + (minor / 100).toFixed(2),
360 ccy: ccy,
361 });
362 });
363 return out;
364 }
365
366 /// A figure in a named currency at a fixed number of decimals.
367 function intlFixed(v, ccy, dp) {
368 try {
369 return v.toLocaleString(intlLocale(), {
370 style: 'currency', currency: ccy,
371 minimumFractionDigits: dp, maximumFractionDigits: dp,
372 });
373 } catch (e) { return ccy + ' ' + v.toFixed(dp); }
374 }
375
376 // ── Applying a table to the document ───────────────────────
377
378 function each(sel, fn) {
379 var ns = document.querySelectorAll(sel);
380 for (var i = 0; i < ns.length; i++) fn(ns[i]);
381 }
382
383 /// Walk `root` and set every marked node from the table. Attributes carry
384 /// their own marks, because a button often needs both a label and a title.
385 function apply(root) {
386 root = root || document;
387 var q = root.querySelectorAll ? root : document;
388 var sel = function (s, fn) {
389 var ns = q.querySelectorAll(s);
390 for (var i = 0; i < ns.length; i++) fn(ns[i]);
391 };
392 // `pick`, not `t`: a mark stamped by `mark` may name several keys, and it
393 // answers null when they have parted and the node must be left alone.
394 var put = function (n, attr, fn) {
395 var v = pick(n.getAttribute(attr));
396 if (v != null) fn(v);
397 };
398 sel('[data-i18n]', function (n) { put(n, 'data-i18n', function (v) { n.textContent = v; }); });
399 sel('[data-i18n-html]', function (n) { put(n, 'data-i18n-html', function (v) { n.innerHTML = v; }); });
400 sel('[data-i18n-title]', function (n) { put(n, 'data-i18n-title', function (v) { n.title = v; }); });
401 sel('[data-i18n-placeholder]', function (n) { put(n, 'data-i18n-placeholder', function (v) { n.placeholder = v; }); });
402 sel('[data-i18n-aria-label]', function (n) { put(n, 'data-i18n-aria-label', function (v) { n.setAttribute('aria-label', v); }); });
403 sel('[data-i18n-alt]', function (n) { put(n, 'data-i18n-alt', function (v) { n.setAttribute('alt', v); }); });
404 // A panel's name is markup (`data-label`), because the layout engine reads
405 // the DOM as its registry. Writing the translation back into that attribute
406 // keeps the one registry there is.
407 //
408 // The same name also becomes the panel's accessible name. An unnamed
409 // <section> is not a landmark at all, and an unnamed <aside> is announced
410 // as "complementary" with nothing to tell it from the next one -- Chrome's
411 // tree showed three of them side by side as `complementary ""`. The name
412 // exists; it was simply never given to the accessibility tree. Doing it
413 // here rather than in the markup means the translated name and the spoken
414 // name cannot drift apart.
415 sel('[data-i18n-label]', function (n) {
416 var label = t(n.getAttribute('data-i18n-label'));
417 n.setAttribute('data-label', label);
418 n.setAttribute('aria-label', label);
419 });
420 }
421
422 // ── Marking a string that has already been resolved ────────
423 //
424 // `data-i18n` in the markup covers everything the page ships with. It cannot
425 // cover a widget that is HANDED a string -- a dialog is given a title, not a
426 // key -- and those are exactly the surfaces that got stuck in the language
427 // they were opened in. Recovering the key from the string closes that gap
428 // without every caller having to pass one.
429
430 /// The table in force as value -> every key that produces it, joined by `|`.
431 ///
432 /// Several keys per string is the normal case, not the exception: `Save` is
433 /// `Save` under half a dozen of them. See `pick` for what is done about it.
434 function reverse() {
435 var code = locale();
436 if (revFor === code && revMap) return revMap;
437 var tbl = TABLES[code] || TABLES.en || {};
438 revMap = {};
439 for (var k in tbl) {
440 if (!Object.prototype.hasOwnProperty.call(tbl, k)) continue;
441 var v = tbl[k];
442 if (typeof v !== 'string' || !v) continue;
443 revMap[v] = revMap[v] ? revMap[v] + '|' + k : k;
444 }
445 revFor = code;
446 return revMap;
447 }
448
449 /// The string for a mark, which names one key or several that produced the
450 /// same words.
451 ///
452 /// Several is only a problem if they have PARTED in the language now in
453 /// force: while `common.cancel` and `graph.cancel` are the same word there
454 /// is nothing to choose between them, and either repaints the node
455 /// correctly. When they differ, nothing on the node says which was meant, so
456 /// it is left as it stands -- a word in the language just left is wrong, but
457 /// the wrong word in the right language is worse.
458 function pick(spec) {
459 if (spec.indexOf('|') === -1) return t(spec);
460 var ks = spec.split('|');
461 var v = t(ks[0]);
462 for (var i = 1; i < ks.length; i++) if (t(ks[i]) !== v) return null;
463 return v;
464 }
465
466 // Which attribute mark carries a given property.
467 var MARKS = {
468 '': 'data-i18n',
469 'title': 'data-i18n-title',
470 'aria-label': 'data-i18n-aria-label',
471 'placeholder': 'data-i18n-placeholder',
472 'alt': 'data-i18n-alt',
473 };
474
475 /// Put an already-resolved string on a node AND record where it came from,
476 /// so the next language change repaints it through `apply` like anything
477 /// marked up in the page.
478 ///
479 /// `attr` is '' for the node's text, or one of `title`, `aria-label`,
480 /// `placeholder`, `alt`. A string the table did not produce -- a name, a
481 /// figure, a sentence with a number interpolated into it -- is set and left
482 /// unmarked, which is exactly what it was before.
483 function mark(node, attr, text) {
484 if (!node) return text;
485 attr = attr || '';
486 text = (text == null) ? '' : String(text);
487 if (attr === '') node.textContent = text;
488 else if (attr === 'title') node.title = text;
489 else if (attr === 'placeholder') node.placeholder = text;
490 else node.setAttribute(attr, text);
491 var slot = MARKS[attr];
492 if (!slot) return text;
493 // What PRODUCED these words, in preference to what could have. `origin`
494 // names the one key `t` last answered with, so a caller handing over a
495 // string it has just resolved is marked exactly; `reverse` is the fallback
496 // for a string that reached here some other way, and it names every key
497 // that could have made it -- which `pick` then refuses to choose between
498 // when they have parted in the language being switched to. Ja `保存` is
499 // `common.save`, `push.save`, `graph.save` AND `files.download`, and Korean
500 // parts the last from the other three: guessed, the Save button stayed
501 // Japanese; told, it repaints.
502 var key = origin[text] || reverse()[text];
503 if (key) node.setAttribute(slot, key);
504 else node.removeAttribute(slot); // a stale mark would repaint over the new text
505 return text;
506 }
507
508 /// The same, for a caller that KNOWS the key. Better than `mark` wherever it
509 /// is available, because it is never left guessing between keys that happen
510 /// to share a word.
511 ///
512 /// A mark read back off a node is accepted too, group and all, so a widget
513 /// that is moved from one host to another can carry its own mark across.
514 function bind(node, attr, key) {
515 if (!node || !key) return '';
516 attr = attr || '';
517 var slot = MARKS[attr];
518 if (slot) node.setAttribute(slot, key);
519 var v = pick(key);
520 if (v == null) return '';
521 if (attr === '') node.textContent = v;
522 else if (attr === 'title') node.title = v;
523 else if (attr === 'placeholder') node.placeholder = v;
524 else node.setAttribute(attr, v);
525 return v;
526 }
527
528 /// Take a table from `i18n/<code>.js`. The first table to arrive for the
529 /// locale in force paints the document; the page is fully parsed by then,
530 /// because these scripts sit at the foot of the body.
531 function register(code, table) {
532 TABLES[code] = table;
533 present[code] = true;
534 revFor = null; // a new table means a new set of strings to trace back
535 if (!booted && (code === locale() || code === 'en')) {
536 booted = true;
537 apply();
538 }
539 // The arrival of the ACTIVE locale's table is a change, whenever it
540 // lands: en.js always arrives first and takes the boot branch, so a
541 // persisted locale loads after surfaces have already drawn themselves
542 // in the English fallback — they must be told, exactly as a manual
543 // switch tells them.
544 if (code === locale() && code !== 'en') {
545 landed = true;
546 origin = Object.create(null); // the strings recorded came from the table just replaced
547 apply();
548 fire();
549 }
550 }
551
552 /// Fetch a locale file, once. Resolves true when the file exists and has
553 /// registered a table, false when it does not — which is how the picker
554 /// knows which languages are really here, rather than being told.
555 function load(code) {
556 if (TABLES[code]) return Promise.resolve(true);
557 if (loading[code]) return loading[code];
558 loading[code] = new Promise(function (done) {
559 var s = document.createElement('script');
560 s.src = 'i18n/' + code + '.js';
561 s.async = true;
562 s.onload = function () { present[code] = !!TABLES[code]; done(!!TABLES[code]); };
563 s.onerror = function () { present[code] = false; done(false); };
564 document.head.appendChild(s);
565 });
566 return loading[code];
567 }
568
569 /// Which of the shipped locales actually have a file. Probed by loading
570 /// them, so adding `i18n/de.js` is the whole of shipping German.
571 function available() {
572 return Promise.all(LOCALES.map(function (l) {
573 return load(l.code).then(function (ok) { return ok ? l.code : null; });
574 })).then(function (a) {
575 return a.filter(function (x) { return x; });
576 });
577 }
578
579 /// The element a registered surface is showing itself in, or null when it is
580 /// not on screen. `host` may be the element or a function returning one, so a
581 /// surface that is built on demand can register before it has a node.
582 function shown(host) {
583 var el = null;
584 try { el = (typeof host === 'function') ? host() : host; } catch (e) { return null; }
585 if (!el || !el.getClientRects) return null;
586 return el.getClientRects().length ? el : null;
587 }
588
589 /// Register a surface that builds its own strings, so a language change
590 /// reaches it WHERE IT STANDS.
591 ///
592 /// `draw` rebuilds the surface; `host` is the node showing it, or a function
593 /// returning that node (or nothing when the surface is not up). On a change
594 /// every registered surface that is on screen is redrawn, and one that is not
595 /// is left alone -- it is built in the new language when it is next opened,
596 /// which is the whole reason a closed surface needs no work.
597 ///
598 /// The visibility test lives HERE rather than in each caller, and that is the
599 /// point of the call. Every surface that shipped stuck in the language it was
600 /// opened in had either no hook at all or a hook that decided for itself
601 /// whether it was on screen; a surface that registers cannot get that half
602 /// wrong, and a surface added later is covered by registering beside the
603 /// others rather than by a new line in a change handler somewhere else.
604 function surface(host, draw) {
605 if (typeof draw !== 'function') return;
606 live.push({ host: host, draw: draw });
607 // Same reason as `onChange`: a table that landed before this registration
608 // has already had its repaint, and this surface missed it.
609 if (landed && shown(host)) {
610 try { draw(); } catch (e) { console.warn('i18n surface failed', e); }
611 }
612 }
613
614 function fire() {
615 hooks.forEach(function (fn) { try { fn(); } catch (e) { console.warn('i18n hook failed', e); } });
616 live.forEach(function (s) {
617 if (!shown(s.host)) return;
618 try { s.draw(); } catch (e) { console.warn('i18n surface failed', e); }
619 });
620 }
621
622 /// Choose a language. Loads its table if need be, repaints the marked
623 /// nodes, and tells everything that draws its own strings to draw again.
624 function setLocale(code) {
625 if (!supported(code)) code = 'en';
626 return load(code).then(function (ok) {
627 curLocale = ok ? code : 'en';
628 origin = Object.create(null); // every string recorded belongs to the language just left
629 try { localStorage.setItem(LS_LOCALE, curLocale); } catch (e) {}
630 document.documentElement.lang = curLocale;
631 apply();
632 fire();
633 return ok;
634 });
635 }
636
637 /// Choose a display currency. Nothing is fetched and nothing is billed
638 /// differently; the figures on screen change and gain a ≈.
639 function setCurrency(code) {
640 code = String(code || 'USD').toUpperCase();
641 if (!RATE[code]) code = 'USD';
642 curCcy = code;
643 try { localStorage.setItem(LS_CCY, code); } catch (e) {}
644 fire();
645 }
646
647 /// Run `fn` after any language or currency change. For what is on screen for
648 /// as long as the app is -- a status line, a list in the rail. A surface that
649 /// COMES AND GOES belongs in `surface` instead, which does the "am I open"
650 /// test on the caller's behalf.
651 function onChange(fn) {
652 if (typeof fn !== 'function') return;
653 hooks.push(fn);
654 // The active locale's table can land before the modules register their
655 // hooks (it loads in the head; they run at the foot of the body). A
656 // hook arriving after that landing has missed its repaint -- run it
657 // now, so registration order cannot decide the language on screen.
658 if (landed) { try { fn(); } catch (e) { console.warn('i18n hook failed', e); } }
659 }
660
661 // The document says what language it is in from the first paint, so a
662 // screen reader and the browser's own translation offer both start right.
663 try { document.documentElement.lang = locale(); } catch (e) {}
664
665 window.DaimondI18n = {
666 t: t,
667 tn: tn,
668 has: has,
669 apply: apply,
670 mark: mark,
671 bind: bind,
672 surface: surface,
673 register: register,
674 load: load,
675 available: available,
676 locales: function () { return LOCALES.slice(); },
677 locale: locale,
678 setLocale: setLocale,
679 currencies: function () { return CURRENCIES.slice(); },
680 currency: currency,
681 converted: converted,
682 setCurrency: setCurrency,
683 ratesAsOf: function () { return RATES_AS_OF; },
684 money: money,
685 moneyMinor: moneyMinor,
686 billed: billed,
687 billedMinor: billedMinor,
688 niceTiers: niceTiers,
689 snap: snap,
690 onChange: onChange,
691 };
692
693 // A remembered language must load itself: `setLocale` fetches its table,
694 // but a RELOADED page only reads the stored code -- without this, every
695 // reload came back English until the picker was touched again.
696 if (locale() !== 'en') load(locale());
697})();