hanzio 1.2.0 → 2.1.0
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.
- package/README.md +786 -239
- package/dist/api-wrapper/builders.d.ts +4 -3
- package/dist/api-wrapper/create-client.d.ts +1 -1
- package/dist/api-wrapper/describe.d.ts +16 -0
- package/dist/api-wrapper/endpoint.d.ts +18 -0
- package/dist/api-wrapper/errors.d.ts +39 -2
- package/dist/api-wrapper/index.d.ts +5 -0
- package/dist/api-wrapper/otel.d.ts +44 -0
- package/dist/api-wrapper/paginate.d.ts +12 -0
- package/dist/api-wrapper/redaction.d.ts +2 -0
- package/dist/api-wrapper/shared.d.ts +16 -2
- package/dist/api-wrapper/testing.d.ts +23 -0
- package/dist/api-wrapper/transport.d.ts +4 -5
- package/dist/api-wrapper/types.d.ts +103 -18
- package/dist/array/index.d.ts +50 -4
- package/dist/cache/createCache.d.ts +31 -0
- package/dist/cache/index.d.ts +19 -2
- package/dist/color/contrast.d.ts +40 -0
- package/dist/color/index.d.ts +2 -0
- package/dist/{math → color}/metricsSeriesColors.d.ts +14 -7
- package/dist/cool-console-log/index.d.ts +34 -5
- package/dist/crypto/encoding.d.ts +18 -0
- package/dist/crypto/hash.d.ts +33 -0
- package/dist/crypto/index.d.ts +4 -0
- package/dist/crypto/sealer.d.ts +25 -0
- package/dist/crypto/webhook.d.ts +20 -0
- package/dist/date/index.d.ts +46 -2
- package/dist/emitter/index.d.ts +21 -0
- package/dist/error/index.d.ts +12 -0
- package/dist/format/index.d.ts +102 -0
- package/dist/function/index.d.ts +60 -0
- package/dist/guard/index.d.ts +20 -0
- package/dist/i18n/index.d.ts +37 -0
- package/dist/index-0zvtcbzy.js +224 -0
- package/dist/index-8r2vdzjg.js +87 -0
- package/dist/index-hy7kbb99.js +157 -0
- package/dist/index-hyt6c56h.js +290 -0
- package/dist/{src/secrets/infisical.js → index-j1ybktbr.js} +500 -538
- package/dist/index-k9yx6bne.js +89 -0
- package/dist/index-kj1phgrx.js +169 -0
- package/dist/index-ktynw3vs.js +37 -0
- package/dist/index-mnkcfyx8.js +78 -0
- package/dist/{p-queue/index.js → index-pwt0dz63.js} +7 -11
- package/dist/index-rp5pqghz.js +157 -0
- package/dist/index-w882zry3.js +29 -0
- package/dist/index.d.ts +7 -7
- package/dist/jwt/index.d.ts +50 -2
- package/dist/math/index.d.ts +2 -2
- package/dist/math/linearRegressionTrend.d.ts +14 -1
- package/dist/math/scale.d.ts +8 -0
- package/dist/object/index.d.ts +19 -0
- package/dist/p-queue/index.d.ts +1 -1
- package/dist/promise/index.d.ts +97 -2
- package/dist/secrets/core.d.ts +7 -0
- package/dist/secrets/index.d.ts +3 -1
- package/dist/secrets/public-config.d.ts +7 -0
- package/dist/secrets/vite.d.ts +1 -0
- package/dist/secrets/worker.d.ts +1 -0
- package/dist/sitemap/index.d.ts +48 -6
- package/dist/src/api-wrapper/index.js +60 -1130
- package/dist/src/api-wrapper/testing.js +20 -0
- package/dist/{cool-console-log → src/cool-console-log}/index.js +80 -33
- package/dist/src/crypto/index.js +195 -0
- package/dist/src/format/index.js +211 -0
- package/dist/src/i18n/index.js +36 -0
- package/dist/src/index.js +739 -3145
- package/dist/src/jwt/index.js +138 -92
- package/dist/src/p-queue/index.js +9 -437
- package/dist/src/secrets/index.js +50 -1428
- package/dist/src/secrets/vite.js +10 -39
- package/dist/src/secrets/worker.js +14 -273
- package/dist/src/sitemap/index.js +183 -511
- package/dist/src/storage/index.js +119 -0
- package/dist/src/zod/index.js +340 -167
- package/dist/state/index.d.ts +21 -0
- package/dist/storage/index.d.ts +58 -0
- package/dist/string/index.d.ts +75 -2
- package/dist/typedswitch/index.d.ts +31 -24
- package/dist/types/index.d.ts +11 -0
- package/dist/url/index.d.ts +50 -1
- package/dist/zod/index.d.ts +32 -3
- package/dist/zod/introspect.d.ts +19 -0
- package/dist/zod/jsonToZodSchemaMapper.d.ts +3 -1
- package/dist/zod/zodMinMaxFilter.d.ts +22 -3
- package/dist/zod/zodToTypeString.d.ts +8 -3
- package/package.json +78 -7
- package/src/api-wrapper/actions-impl.ts +11 -72
- package/src/api-wrapper/builders.ts +32 -25
- package/src/api-wrapper/create-client.ts +171 -41
- package/src/api-wrapper/describe.ts +157 -0
- package/src/api-wrapper/endpoint.ts +26 -0
- package/src/api-wrapper/error-reporting.ts +7 -1
- package/src/api-wrapper/errors.ts +107 -2
- package/src/api-wrapper/headers.ts +11 -6
- package/src/api-wrapper/index.ts +5 -0
- package/src/api-wrapper/otel.ts +139 -0
- package/src/api-wrapper/paginate.ts +28 -0
- package/src/api-wrapper/redaction.ts +42 -0
- package/src/api-wrapper/shared.ts +21 -2
- package/src/api-wrapper/testing.ts +48 -0
- package/src/api-wrapper/transport.ts +41 -43
- package/src/api-wrapper/types.ts +185 -18
- package/src/array/index.ts +195 -42
- package/src/cache/createCache.ts +157 -0
- package/src/cache/index.ts +132 -147
- package/src/color/contrast.ts +163 -0
- package/src/color/index.ts +22 -0
- package/src/{math → color}/metricsSeriesColors.ts +47 -49
- package/src/cool-console-log/index.ts +128 -41
- package/src/crypto/encoding.ts +83 -0
- package/src/crypto/hash.ts +170 -0
- package/src/crypto/index.ts +26 -0
- package/src/crypto/sealer.ts +168 -0
- package/src/crypto/webhook.ts +86 -0
- package/src/date/index.ts +109 -34
- package/src/emitter/index.ts +82 -0
- package/src/error/index.ts +63 -0
- package/src/format/index.ts +389 -0
- package/src/function/index.ts +201 -0
- package/src/guard/index.ts +80 -0
- package/src/i18n/index.ts +125 -0
- package/src/index.ts +11 -7
- package/src/jwt/index.ts +199 -125
- package/src/math/index.ts +5 -10
- package/src/math/linearRegressionTrend.ts +33 -8
- package/src/math/scale.ts +17 -0
- package/src/object/index.ts +99 -0
- package/src/p-queue/index.ts +2 -0
- package/src/promise/index.ts +304 -9
- package/src/secrets/core.ts +72 -16
- package/src/secrets/index.ts +16 -4
- package/src/secrets/public-config.ts +47 -0
- package/src/secrets/vite.ts +2 -0
- package/src/secrets/worker.ts +2 -0
- package/src/sitemap/index.ts +293 -130
- package/src/state/index.ts +133 -81
- package/src/storage/index.ts +187 -0
- package/src/string/index.ts +261 -23
- package/src/typedswitch/index.ts +58 -38
- package/src/types/index.ts +14 -0
- package/src/url/index.ts +146 -1
- package/src/zod/index.ts +105 -9
- package/src/zod/introspect.ts +103 -0
- package/src/zod/jsonToZodSchemaMapper.ts +44 -21
- package/src/zod/zodMinMaxFilter.ts +37 -13
- package/src/zod/zodToTypeString.ts +184 -181
- package/dist/api-wrapper/index.js +0 -250
- package/dist/gzip/index.d.ts +0 -2
- package/dist/gzip/index.js +0 -16
- package/dist/index.js +0 -1823
- package/dist/jwt/index.js +0 -19
- package/dist/sitemap/index.js +0 -552
- package/dist/src/gzip/index.js +0 -16
- package/dist/src/secrets/loaders.js +0 -1529
- package/dist/zod/index.js +0 -210
|
@@ -0,0 +1,389 @@
|
|
|
1
|
+
// Locale-aware formatting on top of `Intl`. Every function takes the locale
|
|
2
|
+
// (a BCP-47 tag) explicitly, and everything that reads the clock takes `now`.
|
|
3
|
+
|
|
4
|
+
import { stableStringify } from '../string'
|
|
5
|
+
|
|
6
|
+
type DateInput = Date | string | number
|
|
7
|
+
|
|
8
|
+
const MAX_CACHED_FORMATTERS = 100
|
|
9
|
+
|
|
10
|
+
// ponytail: one LRU per formatter kind, 100 entries each; raise if an app
|
|
11
|
+
// really cycles through more (locale, options) pairs than that.
|
|
12
|
+
function memoize<O extends object, F>(
|
|
13
|
+
create: (locale: string, options?: O) => F
|
|
14
|
+
): (locale: string, options?: O) => F {
|
|
15
|
+
const cache = new Map<string, F>()
|
|
16
|
+
return (locale, options) => {
|
|
17
|
+
const key = `${locale}|${options ? stableStringify(options) : ''}`
|
|
18
|
+
let formatter = cache.get(key)
|
|
19
|
+
if (formatter) {
|
|
20
|
+
cache.delete(key)
|
|
21
|
+
} else {
|
|
22
|
+
formatter = create(locale, options)
|
|
23
|
+
if (cache.size >= MAX_CACHED_FORMATTERS) {
|
|
24
|
+
const oldest = cache.keys().next().value
|
|
25
|
+
if (oldest !== undefined) cache.delete(oldest)
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
cache.set(key, formatter)
|
|
29
|
+
return formatter
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Cached `Intl.NumberFormat`, one instance per (locale, options). */
|
|
34
|
+
export const getNumberFormat = memoize(
|
|
35
|
+
(locale, options?: Intl.NumberFormatOptions) =>
|
|
36
|
+
new Intl.NumberFormat(locale, options)
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
/** Cached `Intl.DateTimeFormat`, one instance per (locale, options). */
|
|
40
|
+
export const getDateTimeFormat = memoize(
|
|
41
|
+
(locale, options?: Intl.DateTimeFormatOptions) =>
|
|
42
|
+
new Intl.DateTimeFormat(locale, options)
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
/** Cached `Intl.RelativeTimeFormat`, one instance per (locale, options). */
|
|
46
|
+
export const getRelativeTimeFormat = memoize(
|
|
47
|
+
(locale, options?: Intl.RelativeTimeFormatOptions) =>
|
|
48
|
+
new Intl.RelativeTimeFormat(locale, options)
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
/** Cached `Intl.ListFormat`, one instance per (locale, options). */
|
|
52
|
+
export const getListFormat = memoize(
|
|
53
|
+
(locale, options?: Intl.ListFormatOptions) =>
|
|
54
|
+
new Intl.ListFormat(locale, options)
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
/** Cached `Intl.PluralRules`, one instance per (locale, options). */
|
|
58
|
+
export const getPluralRules = memoize(
|
|
59
|
+
(locale, options?: Intl.PluralRulesOptions) =>
|
|
60
|
+
new Intl.PluralRules(locale, options)
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
function toTime(value: DateInput): number {
|
|
64
|
+
return value instanceof Date ? value.getTime() : new Date(value).getTime()
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// ─── Numbers ────────────────────────────────────────────────────────────────
|
|
68
|
+
|
|
69
|
+
export function formatNumber(
|
|
70
|
+
value: number,
|
|
71
|
+
locale: string,
|
|
72
|
+
options?: Intl.NumberFormatOptions
|
|
73
|
+
): string {
|
|
74
|
+
return getNumberFormat(locale, options).format(value)
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** A fraction (`0.25`) as a percentage (`"25%"`). */
|
|
78
|
+
export function formatPercent(
|
|
79
|
+
value: number,
|
|
80
|
+
locale: string,
|
|
81
|
+
options?: Intl.NumberFormatOptions
|
|
82
|
+
): string {
|
|
83
|
+
return formatNumber(value, locale, { ...options, style: 'percent' })
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* An amount in whole currency units (`12.5`, not cents). Pass
|
|
88
|
+
* `{ maximumFractionDigits: 0 }` for whole-unit prices.
|
|
89
|
+
*/
|
|
90
|
+
export function formatCurrency(
|
|
91
|
+
amount: number,
|
|
92
|
+
currency: string,
|
|
93
|
+
locale: string,
|
|
94
|
+
options?: Intl.NumberFormatOptions
|
|
95
|
+
): string {
|
|
96
|
+
return formatNumber(amount, locale, {
|
|
97
|
+
...options,
|
|
98
|
+
style: 'currency',
|
|
99
|
+
currency
|
|
100
|
+
})
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** Short compact notation: `1234` -> `"1.2K"` (en-US), `"1,2 t"` (da-DK). */
|
|
104
|
+
export function formatCompactNumber(value: number, locale: string): string {
|
|
105
|
+
return formatNumber(value, locale, { notation: 'compact' })
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export type FormatBytesOptions = {
|
|
109
|
+
/** Default `'en-US'`. */
|
|
110
|
+
locale?: string
|
|
111
|
+
/** Fraction digits (trailing zeros dropped). Default 0 for KB, 1 above. */
|
|
112
|
+
decimals?: number
|
|
113
|
+
/** 1 KB = 1024 B when true (default), 1000 B when false. */
|
|
114
|
+
binary?: boolean
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const BYTE_UNITS = ['B', 'KB', 'MB', 'GB', 'TB', 'PB'] as const
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Human-readable file size: `"512 B"`, `"12 KB"`, `"3.4 MB"`. Unit labels are
|
|
121
|
+
* not localized; the number is.
|
|
122
|
+
*/
|
|
123
|
+
export function formatBytes(
|
|
124
|
+
bytes: number,
|
|
125
|
+
options: FormatBytesOptions = {}
|
|
126
|
+
): string {
|
|
127
|
+
const { locale = 'en-US', decimals, binary = true } = options
|
|
128
|
+
const base = binary ? 1024 : 1000
|
|
129
|
+
let exponent = 0
|
|
130
|
+
while (
|
|
131
|
+
Math.abs(bytes) >= base ** (exponent + 1) &&
|
|
132
|
+
exponent < BYTE_UNITS.length - 1
|
|
133
|
+
) {
|
|
134
|
+
exponent++
|
|
135
|
+
}
|
|
136
|
+
const maximumFractionDigits =
|
|
137
|
+
exponent === 0 ? 0 : (decimals ?? (exponent === 1 ? 0 : 1))
|
|
138
|
+
const value = formatNumber(bytes / base ** exponent, locale, {
|
|
139
|
+
maximumFractionDigits
|
|
140
|
+
})
|
|
141
|
+
return `${value} ${BYTE_UNITS[exponent]}`
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// ─── Durations ──────────────────────────────────────────────────────────────
|
|
145
|
+
|
|
146
|
+
const SECOND_MS = 1000
|
|
147
|
+
const MINUTE_MS = 60 * SECOND_MS
|
|
148
|
+
const HOUR_MS = 60 * MINUTE_MS
|
|
149
|
+
const DAY_MS = 24 * HOUR_MS
|
|
150
|
+
|
|
151
|
+
export type DurationParts = {
|
|
152
|
+
days: number
|
|
153
|
+
hours: number
|
|
154
|
+
minutes: number
|
|
155
|
+
seconds: number
|
|
156
|
+
milliseconds: number
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** Break milliseconds into whole units. Negative or non-finite input is 0. */
|
|
160
|
+
export function splitDuration(ms: number): DurationParts {
|
|
161
|
+
const total = Number.isFinite(ms) && ms > 0 ? Math.floor(ms) : 0
|
|
162
|
+
return {
|
|
163
|
+
days: Math.floor(total / DAY_MS),
|
|
164
|
+
hours: Math.floor((total % DAY_MS) / HOUR_MS),
|
|
165
|
+
minutes: Math.floor((total % HOUR_MS) / MINUTE_MS),
|
|
166
|
+
seconds: Math.floor((total % MINUTE_MS) / SECOND_MS),
|
|
167
|
+
milliseconds: total % SECOND_MS
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** Stopwatch style: `"1:05:09"` from an hour up, `"5:09"` below. */
|
|
172
|
+
export function formatClockDuration(ms: number): string {
|
|
173
|
+
const { days, hours, minutes, seconds } = splitDuration(ms)
|
|
174
|
+
const totalHours = days * 24 + hours
|
|
175
|
+
const pad = (n: number) => String(n).padStart(2, '0')
|
|
176
|
+
return totalHours > 0
|
|
177
|
+
? `${totalHours}:${pad(minutes)}:${pad(seconds)}`
|
|
178
|
+
: `${minutes}:${pad(seconds)}`
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
export type FormatDurationOptions = {
|
|
182
|
+
/** `'long'` "1 hour, 5 minutes", `'short'` "1 hr, 5 min" (default), `'narrow'` "1h 5m". */
|
|
183
|
+
style?: 'long' | 'short' | 'narrow'
|
|
184
|
+
/** Adjacent units shown, starting at the largest non-zero one. Default 2. */
|
|
185
|
+
maxUnits?: number
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
const DURATION_UNITS = [
|
|
189
|
+
['days', 'day'],
|
|
190
|
+
['hours', 'hour'],
|
|
191
|
+
['minutes', 'minute'],
|
|
192
|
+
['seconds', 'second'],
|
|
193
|
+
['milliseconds', 'millisecond']
|
|
194
|
+
] as const
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* Localized duration, truncated (never rounded up). Units are adjacent: with
|
|
198
|
+
* the default `maxUnits: 2`, 1 day 0 h 5 min reads "1 day", not "1 day, 5 min".
|
|
199
|
+
*/
|
|
200
|
+
export function formatDuration(
|
|
201
|
+
ms: number,
|
|
202
|
+
locale: string,
|
|
203
|
+
options: FormatDurationOptions = {}
|
|
204
|
+
): string {
|
|
205
|
+
const { style = 'short', maxUnits = 2 } = options
|
|
206
|
+
const parts = splitDuration(ms)
|
|
207
|
+
const unit = (value: number, name: string) =>
|
|
208
|
+
formatNumber(value, locale, {
|
|
209
|
+
style: 'unit',
|
|
210
|
+
unit: name,
|
|
211
|
+
unitDisplay: style
|
|
212
|
+
})
|
|
213
|
+
const first = DURATION_UNITS.findIndex(([key]) => parts[key] > 0)
|
|
214
|
+
if (first === -1) return unit(0, 'second')
|
|
215
|
+
const shown = DURATION_UNITS.slice(first, first + Math.max(1, maxUnits))
|
|
216
|
+
.filter(([key]) => parts[key] > 0)
|
|
217
|
+
.map(([key, name]) => unit(parts[key], name))
|
|
218
|
+
return formatList(shown, locale, { type: 'unit', style })
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// ─── Relative time ──────────────────────────────────────────────────────────
|
|
222
|
+
|
|
223
|
+
function relativeUnit(ms: number): {
|
|
224
|
+
value: number
|
|
225
|
+
unit: Intl.RelativeTimeFormatUnitSingular
|
|
226
|
+
} {
|
|
227
|
+
const seconds = ms / 1000
|
|
228
|
+
if (Math.abs(seconds) < 60)
|
|
229
|
+
return { value: Math.round(seconds), unit: 'second' }
|
|
230
|
+
const minutes = seconds / 60
|
|
231
|
+
if (Math.abs(minutes) < 60)
|
|
232
|
+
return { value: Math.round(minutes), unit: 'minute' }
|
|
233
|
+
const hours = minutes / 60
|
|
234
|
+
if (Math.abs(hours) < 24) return { value: Math.round(hours), unit: 'hour' }
|
|
235
|
+
const days = hours / 24
|
|
236
|
+
if (Math.abs(days) < 30) return { value: Math.round(days), unit: 'day' }
|
|
237
|
+
const months = days / 30
|
|
238
|
+
if (Math.abs(months) < 12) return { value: Math.round(months), unit: 'month' }
|
|
239
|
+
return { value: Math.round(days / 365), unit: 'year' }
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
export type FormatRelativeTimeOptions = {
|
|
243
|
+
/** Reference time. Default `Date.now()`. */
|
|
244
|
+
now?: DateInput
|
|
245
|
+
/** `'auto'` (default) says "yesterday"; `'always'` says "1 day ago". */
|
|
246
|
+
numeric?: Intl.RelativeTimeFormatNumeric
|
|
247
|
+
style?: Intl.RelativeTimeFormatStyle
|
|
248
|
+
/**
|
|
249
|
+
* `false` drops "ago"/"in" and prints the bare amount ("5 minutes"), for
|
|
250
|
+
* use next to a label that already says when. Default `true`.
|
|
251
|
+
*/
|
|
252
|
+
withSuffix?: boolean
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* Time from `now` in the best-fitting unit: "5 minutes ago", "in 2 days",
|
|
257
|
+
* "yesterday". Unparseable input returns `''`.
|
|
258
|
+
*/
|
|
259
|
+
export function formatRelativeTime(
|
|
260
|
+
date: DateInput,
|
|
261
|
+
locale: string,
|
|
262
|
+
options: FormatRelativeTimeOptions = {}
|
|
263
|
+
): string {
|
|
264
|
+
const {
|
|
265
|
+
now = Date.now(),
|
|
266
|
+
numeric = 'auto',
|
|
267
|
+
style,
|
|
268
|
+
withSuffix = true
|
|
269
|
+
} = options
|
|
270
|
+
const diff = toTime(date) - toTime(now)
|
|
271
|
+
if (!Number.isFinite(diff)) return ''
|
|
272
|
+
const { value, unit } = relativeUnit(diff)
|
|
273
|
+
if (withSuffix) {
|
|
274
|
+
return getRelativeTimeFormat(locale, { numeric, style }).format(value, unit)
|
|
275
|
+
}
|
|
276
|
+
return formatNumber(Math.abs(value), locale, {
|
|
277
|
+
style: 'unit',
|
|
278
|
+
unit,
|
|
279
|
+
unitDisplay: style ?? 'long'
|
|
280
|
+
})
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/**
|
|
284
|
+
* How long something has waited, compact for a pill or list row: "14 min",
|
|
285
|
+
* "2 hr", "3 days". Floors (never claims more waiting than happened), never
|
|
286
|
+
* shows less than "1 min", and treats a future timestamp as no waiting.
|
|
287
|
+
*
|
|
288
|
+
* A `number` is a duration in milliseconds; a `Date`/string is a start
|
|
289
|
+
* timestamp measured against `now`. Unparseable input returns `''`.
|
|
290
|
+
*/
|
|
291
|
+
export function formatWait(
|
|
292
|
+
value: DateInput,
|
|
293
|
+
locale: string,
|
|
294
|
+
options: { now?: DateInput } = {}
|
|
295
|
+
): string {
|
|
296
|
+
const elapsed =
|
|
297
|
+
typeof value === 'number'
|
|
298
|
+
? value
|
|
299
|
+
: toTime(options.now ?? Date.now()) - toTime(value)
|
|
300
|
+
const waited = Math.max(0, elapsed)
|
|
301
|
+
if (!Number.isFinite(waited)) return ''
|
|
302
|
+
const [amount, unit]: [number, string] =
|
|
303
|
+
waited < HOUR_MS
|
|
304
|
+
? [Math.max(1, Math.floor(waited / MINUTE_MS)), 'minute']
|
|
305
|
+
: waited < DAY_MS
|
|
306
|
+
? [Math.floor(waited / HOUR_MS), 'hour']
|
|
307
|
+
: [Math.floor(waited / DAY_MS), 'day']
|
|
308
|
+
return formatNumber(amount, locale, {
|
|
309
|
+
style: 'unit',
|
|
310
|
+
unit,
|
|
311
|
+
unitDisplay: 'short'
|
|
312
|
+
})
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
// ─── Dates and lists ────────────────────────────────────────────────────────
|
|
316
|
+
|
|
317
|
+
const DATE_COMPONENTS = [
|
|
318
|
+
'weekday',
|
|
319
|
+
'era',
|
|
320
|
+
'year',
|
|
321
|
+
'month',
|
|
322
|
+
'day',
|
|
323
|
+
'dayPeriod',
|
|
324
|
+
'hour',
|
|
325
|
+
'minute',
|
|
326
|
+
'second',
|
|
327
|
+
'fractionalSecondDigits',
|
|
328
|
+
'dateStyle',
|
|
329
|
+
'timeStyle'
|
|
330
|
+
] as const
|
|
331
|
+
|
|
332
|
+
/**
|
|
333
|
+
* One span with the shared parts collapsed: "24–30 Aug 2026". Without any
|
|
334
|
+
* date/time fields in `options` it uses day + short month + year; `timeZone`
|
|
335
|
+
* and friends combine with that default.
|
|
336
|
+
*/
|
|
337
|
+
export function formatDateRange(
|
|
338
|
+
start: DateInput,
|
|
339
|
+
end: DateInput,
|
|
340
|
+
locale: string,
|
|
341
|
+
options: Intl.DateTimeFormatOptions = {}
|
|
342
|
+
): string {
|
|
343
|
+
const hasFields = DATE_COMPONENTS.some((key) => options[key] !== undefined)
|
|
344
|
+
const format = getDateTimeFormat(
|
|
345
|
+
locale,
|
|
346
|
+
hasFields
|
|
347
|
+
? options
|
|
348
|
+
: { day: 'numeric', month: 'short', year: 'numeric', ...options }
|
|
349
|
+
)
|
|
350
|
+
const from = new Date(toTime(start))
|
|
351
|
+
const to = new Date(toTime(end))
|
|
352
|
+
// Older engines (some Hermes builds) lack formatRange.
|
|
353
|
+
return typeof format.formatRange === 'function'
|
|
354
|
+
? format.formatRange(from, to)
|
|
355
|
+
: `${format.format(from)} – ${format.format(to)}`
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
/** "a, b, and c". Falls back to a comma join where `Intl.ListFormat` is missing. */
|
|
359
|
+
export function formatList(
|
|
360
|
+
items: readonly string[],
|
|
361
|
+
locale: string,
|
|
362
|
+
options?: Intl.ListFormatOptions
|
|
363
|
+
): string {
|
|
364
|
+
if (typeof Intl.ListFormat !== 'function') {
|
|
365
|
+
return items.join(options?.style === 'narrow' ? ' ' : ', ')
|
|
366
|
+
}
|
|
367
|
+
return getListFormat(locale, options).format(items)
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
// ─── Counts ─────────────────────────────────────────────────────────────────
|
|
371
|
+
|
|
372
|
+
/** Badge count: `150` -> `"99+"`. Negative or NaN is `"0"`. */
|
|
373
|
+
export function formatCappedCount(count: number, max = 99): string {
|
|
374
|
+
if (!(count > 0)) return '0'
|
|
375
|
+
return count > max ? `${max}+` : String(count)
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
/**
|
|
379
|
+
* Pick the plural form for `count` and fill in `{count}` (locale-formatted).
|
|
380
|
+
* `pluralize(3, { one: '{count} item', other: '{count} items' }, 'en-US')`.
|
|
381
|
+
*/
|
|
382
|
+
export function pluralize(
|
|
383
|
+
count: number,
|
|
384
|
+
forms: Partial<Record<Intl.LDMLPluralRule, string>> & { other: string },
|
|
385
|
+
locale: string
|
|
386
|
+
): string {
|
|
387
|
+
const form = forms[getPluralRules(locale).select(count)] ?? forms.other
|
|
388
|
+
return form.replaceAll('{count}', formatNumber(count, locale))
|
|
389
|
+
}
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
type Timer = ReturnType<typeof setTimeout>
|
|
2
|
+
|
|
3
|
+
export type Debounced<A extends unknown[]> = ((...args: A) => void) & {
|
|
4
|
+
/** Drops the pending call. */
|
|
5
|
+
cancel(): void
|
|
6
|
+
/** Runs the pending call now, if any. */
|
|
7
|
+
flush(): void
|
|
8
|
+
/** True while a trailing call is waiting. */
|
|
9
|
+
pending(): boolean
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Calls `fn` with the latest args once calls stop for `waitMs` (trailing).
|
|
14
|
+
* `leading` also calls on the first call of a burst; `maxWaitMs` forces a
|
|
15
|
+
* call at least that often during a continuous burst.
|
|
16
|
+
*/
|
|
17
|
+
export function debounce<A extends unknown[]>(
|
|
18
|
+
fn: (...args: A) => void,
|
|
19
|
+
waitMs: number,
|
|
20
|
+
options: { leading?: boolean; maxWaitMs?: number } = {}
|
|
21
|
+
): Debounced<A> {
|
|
22
|
+
let timer: Timer | undefined
|
|
23
|
+
let maxTimer: Timer | undefined
|
|
24
|
+
let lastArgs: A | undefined
|
|
25
|
+
|
|
26
|
+
const cancel = () => {
|
|
27
|
+
clearTimeout(timer)
|
|
28
|
+
clearTimeout(maxTimer)
|
|
29
|
+
timer = maxTimer = lastArgs = undefined
|
|
30
|
+
}
|
|
31
|
+
const flush = () => {
|
|
32
|
+
const args = lastArgs
|
|
33
|
+
cancel()
|
|
34
|
+
if (args) fn(...args)
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const debounced = (...args: A) => {
|
|
38
|
+
const idle = timer === undefined
|
|
39
|
+
clearTimeout(timer)
|
|
40
|
+
timer = setTimeout(flush, waitMs)
|
|
41
|
+
if (options.maxWaitMs !== undefined && maxTimer === undefined) {
|
|
42
|
+
maxTimer = setTimeout(flush, options.maxWaitMs)
|
|
43
|
+
}
|
|
44
|
+
if (idle && options.leading) fn(...args)
|
|
45
|
+
else lastArgs = args
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
return Object.assign(debounced, {
|
|
49
|
+
cancel,
|
|
50
|
+
flush,
|
|
51
|
+
pending: () => lastArgs !== undefined
|
|
52
|
+
})
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* One independent trailing debounce per key. `cancel`/`flush` without a key
|
|
57
|
+
* apply to every pending key.
|
|
58
|
+
*/
|
|
59
|
+
export function keyedDebounce<K, A extends unknown[]>(
|
|
60
|
+
fn: (key: K, ...args: A) => void,
|
|
61
|
+
waitMs: number
|
|
62
|
+
): {
|
|
63
|
+
call(key: K, ...args: A): void
|
|
64
|
+
cancel(key?: K): void
|
|
65
|
+
flush(key?: K): void
|
|
66
|
+
} {
|
|
67
|
+
const byKey = new Map<K, Debounced<A>>()
|
|
68
|
+
const each = (key: K | undefined, action: (d: Debounced<A>) => void) => {
|
|
69
|
+
const targets = key === undefined ? [...byKey.values()] : [byKey.get(key)]
|
|
70
|
+
for (const debounced of targets) if (debounced) action(debounced)
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
return {
|
|
74
|
+
call(key, ...args) {
|
|
75
|
+
let debounced = byKey.get(key)
|
|
76
|
+
if (!debounced) {
|
|
77
|
+
debounced = debounce((...latest: A) => {
|
|
78
|
+
byKey.delete(key)
|
|
79
|
+
fn(key, ...latest)
|
|
80
|
+
}, waitMs)
|
|
81
|
+
byKey.set(key, debounced)
|
|
82
|
+
}
|
|
83
|
+
debounced(...args)
|
|
84
|
+
},
|
|
85
|
+
cancel(key) {
|
|
86
|
+
each(key, (debounced) => debounced.cancel())
|
|
87
|
+
if (key === undefined) byKey.clear()
|
|
88
|
+
else byKey.delete(key)
|
|
89
|
+
},
|
|
90
|
+
flush(key) {
|
|
91
|
+
each(key, (debounced) => debounced.flush())
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Calls `fn` immediately, then at most once per `intervalMs`. With `trailing`
|
|
98
|
+
* (default), the last call made during an interval runs when it ends.
|
|
99
|
+
*/
|
|
100
|
+
export function throttle<A extends unknown[]>(
|
|
101
|
+
fn: (...args: A) => void,
|
|
102
|
+
intervalMs: number,
|
|
103
|
+
options: { trailing?: boolean } = {}
|
|
104
|
+
): ((...args: A) => void) & { cancel(): void } {
|
|
105
|
+
const { trailing = true } = options
|
|
106
|
+
let timer: Timer | undefined
|
|
107
|
+
let trailingArgs: A | undefined
|
|
108
|
+
|
|
109
|
+
const invoke = (args: A) => {
|
|
110
|
+
timer = setTimeout(() => {
|
|
111
|
+
timer = undefined
|
|
112
|
+
const next = trailingArgs
|
|
113
|
+
trailingArgs = undefined
|
|
114
|
+
if (next) invoke(next)
|
|
115
|
+
}, intervalMs)
|
|
116
|
+
fn(...args)
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const throttled = (...args: A) => {
|
|
120
|
+
if (timer === undefined) invoke(args)
|
|
121
|
+
else if (trailing) trailingArgs = args
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
return Object.assign(throttled, {
|
|
125
|
+
cancel: () => {
|
|
126
|
+
clearTimeout(timer)
|
|
127
|
+
timer = trailingArgs = undefined
|
|
128
|
+
}
|
|
129
|
+
})
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** Calls `fn` on the first call only; later calls return the first result. */
|
|
133
|
+
export function once<A extends unknown[], R>(
|
|
134
|
+
fn: (...args: A) => R
|
|
135
|
+
): (...args: A) => R {
|
|
136
|
+
let called = false
|
|
137
|
+
let result: R
|
|
138
|
+
return (...args) => {
|
|
139
|
+
if (!called) {
|
|
140
|
+
called = true
|
|
141
|
+
result = fn(...args)
|
|
142
|
+
}
|
|
143
|
+
return result
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
export type HeartbeatOptions = {
|
|
148
|
+
/** Called on the first activity, then at most once per `intervalMs`. */
|
|
149
|
+
send: () => void
|
|
150
|
+
/** Called once when an active heartbeat stops (idle or `stop()`). */
|
|
151
|
+
onStop?: () => void
|
|
152
|
+
intervalMs: number
|
|
153
|
+
/** Stop after this long without `activity()`. */
|
|
154
|
+
idleMs: number
|
|
155
|
+
now?: () => number
|
|
156
|
+
setTimeout?: (callback: () => void, ms: number) => unknown
|
|
157
|
+
clearTimeout?: (timer: unknown) => void
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Turns bursts of activity into heartbeats (e.g. "is typing" signals): sends
|
|
162
|
+
* immediately on the first activity, then at most once per `intervalMs` while
|
|
163
|
+
* activity continues, and stops after `idleMs` without activity.
|
|
164
|
+
*/
|
|
165
|
+
export function createHeartbeat(options: HeartbeatOptions): {
|
|
166
|
+
/** Report activity. */
|
|
167
|
+
activity(): void
|
|
168
|
+
/** Stop now; calls `onStop` if active. Safe to call repeatedly. */
|
|
169
|
+
stop(): void
|
|
170
|
+
} {
|
|
171
|
+
const {
|
|
172
|
+
now = Date.now,
|
|
173
|
+
setTimeout: schedule = (callback, ms) => setTimeout(callback, ms),
|
|
174
|
+
clearTimeout: unschedule = (timer) => clearTimeout(timer as Timer)
|
|
175
|
+
} = options
|
|
176
|
+
let active = false
|
|
177
|
+
let lastSentAt = 0
|
|
178
|
+
let idleTimer: unknown
|
|
179
|
+
|
|
180
|
+
const stop = () => {
|
|
181
|
+
if (idleTimer !== undefined) unschedule(idleTimer)
|
|
182
|
+
idleTimer = undefined
|
|
183
|
+
if (!active) return
|
|
184
|
+
active = false
|
|
185
|
+
options.onStop?.()
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
return {
|
|
189
|
+
activity() {
|
|
190
|
+
const time = now()
|
|
191
|
+
if (!active || time - lastSentAt >= options.intervalMs) {
|
|
192
|
+
active = true
|
|
193
|
+
lastSentAt = time
|
|
194
|
+
options.send()
|
|
195
|
+
}
|
|
196
|
+
if (idleTimer !== undefined) unschedule(idleTimer)
|
|
197
|
+
idleTimer = schedule(stop, options.idleMs)
|
|
198
|
+
},
|
|
199
|
+
stop
|
|
200
|
+
}
|
|
201
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { InvariantError } from '../error'
|
|
2
|
+
import type { NonEmptyArray } from '../types'
|
|
3
|
+
|
|
4
|
+
/** Non-null object that is not an array. */
|
|
5
|
+
export function isRecord(value: unknown): value is Record<string, unknown> {
|
|
6
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/** Object literal or `Object.create(null)`; excludes arrays, class instances, Dates, Maps. */
|
|
10
|
+
export function isPlainObject(
|
|
11
|
+
value: unknown
|
|
12
|
+
): value is Record<string, unknown> {
|
|
13
|
+
if (!isRecord(value)) return false
|
|
14
|
+
const prototype = Object.getPrototypeOf(value)
|
|
15
|
+
return prototype === Object.prototype || prototype === null
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function isDefined<T>(value: T): value is NonNullable<T> {
|
|
19
|
+
return value !== null && value !== undefined
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function isTruthy<T>(
|
|
23
|
+
value: T
|
|
24
|
+
): value is Exclude<T, false | 0 | 0n | '' | null | undefined> {
|
|
25
|
+
return Boolean(value)
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function isString(value: unknown): value is string {
|
|
29
|
+
return typeof value === 'string'
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** `typeof number`, excluding NaN. Infinity passes. */
|
|
33
|
+
export function isNumber(value: unknown): value is number {
|
|
34
|
+
return typeof value === 'number' && !Number.isNaN(value)
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function isBoolean(value: unknown): value is boolean {
|
|
38
|
+
return typeof value === 'boolean'
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// biome-ignore lint/suspicious/noExplicitAny: `any` params so every function signature narrows
|
|
42
|
+
export function isFunction(value: unknown): value is (...args: any[]) => any {
|
|
43
|
+
return typeof value === 'function'
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Whether `value` is in `list`. Omit `value` to get a reusable guard, e.g. for `.filter`. */
|
|
47
|
+
export function isOneOf<const T extends readonly unknown[]>(
|
|
48
|
+
list: T
|
|
49
|
+
): (value: unknown) => value is T[number]
|
|
50
|
+
export function isOneOf<const T extends readonly unknown[]>(
|
|
51
|
+
list: T,
|
|
52
|
+
value: unknown
|
|
53
|
+
): value is T[number]
|
|
54
|
+
export function isOneOf(list: readonly unknown[], ...value: unknown[]) {
|
|
55
|
+
if (value.length === 0) return (item: unknown) => list.includes(item)
|
|
56
|
+
return list.includes(value[0])
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Own-property check that narrows `key` to `keyof O`. */
|
|
60
|
+
export function hasKey<O extends object>(
|
|
61
|
+
object: O,
|
|
62
|
+
key: PropertyKey
|
|
63
|
+
): key is keyof O {
|
|
64
|
+
return Object.hasOwn(object, key)
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function isNonEmpty<T>(
|
|
68
|
+
array: readonly T[]
|
|
69
|
+
): array is readonly [T, ...T[]] {
|
|
70
|
+
return array.length > 0
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Copy of `array` typed as non-empty (e.g. for `z.enum`); throws when empty. */
|
|
74
|
+
export function nonEmpty<T>(
|
|
75
|
+
array: readonly T[],
|
|
76
|
+
message = 'Expected a non-empty array'
|
|
77
|
+
): NonEmptyArray<T> {
|
|
78
|
+
if (!isNonEmpty(array)) throw new InvariantError(message)
|
|
79
|
+
return [...array]
|
|
80
|
+
}
|