@jsenv/humanize 1.8.2 → 1.8.4

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.
@@ -1969,17 +1969,50 @@ const formatCompactNumber = (value, lang) => {
1969
1969
  return Number.isFinite(n) ? memoIntl("NumberFormat", lang).format(n) : value;
1970
1970
  };
1971
1971
 
1972
+ // One verbosity vocabulary is shared by everything here, but a word only means
1973
+ // something where the shape it names exists: a clock has no ISO spelling, a
1974
+ // duration no numeric one, a relative sentence neither. Handed to Intl, a word
1975
+ // it does not know answers `Value X out of range for Intl.Y options property
1976
+ // style` — which names neither the caller, nor the option the caller wrote, nor
1977
+ // the value's own vocabulary, and is thrown mid-render, where it blanks the
1978
+ // subtree being drawn. So every formatter says which words it reads, maps the
1979
+ // ones that have a reading of their own, and writes its default wording for the
1980
+ // rest after naming what it dropped.
1981
+ const formatsWarned = new Set();
1982
+ const resolveFormat = (format, { fnName, reads, as }) => {
1983
+ if (reads.includes(format)) {
1984
+ return format;
1985
+ }
1986
+ if (as && Object.hasOwn(as, format)) {
1987
+ return as[format];
1988
+ }
1989
+ // Warn once per (formatter, value): these run in render loops, and a bad
1990
+ // format is a property of the call site, not of the frame.
1991
+ const warnKey = `${fnName}|${String(format)}`;
1992
+ if (!formatsWarned.has(warnKey)) {
1993
+ formatsWarned.add(warnKey);
1994
+ const accepted = [...reads, ...(as ? Object.keys(as) : [])]
1995
+ .map((word) => `"${word}"`)
1996
+ .join(", ");
1997
+ console.warn(
1998
+ `${fnName}: format "${String(format)}" says nothing here — expected ${accepted}; writing the "long" form`,
1999
+ );
2000
+ }
2001
+ return "long";
2002
+ };
2003
+
1972
2004
  /**
1973
2005
  * Formats a date as a human-readable day string.
1974
2006
  *
1975
2007
  * @param {Date} date
1976
- * @param {{ lang?: string, format?: "long"|"short"|"narrow"|"numeric"|{ weekday?: "long"|"short"|"narrow"|false, day?: boolean, month?: "long"|"short"|"narrow"|"numeric"|false }, year?: boolean|"auto", now?: Date, timeZone?: string }} [options]
2008
+ * @param {{ lang?: string, format?: "long"|"short"|"narrow"|"numeric"|"compact"|{ weekday?: "long"|"short"|"narrow"|false, day?: boolean, month?: "long"|"short"|"narrow"|"numeric"|false }, year?: boolean|"auto", now?: Date, timeZone?: string }} [options]
1977
2009
  * A string spells the weekday and the month the same way. An object spells
1978
2010
  * them apart, each key defaulting to `"long"`: a narrow card usually wants
1979
2011
  * the weekday whole (it is the reading anchor) and the month abbreviated (it
1980
2012
  * is where the characters are — "septembre" is 9 of them, "sept." reads the
1981
2013
  * same). `"numeric"` stays a string-only spelling: it drops the weekday and
1982
- * writes the whole date in digits.
2014
+ * writes the whole date in digits, and `"compact"` is a name for it — a date
2015
+ * has nothing tighter ("narrow" is the single-letter weekday, "J 17 S").
1983
2016
  *
1984
2017
  * In the object form, `false` drops a part: `{ day: false, month: false }`
1985
2018
  * writes the weekday alone ("mardi"), `{ month: false }` the weekday and
@@ -2018,6 +2051,16 @@ const formatDay = (
2018
2051
  timeZone,
2019
2052
  } = {},
2020
2053
  ) => {
2054
+ // The object form spells each part on its own; only a word is resolved.
2055
+ if (typeof format === "string") {
2056
+ format = resolveFormat(format, {
2057
+ fnName: "formatDay",
2058
+ reads: ["long", "short", "narrow", "numeric"],
2059
+ // A date has no notation tighter than its digits: "narrow" is the
2060
+ // single-letter weekday ("J 17 S"), which is not read, it is deciphered.
2061
+ as: { compact: "numeric" },
2062
+ });
2063
+ }
2021
2064
  if (format === "numeric") {
2022
2065
  const yearWritten =
2023
2066
  year === "auto"
@@ -2208,20 +2251,31 @@ const formatMonth = (
2208
2251
  { lang = getRuntimeLang(), format = "long", timeZone } = {},
2209
2252
  ) => {
2210
2253
  return memoIntl("DateTimeFormat", lang, {
2211
- month: format, // "long", "short", or "narrow"
2254
+ month: resolveFormat(format, {
2255
+ fnName: "formatMonth",
2256
+ reads: ["long", "short", "narrow", "numeric"],
2257
+ as: { compact: "numeric" },
2258
+ }),
2212
2259
  year: "numeric",
2213
2260
  timeZone,
2214
2261
  }).format(date);
2215
2262
  };
2216
2263
 
2217
2264
  /**
2218
- * Formats a date as "lun. 11 mai, 14:30" (long), "11 mai, 14:30" (short), "11/05, 14:30" (narrow).
2265
+ * Formats a date as "lun. 11 mai, 14:30" (long), "11 mai, 14:30" (short),
2266
+ * "11/05, 14:30" (narrow, which `"compact"` is a name for).
2219
2267
  * `timeZone` words the instant in that IANA zone instead of the runtime's own.
2220
2268
  */
