@jsenv/humanize 1.8.3 → 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.
@@ -1985,17 +1985,50 @@ const formatCompactNumber = (value, lang) => {
1985
1985
  return Number.isFinite(n) ? memoIntl("NumberFormat", lang).format(n) : value;
1986
1986
  };
1987
1987
 
1988
+ // One verbosity vocabulary is shared by everything here, but a word only means
1989
+ // something where the shape it names exists: a clock has no ISO spelling, a
1990
+ // duration no numeric one, a relative sentence neither. Handed to Intl, a word
1991
+ // it does not know answers `Value X out of range for Intl.Y options property
1992
+ // style` — which names neither the caller, nor the option the caller wrote, nor
1993
+ // the value's own vocabulary, and is thrown mid-render, where it blanks the
1994
+ // subtree being drawn. So every formatter says which words it reads, maps the
1995
+ // ones that have a reading of their own, and writes its default wording for the
1996
+ // rest after naming what it dropped.
1997
+ const formatsWarned = new Set();
1998
+ const resolveFormat = (format, { fnName, reads, as }) => {
1999
+ if (reads.includes(format)) {
2000
+ return format;
2001
+ }
2002
+ if (as && Object.hasOwn(as, format)) {
2003
+ return as[format];
2004
+ }
2005
+ // Warn once per (formatter, value): these run in render loops, and a bad
2006
+ // format is a property of the call site, not of the frame.
2007
+ const warnKey = `${fnName}|${String(format)}`;
2008
+ if (!formatsWarned.has(warnKey)) {
2009
+ formatsWarned.add(warnKey);
2010
+ const accepted = [...reads, ...(as ? Object.keys(as) : [])]
2011
+ .map((word) => `"${word}"`)
2012
+ .join(", ");
2013
+ console.warn(
2014
+ `${fnName}: format "${String(format)}" says nothing here — expected ${accepted}; writing the "long" form`,
2015
+ );
2016
+ }
2017
+ return "long";
2018
+ };
2019
+
1988
2020
  /**
1989
2021
  * Formats a date as a human-readable day string.
1990
2022
  *
1991
2023
  * @param {Date} date
1992
- * @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]
2024
+ * @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]
1993
2025
  * A string spells the weekday and the month the same way. An object spells
1994
2026
  * them apart, each key defaulting to `"long"`: a narrow card usually wants
1995
2027
  * the weekday whole (it is the reading anchor) and the month abbreviated (it
1996
2028
  * is where the characters are — "septembre" is 9 of them, "sept." reads the
1997
2029
  * same). `"numeric"` stays a string-only spelling: it drops the weekday and
1998
- * writes the whole date in digits.
2030
+ * writes the whole date in digits, and `"compact"` is a name for it — a date
2031
+ * has nothing tighter ("narrow" is the single-letter weekday, "J 17 S").
1999
2032
  *
2000
2033
  * In the object form, `false` drops a part: `{ day: false, month: false }`
2001
2034
  * writes the weekday alone ("mardi"), `{ month: false }` the weekday and
@@ -2034,6 +2067,16 @@ const formatDay = (
2034
2067
  timeZone,
2035
2068
  } = {},
2036
2069
  ) => {
2070
+ // The object form spells each part on its own; only a word is resolved.
2071
+ if (typeof format === "string") {
2072
+ format = resolveFormat(format, {
2073
+ fnName: "formatDay",
2074
+ reads: ["long", "short", "narrow", "numeric"],
2075
+ // A date has no notation tighter than its digits: "narrow" is the
2076
+ // single-letter weekday ("J 17 S"), which is not read, it is deciphered.
2077
+ as: { compact: "numeric" },
2078
+ });
2079
+ }
2037
2080
  if (format === "numeric") {
2038
2081
  const yearWritten =
2039
2082
  year === "auto"
@@ -2224,20 +2267,31 @@ const formatMonth = (
2224
2267
  { lang = getRuntimeLang(), format = "long", timeZone } = {},
2225
2268
  ) => {
2226
2269
  return memoIntl("DateTimeFormat", lang, {
2227
- month: format, // "long", "short", or "narrow"
2270
+ month: resolveFormat(format, {
2271
+ fnName: "formatMonth",
2272
+ reads: ["long", "short", "narrow", "numeric"],
2273
+ as: { compact: "numeric" },
2274
+ }),
2228
2275
  year: "numeric",
2229
2276
  timeZone,
2230
2277
  }).format(date);
2231
2278
  };
2232
2279
 
2233
2280
  /**
2234
- * Formats a date as "lun. 11 mai, 14:30" (long), "11 mai, 14:30" (short), "11/05, 14:30" (narrow).
2281
+ * Formats a date as "lun. 11 mai, 14:30" (long), "11 mai, 14:30" (short),
2282
+ * "11/05, 14:30" (narrow, which `"compact"` is a name for).
2235
2283
  * `timeZone` words the instant in that IANA zone instead of the runtime's own.
2236
2284
  */
