utilful 3.0.2 → 3.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -21,13 +21,20 @@ A collection of TypeScript utilities that I use across my projects.
21
21
 
22
22
  ```bash
23
23
  # npm
24
- npm install -D utilful
24
+ npm install utilful
25
25
 
26
26
  # pnpm
27
- pnpm add -D utilful
27
+ pnpm add utilful
28
28
 
29
29
  # yarn
30
- yarn add -D utilful
30
+ yarn add utilful
31
+ ```
32
+
33
+ Every module is also available on its own subpath, so you can import just the part you need:
34
+
35
+ ```ts
36
+ import { defu } from 'utilful' // Everything
37
+ import { joinURL } from 'utilful/path' // Just the path helpers
31
38
  ```
32
39
 
33
40
  ## API
@@ -130,7 +137,12 @@ declare function parseCSV<Header extends string>(
130
137
  ): CSVRow<Header>[]
131
138
  ```
132
139
 
133
- The parser accepts a few lenient deviations from RFC 4180: LF, CR, and CRLF line endings are all recognized, whitespace between a closing quote and the next delimiter or line break is ignored, and quotes inside unquoted fields are kept as literal characters (a field only counts as quoted if it starts with a quote).
140
+ The parser accepts a few lenient deviations from RFC 4180:
141
+
142
+ - LF, CR, and CRLF line endings are all recognized
143
+ - whitespace between a closing quote and the next delimiter or line break is ignored
144
+ - quotes inside unquoted fields are kept as literal characters, since a field only counts as quoted if it starts with a quote
145
+ - text following a closing quote is appended to the field rather than rejected, so `"ab" cd` parses as `abcd`
134
146
 
135
147
  **Example:**
136
148
 
@@ -148,6 +160,9 @@ const data = parseCSV<'name' | 'age'>(csv) // [{ name: 'John', age: '30' }, { na
148
160
 
149
161
  Creates a CSV stream from an iterable or async iterable of objects. Yields complete lines (header and/or data rows) including line endings – useful for large datasets that should not be buffered in memory.
150
162
 
163
+ > [!NOTE]
164
+ > Unlike `createCSV`, `columns` is required here. Inferring them would mean reading every row before writing the first one, which is exactly what streaming avoids.
165
+
151
166
  ```ts
