@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.
@@ -1434,10 +1434,10 @@ const setRuntimeLangSource = (source) => {
1434
1434
  * ```
1435
1435
  *
1436
1436
  * @param {string} [options.fallbackLang]
1437
- * Language consulted when the active language has no translation for a key
1438
- * — per key, not per language: a partially translated language falls through
1439
- * to `fallbackLang` only for the keys it is missing. Without it, a missing
1440
- * translation returns the key itself.
1437
+ * Language consulted for a key none of the requested languages translates,
1438
+ * before giving up and returning the key itself. Set it to a language holding
1439
+ * every key whenever keys are opaque: without it, a reader whose languages
1440
+ * all miss a key sees that key's raw name.
1441
1441
  *
1442
1442
  * @param {string|string[]} [options.runtimeLang]
1443
1443
  * The active language (BCP 47 tag or ordered array of tags) — named
@@ -1480,19 +1480,27 @@ const setRuntimeLangSource = (source) => {
1480
1480
  *
1481
1481
  * **`i18n(key, values?, { lang? })`** — the translation for `key`, with
1482
1482
  * `[placeholder]` occurrences replaced from `values` (see `interpolateText`).
1483
- * Returns `key` itself when nothing matches, so an untranslated string still
1484
- * renders something readable. `i18n.format` is an alias of this call.
1483
+ * `i18n.format` is an alias of this call.
1485
1484
  *
1486
- * **`i18n.has(key, { lang? })`** — whether a translation genuinely exists,
1487
- * i.e. how to tell "no translation" apart from "translation equal to the key".
1485
+ * The language is resolved per key, not once for the whole registry: each
1486
+ * requested language in order (`lang`, else `runtimeLang` — a regional tag
1487
+ * reaching its registered parent, `"de-DE"` → `"de"`), then `fallbackLang`;
1488
+ * the first one translating `key` wins. A language translated for only part of
1489
+ * the keys therefore reads its own words where it has them and the next
1490
+ * language's everywhere else. When none translates it, `key` itself comes
1491
+ * back, so an untranslated string still renders something readable.
1492
+ *
1493
+ * **`i18n.has(key, { lang? })`** — whether a translation genuinely exists in
1494
+ * one of the languages above, i.e. how to tell "no translation" apart from
1495
+ * "translation equal to the key".
1488
1496
  *
1489
1497
  * @returns {Function & { add, addAll, addLangKeys, has, format, languageMap }}
1490
1498
  */
1491
1499
  const createI18n = ({ keyLang, fallbackLang, runtimeLang } = {}) => {
1492
1500
  const languageMap = new Map();
1493
- // Bumped by addLangKeys — the only thing besides the active lang itself
1494
- // that could change what getActiveLang()/getResolvedFallbackLang() below
1495
- // resolve to, so it's what invalidates their own small caches.
1501
+ // Bumped by addLangKeys — the only thing besides the requested lang itself
1502
+ // that could change what resolveLangChain() below resolves to, so it's what
1503
+ // invalidates its small cache.
1496
1504
  let languageMapVersion = 0;
1497
1505
 
1498
1506
  // Without an explicit runtimeLang, the runtime language source is re-read
@@ -1500,49 +1508,39 @@ const createI18n = ({ keyLang, fallbackLang, runtimeLang } = {}) => {
1500
1508
  // ignore an app-wide language change (see runtime_lang.js) for the rest of
1501
1509
  // this instance's life.
1502
1510
  const hasExplicitRuntimeLang = runtimeLang !== undefined;
1511
+ const getDefaultLang = () => {
1512
+ return hasExplicitRuntimeLang ? runtimeLang : getRuntimeLang();
1513
+ };
1503
1514
 
1504
- // matchBestLang does real work (a Map lookup per candidate, a possible
1505
- // "fr-CA" → "fr" split-and-retry loop) — worth skipping on every single
1506
- // format()/has() call in the common case, since what it resolves to only
1507
- // ever changes when languageMap itself changes (addLangKeys) or, for the
1508
- // non-explicit case, when the runtime lang itself changes (see
1509
- // runtime_lang.js; an installed source is expected to keep its reference
1510
- // stable while nothing changed, and the default one caches its string) —
1511
- // comparing those two cheaply (===) is enough to know the cached result
1512
- // below is still valid.
1513
- let cachedActiveLang;
1514
- let cachedActiveLangRuntimeLang;
1515
- let cachedActiveLangVersion = -1;
1516
- const getActiveLang = () => {
1517
- const currentRuntimeLang = hasExplicitRuntimeLang
1518
- ? runtimeLang
1519
- : getRuntimeLang();
1515
+ // Walked per key rather than one language picked for the whole registry: a
1516
+ // registry is rarely translated evenly (@jsenv/humanize ships German time
1517
+ // words, navi ships its buttons in en/fr only), and a single language would
1518
+ // show the raw name of every key it lacks. Cached on the lang reference,
1519
+ // which the runtime lang source keeps stable while nothing changed.
1520
+ let cachedLangChain;
1521
+ let cachedLangChainLang;
1522
+ let cachedLangChainVersion = -1;
1523
+ const resolveLangChain = (lang) => {
1520
1524
  if (
1521
- cachedActiveLangVersion === languageMapVersion &&
1522
- cachedActiveLangRuntimeLang === currentRuntimeLang
1525
+ cachedLangChainVersion === languageMapVersion &&
1526
+ cachedLangChainLang === lang
1523
1527
  ) {
1524
- return cachedActiveLang;
1525
- }
1526
- cachedActiveLang = matchBestLang(currentRuntimeLang, languageMap);
1527
- cachedActiveLangVersion = languageMapVersion;
1528
- cachedActiveLangRuntimeLang = currentRuntimeLang;
1529
- return cachedActiveLang;
1530
- };
1531
-
1532
- // fallbackLang is a plain, never-reactive option set once at creation —
1533
- // its own resolution only ever needs recomputing when languageMap does.
1534
- let cachedResolvedFallbackLang;
1535
- let cachedResolvedFallbackLangVersion = -1;
1536
- const getResolvedFallbackLang = () => {
1537
- if (!fallbackLang) {
1538
- return null;
1528
+ return cachedLangChain;
1539
1529
  }
1540
- if (cachedResolvedFallbackLangVersion === languageMapVersion) {
1541
- return cachedResolvedFallbackLang;
1530
+ const langChain = [];
1531
+ for (const candidate of [
1532
+ ...toLangList(lang),
1533
+ ...toLangList(fallbackLang),
1534
+ ]) {
1535
+ const match = matchLang(candidate, languageMap);
1536
+ if (match && !langChain.includes(match)) {
1537
+ langChain.push(match);
1538
+ }
1542
1539
  }
1543
- cachedResolvedFallbackLang = matchBestLang(fallbackLang, languageMap);
1544
- cachedResolvedFallbackLangVersion = languageMapVersion;
1545
- return cachedResolvedFallbackLang;
1540
+ cachedLangChain = langChain;
1541
+ cachedLangChainLang = lang;
1542
+ cachedLangChainVersion = languageMapVersion;
1543
+ return langChain;
1546
1544
  };
1547
1545
 
1548
1546
  const addLangKeys = (lang, translations) => {
@@ -1581,47 +1579,25 @@ const createI18n = ({ keyLang, fallbackLang, runtimeLang } = {}) => {
1581
1579
  }
1582
1580
  };
1583
1581
 
1584
- const _getTemplate = (key, lang) => {
1585
- // matchBestLang, not matchLang directly: lang can be an ordered array of
1586
- // preferences, and matchLang alone assumes a plain string, throwing on
1587
- // .split() otherwise.
1588
- const resolvedLang = lang ? matchBestLang(lang, languageMap) : null;
1589
- if (resolvedLang) {
1590
- const translations = languageMap.get(resolvedLang);
1591
- const translated = translations[key];
1582
+ const getTemplate = (key, lang) => {
1583
+ for (const resolvedLang of resolveLangChain(lang)) {
1584
+ const translated = languageMap.get(resolvedLang)[key];
1592
1585
  if (translated !== undefined) {
1593
1586
  return translated;
1594
1587
  }
1595
1588
  }
1596
- const resolvedFallbackLang = getResolvedFallbackLang();
1597
- if (resolvedFallbackLang) {
1598
- const fallbackTranslations = languageMap.get(resolvedFallbackLang);
1599
- const fallbackTranslated = fallbackTranslations[key];
1600
- if (fallbackTranslated !== undefined) {
1601
- return fallbackTranslated;
1602
- }
1603
- }
1604
1589
  // No translation found — return key as-is (opaque fallback)
1605
1590
  return key;
1606
1591
  };
1607
1592
 
1608
- const format = (key, values, { lang = getActiveLang() } = {}) => {
1609
- const template = _getTemplate(key, lang);
1593
+ const format = (key, values, { lang = getDefaultLang() } = {}) => {
1594
+ const template = getTemplate(key, lang);
1610
1595
  return interpolateText(template, values);
1611
1596
  };
1612
1597
 
1613
- const has = (key, { lang = getActiveLang() } = {}) => {
1614
- const resolvedLang = lang ? matchBestLang(lang, languageMap) : null;
1615
- if (resolvedLang) {
1616
- const translations = languageMap.get(resolvedLang);
1617
- if (translations && key in translations) {
1618
- return true;
1619
- }
1620
- }
1621
- const resolvedFallbackLang = getResolvedFallbackLang();
1622
- if (resolvedFallbackLang) {
1623
- const fallbackTranslations = languageMap.get(resolvedFallbackLang);
1624
- if (fallbackTranslations && key in fallbackTranslations) {
1598
+ const has = (key, { lang = getDefaultLang() } = {}) => {
1599
+ for (const resolvedLang of resolveLangChain(lang)) {
1600
+ if (key in languageMap.get(resolvedLang)) {
1625
1601
  return true;
1626
1602
  }
1627
1603
  }
@@ -1656,19 +1632,15 @@ const matchLang = (lang, languageMap) => {
1656
1632
  return null;
1657
1633
  };
1658
1634
 
1659
- // lang can be a string or an ordered array of preference strings
1660
- const matchBestLang = (lang, languageMap) => {
1635
+ // lang can be a string, an ordered array of preference strings, or nothing
1636
+ const toLangList = (lang) => {
1661
1637
  if (!lang) {
1662
- return null;
1638
+ return [];
1663
1639
  }
1664
- const candidates = Array.isArray(lang) ? lang : [lang];
1665
- for (const candidate of candidates) {
1666
- const match = matchLang(candidate, languageMap);
1667
- if (match) {
1668
- return match;
1669
- }
1640
+ if (Array.isArray(lang)) {
1641
+ return lang;
1670
1642
  }
1671
- return null;
1643
+ return [lang];
1672
1644
  };
1673
1645
 
1674
1646
  /**
@@ -1688,6 +1660,11 @@ const matchBestLang = (lang, languageMap) => {
1688
1660
  * — the opposite of what an app is advised to do for its own texts; navi's
1689
1661
  * `docs/i18n.md` explains why.
1690
1662
  *
1663
+ * English translates every key, so it is the `fallbackLang`: the other
1664
+ * languages cover part of the keys only (German has the `time.*` words, not
1665
+ * navi's buttons), and a key the reader's languages all lack reads in English
1666
+ * rather than as its raw name.
1667
+ *
1691
1668
  * @example
1692
1669
  * import { humanizeI18n } from "@jsenv/humanize";
1693
1670
  *
@@ -1697,7 +1674,7 @@ const matchBestLang = (lang, languageMap) => {
1697
1674
  * // Teach a language that is not shipped:
1698
1675
  * humanizeI18n.addLangKeys("ja", { "time.midnight": "真夜中" });
1699
1676
  */
1700
- const humanizeI18n = createI18n();
1677
+ const humanizeI18n = createI18n({ fallbackLang: "en" });
1701
1678
 
1702
1679
  // What the time formatters in ../time/format_time.js write in words:
1703
1680
  // relative wording, the midnight word, the mark between the two bounds of a
@@ -4157,7 +4134,8 @@ const startSpinner = ({
4157
4134
 
4158
4135
  const createTaskLog = (
4159
4136
  label,
4160
- { disabled = false, animated = true, stopOnWriteFromOutside } = {},
4137
+ // animated defaults to what the spinner decides: only when stdout is a terminal
4138
+ { disabled = false, animated, stopOnWriteFromOutside } = {},
4161
4139
  ) => {
4162
4140
  if (disabled) {
4163
4141
  return {
@@ -4167,7 +4145,7 @@ const createTaskLog = (
4167
4145
  fail: () => {},
4168
4146
  };
4169
4147
  }
4170
- if (animated && process.env.CAPTURING_SIDE_EFFECTS) {
4148
+ if (process.env.CAPTURING_SIDE_EFFECTS) {
4171
4149
  animated = false;
4172
4150
  }
4173
4151
  const startMs = Date.now();