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.
- 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-181e6vfw.js +29 -0
- package/dist/index-8r2vdzjg.js +87 -0
- package/dist/index-hy7kbb99.js +157 -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-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-srt06xd2.js +15 -0
- package/dist/index-wjyb9bsd.js +263 -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/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 +44 -1428
- package/dist/src/secrets/vite.js +6 -39
- package/dist/src/secrets/worker.js +10 -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/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
package/src/string/index.ts
CHANGED
|
@@ -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
|
-
|
|
15
|
-
|
|
16
|
-
const
|
|
17
|
-
|
|
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(
|
|
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
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
const
|
|
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
|
-
.
|
|
45
|
-
.
|
|
46
|
-
.replace(
|
|
47
|
-
|
|
48
|
-
|
|
103
|
+
.normalize('NFKD')
|
|
104
|
+
.replace(/\p{M}/gu, '')
|
|
105
|
+
.replace(
|
|
106
|
+
/[æøœßđłþðÆØŒẞĐŁÞÐ]/g,
|
|
107
|
+
(letter) => UNDECOMPOSED_LETTERS[letter] ?? ''
|
|
49
108
|
)
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
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
|
|
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
|
+
}
|
package/src/typedswitch/index.ts
CHANGED
|
@@ -109,23 +109,41 @@ export type PartialStringSwitchCases<T extends string, R> = {
|
|
|
109
109
|
// Object input mode types
|
|
110
110
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
111
111
|
|
|
112
|
-
/**
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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 (
|
|
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
|
|
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
|
+
}
|