152
167
  declare function createCSVStream<T extends Record<string, unknown>>(
153
168
  data: AsyncIterable<T> | Iterable<T>,
@@ -228,7 +243,7 @@ escapeCSVValue('contains "quotes"') // '"contains ""quotes"""'
228
243
 
229
244
  ### Defu
230
245
 
231
- Recursively assign default properties. Simplified version based on [unjs/defu](https://github.com/unjs/defu).
246
+ Fills in missing properties from a chain of defaults. A trimmed-down take on [unjs/defu](https://github.com/unjs/defu).
232
247
 
233
248
  #### `defu`
234
249
 
@@ -286,7 +301,7 @@ const result = defu(
286
301
 
287
302
  #### `createDefu`
288
303
 
289
- Creates a custom defu function with a custom merger.
304
+ Creates a `defu` variant that hands every property to your own merger first. Return `true` to signal you handled it; return nothing to fall back to the default behavior.
290
305
 
291
306
  ```ts
292
307
  type DefuMerger<T extends PlainObject = PlainObject> = (
@@ -325,8 +340,7 @@ Tiny functional event emitter / pubsub, based on [mitt](https://github.com/devel
325
340
  ```ts
326
341
  import { createEmitter } from 'utilful'
327
342
 
328
- // eslint-disable-next-line ts/consistent-type-definitions
329
- type Events = {
343
+ interface Events {
330
344
  foo: { a: string }
331
345
  }
332
346
 
@@ -388,12 +402,9 @@ async function loadModule() {
388
402
 
389
403
  #### `memoize`
390
404
 
391
- A simple general purpose memoizer utility.
405
+ Defers a computation until the value is first read, then caches it.
392
406
 
393
- - Lazily computes a value when accessed
394
- - Auto-caches the result by overwriting the getter
395
-
396
- Useful for deferring initialization or expensive operations. Unlike a simple getter, there is no runtime overhead after the first invocation, since the getter itself is overwritten with the memoized value.
407
+ Useful for expensive setup you may never need. Unlike a plain getter, there is no runtime cost after the first read, because the getter replaces itself with the computed value.
397
408
 
398
409
  ```ts
399
410
  declare function memoize<T>(getter: () => T): { value: T }
@@ -426,15 +437,20 @@ declare function objectEntries<T extends Record<any, any>>(obj: T): Array<[keyof
426
437
 
427
438
  #### `deepApply`
428
439
 
429
- Deeply applies a callback to every key-value pair in the given object, as well as nested objects and arrays (including arrays nested inside arrays).
440
+ Applies a callback to every key-value pair of the given object, and to every pair inside nested objects and arrays (including arrays nested inside arrays).
441
+
442
+ The callback also fires for nested objects, so `item` is whichever object the pair belongs to rather than the one you passed in.
430
443
 
431
444
  ```ts
432
- declare function deepApply<T extends Record<any, any>>(data: T, callback: (item: T, key: keyof T, value: T[keyof T]) => void): void
445
+ declare function deepApply<T extends Record<any, any>>(
446
+ data: T,
447
+ callback: (item: Record<string, any>, key: string, value: any) => void
448
+ ): void
433
449
  ```
434
450
 
435
451
  #### `isObject`
436
452
 
437
- Checks if a value is an object with the plain `[object Object]` tag. Returns `true` for object literals, class instances, and `null`-prototype objects.
453
+ Checks whether a value is an object. Object literals, class instances, and `null`-prototype objects all count; arrays, `Date`, `RegExp`, and `null` do not.
438
454
 
439
455
  ```ts
440
456
  declare function isObject(value: unknown): value is Record<any, any>
@@ -442,7 +458,7 @@ declare function isObject(value: unknown): value is Record<any, any>
442
458
 
443
459
  ### Path
444
460
 
445
- Utilities to build and normalize URL paths. All of them are also available from the `utilful/path` subpath export.
461
+ Utilities to build and normalize URL paths. They slice strings instead of parsing a full `URL`, which keeps them cheap enough for hot paths. Only `withQuery` and `getQuery` reach for `URLSearchParams`, where correct percent-encoding is worth the allocation.
446
462
 
447
463
  #### `withoutLeadingSlash` / `withLeadingSlash`
448
464
 
@@ -478,7 +494,7 @@ joinURL('/api/', '/users', '42') // '/api/users/42'
478
494
 
479
495
  #### `withBase` / `withoutBase`
480
496
 
481
- Adds or removes a base path – each is a no-op if the base is already present (or absent).
497
+ Adds or removes a base path – each is a no-op if the base is already present (or absent). An absolute URL is returned as it is, since no base path can prefix it.
482
498
 
483
499
  ```ts
484
500
  declare function withBase(input?: string, base?: string): string
@@ -489,20 +505,36 @@ declare function withoutBase(input?: string, base?: string): string
489
505
 
490
506
  ```ts
491
507
  withBase('/users', '/api') // '/api/users'
508
+ withBase('https://example.com/users', '/api') // 'https://example.com/users'
492
509
  withoutBase('/api/users', '/api') // '/users'
493
510
  ```
494
511
 
495
512
  #### `getPathname`
496
513
 
497
- Returns the pathname of the given path – everything before the query string or hash. Absolute URLs (with a scheme, e.g. `https://example.com/foo`) return the URL's pathname; all other inputs are returned unchanged with the query string and hash removed.
514
+ Returns the pathname of the given path – everything before the query string or hash. Absolute URLs return the part after the host, whether they carry a scheme (`https://example.com/foo`) or are protocol-relative (`//example.com/foo`).
515
+
516
+ The pathname is sliced out as written and never normalized, so percent-encoding and `..` segments survive:
498
517
 
499
518
  ```ts
500
519
  declare function getPathname(path?: string): string
501
520
  ```
502
521
 
522
+ ```ts
523
+ getPathname('/foo?bar#baz') // '/foo'
524
+ getPathname('https://example.com/foo') // '/foo'
525
+ getPathname('//example.com/foo') // '/foo'
526
+ getPathname('https://example.com') // '/'
527
+ getPathname('https://example.com/a/../b') // '/a/../b' – use `new URL` if you need this resolved
528
+ ```
529
+
503
530
  #### `withQuery`
504
531
 
505
- Returns the URL with the given query parameters merged in. `undefined` values remove the parameter, array values append one entry per item, and object values are JSON-stringified.
532
+ Returns the URL with the given query parameters merged in. A fragment stays where it belongs, at the very end.
533
+
534
+ - `undefined` removes the parameter
535
+ - `null` keeps the parameter with an empty value
536
+ - arrays append one entry per item, and empty arrays are skipped
537
+ - objects are JSON-stringified
506
538
 
507
539
  ```ts
508
540
  type QueryValue = string | number | boolean | QueryValue[] | Record<string, any> | null | undefined
@@ -515,6 +547,25 @@ declare function withQuery(input: string, query?: QueryObject): string
515
547
 
516
548
  ```ts
517
549
  withQuery('/api/users', { page: 2, tags: ['a', 'b'] }) // '/api/users?page=2&tags=a&tags=b'
550
+ withQuery('/api/users#list', { page: 2 }) // '/api/users?page=2#list'
551
+ withQuery('/api/users?page=2', { page: undefined }) // '/api/users'
552
+ ```
553
+
554
+ #### `getQuery`
555
+
556
+ Reads the query parameters back out of a URL, ignoring the fragment. A parameter that appears more than once becomes an array of its values.
557
+
558
+ ```ts
559
+ type ParsedQuery = Record<string, string | string[]>
560
+
561
+ declare function getQuery(input: string): ParsedQuery
562
+ ```
563
+
564
+ **Example:**
565
+
566
+ ```ts
567
+ getQuery('/api/users?page=2&tags=a&tags=b') // { page: '2', tags: ['a', 'b'] }
568
+ getQuery('/api/users') // {}
518
569
  ```
519
570
 
520
571
  ### Result
@@ -525,7 +576,7 @@ The `Result` type represents either success (`Ok`) or failure (`Err`). It provid
525
576
  type Result<T, E> = Ok<T, E> | Err<T, E>
526
577
  ```
527
578
 
528
- Both `Ok` and `Err` carry phantom types for proper type inference in unions.
579
+ Both variants carry the success *and* the error type, so the two stay in sync as you chain `map` and `mapError` calls.
529
580
 
530
581
  **Basic example:**
531
582
 
@@ -616,7 +667,7 @@ Chains a function that returns a `Result`. Useful for composing fallible operati
616
667
 
617
668
  ```ts
618
669
  ok(2).andThen(x => x > 0 ? ok(x) : err('negative')) // Ok(2)
619
- err('fail').andThen(x => ok(x * 2)) // Err('fail') - short-circuits
670
+ err('fail').andThen(x => ok(x * 2)) // Err('fail') – short-circuits
620
671
  ```
621
672
 
622
673
  #### `Result.unwrap`
@@ -629,6 +680,16 @@ err('fail').unwrap() // throws Error
629
680
  err('fail').unwrap('custom message') // throws Error('custom message')
630
681
  ```
631
682
 
683
+ #### `Result.unwrapErr`
684
+
685
+ Extracts the error, or throws if the result is `Ok`. The mirror image of `unwrap`.
686
+
687
+ ```ts
688
+ err('fail').unwrapErr() // 'fail'
689
+ ok(42).unwrapErr() // throws Error
690
+ ok(42).unwrapErr('custom message') // throws Error('custom message')
691
+ ```
692
+
632
693
  #### `Result.unwrapOr`
633
694
 
634
695
  Extracts the value or returns a fallback.
@@ -690,6 +751,9 @@ declare function tryCatch<T, E = unknown>(fn: () => T): { value: T, error: undef
690
751
  declare function tryCatch<T, E = unknown>(promise: Promise<T>): Promise<{ value: T, error: undefined } | { value: undefined, error: E }>
691
752
  ```
692
753
 
754
+ > [!NOTE]
755
+ > Like `toResult`, the function overload must be synchronous, and passing a function that returns a promise throws a `TypeError` rather than returning it as an error. Pass the promise itself.
756
+
693
757
  **Example:**
694
758
 
695
759
  ```ts
@@ -704,7 +768,9 @@ const { value, error } = await tryCatch(fetch('https://api.example.com').then(r
704
768
 
705
769
  #### `template`
706
770
 
707
- Simple template engine to replace variables in a string.
771
+ Replaces `{name}` placeholders in a string with the matching variable.
772
+
773
+ A placeholder with no matching variable is left as its own key, unless you pass a `fallback` – either a fixed string or a function receiving the key. A variable that is present but `null` or `undefined` is treated the same as a missing one. Only own properties are read, so `{constructor}` cannot reach prototype members.
708
774
 
709
775
  ```ts
710
776
  declare function template(
@@ -727,7 +793,10 @@ console.log(template(str, variables)) // Hello, world!
727
793
 
728
794
  #### `generateRandomId`
729
795
 
730
- Generates a random string. The function is ported from [`nanoid`](https://github.com/ai/nanoid). You can specify the size of the string and the dictionary of characters to use.
796
+ Generates a random string. Ported from [`nanoid`](https://github.com/ai/nanoid). You can specify the length and the dictionary of characters to draw from.
797
+
798
+ > [!WARNING]
799
+ > Backed by `Math.random()` and therefore not cryptographically secure. Use `crypto.randomUUID()` or `crypto.getRandomValues()` for session tokens, password resets, and anything else an attacker would like to guess.
731
800
 
732
801
  ```ts
733
802
  declare function generateRandomId(size?: number, dict?: string): string
package/dist/csv.d.mts CHANGED
@@ -106,8 +106,8 @@ declare function createCSVAsync<T extends Record<string, unknown>>(data: AsyncIt
106
106
  * Within quoted values, double quotes are escaped by doubling them.
107
107
  *
108
108
  * @example
109
- * escapeCSVValue('hello, world') // "hello, world"
110
- * escapeCSVValue('contains "quotes"') // "contains ""quotes"""
109
+ * escapeCSVValue('hello, world') // '"hello, world"'
110
+ * escapeCSVValue('contains "quotes"') // '"contains ""quotes"""'
111
111
  */
112
112
  declare function escapeCSVValue(value: unknown, options?: {
113
113
  /** @default ',' */
package/dist/csv.mjs CHANGED
@@ -17,9 +17,9 @@ function createCSV(data, columnsOrOptions, maybeOptions = {}) {
17
17
  columns = inferColumns(data);
18
18
  options = columnsOrOptions ?? {};
19
19
  }
20
- if (columns.length === 0 && data.length === 0) return "";
21
20
  const { delimiter = COMMA, addHeader = true, quoteAll = false, lineEnding = NEWLINE } = options;
22
21
  assertValidCSVDelimiter(delimiter);
22
+ if (columns.length === 0) return "";
23
23
  if (addHeader) {
24
24
  const header = encodeCSVHeader(columns.map(String), delimiter, quoteAll);
25
25
  if (data.length === 0) return header;
@@ -97,8 +97,8 @@ function encodeCSVRow(row, columns, delimiter, quoteAll) {
97
97
  * Within quoted values, double quotes are escaped by doubling them.
98
98
  *
99
99
  * @example
100
- * escapeCSVValue('hello, world') // "hello, world"
101
- * escapeCSVValue('contains "quotes"') // "contains ""quotes"""
100
+ * escapeCSVValue('hello, world') // '"hello, world"'
101
+ * escapeCSVValue('contains "quotes"') // '"contains ""quotes"""'
102
102
  */
103
103
  function escapeCSVValue(value, options = {}) {
104
104
  const { delimiter = COMMA, quoteAll = false } = options;
@@ -4,8 +4,8 @@ type Handler<T = unknown> = (event: T) => void;
4
4
  type WildcardHandler<T = Record<string, unknown>> = (type: keyof T, event: T[keyof T]) => void;
5
5
  type EventHandlerList<T = unknown> = Handler<T>[];
6
6
  type WildCardEventHandlerList<T = Record<string, unknown>> = WildcardHandler<T>[];
7
- type EventHandlerMap<Events extends Record<EventType, unknown>> = Map<keyof Events | "*", EventHandlerList<Events[keyof Events]> | WildCardEventHandlerList<Events>>;
8
- interface Emitter<Events extends Record<EventType, unknown>> {
7
+ type EventHandlerMap<Events extends Record<EventType, any>> = Map<keyof Events | "*", EventHandlerList<Events[keyof Events]> | WildCardEventHandlerList<Events>>;
8
+ interface Emitter<Events extends Record<EventType, any>> {
9
9
  events: EventHandlerMap<Events>;
10
10
  on<Key extends keyof Events>(type: Key, handler: Handler<Events[Key]>): void;
11
11
  on(type: "*", handler: WildcardHandler<Events>): void;
@@ -20,6 +20,6 @@ interface Emitter<Events extends Record<EventType, unknown>> {
20
20
  * @remarks Ported from `mitt`.
21
21
  * @see https://github.com/developit/mitt
22
22
  */
23
- declare function createEmitter<Events extends Record<EventType, unknown>>(events?: EventHandlerMap<Events>): Emitter<Events>;
23
+ declare function createEmitter<Events extends Record<EventType, any>>(events?: EventHandlerMap<Events>): Emitter<Events>;
24
24
  //#endregion
25
25
  export { Emitter, EventHandlerList, EventHandlerMap, EventType, Handler, WildCardEventHandlerList, WildcardHandler, createEmitter };
package/dist/emitter.mjs CHANGED
@@ -14,8 +14,6 @@ function createEmitter(events) {
14
14
  events,
15
15
  /**
16
16
  * Registers an event handler for the given type.
17
- *
18
- * @memberOf createEmitter
19
17
  */
20
18
  on(type, handler) {
21
19
  const handlers = events.get(type);
@@ -27,8 +25,6 @@ function createEmitter(events) {
27
25
  *
28
26
  * @remarks
29
27
  * If `handler` is omitted, all handlers of the given type are removed.
30
- *
31
- * @memberOf createEmitter
32
28
  */
33
29
  off(type, handler) {
34
30
  const handlers = events.get(type);
@@ -41,8 +37,6 @@ function createEmitter(events) {
41
37
  * @remarks
42
38
  * If present, `'*'` handlers are invoked after type-matched handlers.
43
39
  * Manually firing `'*'` handlers is not supported.
44
- *
45
- * @memberOf createEmitter
46
40
  */
47
41
  emit(type, evt) {
48
42
  let handlers = events.get(type);
package/dist/index.d.mts CHANGED
@@ -5,8 +5,8 @@ import { Emitter, EventHandlerList, EventHandlerMap, EventType, Handler, WildCar
5
5
  import { tryParseJSON } from "./json.mjs";
6
6
  import { interopDefault } from "./module.mjs";
7
7
  import { deepApply, isObject, memoize, objectEntries, objectKeys } from "./object.mjs";
8
- import { QueryObject, QueryValue, getPathname, joinURL, withBase, withLeadingSlash, withQuery, withTrailingSlash, withoutBase, withoutLeadingSlash, withoutTrailingSlash } from "./path.mjs";
8
+ import { ParsedQuery, QueryObject, QueryValue, getPathname, getQuery, joinURL, withBase, withLeadingSlash, withQuery, withTrailingSlash, withoutBase, withoutLeadingSlash, withoutTrailingSlash } from "./path.mjs";
9
9
  import { Err, ErrData, Ok, OkData, Result, ResultData, err, isErr, isOk, ok, toResult, tryCatch, unwrapResult } from "./result.mjs";
10
10
  import { TEMPLATE_PLACEHOLDER_RE, generateRandomId, template } from "./string.mjs";
11
- import { AutocompletableString, BrandedType, LooseAutocomplete, UnifyIntersection } from "./types.mjs";
12
- export { AutocompletableString, BrandedType, CSVCreateOptions, CSVParseOptions, CSVRow, Defu, DefuFn, DefuMerger, Emitter, Err, ErrData, EventHandlerList, EventHandlerMap, EventType, Handler, LooseAutocomplete, MaybeArray, Ok, OkData, QueryObject, QueryValue, Result, ResultData, TEMPLATE_PLACEHOLDER_RE, UnifyIntersection, WildCardEventHandlerList, WildcardHandler, createCSV, createCSVAsync, createCSVStream, createDefu, createEmitter, deepApply, defu, err, escapeCSVValue, generateRandomId, getPathname, interopDefault, isErr, isObject, isOk, joinURL, memoize, objectEntries, objectKeys, ok, parseCSV, parseCSVStream, template, toArray, toResult, tryCatch, tryParseJSON, unwrapResult, withBase, withLeadingSlash, withQuery, withTrailingSlash, withoutBase, withoutLeadingSlash, withoutTrailingSlash };
11
+ import { BrandedType, UnifyIntersection } from "./types.mjs";
12
+ export { BrandedType, CSVCreateOptions, CSVParseOptions, CSVRow, Defu, DefuFn, DefuMerger, Emitter, Err, ErrData, EventHandlerList, EventHandlerMap, EventType, Handler, MaybeArray, Ok, OkData, ParsedQuery, QueryObject, QueryValue, Result, ResultData, TEMPLATE_PLACEHOLDER_RE, UnifyIntersection, WildCardEventHandlerList, WildcardHandler, createCSV, createCSVAsync, createCSVStream, createDefu, createEmitter, deepApply, defu, err, escapeCSVValue, generateRandomId, getPathname, getQuery, interopDefault, isErr, isObject, isOk, joinURL, memoize, objectEntries, objectKeys, ok, parseCSV, parseCSVStream, template, toArray, toResult, tryCatch, tryParseJSON, unwrapResult, withBase, withLeadingSlash, withQuery, withTrailingSlash, withoutBase, withoutLeadingSlash, withoutTrailingSlash };
package/dist/index.mjs CHANGED
@@ -5,7 +5,7 @@ import { createEmitter } from "./emitter.mjs";
5
5
  import { tryParseJSON } from "./json.mjs";
6
6
  import { interopDefault } from "./module.mjs";
7
7
  import { deepApply, isObject, memoize, objectEntries, objectKeys } from "./object.mjs";
8
- import { getPathname, joinURL, withBase, withLeadingSlash, withQuery, withTrailingSlash, withoutBase, withoutLeadingSlash, withoutTrailingSlash } from "./path.mjs";
8
+ import { getPathname, getQuery, joinURL, withBase, withLeadingSlash, withQuery, withTrailingSlash, withoutBase, withoutLeadingSlash, withoutTrailingSlash } from "./path.mjs";
9
9
  import { Err, Ok, err, isErr, isOk, ok, toResult, tryCatch, unwrapResult } from "./result.mjs";
10
10
  import { TEMPLATE_PLACEHOLDER_RE, generateRandomId, template } from "./string.mjs";
11
- export { Err, Ok, TEMPLATE_PLACEHOLDER_RE, createCSV, createCSVAsync, createCSVStream, createDefu, createEmitter, deepApply, defu, err, escapeCSVValue, generateRandomId, getPathname, interopDefault, isErr, isObject, isOk, joinURL, memoize, objectEntries, objectKeys, ok, parseCSV, parseCSVStream, template, toArray, toResult, tryCatch, tryParseJSON, unwrapResult, withBase, withLeadingSlash, withQuery, withTrailingSlash, withoutBase, withoutLeadingSlash, withoutTrailingSlash };
11
+ export { Err, Ok, TEMPLATE_PLACEHOLDER_RE, createCSV, createCSVAsync, createCSVStream, createDefu, createEmitter, deepApply, defu, err, escapeCSVValue, generateRandomId, getPathname, getQuery, interopDefault, isErr, isObject, isOk, joinURL, memoize, objectEntries, objectKeys, ok, parseCSV, parseCSVStream, template, toArray, toResult, tryCatch, tryParseJSON, unwrapResult, withBase, withLeadingSlash, withQuery, withTrailingSlash, withoutBase, withoutLeadingSlash, withoutTrailingSlash };
package/dist/object.d.mts CHANGED
@@ -23,9 +23,14 @@ declare function objectKeys<T extends Record<any, any>>(obj: T): Array<`${Extrac
23
23
  */
24
24
  declare function objectEntries<T extends Record<any, any>>(obj: T): Array<[keyof T, T[keyof T]]>;
25
25
  /**
26
- * Deeply applies a callback to every key-value pair in the given object, as well as nested objects and arrays (including arrays nested inside arrays).
26
+ * Applies a callback to every key-value pair of the given object, and to every pair
27
+ * inside nested objects and arrays (including arrays nested inside arrays).
28
+ *
29
+ * @remarks
30
+ * The callback also fires for nested objects, so `item` is whichever object the pair
31
+ * belongs to rather than the one that was passed in.
27
32
  */
28
- declare function deepApply<T extends Record<any, any>>(data: T, callback: (item: T, key: keyof T, value: T[keyof T]) => void): void;
33
+ declare function deepApply<T extends Record<any, any>>(data: T, callback: (item: Record<string, any>, key: string, value: any) => void): void;
29
34
  /**
30
35
  * Checks if a value is an object with the plain `[object Object]` tag.
31
36
  *
package/dist/object.mjs CHANGED
@@ -31,7 +31,12 @@ function objectEntries(obj) {
31
31
  return Object.entries(obj);
32
32
  }
33
33
  /**
34
- * Deeply applies a callback to every key-value pair in the given object, as well as nested objects and arrays (including arrays nested inside arrays).
34
+ * Applies a callback to every key-value pair of the given object, and to every pair
35
+ * inside nested objects and arrays (including arrays nested inside arrays).
36
+ *
37
+ * @remarks
38
+ * The callback also fires for nested objects, so `item` is whichever object the pair
39
+ * belongs to rather than the one that was passed in.
35
40
  */
36
41
  function deepApply(data, callback) {
37
42
  for (const [key, value] of Object.entries(data)) {
package/dist/path.d.mts CHANGED
@@ -1,6 +1,8 @@
1
1
  //#region src/path.d.ts
2
2
  type QueryValue = string | number | boolean | QueryValue[] | Record<string, any> | null | undefined;
3
3
  type QueryObject = Record<string, QueryValue | QueryValue[]>;
4
+ /** Query parameters as read back from a URL, where a repeated key holds all its values. */
5
+ type ParsedQuery = Record<string, string | string[]>;
4
6
  /**
5
7
  * Removes the leading slash from the given path if it has one.
6
8
  */
@@ -25,6 +27,11 @@ declare function withTrailingSlash(path?: string): string;
25
27
  declare function joinURL(...paths: (string | undefined)[]): string;
26
28
  /**
27
29
  * Adds the base path to the input path, if it is not already present.
30
+ *
31
+ * @remarks
32
+ * An absolute URL is returned as it is, since a base path cannot prefix one –
33
+ * whether it carries a scheme (`https://example.com/foo`) or is protocol-relative
34
+ * (`//example.com/foo`).
28
35
  */
29
36
  declare function withBase(input?: string, base?: string): string;
30
37
  /**
@@ -35,13 +42,23 @@ declare function withoutBase(input?: string, base?: string): string;
35
42
  * Returns the pathname of the given path, which is the path without the query string or hash.
36
43
  *
37
44
  * @remarks
38
- * Absolute URLs (with a scheme, e.g. `https://example.com/foo`) return the URL's pathname.
39
- * All other inputs are returned unchanged with the query string and hash removed.
45
+ * Absolute URLs return the segment after the authority, whether they carry a
46
+ * scheme (`https://example.com/foo`) or are protocol-relative (`//example.com/foo`).
47
+ * The result is sliced out verbatim, never normalized, so percent-encoding and
48
+ * `..` segments survive as written.
40
49
  */
41
50
  declare function getPathname(path?: string): string;
42
51
  /**
43
52
  * Returns the URL with the given query parameters. If a query parameter is `undefined`, it is omitted.
44
53
  */
45
54
  declare function withQuery(input: string, query?: QueryObject): string;
55
+ /**
56
+ * Reads the query parameters of the given URL, ignoring the fragment.
57
+ *
58
+ * @remarks
59
+ * A parameter that appears more than once becomes an array of its values,
60
+ * in the order they appear. Values are percent-decoded.
61
+ */
62
+ declare function getQuery(input: string): ParsedQuery;
46
63
  //#endregion
47
- export { QueryObject, QueryValue, getPathname, joinURL, withBase, withLeadingSlash, withQuery, withTrailingSlash, withoutBase, withoutLeadingSlash, withoutTrailingSlash };
64
+ export { ParsedQuery, QueryObject, QueryValue, getPathname, getQuery, joinURL, withBase, withLeadingSlash, withQuery, withTrailingSlash, withoutBase, withoutLeadingSlash, withoutTrailingSlash };
package/dist/path.mjs CHANGED
@@ -1,4 +1,14 @@
1
1
  //#region src/path.ts
2
+ const URL_SCHEME_RE = /^[a-z][\w+.-]*:\/\//i;
3
+ /**
4
+ * Returns the index at which the authority of an absolute URL starts, or `-1`
5
+ * for a path that carries none. A protocol-relative input such as
6
+ * `//example.com/foo` counts as absolute: its authority follows the leading `//`.
7
+ */
8
+ function getAuthorityStart(path) {
9
+ if (path.startsWith("//")) return 2;
10
+ return URL_SCHEME_RE.test(path) ? path.indexOf("://") + 3 : -1;
11
+ }
2
12
  /**
3
13
  * Removes the leading slash from the given path if it has one.
4
14
  */
@@ -66,9 +76,14 @@ function joinURL(...paths) {
66
76
  }
67
77
  /**
68
78
  * Adds the base path to the input path, if it is not already present.
79
+ *
80
+ * @remarks
81
+ * An absolute URL is returned as it is, since a base path cannot prefix one –
82
+ * whether it carries a scheme (`https://example.com/foo`) or is protocol-relative
83
+ * (`//example.com/foo`).
69
84
  */
70
85
  function withBase(input = "", base = "") {
71
- if (!base || base === "/") return input;
86
+ if (!base || base === "/" || getAuthorityStart(input) !== -1) return input;
72
87
  const _base = withoutTrailingSlash(base);
73
88
  if (input.startsWith(_base) && (input.length === _base.length || input[_base.length] === "/" || input[_base.length] === "?" || input[_base.length] === "#")) return input;
74
89
  return joinURL(_base, input);
@@ -83,32 +98,47 @@ function withoutBase(input = "", base = "") {
83
98
  const trimmed = input.slice(_base.length);
84
99
  return trimmed[0] === "/" ? trimmed : `/${trimmed}`;
85
100
  }
86
- const URL_SCHEME_RE = /^[a-z][\w+.-]*:\/\//i;
87
101
  /**
88
102
  * Returns the pathname of the given path, which is the path without the query string or hash.
89
103
  *
90
104
  * @remarks
91
- * Absolute URLs (with a scheme, e.g. `https://example.com/foo`) return the URL's pathname.
92
- * All other inputs are returned unchanged with the query string and hash removed.
105
+ * Absolute URLs return the segment after the authority, whether they carry a
106
+ * scheme (`https://example.com/foo`) or are protocol-relative (`//example.com/foo`).
107
+ * The result is sliced out verbatim, never normalized, so percent-encoding and
108
+ * `..` segments survive as written.
93
109
  */
94
110
  function getPathname(path = "/") {
95
- if (URL_SCHEME_RE.test(path)) return new URL(path).pathname;
111
+ let pathStart = 0;
112
+ const authorityStart = getAuthorityStart(path);
113
+ if (authorityStart !== -1) {
114
+ let authorityEnd = path.length;
115
+ for (let i = authorityStart; i < path.length; i++) {
116
+ const character = path[i];
117
+ if (character === "/" || character === "?" || character === "#") {
118
+ authorityEnd = i;
119
+ break;
120
+ }
121
+ }
122
+ if (path[authorityEnd] !== "/") return "/";
123
+ pathStart = authorityEnd;
124
+ }
96
125
  let pathEnd = path.length;
97
- const queryIndex = path.indexOf("?");
98
- const hashIndex = path.indexOf("#");
126
+ const queryIndex = path.indexOf("?", pathStart);
127
+ const hashIndex = path.indexOf("#", pathStart);
99
128
  if (queryIndex !== -1) pathEnd = queryIndex;
100
129
  if (hashIndex !== -1 && hashIndex < pathEnd) pathEnd = hashIndex;
101
- return path.slice(0, pathEnd) || "/";
130
+ return path.slice(pathStart, pathEnd) || "/";
102
131
  }
103
132
  /**
104
133
  * Returns the URL with the given query parameters. If a query parameter is `undefined`, it is omitted.
105
134
  */
106
135
  function withQuery(input, query) {
107
136
  if (!query || Object.keys(query).length === 0) return input;
108
- const searchIndex = input.indexOf("?");
137
+ const { beforeHash, hash } = splitFragment(input);
138
+ const searchIndex = beforeHash.indexOf("?");
109
139
  const hasExistingParams = searchIndex !== -1;
110
- const base = hasExistingParams ? input.slice(0, searchIndex) : input;
111
- const searchParams = new URLSearchParams(hasExistingParams ? input.slice(searchIndex + 1) : void 0);
140
+ const base = hasExistingParams ? beforeHash.slice(0, searchIndex) : beforeHash;
141
+ const searchParams = new URLSearchParams(hasExistingParams ? beforeHash.slice(searchIndex + 1) : void 0);
112
142
  for (const [key, value] of Object.entries(query)) {
113
143
  if (value === void 0) {
114
144
  searchParams.delete(key);
@@ -120,7 +150,37 @@ function withQuery(input, query) {
120
150
  } else searchParams.set(key, normalizeQueryValue(value));
121
151
  }
122
152
  const queryString = searchParams.toString();
123
- return queryString ? `${base}?${queryString}` : base;
153
+ return queryString ? `${base}?${queryString}${hash}` : base + hash;
154
+ }
155
+ /**
156
+ * Reads the query parameters of the given URL, ignoring the fragment.
157
+ *
158
+ * @remarks
159
+ * A parameter that appears more than once becomes an array of its values,
160
+ * in the order they appear. Values are percent-decoded.
161
+ */
162
+ function getQuery(input) {
163
+ const { beforeHash } = splitFragment(input);
164
+ const searchIndex = beforeHash.indexOf("?");
165
+ if (searchIndex === -1) return {};
166
+ const query = Object.create(null);
167
+ for (const [key, value] of new URLSearchParams(beforeHash.slice(searchIndex + 1))) {
168
+ const existingValue = query[key];
169
+ if (existingValue === void 0) query[key] = value;
170
+ else if (Array.isArray(existingValue)) existingValue.push(value);
171
+ else query[key] = [existingValue, value];
172
+ }
173
+ return { ...query };
174
+ }
175
+ function splitFragment(input) {
176
+ const hashIndex = input.indexOf("#");
177
+ return hashIndex === -1 ? {
178
+ beforeHash: input,
179
+ hash: ""
180
+ } : {
181
+ beforeHash: input.slice(0, hashIndex),
182
+ hash: input.slice(hashIndex)
183
+ };
124
184
  }
125
185
  function normalizeQueryValue(value) {
126
186
  if (value === null) return "";
@@ -129,4 +189,4 @@ function normalizeQueryValue(value) {
129
189
  return String(value);
130
190
  }
131
191
  //#endregion
132
- export { getPathname, joinURL, withBase, withLeadingSlash, withQuery, withTrailingSlash, withoutBase, withoutLeadingSlash, withoutTrailingSlash };
192
+ export { getPathname, getQuery, joinURL, withBase, withLeadingSlash, withQuery, withTrailingSlash, withoutBase, withoutLeadingSlash, withoutTrailingSlash };
package/dist/result.d.mts CHANGED
@@ -24,11 +24,10 @@ declare class Ok<T, E = never> {
24
24
  mapError<E2>(_fn: (error: E) => E2): Ok<T, E2>;
25
25
  /** Chains a `Result`-returning function. */
26
26
  andThen<U, E2>(fn: (value: T) => Result<U, E2>): Result<U, E | E2>;
27
- /** Extracts the value. */
28
27
  unwrap(_message?: string): T;
28
+ unwrapErr(message?: string): never;
29
29
  /** Returns the value, ignoring the fallback. */
30
30
  unwrapOr<U>(_fallback: U): T;
31
- /** Pattern matches on the `Result`. */
32
31
  match<R>(handlers: {
33
32
  ok: (value: T) => R;
34
33
  err: (error: E) => R;
@@ -49,11 +48,10 @@ declare class Err<T, E> {
49
48
  mapError<E2>(fn: (error: E) => E2): Err<T, E2>;
50
49
  /** Returns this `Err` unchanged, with a widened error type. */
51
50
  andThen<U, E2>(_fn: (value: T) => Result<U, E2>): Err<U, E | E2>;
52
- /** Throws an error with the given message. */
53
51
  unwrap(message?: string): never;
52
+ unwrapErr(_message?: string): E;
54
53
  /** Returns the fallback value. */
55
54
  unwrapOr<U>(fallback: U): T | U;
56
- /** Pattern matches on the `Result`. */
57
55
  match<R>(handlers: {
58
56
  ok: (value: T) => R;
59
57
  err: (error: E) => R;
package/dist/result.mjs CHANGED
@@ -21,15 +21,16 @@ var Ok = class Ok {
21
21
  andThen(fn) {
22
22
  return fn(this.value);
23
23
  }
24
- /** Extracts the value. */
25
24
  unwrap(_message) {
26
25
  return this.value;
27
26
  }
27
+ unwrapErr(message) {
28
+ throw new Error(message ?? `unwrapErr called on Ok: ${String(this.value)}`);
29
+ }
28
30
  /** Returns the value, ignoring the fallback. */
29
31
  unwrapOr(_fallback) {
30
32
  return this.value;
31
33
  }
32
- /** Pattern matches on the `Result`. */
33
34
  match(handlers) {
34
35
  return handlers.ok(this.value);
35
36
  }
@@ -56,15 +57,16 @@ var Err = class Err {
56
57
  andThen(_fn) {
57
58
  return this;
58
59
  }
59
- /** Throws an error with the given message. */
60
60
  unwrap(message) {
61
- throw new Error(message ?? `Unwrap called on Err: ${String(this.error)}`);
61
+ throw new Error(message ?? `unwrap called on Err: ${String(this.error)}`);
62
+ }
63
+ unwrapErr(_message) {
64
+ return this.error;
62
65
  }
63
66
  /** Returns the fallback value. */
64
67
  unwrapOr(fallback) {
65
68
  return fallback;
66
69
  }
67
- /** Pattern matches on the `Result`. */
68
70
  match(handlers) {
69
71
  return handlers.err(this.error);
70
72
  }
package/dist/types.d.mts CHANGED
@@ -1,6 +1,4 @@
1
1
  //#region src/types.d.ts
2
- type AutocompletableString = string & {};
3
- type LooseAutocomplete<T extends string> = T | AutocompletableString;
4
2
  /** Also commonly referred to as `Prettify`. */
5
3
  type UnifyIntersection<T> = { [K in keyof T]: T[K]; } & {};
6
4
  declare const brand: unique symbol;
@@ -8,4 +6,4 @@ type BrandedType<T, B> = T & {
8
6
  [brand]: B;
9
7
  };
10
8
  //#endregion
11
- export { AutocompletableString, BrandedType, LooseAutocomplete, UnifyIntersection };
9
+ export { BrandedType, UnifyIntersection };
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "utilful",
3
3
  "type": "module",
4
- "version": "3.0.2",
4
+ "version": "3.1.0",
5
5
  "packageManager": "pnpm@11.15.1",
6
6
  "description": "A collection of TypeScript utilities",
7
7
  "author": "Johann Schopplich <hello@johannschopplich.com>",
@@ -14,6 +14,15 @@
14
14
  "bugs": {
15
15
  "url": "https://github.com/johannschopplich/utilful/issues"
16
16
  },
17
+ "keywords": [
18
+ "csv",
19
+ "defu",
20
+ "emitter",
21
+ "result",
22
+ "typescript",
23
+ "url",
24
+ "utilities"
25
+ ],
17
26
  "sideEffects": false,
18
27
  "exports": {
19
28
  ".": {