@jsenv/humanize 1.7.6 → 1.7.8

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.
@@ -1,4 +1,4 @@
1
- import { createSupportsColor, isUnicodeSupported, emojiRegex, eastAsianWidth } from "./jsenv_humanize_node_modules.js";
1
+ import { createSupportsColor, isUnicodeSupported, eastAsianWidth } from "./jsenv_humanize_node_modules.js";
2
2
  import stripAnsi from "strip-ansi";
3
3
  import { stripVTControlCharacters } from "node:util";
4
4
  import ansiEscapes from "ansi-escapes";
@@ -677,10 +677,13 @@ const derivedErrorNameArray = [
677
677
  ];
678
678
 
679
679
  const inspectDate = (value, { nestedHumanize, useNew, parenthesis }) => {
680
- const dateSource = nestedHumanize(value.valueOf(), {
680
+ const dateSource = nestedHumanize(value.toISOString(), {
681
681
  numericSeparator: false,
682
682
  });
683
- return inspectConstructor(`Date(${dateSource})`, { useNew, parenthesis });
683
+ return inspectConstructor(`Date(${dateSource})`, {
684
+ useNew,
685
+ parenthesis,
686
+ });
684
687
  };
685
688
 
686
689
  const inspectFunction = (
@@ -1014,29 +1017,16 @@ const TIME_DICTIONARY_EN = {
1014
1017
  second: { long: "second", plural: "seconds", short: "s" },
1015
1018
  joinDuration: (primary, remaining) => `${primary} and ${remaining}`,
1016
1019
  };
1017
- const TIME_DICTIONARY_FR = {
1018
- year: { long: "an", plural: "ans", short: "a" },
1019
- month: { long: "mois", plural: "mois", short: "m" },
1020
- week: { long: "semaine", plural: "semaines", short: "s" },
1021
- day: { long: "jour", plural: "jours", short: "j" },
1022
- hour: { long: "heure", plural: "heures", short: "h" },
1023
- minute: { long: "minute", plural: "minutes", short: "m" },
1024
- second: { long: "seconde", plural: "secondes", short: "s" },
1025
- joinDuration: (primary, remaining) => `${primary} et ${remaining}`,
1026
- };
1027
1020
 
1028
1021
  const humanizeEllapsedTime = (
1029
1022
  ms,
1030
- {
1031
- short,
1032
- lang = "en",
1033
- timeDictionnary = lang === "fr" ? TIME_DICTIONARY_FR : TIME_DICTIONARY_EN,
1034
- } = {},
1023
+ { short, timeDictionnary = TIME_DICTIONARY_EN } = {},
1035
1024
  ) => {
1036
1025
  if (ms < 1000) {
1037
- return short
1038
- ? `0${timeDictionnary.second.short}`
1039
- : `0 ${timeDictionnary.second.long}`;
1026
+ if (short) {
1027
+ return `0${timeDictionnary.second.short}`;
1028
+ }
1029
+ return `0 ${timeDictionnary.second.long}`;
1040
1030
  }
1041
1031
  const { primary, remaining } = parseMs(ms);
1042
1032
  if (!remaining) {
@@ -1068,33 +1058,52 @@ const inspectEllapsedUnit = (unit, { short, timeDictionnary }) => {
1068
1058
  return `${count} ${unitText}`;
1069
1059
  };
1070
1060
 
1061
+ /**
1062
+ * Converts a duration in milliseconds into a human-readable string intended for display in
1063
+ * CLI output — where readability matters more than precision.
1064
+ *
1065
+ * - Values below 1ms are displayed as "0 second". Sub-millisecond durations are not
1066
+ * meaningful at human scale, and showing "0.0001 second" (or switching to a "millisecond"
1067
+ * unit) would hurt readability. The chosen trade-off is to always use "second" as the
1068
+ * smallest unit and accept the loss of precision for very small values.
1069
+ * - Values below 1s are displayed in fractional seconds (e.g. "0.05 second").
1070
+ * - Values are expressed using the two most significant units (e.g. "1 hour and 23 minutes").
1071
+ * - Rounding never causes a value to display as the next unit boundary
1072
+ * (e.g. 59_999ms → "59.9 seconds", never "60 seconds").
1073
+ *
1074
+ * @param {number} ms - Duration in milliseconds.
1075
+ * @param {object} [options]
1076
+ * @param {boolean} [options.short=false] - Use compact unit symbols (e.g. "1h and 23m").
1077
+ * @param {boolean} [options.rounded=true] - Round the last displayed digit. When false, truncates instead.
1078
+ * @param {number} [options.decimals] - Override the number of decimal places shown.
1079
+ * @returns {string}
1080
+ */
1071
1081
  const humanizeDuration = (
1072
1082
  ms,
1073
1083
  {
1074
1084
  short,
1075
1085
  rounded = true,
1076
1086
  decimals,
1077
- lang = "en",
1078
- timeDictionnary = lang === "fr" ? TIME_DICTIONARY_FR : TIME_DICTIONARY_EN,
1087
+ timeDictionnary = TIME_DICTIONARY_EN,
1079
1088
  } = {},
1080
1089
  ) => {
1081
- // ignore ms below meaningfulMs so that:
1082
- // humanizeDuration(0.5) -> "0 second"
1083
- // humanizeDuration(1.1) -> "0.001 second" (and not "0.0011 second")
1084
- // This tool is meant to be read by humans and it would be barely readable to see
1085
- // "0.0001 second" (stands for 0.1 millisecond)
1086
- // yes we could return "0.1 millisecond" but we choosed consistency over precision
1087
- // so that the prefered unit is "second" (and does not become millisecond when ms is super small)
1088
1090
  if (ms < 1) {
1089
- return short
1090
- ? `0${timeDictionnary.second.short}`
1091
- : `0 ${timeDictionnary.second.long}`;
1091
+ if (short) {
1092
+ return `0${timeDictionnary.second.short}`;
1093
+ }
1094
+ return `0 ${timeDictionnary.second.long}`;
1092
1095
  }
1093
1096
  const { primary, remaining } = parseMs(ms);
1094
1097
  if (!remaining) {
1098
+ const primaryUnitIndex = UNIT_KEYS.indexOf(primary.name);
1099
+ const nextUnitName = UNIT_KEYS[primaryUnitIndex - 1];
1100
+ const maxCount = nextUnitName
1101
+ ? UNIT_MS[nextUnitName] / UNIT_MS[primary.name]
1102
+ : null;
1095
1103
  return humanizeDurationUnit(primary, {
1096
1104
  decimals:
1097
1105
  decimals === undefined ? (primary.name === "second" ? 1 : 0) : decimals,
1106
+ maxCount,
1098
1107
  short,
1099
1108
  rounded,
1100
1109
  timeDictionnary,
@@ -1112,15 +1121,23 @@ const humanizeDuration = (
1112
1121
  rounded,
1113
1122
  timeDictionnary,
1114
1123
  });
1124
+ if (short) {
1125
+ return `${primaryText}${remainingText}`;
1126
+ }
1115
1127
  return timeDictionnary.joinDuration(primaryText, remainingText);
1116
1128
  };
1117
1129
  const humanizeDurationUnit = (
1118
1130
  unit,
1119
- { decimals, short, rounded, timeDictionnary },
1131
+ { decimals, maxCount, short, rounded, timeDictionnary },
1120
1132
  ) => {
1121
- const count = rounded
1133
+ let count = rounded
1122
1134
  ? setRoundedPrecision(unit.count, { decimals })
1123
1135
  : setPrecision(unit.count, { decimals });
1136
+ if (maxCount !== null && maxCount !== undefined && count >= maxCount) {
1137
+ // Prevent rounding up to the next unit boundary (e.g. 59.999s → 60s → cap to 59.9s)
1138
+ const factor = Math.pow(10, decimals ?? 0);
1139
+ count = Math.floor(unit.count * factor) / factor;
1140
+ }
1124
1141
  const name = unit.name;
1125
1142
  if (short) {
1126
1143
  const unitText = timeDictionnary[name].short;
@@ -1173,6 +1190,17 @@ const parseMs = (ms) => {
1173
1190
  },
1174
1191
  };
1175
1192
  }
1193
+ // When remaining rounds up to a full next-unit (e.g. 59.999s rounds to 60s = 1min),
1194
+ // drop the remaining to avoid displaying "59 minutes and 60 seconds".
1195
+ const remainingUnitMs = UNIT_MS[remainingUnitName];
1196
+ const nextUnitMs = UNIT_MS[firstUnitName];
1197
+ const maxRemainingCount = nextUnitMs / remainingUnitMs; // e.g. 60 for seconds-in-a-minute
1198
+ // Cap remaining so it never rounds up to the next unit boundary
1199
+ // (e.g. 59.5s stays as 59s instead of rounding to 60s = 1min)
1200
+ const cappedRemainingCount =
1201
+ remainingUnitCount >= maxRemainingCount - 1
1202
+ ? maxRemainingCount - 1
1203
+ : remainingUnitCount;
1176
1204
  // - 1 year and 1 month is great
1177
1205
  return {
1178
1206
  primary: {
@@ -1181,7 +1209,7 @@ const parseMs = (ms) => {
1181
1209
  },
1182
1210
  remaining: {
1183
1211
  name: remainingUnitName,
1184
- count: remainingUnitCount,
1212
+ count: cappedRemainingCount,
1185
1213
  },
1186
1214
  };
1187
1215
  };
@@ -1701,23 +1729,122 @@ const error = (...args) => console.error(...args);
1701
1729
 
1702
1730
  const errorDisabled = () => {};
1703
1731
 
1732
+ // Whole-cluster zero-width: Default_Ignorable, Control, Format, Mark, Surrogate
1733
+ const zeroWidthClusterRegex =
1734
+ /^[\p{Default_Ignorable_Code_Point}\p{Control}\p{Format}\p{Mark}\p{Surrogate}]+$/v;
1735
+
1736
+ // Strip leading non-printing chars to get the first visible scalar of a cluster
1737
+ const leadingNonPrintingRegex =
1738
+ /^[\p{Default_Ignorable_Code_Point}\p{Control}\p{Format}\p{Mark}\p{Surrogate}]+/v;
1739
+
1740
+ // RGI emoji sequences (e.g. flag sequences, ZWJ families, keycap+VS16)
1741
+ const rgiEmojiRegex = /^\p{RGI_Emoji}$/v;
1742
+
1743
+ // Unqualified keycap: digit/# /* + combining enclosing keycap (no VS16)
1744
+ const unqualifiedKeycapRegex = /^[\d#*]\u20E3$/;
1745
+ const extendedPictographicRegex = /\p{Extended_Pictographic}/gv;
1746
+
1747
+ const isDoubleWidthNonRgiEmojiSequence = (segment) => {
1748
+ if (segment.length > 50) {
1749
+ return false;
1750
+ }
1751
+ if (unqualifiedKeycapRegex.test(segment)) {
1752
+ return true;
1753
+ }
1754
+ // ZWJ sequences with 2+ Extended_Pictographic
1755
+ if (segment.includes("\u200D")) {
1756
+ const pictographics = segment.match(extendedPictographicRegex);
1757
+ return pictographics !== null && pictographics.length >= 2;
1758
+ }
1759
+ return false;
1760
+ };
1761
+
1762
+ const baseVisible = (segment) => {
1763
+ return segment.replace(leadingNonPrintingRegex, "");
1764
+ };
1765
+
1766
+ const isHangulLeadingJamo = (cp) => {
1767
+ return (cp >= 0x11_00 && cp <= 0x11_5f) || (cp >= 0xa9_60 && cp <= 0xa9_7c);
1768
+ };
1769
+ const isHangulVowelJamo = (cp) => {
1770
+ return (cp >= 0x11_60 && cp <= 0x11_a7) || (cp >= 0xd7_b0 && cp <= 0xd7_c6);
1771
+ };
1772
+ const isHangulTrailingJamo = (cp) => {
1773
+ return (cp >= 0x11_a8 && cp <= 0x11_ff) || (cp >= 0xd7_cb && cp <= 0xd7_fb);
1774
+ };
1775
+ const isHangulJamo = (cp) => {
1776
+ return (
1777
+ isHangulLeadingJamo(cp) || isHangulVowelJamo(cp) || isHangulTrailingJamo(cp)
1778
+ );
1779
+ };
1780
+
1781
+ const hangulClusterWidth = (visibleSegment, eastAsianWidthOptions) => {
1782
+ const codePoints = [];
1783
+ for (const character of visibleSegment) {
1784
+ if (zeroWidthClusterRegex.test(character)) {
1785
+ continue;
1786
+ }
1787
+ codePoints.push(character.codePointAt(0));
1788
+ }
1789
+ if (codePoints.length === 0) {
1790
+ return undefined;
1791
+ }
1792
+ let width = 0;
1793
+ for (let index = 0; index < codePoints.length; index++) {
1794
+ const codePoint = codePoints[index];
1795
+ if (!isHangulJamo(codePoint)) {
1796
+ if (width === 0) {
1797
+ return undefined;
1798
+ }
1799
+ for (let remaining = index; remaining < codePoints.length; remaining++) {
1800
+ width += eastAsianWidth(codePoints[remaining], eastAsianWidthOptions);
1801
+ }
1802
+ return width;
1803
+ }
1804
+ if (
1805
+ isHangulLeadingJamo(codePoint) &&
1806
+ isHangulVowelJamo(codePoints[index + 1])
1807
+ ) {
1808
+ width += 2;
1809
+ index += isHangulTrailingJamo(codePoints[index + 2]) ? 2 : 1;
1810
+ continue;
1811
+ }
1812
+ width += eastAsianWidth(codePoint, eastAsianWidthOptions);
1813
+ }
1814
+ return width;
1815
+ };
1816
+
1817
+ const trailingHalfwidthWidth = (visibleSegment, eastAsianWidthOptions) => {
1818
+ let extra = 0;
1819
+ let first = true;
1820
+ for (const character of visibleSegment) {
1821
+ if (first) {
1822
+ first = false;
1823
+ continue;
1824
+ }
1825
+ if (character >= "\uFF00" && character <= "\uFFEF") {
1826
+ extra += eastAsianWidth(character.codePointAt(0), eastAsianWidthOptions);
1827
+ }
1828
+ }
1829
+ return extra;
1830
+ };
1831
+
1704
1832
  const createMeasureTextWidth = ({ stripAnsi }) => {
1705
1833
  const segmenter = new Intl.Segmenter();
1706
- const defaultIgnorableCodePointRegex = /^\p{Default_Ignorable_Code_Point}$/u;
1707
1834
 
1708
1835
  const measureTextWidth = (
1709
1836
  string,
1710
- {
1711
- ambiguousIsNarrow = true,
1712
- countAnsiEscapeCodes = false,
1713
- skipEmojis = false,
1714
- } = {},
1837
+ { ambiguousIsNarrow = true, countAnsiEscapeCodes = false } = {},
1715
1838
  ) => {
1716
1839
  if (typeof string !== "string" || string.length === 0) {
1717
1840
  return 0;
1718
1841
  }
1719
1842
 
1720
- if (!countAnsiEscapeCodes) {
1843
+ // Only strip ANSI when escape codes are actually present
1844
+ if (
1845
+ !countAnsiEscapeCodes &&
1846
+ (string.includes("\u001B") || string.includes("\u009B"))
1847
+ ) {
1721
1848
  string = stripAnsi(string);
1722
1849
  }
1723
1850
 
@@ -1725,70 +1852,54 @@ const createMeasureTextWidth = ({ stripAnsi }) => {
1725
1852
  return 0;
1726
1853
  }
1727
1854
 
1855
+ // Fast path: printable ASCII needs no segmenter or EAW lookup
1856
+ if (/^[\u0020-\u007E]*$/.test(string)) {
1857
+ return string.length;
1858
+ }
1859
+
1728
1860
  let width = 0;
1729
1861
  const eastAsianWidthOptions = { ambiguousAsWide: !ambiguousIsNarrow };
1730
1862
 
1731
- for (const { segment: character } of segmenter.segment(string)) {
1732
- const codePoint = character.codePointAt(0);
1733
-
1734
- // Ignore control characters
1735
- if (codePoint <= 0x1f || (codePoint >= 0x7f && codePoint <= 0x9f)) {
1863
+ for (const { segment } of segmenter.segment(string)) {
1864
+ if (zeroWidthClusterRegex.test(segment)) {
1736
1865
  continue;
1737
1866
  }
1738
1867
 
1739
- // Ignore zero-width characters
1868
+ // RGI emoji + unqualified emoji sequences are double-width
1740
1869
  if (
1741
- (codePoint >= 0x20_0b && codePoint <= 0x20_0f) || // Zero-width space, non-joiner, joiner, left-to-right mark, right-to-left mark
1742
- codePoint === 0xfe_ff // Zero-width no-break space
1870
+ rgiEmojiRegex.test(segment) ||
1871
+ isDoubleWidthNonRgiEmojiSequence(segment)
1743
1872
  ) {
1873
+ if (process.env.CAPTURING_SIDE_EFFECTS && segment === "✔️") {
1874
+ width += 2;
1875
+ continue;
1876
+ }
1877
+ width += 2;
1744
1878
  continue;
1745
1879
  }
1746
1880
 
1747
- // Ignore combining characters
1748
- if (
1749
- (codePoint >= 0x3_00 && codePoint <= 0x3_6f) || // Combining diacritical marks
1750
- (codePoint >= 0x1a_b0 && codePoint <= 0x1a_ff) || // Combining diacritical marks extended
1751
- (codePoint >= 0x1d_c0 && codePoint <= 0x1d_ff) || // Combining diacritical marks supplement
1752
- (codePoint >= 0x20_d0 && codePoint <= 0x20_ff) || // Combining diacritical marks for symbols
1753
- (codePoint >= 0xfe_20 && codePoint <= 0xfe_2f) // Combining half marks
1754
- ) {
1755
- continue;
1756
- }
1757
-
1758
- // Ignore surrogate pairs
1759
- if (codePoint >= 0xd8_00 && codePoint <= 0xdf_ff) {
1760
- continue;
1761
- }
1762
-
1763
- // Ignore variation selectors
1764
- if (codePoint >= 0xfe_00 && codePoint <= 0xfe_0f) {
1765
- continue;
1766
- }
1767
-
1768
- // This covers some of the above cases, but we still keep them for performance reasons.
1769
- if (defaultIgnorableCodePointRegex.test(character)) {
1770
- continue;
1771
- }
1881
+ const visibleSegment = baseVisible(segment);
1772
1882
 
1773
- if (!skipEmojis && emojiRegex().test(character)) {
1774
- if (process.env.CAPTURING_SIDE_EFFECTS) {
1775
- if (character === "✔️") {
1776
- width += 2;
1777
- continue;
1778
- }
1779
- }
1780
- width += measureTextWidth(character, {
1781
- skipEmojis: true,
1782
- countAnsiEscapeCodes: true, // to skip call to stripAnsi
1783
- });
1883
+ const hangulWidth = hangulClusterWidth(
1884
+ visibleSegment,
1885
+ eastAsianWidthOptions,
1886
+ );
1887
+ if (hangulWidth !== undefined) {
1888
+ width += hangulWidth;
1784
1889
  continue;
1785
1890
  }
1786
1891
 
1892
+ // EAW of the cluster's first visible scalar
1893
+ const codePoint = visibleSegment.codePointAt(0);
1787
1894
  width += eastAsianWidth(codePoint, eastAsianWidthOptions);
1895
+
1896
+ // Add width for trailing Halfwidth/Fullwidth Forms (e.g. ゙, ゚, ー)
1897
+ width += trailingHalfwidthWidth(visibleSegment, eastAsianWidthOptions);
1788
1898
  }
1789
1899
 
1790
1900
  return width;
1791
1901
  };
1902
+
1792
1903
  return measureTextWidth;
1793
1904
  };
1794
1905