2221
2269
  const formatDatetime = (
2222
2270
  date,
2223
2271
  { lang = getRuntimeLang(), format = "long", timeZone } = {},
2224
2272
  ) => {
2273
+ format = resolveFormat(format, {
2274
+ fnName: "formatDatetime",
2275
+ reads: ["long", "short", "narrow"],
2276
+ // The narrow datetime is already all digits ("17/09 14:30").
2277
+ as: { compact: "narrow" },
2278
+ });
2225
2279
  if (format === "long") {
2226
2280
  return memoIntl("DateTimeFormat", lang, {
2227
2281
  weekday: "short",
@@ -2313,6 +2367,10 @@ const formatTimeOfDay = (
2313
2367
  // An "HH:MM" string is a wall-clock reading, not an instant — re-reading
2314
2368
  // it in another zone would shift what the caller already spelled out.
2315
2369
  const zone = typeof value === "string" ? undefined : timeZone;
2370
+ format = resolveFormat(format, {
2371
+ fnName: "formatTimeOfDay",
2372
+ reads: ["long", "short", "narrow", "compact", "timestring"],
2373
+ });
2316
2374
  if (format === "timestring") {
2317
2375
  return formatTime(date, { lang, timeZone: zone });
2318
2376
  }
@@ -2517,6 +2575,10 @@ const formatMinuteDuration = (
2517
2575
  forceUnit = false,
2518
2576
  } = {},
2519
2577
  ) => {
2578
+ format = resolveFormat(format, {
2579
+ fnName: "formatMinuteDuration",
2580
+ reads: ["long", "short", "narrow", "compact"],
2581
+ });
2520
2582
  if (minutes < 0) {
2521
2583
  // the d/h/m split below only holds for a positive value; formatting the
2522
2584
  // magnitude and putting the sign back is the only reading that works
@@ -2608,13 +2670,21 @@ const formatSingleUnit = (value, unit, { lang, format }) => {
2608
2670
  * formatHourDuration(36, { lang: "fr", forceUnit: true }) // "36 heures"
2609
2671
  */
2610
2672
  const formatHourDuration = (hours, options = {}) => {
2611
- const { lang = getRuntimeLang(), format = "long", forceUnit } = options;
2673
+ const { lang = getRuntimeLang(), forceUnit } = options;
2674
+ const format = resolveFormat(options.format ?? "long", {
2675
+ fnName: "formatHourDuration",
2676
+ reads: ["long", "short", "narrow", "compact"],
2677
+ });
2612
2678
  if (hours === 0 || (forceUnit && Number.isInteger(hours))) {
2613
2679
  return formatSingleUnit(hours, "hour", { lang, format });
2614
2680
  }
2615
2681
  // a fractional value has no single-unit spelling, it needs its minutes
2616
2682
  const totalMinutes = Math.round(hours * 60);
2617
- return formatMinuteDuration(totalMinutes, { ...options, forceUnit: false });
2683
+ return formatMinuteDuration(totalMinutes, {
2684
+ ...options,
2685
+ format,
2686
+ forceUnit: false,
2687
+ });
2618
2688
  };
2619
2689
 
2620
2690
  /**
@@ -2638,6 +2708,10 @@ const formatSecondDuration = (
2638
2708
  seconds,
2639
2709
  { lang = getRuntimeLang(), format = "long", forceUnit = false } = {},
2640
2710
  ) => {
2711
+ format = resolveFormat(format, {
2712
+ fnName: "formatSecondDuration",
2713
+ reads: ["long", "short", "narrow", "compact"],
2714
+ });
2641
2715
  if (seconds < 0) {
2642
2716
  // the d/h/m/s split below only holds for a positive value; formatting the
2643
2717
  // magnitude and putting the sign back is the only reading that works
@@ -2703,6 +2777,10 @@ const formatDuration = (
2703
2777
  duration,
2704
2778
  { lang = getRuntimeLang(), format = "long" } = {},
2705
2779
  ) => {
2780
+ format = resolveFormat(format, {
2781
+ fnName: "formatDuration",
2782
+ reads: ["long", "short", "narrow", "compact"],
2783
+ });
2706
2784
  if (typeof duration === "string") {
2707
2785
  duration = parseDuration(duration) ?? {};
2708
2786
  } else if (typeof duration === "number") {
@@ -2848,6 +2926,20 @@ const smallestUnitOf = (duration) => {
2848
2926
  return null;
2849
2927
  };
2850
2928
 
2929
+ // A relative time is a sentence around a number ("dans 3 heures"), and only
2930
+ // Intl words it — in three styles. "compact" is our own notation, so the
2931
+ // sentence takes the narrowest wording Intl has and the duration inside it
2932
+ // stays compact (see formatFuture).
2933
+ const resolveRelativeFormat = (format) => {
2934
+ return resolveFormat(format, {
2935
+ fnName: "formatTimeRelative",
2936
+ reads: ["long", "short", "narrow", "compact"],
2937
+ });
2938
+ };
2939
+ const toRelativeTimeStyle = (relativeFormat) => {
2940
+ return relativeFormat === "compact" ? "narrow" : relativeFormat;
2941
+ };
2942
+
2851
2943
  /**
2852
2944
  * Formats a date relative to now: "il y a 3 jours", "dans 2 heures", etc.
2853
2945
  */
@@ -2857,7 +2949,7 @@ const formatTimeAgo = (
2857
2949
  ) => {
2858
2950
  const rtf = memoIntl("RelativeTimeFormat", lang, {
2859
2951
  numeric: "auto",
2860
- style: format,
2952
+ style: toRelativeTimeStyle(resolveRelativeFormat(format)),
2861
2953
  });
2862
2954
  const nowMs = now instanceof Date ? now.getTime() : now;
2863
2955
  const diff = date.getTime() - nowMs;
@@ -2911,7 +3003,12 @@ const formatTimeAgo = (
2911
3003
  *
2912
3004
  * @param {Date|number} start Start of the event (Date or ms timestamp)
2913
3005
  * @param {number} durationMs Duration in milliseconds (0 = instant event)
2914
- * @param {{ lang?: string, now?: Date|number, bare?: boolean, format?: "long"|"short"|"narrow" }} options
3006
+ * @param {{ lang?: string, now?: Date|number, bare?: boolean, format?: "long"|"short"|"narrow"|"compact" }} options
3007
+ * `format` is the verbosity of the wording: `"long"` ("dans 3 heures"),
3008
+ * `"short"` ("dans 3 h"), `"narrow"` ("+3 h") and `"compact"`, which words
3009
+ * the sentence like `"narrow"` and tightens the duration inside it ("dans
3010
+ * 1h30"). The verbosities naming another shape entirely — `"numeric"`,
3011
+ * `"timestring"`, `"iso"` — say nothing here and format as `"long"`.
2915
3012
  *
2916
3013
  * @example
2917
3014
  * // 90 min from now
@@ -2945,9 +3042,10 @@ const formatTimeRelative = (
2945
3042
  };
2946
3043
 
2947
3044
  const formatFuture = (date, diff, { lang, now, format = "long" }) => {
3045
+ const relativeFormat = resolveRelativeFormat(format);
2948
3046
  const rtf = memoIntl("RelativeTimeFormat", lang, {
2949
3047
  numeric: "auto",
2950
- style: format,
3048
+ style: toRelativeTimeStyle(relativeFormat),
2951
3049
  });
2952
3050
  const nowDate = now instanceof Date ? now : new Date(now);
2953
3051
 
@@ -2970,7 +3068,7 @@ const formatFuture = (date, diff, { lang, now, format = "long" }) => {
2970
3068
  }
2971
3069
  const duration = formatMinuteDuration(hours * 60 + minutes, {
2972
3070
  lang,
2973
- format,
3071
+ format: relativeFormat,
2974
3072
  });
2975
3073
  const template = humanizeI18n("time.in_duration", undefined, { lang });
2976
3074
  if (template !== "time.in_duration") {