hanzio 1.2.0 → 2.0.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.
Files changed (145) hide show
  1. package/README.md +786 -239
  2. package/dist/api-wrapper/builders.d.ts +4 -3
  3. package/dist/api-wrapper/create-client.d.ts +1 -1
  4. package/dist/api-wrapper/describe.d.ts +16 -0
  5. package/dist/api-wrapper/endpoint.d.ts +18 -0
  6. package/dist/api-wrapper/errors.d.ts +39 -2
  7. package/dist/api-wrapper/index.d.ts +5 -0
  8. package/dist/api-wrapper/otel.d.ts +44 -0
  9. package/dist/api-wrapper/paginate.d.ts +12 -0
  10. package/dist/api-wrapper/redaction.d.ts +2 -0
  11. package/dist/api-wrapper/shared.d.ts +16 -2
  12. package/dist/api-wrapper/testing.d.ts +23 -0
  13. package/dist/api-wrapper/transport.d.ts +4 -5
  14. package/dist/api-wrapper/types.d.ts +103 -18
  15. package/dist/array/index.d.ts +50 -4
  16. package/dist/cache/createCache.d.ts +31 -0
  17. package/dist/cache/index.d.ts +19 -2
  18. package/dist/color/contrast.d.ts +40 -0
  19. package/dist/color/index.d.ts +2 -0
  20. package/dist/{math → color}/metricsSeriesColors.d.ts +14 -7
  21. package/dist/cool-console-log/index.d.ts +34 -5
  22. package/dist/crypto/encoding.d.ts +18 -0
  23. package/dist/crypto/hash.d.ts +33 -0
  24. package/dist/crypto/index.d.ts +4 -0
  25. package/dist/crypto/sealer.d.ts +25 -0
  26. package/dist/crypto/webhook.d.ts +20 -0
  27. package/dist/date/index.d.ts +46 -2
  28. package/dist/emitter/index.d.ts +21 -0
  29. package/dist/error/index.d.ts +12 -0
  30. package/dist/format/index.d.ts +102 -0
  31. package/dist/function/index.d.ts +60 -0
  32. package/dist/guard/index.d.ts +20 -0
  33. package/dist/i18n/index.d.ts +37 -0
  34. package/dist/index-0zvtcbzy.js +224 -0
  35. package/dist/index-181e6vfw.js +29 -0
  36. package/dist/index-8r2vdzjg.js +87 -0
  37. package/dist/index-hy7kbb99.js +157 -0
  38. package/dist/{src/secrets/infisical.js → index-j1ybktbr.js} +500 -538
  39. package/dist/index-k9yx6bne.js +89 -0
  40. package/dist/index-kj1phgrx.js +169 -0
  41. package/dist/index-mnkcfyx8.js +78 -0
  42. package/dist/{p-queue/index.js → index-pwt0dz63.js} +7 -11
  43. package/dist/index-rp5pqghz.js +157 -0
  44. package/dist/index-srt06xd2.js +15 -0
  45. package/dist/index-wjyb9bsd.js +263 -0
  46. package/dist/index.d.ts +7 -7
  47. package/dist/jwt/index.d.ts +50 -2
  48. package/dist/math/index.d.ts +2 -2
  49. package/dist/math/linearRegressionTrend.d.ts +14 -1
  50. package/dist/math/scale.d.ts +8 -0
  51. package/dist/object/index.d.ts +19 -0
  52. package/dist/p-queue/index.d.ts +1 -1
  53. package/dist/promise/index.d.ts +97 -2
  54. package/dist/sitemap/index.d.ts +48 -6
  55. package/dist/src/api-wrapper/index.js +60 -1130
  56. package/dist/src/api-wrapper/testing.js +20 -0
  57. package/dist/{cool-console-log → src/cool-console-log}/index.js +80 -33
  58. package/dist/src/crypto/index.js +195 -0
  59. package/dist/src/format/index.js +211 -0
  60. package/dist/src/i18n/index.js +36 -0
  61. package/dist/src/index.js +739 -3145
  62. package/dist/src/jwt/index.js +138 -92
  63. package/dist/src/p-queue/index.js +9 -437
  64. package/dist/src/secrets/index.js +44 -1428
  65. package/dist/src/secrets/vite.js +6 -39
  66. package/dist/src/secrets/worker.js +10 -273
  67. package/dist/src/sitemap/index.js +183 -511
  68. package/dist/src/storage/index.js +119 -0
  69. package/dist/src/zod/index.js +340 -167
  70. package/dist/state/index.d.ts +21 -0
  71. package/dist/storage/index.d.ts +58 -0
  72. package/dist/string/index.d.ts +75 -2
  73. package/dist/typedswitch/index.d.ts +31 -24
  74. package/dist/types/index.d.ts +11 -0
  75. package/dist/url/index.d.ts +50 -1
  76. package/dist/zod/index.d.ts +32 -3
  77. package/dist/zod/introspect.d.ts +19 -0
  78. package/dist/zod/jsonToZodSchemaMapper.d.ts +3 -1
  79. package/dist/zod/zodMinMaxFilter.d.ts +22 -3
  80. package/dist/zod/zodToTypeString.d.ts +8 -3
  81. package/package.json +78 -7
  82. package/src/api-wrapper/actions-impl.ts +11 -72
  83. package/src/api-wrapper/builders.ts +32 -25
  84. package/src/api-wrapper/create-client.ts +171 -41
  85. package/src/api-wrapper/describe.ts +157 -0
  86. package/src/api-wrapper/endpoint.ts +26 -0
  87. package/src/api-wrapper/error-reporting.ts +7 -1
  88. package/src/api-wrapper/errors.ts +107 -2
  89. package/src/api-wrapper/headers.ts +11 -6
  90. package/src/api-wrapper/index.ts +5 -0
  91. package/src/api-wrapper/otel.ts +139 -0
  92. package/src/api-wrapper/paginate.ts +28 -0
  93. package/src/api-wrapper/redaction.ts +42 -0
  94. package/src/api-wrapper/shared.ts +21 -2
  95. package/src/api-wrapper/testing.ts +48 -0
  96. package/src/api-wrapper/transport.ts +41 -43
  97. package/src/api-wrapper/types.ts +185 -18
  98. package/src/array/index.ts +195 -42
  99. package/src/cache/createCache.ts +157 -0
  100. package/src/cache/index.ts +132 -147
  101. package/src/color/contrast.ts +163 -0
  102. package/src/color/index.ts +22 -0
  103. package/src/{math → color}/metricsSeriesColors.ts +47 -49
  104. package/src/cool-console-log/index.ts +128 -41
  105. package/src/crypto/encoding.ts +83 -0
  106. package/src/crypto/hash.ts +170 -0
  107. package/src/crypto/index.ts +26 -0
  108. package/src/crypto/sealer.ts +168 -0
  109. package/src/crypto/webhook.ts +86 -0
  110. package/src/date/index.ts +109 -34
  111. package/src/emitter/index.ts +82 -0
  112. package/src/error/index.ts +63 -0
  113. package/src/format/index.ts +389 -0
  114. package/src/function/index.ts +201 -0
  115. package/src/guard/index.ts +80 -0
  116. package/src/i18n/index.ts +125 -0
  117. package/src/index.ts +11 -7
  118. package/src/jwt/index.ts +199 -125
  119. package/src/math/index.ts +5 -10
  120. package/src/math/linearRegressionTrend.ts +33 -8
  121. package/src/math/scale.ts +17 -0
  122. package/src/object/index.ts +99 -0
  123. package/src/p-queue/index.ts +2 -0
  124. package/src/promise/index.ts +304 -9
  125. package/src/sitemap/index.ts +293 -130
  126. package/src/state/index.ts +133 -81
  127. package/src/storage/index.ts +187 -0
  128. package/src/string/index.ts +261 -23
  129. package/src/typedswitch/index.ts +58 -38
  130. package/src/types/index.ts +14 -0
  131. package/src/url/index.ts +146 -1
  132. package/src/zod/index.ts +105 -9
  133. package/src/zod/introspect.ts +103 -0
  134. package/src/zod/jsonToZodSchemaMapper.ts +44 -21
  135. package/src/zod/zodMinMaxFilter.ts +37 -13
  136. package/src/zod/zodToTypeString.ts +184 -181
  137. package/dist/api-wrapper/index.js +0 -250
  138. package/dist/gzip/index.d.ts +0 -2
  139. package/dist/gzip/index.js +0 -16
  140. package/dist/index.js +0 -1823
  141. package/dist/jwt/index.js +0 -19
  142. package/dist/sitemap/index.js +0 -552
  143. package/dist/src/gzip/index.js +0 -16
  144. package/dist/src/secrets/loaders.js +0 -1529
  145. package/dist/zod/index.js +0 -210
