@jsenv/humanize 1.8.4 → 1.8.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1418,10 +1418,10 @@ const setRuntimeLangSource = (source) => {
1418
1418
  * ```
1419
1419
  *
1420
1420
  * @param {string} [options.fallbackLang]
1421
- * Language consulted when the active language has no translation for a key
1422
- * — per key, not per language: a partially translated language falls through
1423
- * to `fallbackLang` only for the keys it is missing. Without it, a missing
1424
- * translation returns the key itself.
1421
+ * Language consulted for a key none of the requested languages translates,
1422
+ * before giving up and returning the key itself. Set it to a language holding
1423
+ * every key whenever keys are opaque: without it, a reader whose languages
1424
+ * all miss a key sees that key's raw name.
1425
1425
  *
1426
1426
  * @param {string|string[]} [options.runtimeLang]
1427
1427
  * The active language (BCP 47 tag or ordered array of tags) — named
@@ -1464,19 +1464,27 @@ const setRuntimeLangSource = (source) => {
1464
1464
  *
1465
1465
  * **`i18n(key, values?, { lang? })`** — the translation for `key`, with
1466
1466
  * `[placeholder]` occurrences replaced from `values` (see `interpolateText`).
1467
- * Returns `key` itself when nothing matches, so an untranslated string still
1468
- * renders something readable. `i18n.format` is an alias of this call.
1467
+ * `i18n.format` is an alias of this call.
1469
1468
  *
1470
- * **`i18n.has(key, { lang? })`** — whether a translation genuinely exists,
1471
- * i.e. how to tell "no translation" apart from "translation equal to the key".
1469
+ * The language is resolved per key, not once for the whole registry: each
1470
+ * requested language in order (`lang`, else `runtimeLang` — a regional tag
1471
+ * reaching its registered parent, `"de-DE"` → `"de"`), then `fallbackLang`;
1472
+ * the first one translating `key` wins. A language translated for only part of
1473
+ * the keys therefore reads its own words where it has them and the next
1474
+ * language's everywhere else. When none translates it, `key` itself comes
1475
+ * back, so an untranslated string still renders something readable.
1476
+ *
1477
+ * **`i18n.has(key, { lang? })`** — whether a translation genuinely exists in
1478
+ * one of the languages above, i.e. how to tell "no translation" apart from
1479
+ * "translation equal to the key".
1472
1480
  *
1473
1481
  * @returns {Function & { add, addAll, addLangKeys, has, format, languageMap }}
1474
1482
  */
1475
1483
  const createI18n = ({ keyLang, fallbackLang, runtimeLang } = {}) => {
1476
1484
  const languageMap = new Map();
1477
- // Bumped by addLangKeys — the only thing besides the active lang itself
1478
- // that could change what getActiveLang()/getResolvedFallbackLang() below
1479
- // resolve to, so it's what invalidates their own small caches.
1485
+ // Bumped by addLangKeys — the only thing besides the requested lang itself
1486
+ // that could change what resolveLangChain() below resolves to, so it's what
1487
+ // invalidates its small cache.
1480
1488
  let languageMapVersion = 0;
1481
1489
 
1482
1490
  // Without an explicit runtimeLang, the runtime language source is re-read
@@ -1484,49 +1492,39 @@ const createI18n = ({ keyLang, fallbackLang, runtimeLang } = {}) => {
1484
1492
  // ignore an app-wide language change (see runtime_lang.js) for the rest of
1485
1493
  // this instance's life.
1486
1494
  const hasExplicitRuntimeLang = runtimeLang !== undefined;
1495
+ const getDefaultLang = () => {
1496
+ return hasExplicitRuntimeLang ? runtimeLang : getRuntimeLang();
1497
+ };
1487
1498
 
1488
- // matchBestLang does real work (a Map lookup per candidate, a possible
1489
- // "fr-CA" → "fr" split-and-retry loop) — worth skipping on every single
1490
- // format()/has() call in the common case, since what it resolves to only
1491
- // ever changes when languageMap itself changes (addLangKeys) or, for the
1492
- // non-explicit case, when the runtime lang itself changes (see
1493
- // runtime_lang.js; an installed source is expected to keep its reference
1494
- // stable while nothing changed, and the default one caches its string) —
1495
- // comparing those two cheaply (===) is enough to know the cached result
1496
- // below is still valid.
1497
- let cachedActiveLang;
1498
- let cachedActiveLangRuntimeLang;
1499
- let cachedActiveLangVersion = -1;
1500
- const getActiveLang = () => {
1501
- const currentRuntimeLang = hasExplicitRuntimeLang
1502
- ? runtimeLang
1503
- : getRuntimeLang();
1499
+ // Walked per key rather than one language picked for the whole registry: a
1500
+ // registry is rarely translated evenly (@jsenv/humanize ships German time
1501
+ // words, navi ships its buttons in en/fr only), and a single language would
1502
+ // show the raw name of every key it lacks. Cached on the lang reference,
1503
+ // which the runtime lang source keeps stable while nothing changed.
1504
+ let cachedLangChain;
1505
+ let cachedLangChainLang;
1506
+ let cachedLangChainVersion = -1;
1507
+ const resolveLangChain = (lang) => {
1504
1508
  if (
1505
- cachedActiveLangVersion === languageMapVersion &&
1506
- cachedActiveLangRuntimeLang === currentRuntimeLang
1509
+ cachedLangChainVersion === languageMapVersion &&
1510
+ cachedLangChainLang === lang
1507
1511
  ) {
1508
- return cachedActiveLang;
1509
- }
1510
- cachedActiveLang = matchBestLang(currentRuntimeLang, languageMap);
1511
- cachedActiveLangVersion = languageMapVersion;
1512
- cachedActiveLangRuntimeLang = currentRuntimeLang;
1513
- return cachedActiveLang;
1514
- };
1515
-
1516
- // fallbackLang is a plain, never-reactive option set once at creation —
1517
- // its own resolution only ever needs recomputing when languageMap does.
1518
- let cachedResolvedFallbackLang;
1519
- let cachedResolvedFallbackLangVersion = -1;
1520
- const getResolvedFallbackLang = () => {
1521
- if (!fallbackLang) {
1522
- return null;
1512
+ return cachedLangChain;
1523
1513
  }
1524
- if (cachedResolvedFallbackLangVersion === languageMapVersion) {
1525
- return cachedResolvedFallbackLang;
1514
+ const langChain = [];
1515
+ for (const candidate of [
1516
+ ...toLangList(lang),
1517
+ ...toLangList(fallbackLang),
1518
+ ]) {
1519
+ const match = matchLang(candidate, languageMap);
1520
+ if (match && !langChain.includes(match)) {
1521
+ langChain.push(match);
1522
+ }
1526
1523
  }
1527
- cachedResolvedFallbackLang = matchBestLang(fallbackLang, languageMap);
1528
- cachedResolvedFallbackLangVersion = languageMapVersion;
1529
- return cachedResolvedFallbackLang;
1524
+ cachedLangChain = langChain;
1525
+ cachedLangChainLang = lang;
1526
+ cachedLangChainVersion = languageMapVersion;
1527
+ return langChain;
1530
1528
  };
1531
1529
 
1532
1530
  const addLangKeys = (lang, translations) => {
@@ -1565,47 +1563,25 @@ const createI18n = ({ keyLang, fallbackLang, runtimeLang } = {}) => {
1565
1563
  }
1566
1564
  };
1567
1565
 
1568
- const _getTemplate = (key, lang) => {
1569
- // matchBestLang, not matchLang directly: lang can be an ordered array of
1570
- // preferences, and matchLang alone assumes a plain string, throwing on
1571
- // .split() otherwise.
1572
- const resolvedLang = lang ? matchBestLang(lang, languageMap) : null;
1573
- if (resolvedLang) {
1574
- const translations = languageMap.get(resolvedLang);
1575
- const translated = translations[key];
1566
+ const getTemplate = (key, lang) => {
1567
+ for (const resolvedLang of resolveLangChain(lang)) {
1568
+ const translated = languageMap.get(resolvedLang)[key];
1576
1569
  if (translated !== undefined) {
1577
1570
  return translated;
1578
1571
  }
1579
1572
  }
1580
- const resolvedFallbackLang = getResolvedFallbackLang();
1581
- if (resolvedFallbackLang) {
1582
- const fallbackTranslations = languageMap.get(resolvedFallbackLang);
1583
- const fallbackTranslated = fallbackTranslations[key];
1584
- if (fallbackTranslated !== undefined) {
1585
- return fallbackTranslated;
1586
- }
1587
- }
1588
1573
  // No translation found — return key as-is (opaque fallback)
1589
1574
  return key;
1590
1575
  };
1591
1576
 
1592
- const format = (key, values, { lang = getActiveLang() } = {}) => {
1593
- const template = _getTemplate(key, lang);
1577
+ const format = (key, values, { lang = getDefaultLang() } = {}) => {
1578
+ const template = getTemplate(key, lang);
1594
1579
  return interpolateText(template, values);
1595
1580
  };
1596
1581
 
1597
- const has = (key, { lang = getActiveLang() } = {}) => {
1598
- const resolvedLang = lang ? matchBestLang(lang, languageMap) : null;
1599
- if (resolvedLang) {
1600
- const translations = languageMap.get(resolvedLang);
1601
- if (translations && key in translations) {
1602
- return true;
1603
- }
1604
- }
1605
- const resolvedFallbackLang = getResolvedFallbackLang();
1606
- if (resolvedFallbackLang) {
1607
- const fallbackTranslations = languageMap.get(resolvedFallbackLang);
1608
- if (fallbackTranslations && key in fallbackTranslations) {
1582
+ const has = (key, { lang = getDefaultLang() } = {}) => {
1583
+ for (const resolvedLang of resolveLangChain(lang)) {
1584
+ if (key in languageMap.get(resolvedLang)) {
1609
1585
  return true;
1610
1586
  }
1611
1587
  }
@@ -1640,19 +1616,15 @@ const matchLang = (lang, languageMap) => {
1640
1616
  return null;
1641
1617
  };
1642
1618
 
1643
- // lang can be a string or an ordered array of preference strings
1644
- const matchBestLang = (lang, languageMap) => {
1619
+ // lang can be a string, an ordered array of preference strings, or nothing
1620
+ const toLangList = (lang) => {
1645
1621
  if (!lang) {
1646
- return null;
1622
+ return [];
1647
1623
  }
1648
- const candidates = Array.isArray(lang) ? lang : [lang];
1649
- for (const candidate of candidates) {
1650
- const match = matchLang(candidate, languageMap);
1651
- if (match) {
1652
- return match;
1653
- }
1624
+ if (Array.isArray(lang)) {
1625
+ return lang;
1654
1626
  }
1655
- return null;
1627
+ return [lang];
1656
1628
  };
1657
1629
 
1658
1630
  /**
@@ -1672,6 +1644,11 @@ const matchBestLang = (lang, languageMap) => {
1672
1644
  * — the opposite of what an app is advised to do for its own texts; navi's
1673
1645
  * `docs/i18n.md` explains why.
1674
1646
  *
1647
+ * English translates every key, so it is the `fallbackLang`: the other
1648
+ * languages cover part of the keys only (German has the `time.*` words, not
1649
+ * navi's buttons), and a key the reader's languages all lack reads in English
1650
+ * rather than as its raw name.
1651
+ *
1675
1652
  * @example
1676
1653
  * import { humanizeI18n } from "@jsenv/humanize";
1677
1654
  *
@@ -1681,7 +1658,7 @@ const matchBestLang = (lang, languageMap) => {
1681
1658
  * // Teach a language that is not shipped:
1682
1659
  * humanizeI18n.addLangKeys("ja", { "time.midnight": "真夜中" });
1683
1660
  */
1684
- const humanizeI18n = createI18n();
1661
+ const humanizeI18n = createI18n({ fallbackLang: "en" });
1685
1662
 
1686
1663
  // What the time formatters in ../time/format_time.js write in words:
1687
1664
  // relative wording, the midnight word, the mark between the two bounds of a