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 | })(); |