@@ -1,3 +1,5 @@
1
+ import type { Result } from '../promise'
2
+
1
3
  export type JsonValue =
2
4
  | string
3
5
  | number
@@ -8,18 +10,44 @@ export type JsonValue =
8
10
  export type JsonObject = { [key: string]: JsonValue | undefined }
9
11
  export type JsonArray = Array<JsonValue | undefined>
10
12
 
13
+ /**
14
+ * Parses the first number-like token in a string. Only a `-` directly before
15
+ * the digits is a sign, so `'2024-01-05'` gives `2024`. Whitespace and the
16
+ * separator that isn't `decimalSeparator` are treated as thousands separators:
17
+ * `'1,234.56'` gives `1234.56`, and `'1.234,56'` with `decimalSeparator: ','`
18
+ * gives `1234.56`.
19
+ */
11
20
  export function extractNumber(
12
- value: string | null | undefined
21
+ value: string | null | undefined,
22
+ options: { decimalSeparator?: '.' | ',' } = {}
13
23
  ): number | undefined {
14
- if (!value?.trim()) return undefined
15
-
16
- const cleanedValue = value.replace(/[^\d.,-]/g, '')
17
- if (!cleanedValue) return undefined
24
+ const { decimalSeparator = '.' } = options
25
+ const decimal = decimalSeparator === '.' ? '\\.' : ','
26
+ const thousands = decimalSeparator === '.' ? ',' : '\\.'
27
+ // First number-like token. A thousands separator (or whitespace) only counts
28
+ // when exactly three digits follow it, so '12,5' reads as 12, not 125.
29
+ const token = value?.match(
30
+ new RegExp(
31
+ `-?(?:\\d+(?:(?:${thousands}|\\s)\\d{3}(?!\\d))*(?:${decimal}\\d+)?|${decimal}\\d+)`
32
+ )
33
+ )?.[0]
34
+ if (!token) return undefined
18
35
 
19
- const parsedNumber = Number.parseFloat(cleanedValue)
36
+ const parsedNumber = Number.parseFloat(
37
+ token
38
+ .replace(/\s/g, '')
39
+ .replaceAll(decimalSeparator === '.' ? ',' : '.', '')
40
+ .replace(decimalSeparator, '.')
41
+ )
20
42
  return Number.isNaN(parsedNumber) ? undefined : parsedNumber
21
43
  }