2237
2285
  const formatDatetime = (
2238
2286
  date,
2239
2287
  { lang = getRuntimeLang(), format = "long", timeZone } = {},
2240
2288
  ) => {
2289
+ format = resolveFormat(format, {
2290
+ fnName: "formatDatetime",
2291
+ reads: ["long", "short", "narrow"],
2292
+ // The narrow datetime is already all digits ("17/09 14:30").
2293
+ as: { compact: "narrow" },
2294
+ });
2241
2295
  if (format === "long") {
2242
2296
  return memoIntl("DateTimeFormat", lang, {
2243
2297
  weekday: "short",
@@ -2329,6 +2383,10 @@ const formatTimeOfDay = (
2329
2383
  // An "HH:MM" string is a wall-clock reading, not an instant — re-reading
2330
2384
  // it in another zone would shift what the caller already spelled out.
2331
2385
  const zone = typeof value === "string" ? undefined : timeZone;
2386
+ format = resolveFormat(format, {
2387
+ fnName: "formatTimeOfDay",
2388
+ reads: ["long", "short", "narrow", "compact", "timestring"],
2389
+ });
2332
2390
  if (format === "timestring") {
2333
2391
  return formatTime(date, { lang, timeZone: zone });
2334
2392
  }
@@ -2533,6 +2591,10 @@ const formatMinuteDuration = (
2533
2591
  forceUnit = false,
2534
2592
  } = {},
2535
2593
  ) => {
2594
+ format = resolveFormat(format, {
2595
+ fnName: "formatMinuteDuration",
2596
+ reads: ["long", "short", "narrow", "compact"],
2597
+ });
2536
2598
  if (minutes < 0) {
2537
2599
  // the d/h/m split below only holds for a positive value; formatting the
2538
2600
  // magnitude and putting the sign back is the only reading that works
@@ -2624,13 +2686,21 @@ const formatSingleUnit = (value, unit, { lang, format }) => {
2624
2686
  * formatHourDuration(36, { lang: "fr", forceUnit: true }) // "36 heures"
2625
2687
  */
2626
2688
  const formatHourDuration = (hours, options = {}) => {
2627
- const { lang = getRuntimeLang(), format = "long", forceUnit } = options;
2689
+ const { lang = getRuntimeLang(), forceUnit } = options;
2690
+ const format = resolveFormat(options.format ?? "long", {
2691
+ fnName: "formatHourDuration",
2692
+ reads: ["long", "short", "narrow", "compact"],
2693
+ });
2628
2694
  if (hours === 0 || (forceUnit && Number.isInteger(hours))) {
2629
2695
  return formatSingleUnit(hours, "hour", { lang, format });
2630
2696
  }
2631
2697
  // a fractional value has no single-unit spelling, it needs its minutes
2632
2698
  const totalMinutes = Math.round(hours * 60);
2633
- return formatMinuteDuration(totalMinutes, { ...options, forceUnit: false });
2699
+ return formatMinuteDuration(totalMinutes, {
2700
+ ...options,
2701
+ format,
2702
+ forceUnit: false,
2703
+ });
2634
2704
  };
2635
2705
 
2636
2706
  /**
@@ -2654,6 +2724,10 @@ const formatSecondDuration = (
2654
2724
  seconds,
2655
2725
  { lang = getRuntimeLang(), format = "long", forceUnit = false } = {},
2656
2726
  ) => {
2727
+ format = resolveFormat(format, {
2728
+ fnName: "formatSecondDuration",
2729
+ reads: ["long", "short", "narrow", "compact"],
2730
+ });
2657
2731
  if (seconds < 0) {
2658
2732
  // the d/h/m/s split below only holds for a positive value; formatting the
2659
2733
  // magnitude and putting the sign back is the only reading that works
@@ -2719,6 +2793,10 @@ const formatDuration = (
2719
2793
  duration,
2720
2794
  { lang = getRuntimeLang(), format = "long" } = {},
2721
2795
  ) => {
2796
+ format = resolveFormat(format, {
2797
+ fnName: "formatDuration",
2798
+ reads: ["long", "short", "narrow", "compact"],
2799
+ });
2722
2800
  if (typeof duration === "string") {
2723
2801
  duration = parseDuration(duration) ?? {};
2724
2802
  } else if (typeof duration === "number") {
@@ -2864,6 +2942,20 @@ const smallestUnitOf = (duration) => {
2864
2942
  return null;
2865
2943
  };
2866
2944
 
2945
+ // A relative time is a sentence around a number ("dans 3 heures"), and only
2946
+ // Intl words it — in three styles. "compact" is our own notation, so the
2947
+ // sentence takes the narrowest wording Intl has and the duration inside it
2948
+ // stays compact (see formatFuture).
2949
+ const resolveRelativeFormat = (format) => {
2950
+ return resolveFormat(format, {
2951
+ fnName: "formatTimeRelative",
2952
+ reads: ["long", "short", "narrow", "compact"],
2953
+ });
2954
+ };
2955
+ const toRelativeTimeStyle = (relativeFormat) => {
2956
+ return relativeFormat === "compact" ? "narrow" : relativeFormat;
2957
+ };
2958
+
2867
2959
  /**
2868
2960
  * Formats a date relative to now: "il y a 3 jours", "dans 2 heures", etc.
2869
2961
  */
@@ -2873,7 +2965,7 @@ const formatTimeAgo = (
2873
2965
  ) => {
2874
2966
  const rtf = memoIntl("RelativeTimeFormat", lang, {
2875
2967
  numeric: "auto",
2876
- style: format,
2968
+ style: toRelativeTimeStyle(resolveRelativeFormat(format)),
2877
2969
  });
2878
2970
  const nowMs = now instanceof Date ? now.getTime() : now;
2879
2971
  const diff = date.getTime() - nowMs;
@@ -2927,7 +3019,12 @@ const formatTimeAgo = (
2927
3019
  *
2928
3020
  * @param {Date|number} start Start of the event (Date or ms timestamp)
2929
3021
  * @param {number} durationMs Duration in milliseconds (0 = instant event)
2930
- * @param {{ lang?: string, now?: Date|number, bare?: boolean, format?: "long"|"short"|"narrow" }} options
3022
+ * @param {{ lang?: string, now?: Date|number, bare?: boolean, format?: "long"|"short"|"narrow"|"compact" }} options
3023
+ * `format` is the verbosity of the wording: `"long"` ("dans 3 heures"),
3024
+ * `"short"` ("dans 3 h"), `"narrow"` ("+3 h") and `"compact"`, which words
3025
+ * the sentence like `"narrow"` and tightens the duration inside it ("dans
3026
+ * 1h30"). The verbosities naming another shape entirely — `"numeric"`,
3027
+ * `"timestring"`, `"iso"` — say nothing here and format as `"long"`.
2931
3028
  *
2932
3029
  * @example
2933
3030
  * // 90 min from now
@@ -2961,9 +3058,10 @@ const formatTimeRelative = (
2961
3058
  };
2962
3059
 
2963
3060
  const formatFuture = (date, diff, { lang, now, format = "long" }) => {
3061
+ const relativeFormat = resolveRelativeFormat(format);
2964
3062
  const rtf = memoIntl("RelativeTimeFormat", lang, {
2965
3063
  numeric: "auto",
2966
- style: format,
3064
+ style: toRelativeTimeStyle(relativeFormat),
2967
3065
  });
2968
3066
  const nowDate = now instanceof Date ? now : new Date(now);
2969
3067
 
@@ -2986,7 +3084,7 @@ const formatFuture = (date, diff, { lang, now, format = "long" }) => {
2986
3084
  }
2987
3085
  const duration = formatMinuteDuration(hours * 60 + minutes, {
2988
3086
  lang,
2989
- format,
3087
+ format: relativeFormat,
2990
3088
  });
2991
3089
  const template = humanizeI18n("time.in_duration", undefined, { lang });
2992
3090
  if (template !== "time.in_duration") {