hanzio 1.1.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 +832 -219
- package/dist/api-wrapper/builders.d.ts +15 -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 +40 -2
- package/dist/api-wrapper/headers.d.ts +2 -1
- 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 +12 -10
- package/dist/api-wrapper/types.d.ts +104 -18
- package/dist/array/index.d.ts +50 -4
- package/dist/cache/createCache.d.ts +31 -0
- package/dist/cache/index.d.ts +45 -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/loaders.js → index-j1ybktbr.js} +608 -405
- 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/secrets/core.d.ts +47 -0
- package/dist/secrets/deadline.d.ts +6 -0
- package/dist/secrets/environment.d.ts +9 -0
- package/dist/secrets/errors.d.ts +11 -0
- package/dist/secrets/index.d.ts +13 -25
- package/dist/secrets/infisical-cli.d.ts +6 -0
- package/dist/secrets/infisical-config.d.ts +17 -0
- package/dist/secrets/infisical-session.d.ts +4 -0
- package/dist/secrets/infisical.d.ts +8 -13
- package/dist/secrets/loaders.d.ts +5 -12
- package/dist/secrets/vite.d.ts +7 -3
- package/dist/secrets/worker-loader.d.ts +9 -0
- package/dist/secrets/worker.d.ts +3 -0
- package/dist/sitemap/index.d.ts +48 -6
- package/dist/src/api-wrapper/index.js +60 -1100
- 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 +863 -2809
- package/dist/src/jwt/index.js +138 -92
- package/dist/src/p-queue/index.js +9 -437
- package/dist/src/secrets/index.js +261 -1377
- package/dist/src/secrets/vite.js +6 -17
- package/dist/src/secrets/worker.js +12 -0
- package/dist/src/sitemap/index.js +183 -511
- package/dist/src/storage/index.js +119 -0
- package/dist/src/zod/index.js +347 -174
- 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 +37 -26
- 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 +87 -7
- package/src/api-wrapper/actions-impl.ts +11 -72
- package/src/api-wrapper/builders.ts +84 -52
- package/src/api-wrapper/create-client.ts +246 -96
- 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 +117 -2
- package/src/api-wrapper/headers.ts +19 -1
- 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 +43 -0
- package/src/api-wrapper/shared.ts +21 -2
- package/src/api-wrapper/testing.ts +48 -0
- package/src/api-wrapper/transport.ts +68 -74
- package/src/api-wrapper/types.ts +186 -18
- package/src/array/index.ts +195 -42
- package/src/cache/createCache.ts +157 -0
- package/src/cache/index.ts +235 -83
- 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 +348 -0
- package/src/secrets/deadline.ts +69 -0
- package/src/secrets/environment.ts +69 -0
- package/src/secrets/errors.ts +29 -0
- package/src/secrets/index.ts +79 -210
- package/src/secrets/infisical-cli.ts +99 -0
- package/src/secrets/infisical-config.ts +70 -0
- package/src/secrets/infisical-session.ts +42 -0
- package/src/secrets/infisical.ts +214 -66
- package/src/secrets/loaders.ts +24 -48
- package/src/secrets/vite.ts +44 -15
- package/src/secrets/worker-loader.ts +56 -0
- package/src/secrets/worker.ts +4 -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 +134 -103
- 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/infisical.js +0 -1194
- 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
|
@@ -74,15 +74,27 @@ type RawHandlerReturnType<T> = T extends (...args: any[]) => infer R ? R : never
|
|
|
74
74
|
|
|
75
75
|
/** Get union of all raw return types from a cases object's values */
|
|
76
76
|
type RawCasesReturnType<Cases> = {
|
|
77
|
-
[K in keyof Cases]: Cases[K]
|
|
77
|
+
[K in keyof Cases]: RawHandlerReturnType<Cases[K]>
|
|
78
|
+
}[keyof Cases]
|
|
79
|
+
|
|
80
|
+
/** Only required, defined handlers guarantee that a case is handled. */
|
|
81
|
+
type HandledCaseKeys<Cases> = {
|
|
82
|
+
// biome-ignore lint/complexity/noBannedTypes: Empty-object assignability detects optional keys and index signatures.
|
|
83
|
+
[K in keyof Cases]-?: {} extends Pick<Cases, K>
|
|
78
84
|
? never
|
|
79
|
-
:
|
|
85
|
+
: undefined extends Cases[K]
|
|
86
|
+
? never
|
|
87
|
+
: K
|
|
80
88
|
}[keyof Cases]
|
|
81
89
|
|
|
82
90
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
83
91
|
// String input mode types
|
|
84
92
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
85
93
|
|
|
94
|
+
// The exported case-map types below are annotation helpers for consumers who
|
|
95
|
+
// want to type reusable case maps. The typedSwitch overloads infer their own
|
|
96
|
+
// shapes and do not depend on these types.
|
|
97
|
+
|
|
86
98
|
/** Cases for string input - all cases required */
|
|
87
99
|
export type StringSwitchCases<T extends string, R> = {
|
|
88
100
|
[K in T]: (value: K) => R | Promise<R>
|
|
@@ -97,88 +109,45 @@ export type PartialStringSwitchCases<T extends string, R> = {
|
|
|
97
109
|
// Object input mode types
|
|
98
110
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
99
111
|
|
|
100
|
-
/**
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
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
|
|
105
120
|
: never
|
|
106
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
|
+
|
|
107
141
|
/** Partial cases for object input with custom key (when default is provided) */
|
|
108
142
|
export type PartialObjectSwitchCases<
|
|
109
143
|
T,
|
|
110
144
|
K extends keyof T,
|
|
111
145
|
R
|
|
112
|
-
> = T[K] extends
|
|
113
|
-
? {
|
|
114
|
-
[V in T[K]]?: (value: Extract<T, Record<K, V>>) => R | Promise<R>
|
|
115
|
-
}
|
|
116
|
-
: never
|
|
146
|
+
> = T[K] extends Discriminant ? PartialObjectCases<T, K, R | Promise<R>> : never
|
|
117
147
|
|
|
118
148
|
type Handler = (value: unknown) => unknown
|
|
119
149
|
type CasesRecord = Record<string, Handler | undefined>
|
|
120
150
|
|
|
121
|
-
function runSwitch(
|
|
122
|
-
value: string | Record<string, unknown>,
|
|
123
|
-
keyOrCases: string | CasesRecord,
|
|
124
|
-
casesOrDefault?: CasesRecord | Handler,
|
|
125
|
-
defaultCase?: Handler
|
|
126
|
-
): unknown {
|
|
127
|
-
let key: string
|
|
128
|
-
let cases: CasesRecord
|
|
129
|
-
let defaultHandler: Handler | undefined
|
|
130
|
-
let discriminantKey: string | undefined
|
|
131
|
-
|
|
132
|
-
if (typeof value === 'string') {
|
|
133
|
-
// String input: typedSwitch(string, cases, default?)
|
|
134
|
-
key = value
|
|
135
|
-
cases = keyOrCases as CasesRecord
|
|
136
|
-
defaultHandler = casesOrDefault as Handler | undefined
|
|
137
|
-
} else {
|
|
138
|
-
// Object input: typedSwitch(obj, key, cases, default?)
|
|
139
|
-
if (typeof keyOrCases !== 'string') {
|
|
140
|
-
throw new Error(
|
|
141
|
-
'Invalid typedSwitch call: object mode requires a string discriminant key'
|
|
142
|
-
)
|
|
143
|
-
}
|
|
144
|
-
|
|
145
|
-
discriminantKey = keyOrCases
|
|
146
|
-
const discriminantValue = value[discriminantKey]
|
|
147
|
-
if (typeof discriminantValue !== 'string') {
|
|
148
|
-
throw new Error(
|
|
149
|
-
`Invalid discriminant value for key "${discriminantKey}": expected string, received ${typeof discriminantValue}`
|
|
150
|
-
)
|
|
151
|
-
}
|
|
152
|
-
|
|
153
|
-
key = discriminantValue
|
|
154
|
-
cases = casesOrDefault as CasesRecord
|
|
155
|
-
defaultHandler = defaultCase
|
|
156
|
-
}
|
|
157
|
-
|
|
158
|
-
const handler = cases[key]
|
|
159
|
-
|
|
160
|
-
if (handler) {
|
|
161
|
-
return handler(value)
|
|
162
|
-
}
|
|
163
|
-
|
|
164
|
-
if (defaultHandler) {
|
|
165
|
-
return defaultHandler(value)
|
|
166
|
-
}
|
|
167
|
-
|
|
168
|
-
const availableCases = Object.keys(cases).join(', ')
|
|
169
|
-
const availableCasesSuffix =
|
|
170
|
-
availableCases.length > 0
|
|
171
|
-
? ` Available cases: ${availableCases}`
|
|
172
|
-
: ' Available cases: (none)'
|
|
173
|
-
const contextSuffix = discriminantKey
|
|
174
|
-
? ` (discriminant key: "${discriminantKey}")`
|
|
175
|
-
: ''
|
|
176
|
-
|
|
177
|
-
throw new Error(
|
|
178
|
-
`Unhandled case: ${key}${contextSuffix}.${availableCasesSuffix}`
|
|
179
|
-
)
|
|
180
|
-
}
|
|
181
|
-
|
|
182
151
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
183
152
|
// Function overloads
|
|
184
153
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
@@ -190,10 +159,11 @@ export function typedSwitch<
|
|
|
190
159
|
>(value: T, cases: Cases): RawCasesReturnType<Cases>
|
|
191
160
|
|
|
192
161
|
// String input - partial cases allowed (with default)
|
|
162
|
+
// The default handler only receives the cases not covered by `cases`.
|
|
193
163
|
export function typedSwitch<
|
|
194
164
|
T extends string,
|
|
195
165
|
Cases extends { [K in T]?: (value: K) => unknown },
|
|
196
|
-
DefaultCase extends (value: T) => unknown
|
|
166
|
+
DefaultCase extends (value: Exclude<T, HandledCaseKeys<Cases>>) => unknown
|
|
197
167
|
>(
|
|
198
168
|
value: T,
|
|
199
169
|
cases: Cases,
|
|
@@ -202,19 +172,20 @@ export function typedSwitch<
|
|
|
202
172
|
|
|
203
173
|
// Object input with key - all cases required (no default)
|
|
204
174
|
export function typedSwitch<
|
|
205
|
-
T extends
|
|
175
|
+
T extends object,
|
|
206
176
|
K extends keyof T,
|
|
207
|
-
|
|
208
|
-
Cases extends { [V in TKey]: (value: Extract<T, Record<K, V>>) => unknown }
|
|
177
|
+
Cases extends ObjectCases<T, K, unknown>
|
|
209
178
|
>(value: T, key: K, cases: Cases): RawCasesReturnType<Cases>
|
|
210
179
|
|
|
211
180
|
// Object input with key - partial cases allowed (with default)
|
|
181
|
+
// The default handler only receives the union members not covered by `cases`.
|
|
212
182
|
export function typedSwitch<
|
|
213
|
-
T extends
|
|
183
|
+
T extends object,
|
|
214
184
|
K extends keyof T,
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
185
|
+
Cases extends PartialObjectCases<T, K, unknown>,
|
|
186
|
+
DefaultCase extends (
|
|
187
|
+
value: Exclude<T, Record<K, ValuesForKeys<T[K], HandledCaseKeys<Cases>>>>
|
|
188
|
+
) => unknown
|
|
218
189
|
>(
|
|
219
190
|
value: T,
|
|
220
191
|
key: K,
|
|
@@ -249,13 +220,7 @@ export function typedSwitch<
|
|
|
249
220
|
Constraint,
|
|
250
221
|
T extends Record<string, unknown> = Record<string, unknown>,
|
|
251
222
|
K extends keyof T = keyof T
|
|
252
|
-
>(
|
|
253
|
-
value: T,
|
|
254
|
-
key: K,
|
|
255
|
-
cases: {
|
|
256
|
-
[V in T[K] & string]: (value: Extract<T, Record<K, V>>) => Constraint
|
|
257
|
-
}
|
|
258
|
-
): Constraint
|
|
223
|
+
>(value: T, key: K, cases: ObjectCases<T, K, Constraint>): Constraint
|
|
259
224
|
export function typedSwitch<
|
|
260
225
|
Constraint,
|
|
261
226
|
T extends Record<string, unknown> = Record<string, unknown>,
|
|
@@ -263,11 +228,7 @@ export function typedSwitch<
|
|
|
263
228
|
>(
|
|
264
229
|
value: T,
|
|
265
230
|
key: K,
|
|
266
|
-
cases:
|
|
267
|
-
[V in T[K] & string]: (
|
|
268
|
-
value: Extract<T, Record<K, V>>
|
|
269
|
-
) => Constraint | Promise<Constraint>
|
|
270
|
-
}
|
|
231
|
+
cases: ObjectCases<T, K, Constraint | Promise<Constraint>>
|
|
271
232
|
): Constraint | Promise<Constraint>
|
|
272
233
|
|
|
273
234
|
// Object input with key - partial cases allowed with return constraint (with default)
|
|
@@ -278,9 +239,7 @@ export function typedSwitch<
|
|
|
278
239
|
>(
|
|
279
240
|
value: T,
|
|
280
241
|
key: K,
|
|
281
|
-
cases:
|
|
282
|
-
[V in T[K] & string]?: (value: Extract<T, Record<K, V>>) => Constraint
|
|
283
|
-
},
|
|
242
|
+
cases: PartialObjectCases<T, K, Constraint>,
|
|
284
243
|
defaultCase: (value: T) => Constraint
|
|
285
244
|
): Constraint
|
|
286
245
|
export function typedSwitch<
|
|
@@ -290,20 +249,92 @@ export function typedSwitch<
|
|
|
290
249
|
>(
|
|
291
250
|
value: T,
|
|
292
251
|
key: K,
|
|
293
|
-
cases:
|
|
294
|
-
[V in T[K] & string]?: (
|
|
295
|
-
value: Extract<T, Record<K, V>>
|
|
296
|
-
) => Constraint | Promise<Constraint>
|
|
297
|
-
},
|
|
252
|
+
cases: PartialObjectCases<T, K, Constraint | Promise<Constraint>>,
|
|
298
253
|
defaultCase: (value: T) => Constraint | Promise<Constraint>
|
|
299
254
|
): Constraint | Promise<Constraint>
|
|
300
255
|
|
|
301
256
|
// Implementation
|
|
302
257
|
export function typedSwitch(
|
|
303
|
-
value: string |
|
|
258
|
+
value: string | object,
|
|
304
259
|
keyOrCases: string | CasesRecord,
|
|
305
260
|
casesOrDefault?: CasesRecord | Handler,
|
|
306
261
|
defaultCase?: Handler
|
|
307
262
|
): unknown {
|
|
308
|
-
|
|
263
|
+
let key: string
|
|
264
|
+
let cases: CasesRecord
|
|
265
|
+
let defaultHandler: Handler | undefined
|
|
266
|
+
let discriminantKey: string | undefined
|
|
267
|
+
|
|
268
|
+
if (typeof value === 'string') {
|
|
269
|
+
// String input: typedSwitch(string, cases, default?)
|
|
270
|
+
key = value
|
|
271
|
+
cases = keyOrCases as CasesRecord
|
|
272
|
+
defaultHandler = casesOrDefault as Handler | undefined
|
|
273
|
+
} else {
|
|
274
|
+
// Object input: typedSwitch(obj, key, cases, default?)
|
|
275
|
+
if (typeof keyOrCases !== 'string') {
|
|
276
|
+
throw new Error(
|
|
277
|
+
'Invalid typedSwitch call: object mode requires a string discriminant key'
|
|
278
|
+
)
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
discriminantKey = keyOrCases
|
|
282
|
+
const discriminantValue = (value as Record<string, unknown>)[
|
|
283
|
+
discriminantKey
|
|
284
|
+
]
|
|
285
|
+
if (
|
|
286
|
+
typeof discriminantValue !== 'string' &&
|
|
287
|
+
typeof discriminantValue !== 'number' &&
|
|
288
|
+
typeof discriminantValue !== 'boolean'
|
|
289
|
+
) {
|
|
290
|
+
throw new Error(
|
|
291
|
+
`Invalid discriminant value for key "${discriminantKey}": expected string, number or boolean, received ${typeof discriminantValue}`
|
|
292
|
+
)
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
key = String(discriminantValue)
|
|
296
|
+
cases = casesOrDefault as CasesRecord
|
|
297
|
+
defaultHandler = defaultCase
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
// Only own properties count as cases - a key like 'toString' must not
|
|
301
|
+
// resolve to an inherited Object.prototype method.
|
|
302
|
+
const handler = Object.hasOwn(cases, key) ? cases[key] : undefined
|
|
303
|
+
|
|
304
|
+
if (handler) {
|
|
305
|
+
return handler(value)
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
if (defaultHandler) {
|
|
309
|
+
return defaultHandler(value)
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
const availableCases = Object.keys(cases).join(', ')
|
|
313
|
+
const availableCasesSuffix =
|
|
314
|
+
availableCases.length > 0
|
|
315
|
+
? ` Available cases: ${availableCases}`
|
|
316
|
+
: ' Available cases: (none)'
|
|
317
|
+
const contextSuffix = discriminantKey
|
|
318
|
+
? ` (discriminant key: "${discriminantKey}")`
|
|
319
|
+
: ''
|
|
320
|
+
|
|
321
|
+
throw new Error(
|
|
322
|
+
`Unhandled case: ${key}${contextSuffix}.${availableCasesSuffix}`
|
|
323
|
+
)
|
|
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)}`)
|
|
309
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
|