22
44
 
45
+ /**
46
+ * A stable, short, non-cryptographic hash key (32-bit, 8 hex chars) for
47
+ * JSON-like input. Object key order and query-string parameter order don't
48
+ * matter. Collisions become likely above ~10k distinct inputs; use `sha256Id`
49
+ * where that matters.
50
+ */
23
51
  export function generateId(input: JsonValue | undefined): string {
24
52
  const data = normalizeIdInput(input)
25
53
  let hash = 0
@@ -33,32 +61,234 @@ export function generateId(input: JsonValue | undefined): string {
33
61
  return Math.abs(hash).toString(16).padStart(8, '0')
34
62
  }
35
63
 
36
- export function slugify(value: string): string {
37
- const source =
38
- 'àáâäæãåāăąçćčđďèéêëēėęěğǵḧîïíīįìłḿñńǹňôöòóœøōõṕŕřßśšşșťțûüùúūǘůűųẃẍÿýžźż·/_,:;'
39
- const replacement =
40
- 'aaaaaaaaaacccddeeeeeeeegghiiiiiilmnnnnooooooooprrsssssttuuuuuuuuuwxyyzzz------'
41
- const specialCharacterPattern = new RegExp(source.split('').join('|'), 'g')
64
+ /** Like `generateId`, but a SHA-256 hex digest truncated to `length` chars. */
65
+ export async function sha256Id(
66
+ input: JsonValue | undefined,
67
+ length = 16
68
+ ): Promise<string> {
69
+ const digest = await crypto.subtle.digest(
70
+ 'SHA-256',
71
+ new TextEncoder().encode(normalizeIdInput(input))
72
+ )
73
+ return Array.from(new Uint8Array(digest), (byte) =>
74
+ byte.toString(16).padStart(2, '0')
75
+ )
76
+ .join('')
77
+ .slice(0, length)
78
+ }
79
+
80
+ // Letters that NFKD doesn't split into a base letter plus a combining mark.
81
+ const UNDECOMPOSED_LETTERS: Record<string, string> = {
82
+ æ: 'ae',
83
+ Æ: 'AE',
84
+ ø: 'o',
85
+ Ø: 'O',
86
+ œ: 'oe',
87
+ Œ: 'OE',
88
+ ß: 'ss',
89
+ ẞ: 'SS',
90
+ đ: 'd',
91
+ Đ: 'D',
92
+ ł: 'l',
93
+ Ł: 'L',
94
+ þ: 'th',
95
+ Þ: 'TH',
96
+ ð: 'd',
97
+ Ð: 'D'
98
+ }
42
99
 
100
+ /** Removes accents and spells out letters like `æ`, `ø` and `ß` in ASCII. */
101
+ function stripAccents(value: string): string {
43
102
  return value
44
- .toString()
45
- .toLowerCase()
46
- .replace(/\s+/g, '-')
47
- .replace(specialCharacterPattern, (character) =>
48
- replacement.charAt(source.indexOf(character))
103
+ .normalize('NFKD')
104
+ .replace(/\p{M}/gu, '')
105
+ .replace(
106
+ /[æøœßđłþðÆØŒẞĐŁÞÐ]/g,
107
+ (letter) => UNDECOMPOSED_LETTERS[letter] ?? ''
49
108
  )
50
- .replace(/&/g, '-and-')
51
- .replace(/[^\w-]+/g, '')
52
- .replace(/--+/g, '-')
53
- .replace(/^-+/, '')
54
- .replace(/-+$/, '')
109
+ }
110
+
111
+ export type SlugifyOptions = {
112
+ /** Placed between words (default `'-'`). */
113
+ separator?: string
114
+ /** Default `true`. */
115
+ lowercase?: boolean
116
+ /** Cuts the slug to at most this many characters, without a trailing separator. */
117
+ maxLength?: number
118
+ }
119
+
120
+ /**
121
+ * Turns text into a URL slug: strips accents, turns `&` into `and`, splits
122
+ * words on whitespace and `-_/,:;·`, and drops every other non-ASCII-alphanumeric
123
+ * character.
124
+ */
125
+ export function slugify(value: string, options: SlugifyOptions = {}): string {
126
+ const { separator = '-', lowercase = true, maxLength } = options
127
+ const slug = stripAccents(value)
128
+ .replace(/&/g, ' and ')
129
+ .replace(/[\s\-_/,:;·]+/g, ' ')
130
+ .replace(/[^a-zA-Z0-9 ]/g, '')
131
+ .trim()
132
+ .split(/ +/)
133
+ .join(separator)
134
+ const cased = lowercase ? slug.toLowerCase() : slug
135
+
136
+ if (maxLength === undefined || cased.length <= maxLength) return cased
137
+ // Words are alphanumeric, so anything non-alphanumeric at the end is
138
+ // (part of) a separator.
139
+ return cased.slice(0, maxLength).replace(/[^a-zA-Z0-9]+$/, '')
140
+ }
141
+
142
+ /**
143
+ * JSON.stringify with sorted object keys, so `{ a, b }` and `{ b, a }` produce
144
+ * the same string. Also encodes values JSON drops or rejects: `bigint`,
145
+ * `Map` and `Set`. Use it for cache and dedupe keys.
146
+ */
147
+ export function stableStringify(value: unknown): string {
148
+ return (
149
+ JSON.stringify(value, (_key, item: unknown) => {
150
+ if (typeof item === 'bigint') return `${item}n`
151
+ if (item instanceof Map) return { $map: [...item.entries()] }
152
+ if (item instanceof Set) return { $set: [...item] }
153
+ if (item && typeof item === 'object' && !Array.isArray(item)) {
154
+ const record = item as Record<string, unknown>
155
+ return Object.fromEntries(
156
+ Object.keys(record)
157
+ .sort()
158
+ .map((key) => [key, record[key]])
159
+ )
160
+ }
161
+ return item
162
+ }) ?? 'undefined'
163
+ )
164
+ }
165
+
166
+ /**
167
+ * Cuts text to at most `maxLength` user-visible characters, ellipsis included.
168
+ * Never splits an emoji or accented letter, and drops whitespace before the
169
+ * ellipsis. With `wordBoundary`, cuts at the last space unless that would leave
170
+ * nothing.
171
+ */
172
+ export function truncate(
173
+ text: string,
174
+ maxLength: number,
175
+ options: { ellipsis?: string; wordBoundary?: boolean } = {}
176
+ ): string {
177
+ const { ellipsis = '…', wordBoundary = false } = options
178
+ const characters = graphemes(text)
179
+ if (characters.length <= maxLength) return text
180
+
181
+ const ellipsisCharacters = graphemes(ellipsis)
182
+ const room = maxLength - ellipsisCharacters.length
183
+ if (room <= 0) return ellipsisCharacters.slice(0, maxLength).join('')
184
+
185
+ let cut = characters.slice(0, room).join('')
186
+ if (wordBoundary && !/^\s/.test(characters[room] ?? '')) {
187
+ const lastSpace = cut.search(/\s\S*$/)
188
+ if (lastSpace > 0) cut = cut.slice(0, lastSpace)
189
+ }
190
+ return cut.trimEnd() + ellipsis
191
+ }
192
+
193
+ /** Collapses every whitespace run (NBSP and newlines included) to one space and trims. */
194
+ export function normalizeWhitespace(text: string): string {
195
+ return text.replace(/\s+/g, ' ').trim()
196
+ }
197
+
198
+ /**
199
+ * Escapes text for literal use in a `RegExp`. `-` becomes `\x2d` so the result
200
+ * is also valid with the `u` and `v` flags.
201
+ */
202
+ export function escapeRegExp(text: string): string {
203
+ return text.replace(/[.*+?^${}()|[\]\\/]/g, '\\$&').replace(/-/g, '\\x2d')
204
+ }
205
+
206
+ /** Lowercases and strips accents, so `'Ærø Café'` becomes `'aero cafe'`. */
207
+ export function foldForSearch(text: string): string {
208
+ return stripAccents(text).toLowerCase()
209
+ }
210
+
211
+ /**
212
+ * True when every whitespace-separated query term appears in at least one
213
+ * field, ignoring case and accents. An empty query matches everything.
214
+ */
215
+ export function matchesSearch(
216
+ query: string,
217
+ ...fields: (string | null | undefined)[]
218
+ ): boolean {
219
+ const haystack = foldForSearch(fields.filter(Boolean).join('\n'))
220
+ return foldForSearch(query)
221
+ .split(/\s+/)
222
+ .every((term) => haystack.includes(term))
223
+ }
224
+
225
+ const HAS_LETTER = /\p{L}/u
226
+
227
+ /**
228
+ * Up to `max` (default 2) uppercase letters for an avatar, or `''` when the
229
+ * value has no letters (phone numbers, `'—'`). One word gives its first
230
+ * letters (`'Ada'` → `'AD'`), several give first and last (`'Hans Kristoffer
231
+ * Hansen'` → `'HH'`). An email is read as its local part without the `+tag`:
232
+ * `'ada.lovelace+news@x.com'` → `'AL'`.
233
+ */
234
+ export function initials(
235
+ name: string | null | undefined,
236
+ options: { max?: number } = {}
237
+ ): string {
238
+ const { max = 2 } = options
239
+ const trimmed = name?.trim() ?? ''
240
+ const source = /^[^\s@]+@[^\s@]+$/.test(trimmed)
241
+ ? (trimmed.split('@')[0] ?? '').replace(/\+.*$/, '').replace(/[._-]+/g, ' ')
242
+ : trimmed
243
+ const words = source.split(/\s+/).filter((word) => HAS_LETTER.test(word))
244
+ const first = words[0]
245
+ if (!first) return ''
246
+ if (words.length === 1) return fromFirstLetter(first, max).toUpperCase()
247
+
248
+ // Keep the last word (the surname) when there are more words than letters.
249
+ const head = words.slice(0, Math.max(1, max - 1))
250
+ const picked =
251
+ words.length > max ? [...head, words.at(-1) ?? first].slice(0, max) : words
252
+ return picked
253
+ .map((word) => fromFirstLetter(word, 1))
254
+ .join('')
255
+ .toUpperCase()
256
+ }
257
+
258
+ /** `JSON.parse` that returns a `Result` instead of throwing. */
259
+ export function safeJsonParse<T = unknown>(
260
+ text: string
261
+ ): Result<T, SyntaxError> {
262
+ try {
263
+ return { ok: true, data: JSON.parse(text) as T, error: null }
264
+ } catch (error) {
265
+ return { ok: false, data: null, error: error as SyntaxError }
266
+ }
267
+ }
268
+
269
+ // `count` characters from the word's first letter, so brackets around a name
270
+ // are skipped but digits after it are kept (`'R2D2'` → `'R2'`).
271
+ function fromFirstLetter(word: string, count: number): string {
272
+ const characters = graphemes(word)
273
+ const start = characters.findIndex((character) => HAS_LETTER.test(character))
274
+ if (start === -1) return ''
275
+ return characters.slice(start, start + count).join('')
276
+ }
277
+
278
+ let segmenter: Intl.Segmenter | undefined
279
+
280
+ // Grapheme clusters when `Intl.Segmenter` exists, otherwise code points.
281
+ function graphemes(text: string): string[] {
282
+ if (typeof Intl === 'undefined' || !('Segmenter' in Intl)) return [...text]
283
+ segmenter ??= new Intl.Segmenter()
284
+ return Array.from(segmenter.segment(text), ({ segment }) => segment)
55
285
  }
56
286
 
57
287
  function normalizeIdInput(input: JsonValue | undefined): string {
58
288
  if (input === undefined) return 'undefined'
59
289
  if (input === null) return 'null'
60
290
  if (typeof input === 'string') {
61
- return decodeURIComponent(input).split('&').sort().join('&')
291
+ return safeDecodeURIComponent(input).split('&').sort().join('&')
62
292
  }
63
293
  if (typeof input === 'number' || typeof input === 'boolean') {
64
294
  return input.toString()
@@ -84,3 +314,11 @@ function sortJsonValue(value: JsonValue | undefined): JsonValue | null {
84
314
 
85
315
  return value
86
316
  }
317
+
318
+ function safeDecodeURIComponent(value: string): string {
319
+ try {
320
+ return decodeURIComponent(value)
321
+ } catch {
322
+ return value
323
+ }
324
+ }
@@ -109,23 +109,41 @@ export type PartialStringSwitchCases<T extends string, R> = {
109
109
  // Object input mode types
110
110
  // ═══════════════════════════════════════════════════════════════════════════
111
111
 
112
- /** Cases for object input with custom key - all cases required */
113
- export type ObjectSwitchCases<T, K extends keyof T, R> = T[K] extends string
114
- ? {
115
- [V in T[K]]: (value: Extract<T, Record<K, V>>) => R | Promise<R>
116
- }
112
+ /** Discriminant values supported in object mode. Cases are keyed by `${value}`. */
113
+ type Discriminant = string | number | boolean
114
+
115
+ /** Discriminant values of `V` whose case key (`${value}`) is in `Keys` */
116
+ type ValuesForKeys<V, Keys> = V extends Discriminant
117
+ ? `${V}` extends `${Keys & (string | number)}`
118
+ ? V
119
+ : never
117
120
  : never
118
121
 
122
+ type ObjectCases<T, K extends keyof T, R> = {
123
+ [V in Extract<T[K], Discriminant> as `${V}`]: (
124
+ value: Extract<T, Record<K, V>>
125
+ ) => R
126
+ }
127
+
128
+ type PartialObjectCases<T, K extends keyof T, R> = {
129
+ [V in Extract<T[K], Discriminant> as `${V}`]?: (
130
+ value: Extract<T, Record<K, V>>
131
+ ) => R
132
+ }
133
+
134
+ /** Cases for object input with custom key - all cases required */
135
+ export type ObjectSwitchCases<
136
+ T,
137
+ K extends keyof T,
138
+ R
139
+ > = T[K] extends Discriminant ? ObjectCases<T, K, R | Promise<R>> : never
140
+
119
141
  /** Partial cases for object input with custom key (when default is provided) */
120
142
  export type PartialObjectSwitchCases<
121
143
  T,
122
144
  K extends keyof T,
123
145
  R
124
- > = T[K] extends string
125
- ? {
126
- [V in T[K]]?: (value: Extract<T, Record<K, V>>) => R | Promise<R>
127
- }
128
- : never
146
+ > = T[K] extends Discriminant ? PartialObjectCases<T, K, R | Promise<R>> : never
129
147
 
130
148
  type Handler = (value: unknown) => unknown
131
149
  type CasesRecord = Record<string, Handler | undefined>
@@ -156,8 +174,7 @@ export function typedSwitch<
156
174
  export function typedSwitch<
157
175
  T extends object,
158
176
  K extends keyof T,
159
- TKey extends T[K] & string,
160
- Cases extends { [V in TKey]: (value: Extract<T, Record<K, V>>) => unknown }
177
+ Cases extends ObjectCases<T, K, unknown>
161
178
  >(value: T, key: K, cases: Cases): RawCasesReturnType<Cases>
162
179
 
163
180
  // Object input with key - partial cases allowed (with default)
@@ -165,10 +182,9 @@ export function typedSwitch<
165
182
  export function typedSwitch<
166
183
  T extends object,
167
184
  K extends keyof T,
168
- TKey extends T[K] & string,
169
- Cases extends { [V in TKey]?: (value: Extract<T, Record<K, V>>) => unknown },
185
+ Cases extends PartialObjectCases<T, K, unknown>,
170
186
  DefaultCase extends (
171
- value: Exclude<T, Record<K, HandledCaseKeys<Cases> & string>>
187
+ value: Exclude<T, Record<K, ValuesForKeys<T[K], HandledCaseKeys<Cases>>>>
172
188
  ) => unknown
173
189
  >(
174
190
  value: T,
@@ -204,13 +220,7 @@ export function typedSwitch<
204
220
  Constraint,
205
221
  T extends Record<string, unknown> = Record<string, unknown>,
206
222
  K extends keyof T = keyof T
207
- >(
208
- value: T,
209
- key: K,
210
- cases: {
211
- [V in T[K] & string]: (value: Extract<T, Record<K, V>>) => Constraint
212
- }
213
- ): Constraint
223
+ >(value: T, key: K, cases: ObjectCases<T, K, Constraint>): Constraint
214
224
  export function typedSwitch<
215
225
  Constraint,
216
226
  T extends Record<string, unknown> = Record<string, unknown>,
@@ -218,11 +228,7 @@ export function typedSwitch<
218
228
  >(
219
229
  value: T,
220
230
  key: K,
221
- cases: {
222
- [V in T[K] & string]: (
223
- value: Extract<T, Record<K, V>>
224
- ) => Constraint | Promise<Constraint>
225
- }
231
+ cases: ObjectCases<T, K, Constraint | Promise<Constraint>>
226
232
  ): Constraint | Promise<Constraint>
227
233
 
228
234
  // Object input with key - partial cases allowed with return constraint (with default)
@@ -233,9 +239,7 @@ export function typedSwitch<
233
239
  >(
234
240
  value: T,
235
241
  key: K,
236
- cases: {
237
- [V in T[K] & string]?: (value: Extract<T, Record<K, V>>) => Constraint
238
- },
242
+ cases: PartialObjectCases<T, K, Constraint>,
239
243
  defaultCase: (value: T) => Constraint
240
244
  ): Constraint
241
245
  export function typedSwitch<
@@ -245,11 +249,7 @@ export function typedSwitch<
245
249
  >(
246
250
  value: T,
247
251
  key: K,
248
- cases: {
249
- [V in T[K] & string]?: (
250
- value: Extract<T, Record<K, V>>
251
- ) => Constraint | Promise<Constraint>
252
- },
252
+ cases: PartialObjectCases<T, K, Constraint | Promise<Constraint>>,
253
253
  defaultCase: (value: T) => Constraint | Promise<Constraint>
254
254
  ): Constraint | Promise<Constraint>
255
255
 
@@ -282,13 +282,17 @@ export function typedSwitch(
282
282
  const discriminantValue = (value as Record<string, unknown>)[
283
283
  discriminantKey
284
284
  ]
285
- if (typeof discriminantValue !== 'string') {
285
+ if (
286
+ typeof discriminantValue !== 'string' &&
287
+ typeof discriminantValue !== 'number' &&
288
+ typeof discriminantValue !== 'boolean'
289
+ ) {
286
290
  throw new Error(
287
- `Invalid discriminant value for key "${discriminantKey}": expected string, received ${typeof discriminantValue}`
291
+ `Invalid discriminant value for key "${discriminantKey}": expected string, number or boolean, received ${typeof discriminantValue}`
288
292
  )
289
293
  }
290
294
 
291
- key = discriminantValue
295
+ key = String(discriminantValue)
292
296
  cases = casesOrDefault as CasesRecord
293
297
  defaultHandler = defaultCase
294
298
  }
@@ -318,3 +322,19 @@ export function typedSwitch(
318
322
  `Unhandled case: ${key}${contextSuffix}.${availableCasesSuffix}`
319
323
  )
320
324
  }
325
+
326
+ /**
327
+ * Exhaustiveness helper for regular `switch` statements. Place it in the
328
+ * `default` branch: it only type-checks when every case has been handled, and
329
+ * throws if an unexpected value reaches it at runtime.
330
+ *
331
+ * @example
332
+ * switch (status) {
333
+ * case 'success': return 1
334
+ * case 'error': return 0
335
+ * default: return assertNever(status)
336
+ * }
337
+ */
338
+ export function assertNever(value: never, message?: string): never {
339
+ throw new Error(message ?? `Unexpected value: ${String(value)}`)
340
+ }
@@ -0,0 +1,14 @@
1
+ export type MaybePromise<T> = T | Promise<T>
2
+
3
+ /** Union of an object type's values. */
4
+ export type ValueOf<T> = T[keyof T]
5
+
6
+ /** Union of an array or tuple's element types. */
7
+ export type ElementOf<T extends readonly unknown[]> = T[number]
8
+
9
+ export type NonEmptyArray<T> = [T, ...T[]]
10
+
11
+ /** Flattens intersections into one object type so hovers stay readable. */
12
+ export type Simplify<T> = { [K in keyof T]: T[K] } & {}
13
+
14
+ export type Nullable<T> = T | null | undefined
package/src/url/index.ts CHANGED
@@ -1,5 +1,150 @@
1
- export function getDomainFaviconUrl(domain: string, size = 128): string {
1
+ export type UrlQueryValue = string | number | boolean | null | undefined
2
+ export type UrlQueryParams = Record<
3
+ string,
4
+ UrlQueryValue | readonly UrlQueryValue[]
5
+ >
6
+
7
+ /**
8
+ * Serializes query params. Arrays repeat the key (`a=1&a=2`); `null` and
9
+ * `undefined` are skipped. Returns the string without a leading `?`.
10
+ */
11
+ export function buildQueryString(
12
+ params: UrlQueryParams,
13
+ options: { encode?: boolean } = {}
14
+ ): string {
15
+ const pairs: [string, string][] = []
16
+ for (const [key, value] of Object.entries(params)) {
17
+ const values = Array.isArray(value) ? value : [value]
18
+ for (const item of values) {
19
+ if (item != null) pairs.push([key, String(item)])
20
+ }
21
+ }
22
+
23
+ if (options.encode === false) {
24
+ return pairs.map(([key, value]) => `${key}=${value}`).join('&')
25
+ }
26
+ return new URLSearchParams(pairs).toString()
27
+ }
28
+
29
+ /**
30
+ * Replaces `:name` placeholders with encoded values. Throws when a placeholder
31
+ * has no value. Only `:` followed by a letter or `_` counts, so ports like
32
+ * `:8080` are left alone.
33
+ */
34
+ export function replacePathParams(
35
+ path: string,
36
+ params: Record<string, string | number | boolean | undefined> = {}
37
+ ): string {
38
+ return path.replace(/:([A-Za-z_]\w*)/g, (_, key: string) => {
39
+ const value = params[key]
40
+ if (value === undefined) {
41
+ throw new Error(`Missing required path parameter: ${key}`)
42
+ }
43
+ return encodeURIComponent(String(value))
44
+ })
45
+ }
46
+
47
+ /** Joins base and path with exactly one `/`, fills `:params`, appends query. */
48
+ export function buildUrl(
49
+ base: string,
50
+ path = '',
51
+ options: {
52
+ params?: Record<string, string | number | boolean | undefined>
53
+ query?: UrlQueryParams
54
+ } = {}
55
+ ): string {
56
+ const joined = path
57
+ ? `${trimTrailingSlash(base)}/${path.replace(/^\/+/, '')}`
58
+ : base
59
+ const url = replacePathParams(joined, options.params)
60
+ const query = options.query ? buildQueryString(options.query) : ''
61
+ if (!query) return url
62
+ return `${url}${url.includes('?') ? '&' : '?'}${query}`
63
+ }
64
+
65
+ /**
66
+ * Normalizes a domain or URL to its origin: adds `https://` when no scheme is
67
+ * given and strips any path, query or trailing slash.
68
+ */
69
+ export function normalizeDomain(input: string): string {
70
+ const trimmed = input.trim()
71
+ const withScheme = /^[a-z][a-z\d+.-]*:\/\//i.test(trimmed)
72
+ ? trimmed
73
+ : `https://${trimmed}`
74
+ return new URL(withScheme).origin
75
+ }
76
+
77
+ /** Accepts a bare domain (`example.com`) or a full URL. */
78
+ export function getDomainFaviconUrl(domainOrUrl: string, size = 128): string {
79
+ let domain = domainOrUrl
80
+ try {
81
+ if (/^[a-z][a-z\d+.-]*:\/\//i.test(domainOrUrl)) {
82
+ domain = new URL(domainOrUrl).hostname
83
+ }
84
+ } catch {
85
+ // Not a URL; use the input as-is.
86
+ }
2
87
  return `https://www.google.com/s2/favicons?domain=${encodeURIComponent(
3
88
  domain
4
89
  )}&sz=${size}`
5
90
  }
91
+
92
+ /** Removes trailing slashes: `'https://x.com/a/'` → `'https://x.com/a'`. */
93
+ export function trimTrailingSlash(url: string): string {
94
+ return url.replace(/\/+$/, '')
95
+ }
96
+
97
+ /** Prefixes `/` unless the path already starts with one. */
98
+ export function ensureLeadingSlash(path: string): string {
99
+ return path.startsWith('/') ? path : `/${path}`
100
+ }
101
+
102
+ type MailtoRecipients = string | readonly string[]
103
+
104
+ /**
105
+ * Builds a `mailto:` link. Spaces become `%20` (not `+`, which mail clients
106
+ * show literally) and body line breaks become `%0D%0A`.
107
+ */
108
+ export function buildMailto(options: {
109
+ to?: MailtoRecipients
110
+ cc?: MailtoRecipients
111
+ bcc?: MailtoRecipients
112
+ subject?: string
113
+ body?: string
114
+ }): string {
115
+ const { to, cc, bcc, subject, body } = options
116
+ const headers = Object.entries({
117
+ cc: cc && recipients(cc),
118
+ bcc: bcc && recipients(bcc),
119
+ subject: subject && encodeURIComponent(subject),
120
+ body: body && encodeURIComponent(body.replace(/\r?\n/g, '\r\n'))
121
+ })
122
+ .filter(([, value]) => value)
123
+ .map(([key, value]) => `${key}=${value}`)
124
+ .join('&')
125
+ return `mailto:${to ? recipients(to) : ''}${headers ? `?${headers}` : ''}`
126
+ }
127
+
128
+ function recipients(value: MailtoRecipients): string {
129
+ return (typeof value === 'string' ? [value] : value)
130
+ .map((address) => encodeURIComponent(address.trim()).replace(/%40/g, '@'))
131
+ .join(',')
132
+ }
133
+
134
+ /**
135
+ * Milliseconds to wait from a `Retry-After` header: delta-seconds (fractions
136
+ * allowed) or an HTTP date relative to `now`. Past dates and negative values
137
+ * give `0`; a missing or invalid value gives `undefined`.
138
+ */
139
+ export function parseRetryAfter(
140
+ value: string | null | undefined,
141
+ now = Date.now()
142
+ ): number | undefined {
143
+ const trimmed = value?.trim()
144
+ if (!trimmed) return undefined
145
+ if (/^-?\d*\.?\d+$/.test(trimmed)) {
146
+ return Math.max(0, Number(trimmed) * 1000)
147
+ }
148
+ const date = Date.parse(trimmed)
149
+ return Number.isNaN(date) ? undefined : Math.max(0, date - now)
150
+ }