@mantiq/helpers 0.0.1
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 +19 -0
- package/package.json +52 -0
- package/src/Arr.ts +264 -0
- package/src/Collection.ts +804 -0
- package/src/Duration.ts +172 -0
- package/src/Http.ts +573 -0
- package/src/HttpFake.ts +361 -0
- package/src/Num.ts +187 -0
- package/src/Result.ts +196 -0
- package/src/Str.ts +457 -0
- package/src/async.ts +209 -0
- package/src/functions.ts +153 -0
- package/src/index.ts +65 -0
- package/src/is.ts +195 -0
- package/src/match.ts +78 -0
- package/src/objects.ts +180 -0
package/src/Result.ts
ADDED
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Rust-inspired Result type for typed error handling.
|
|
3
|
+
* Eliminates try/catch spaghetti with composable, type-safe operations.
|
|
4
|
+
*
|
|
5
|
+
* @example
|
|
6
|
+
* ```ts
|
|
7
|
+
* const result = Result.try(() => JSON.parse(input))
|
|
8
|
+
* if (result.isOk()) {
|
|
9
|
+
* console.log(result.unwrap())
|
|
10
|
+
* } else {
|
|
11
|
+
* console.error(result.unwrapErr())
|
|
12
|
+
* }
|
|
13
|
+
*
|
|
14
|
+
* // Chainable
|
|
15
|
+
* const name = await Result.tryAsync(() => fetchUser(id))
|
|
16
|
+
* .map(user => user.name)
|
|
17
|
+
* .unwrapOr('Anonymous')
|
|
18
|
+
* ```
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
export type Result<T, E = Error> = Ok<T, E> | Err<T, E>
|
|
22
|
+
|
|
23
|
+
class Ok<T, E> {
|
|
24
|
+
readonly _tag = 'Ok' as const
|
|
25
|
+
constructor(readonly value: T) {}
|
|
26
|
+
|
|
27
|
+
isOk(): this is Ok<T, E> { return true }
|
|
28
|
+
isErr(): this is Err<T, E> { return false }
|
|
29
|
+
|
|
30
|
+
/** Get the value or throw */
|
|
31
|
+
unwrap(): T { return this.value }
|
|
32
|
+
|
|
33
|
+
/** Get the value or return the default */
|
|
34
|
+
unwrapOr(_defaultValue: T): T { return this.value }
|
|
35
|
+
|
|
36
|
+
/** Get the value or compute a default */
|
|
37
|
+
unwrapOrElse(_fn: (error: E) => T): T { return this.value }
|
|
38
|
+
|
|
39
|
+
/** Get the error — throws if Ok */
|
|
40
|
+
unwrapErr(): never {
|
|
41
|
+
throw new Error('Called unwrapErr() on an Ok value')
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Transform the success value */
|
|
45
|
+
map<U>(fn: (value: T) => U): Result<U, E> {
|
|
46
|
+
return new Ok(fn(this.value))
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Transform the error (no-op for Ok) */
|
|
50
|
+
mapErr<F>(_fn: (error: E) => F): Result<T, F> {
|
|
51
|
+
return new Ok(this.value)
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Chain with another Result-returning function */
|
|
55
|
+
flatMap<U>(fn: (value: T) => Result<U, E>): Result<U, E> {
|
|
56
|
+
return fn(this.value)
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Alias for flatMap */
|
|
60
|
+
andThen<U>(fn: (value: T) => Result<U, E>): Result<U, E> {
|
|
61
|
+
return fn(this.value)
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Return alternative if Err (no-op for Ok) */
|
|
65
|
+
or(_result: Result<T, E>): Result<T, E> { return this }
|
|
66
|
+
|
|
67
|
+
/** Return alternative computed if Err (no-op for Ok) */
|
|
68
|
+
orElse(_fn: (error: E) => Result<T, E>): Result<T, E> { return this }
|
|
69
|
+
|
|
70
|
+
/** Run a callback for Ok (for side effects, returns this) */
|
|
71
|
+
tap(fn: (value: T) => void): Result<T, E> {
|
|
72
|
+
fn(this.value)
|
|
73
|
+
return this
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Match Ok/Err with callbacks */
|
|
77
|
+
match<U>(handlers: { ok: (value: T) => U; err: (error: E) => U }): U {
|
|
78
|
+
return handlers.ok(this.value)
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Convert to a plain object */
|
|
82
|
+
toJSON(): { ok: true; value: T } {
|
|
83
|
+
return { ok: true, value: this.value }
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
class Err<T, E> {
|
|
88
|
+
readonly _tag = 'Err' as const
|
|
89
|
+
constructor(readonly error: E) {}
|
|
90
|
+
|
|
91
|
+
isOk(): this is Ok<T, E> { return false }
|
|
92
|
+
isErr(): this is Err<T, E> { return true }
|
|
93
|
+
|
|
94
|
+
unwrap(): never {
|
|
95
|
+
throw this.error instanceof Error ? this.error : new Error(String(this.error))
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
unwrapOr(defaultValue: T): T { return defaultValue }
|
|
99
|
+
|
|
100
|
+
unwrapOrElse(fn: (error: E) => T): T { return fn(this.error) }
|
|
101
|
+
|
|
102
|
+
unwrapErr(): E { return this.error }
|
|
103
|
+
|
|
104
|
+
map<U>(_fn: (value: T) => U): Result<U, E> {
|
|
105
|
+
return new Err(this.error)
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
mapErr<F>(fn: (error: E) => F): Result<T, F> {
|
|
109
|
+
return new Err(fn(this.error))
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
flatMap<U>(_fn: (value: T) => Result<U, E>): Result<U, E> {
|
|
113
|
+
return new Err(this.error)
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
andThen<U>(_fn: (value: T) => Result<U, E>): Result<U, E> {
|
|
117
|
+
return new Err(this.error)
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
or(result: Result<T, E>): Result<T, E> { return result }
|
|
121
|
+
|
|
122
|
+
orElse(fn: (error: E) => Result<T, E>): Result<T, E> { return fn(this.error) }
|
|
123
|
+
|
|
124
|
+
tap(_fn: (value: T) => void): Result<T, E> { return this }
|
|
125
|
+
|
|
126
|
+
match<U>(handlers: { ok: (value: T) => U; err: (error: E) => U }): U {
|
|
127
|
+
return handlers.err(this.error)
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
toJSON(): { ok: false; error: E } {
|
|
131
|
+
return { ok: false, error: this.error }
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// ── Factory functions ─────────────────────────────────────────────
|
|
136
|
+
|
|
137
|
+
export const Result = {
|
|
138
|
+
/** Create a successful Result */
|
|
139
|
+
ok<T, E = Error>(value: T): Result<T, E> {
|
|
140
|
+
return new Ok(value)
|
|
141
|
+
},
|
|
142
|
+
|
|
143
|
+
/** Create a failed Result */
|
|
144
|
+
err<T, E = Error>(error: E): Result<T, E> {
|
|
145
|
+
return new Err(error)
|
|
146
|
+
},
|
|
147
|
+
|
|
148
|
+
/** Wrap a synchronous function call in a Result */
|
|
149
|
+
try<T>(fn: () => T): Result<T, Error> {
|
|
150
|
+
try {
|
|
151
|
+
return new Ok(fn())
|
|
152
|
+
} catch (e) {
|
|
153
|
+
return new Err(e instanceof Error ? e : new Error(String(e)))
|
|
154
|
+
}
|
|
155
|
+
},
|
|
156
|
+
|
|
157
|
+
/** Wrap an async function call in a Result */
|
|
158
|
+
async tryAsync<T>(fn: () => Promise<T>): Promise<Result<T, Error>> {
|
|
159
|
+
try {
|
|
160
|
+
return new Ok(await fn())
|
|
161
|
+
} catch (e) {
|
|
162
|
+
return new Err(e instanceof Error ? e : new Error(String(e)))
|
|
163
|
+
}
|
|
164
|
+
},
|
|
165
|
+
|
|
166
|
+
/** Collect an array of Results into a Result of an array */
|
|
167
|
+
all<T, E>(results: Result<T, E>[]): Result<T[], E> {
|
|
168
|
+
const values: T[] = []
|
|
169
|
+
for (const result of results) {
|
|
170
|
+
if (result.isErr()) return new Err(result.error)
|
|
171
|
+
values.push(result.value)
|
|
172
|
+
}
|
|
173
|
+
return new Ok(values)
|
|
174
|
+
},
|
|
175
|
+
|
|
176
|
+
/** Return the first Ok, or the last Err */
|
|
177
|
+
any<T, E>(results: Result<T, E>[]): Result<T, E> {
|
|
178
|
+
let lastErr: Err<T, E> | null = null
|
|
179
|
+
for (const result of results) {
|
|
180
|
+
if (result.isOk()) return result
|
|
181
|
+
lastErr = result as Err<T, E>
|
|
182
|
+
}
|
|
183
|
+
return lastErr ?? new Err(new Error('No results') as unknown as E)
|
|
184
|
+
},
|
|
185
|
+
|
|
186
|
+
/** Partition results into [ok values, err values] */
|
|
187
|
+
partition<T, E>(results: Result<T, E>[]): [T[], E[]] {
|
|
188
|
+
const oks: T[] = []
|
|
189
|
+
const errs: E[] = []
|
|
190
|
+
for (const result of results) {
|
|
191
|
+
if (result.isOk()) oks.push(result.value)
|
|
192
|
+
else errs.push(result.error)
|
|
193
|
+
}
|
|
194
|
+
return [oks, errs]
|
|
195
|
+
},
|
|
196
|
+
}
|
package/src/Str.ts
ADDED
|
@@ -0,0 +1,457 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* String utility functions and a fluent Stringable wrapper.
|
|
3
|
+
*
|
|
4
|
+
* @example
|
|
5
|
+
* ```ts
|
|
6
|
+
* Str.camel('foo_bar') // 'fooBar'
|
|
7
|
+
* Str.slug('Hello World!') // 'hello-world'
|
|
8
|
+
* Str.of('hello').upper().slug().toString() // 'HELLO'
|
|
9
|
+
* ```
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
// ── Static helpers ────────────────────────────────────────────────
|
|
13
|
+
|
|
14
|
+
export const Str = {
|
|
15
|
+
/** Convert to camelCase */
|
|
16
|
+
camel(value: string): string {
|
|
17
|
+
return value
|
|
18
|
+
.replace(/[-_\s]+(.)/g, (_, c) => c.toUpperCase())
|
|
19
|
+
.replace(/^(.)/, (_, c) => c.toLowerCase())
|
|
20
|
+
},
|
|
21
|
+
|
|
22
|
+
/** Convert to snake_case */
|
|
23
|
+
snake(value: string): string {
|
|
24
|
+
return value
|
|
25
|
+
.replace(/([a-z\d])([A-Z])/g, '$1_$2')
|
|
26
|
+
.replace(/[-\s]+/g, '_')
|
|
27
|
+
.toLowerCase()
|
|
28
|
+
},
|
|
29
|
+
|
|
30
|
+
/** Convert to kebab-case */
|
|
31
|
+
kebab(value: string): string {
|
|
32
|
+
return value
|
|
33
|
+
.replace(/([a-z\d])([A-Z])/g, '$1-$2')
|
|
34
|
+
.replace(/[_\s]+/g, '-')
|
|
35
|
+
.toLowerCase()
|
|
36
|
+
},
|
|
37
|
+
|
|
38
|
+
/** Convert to PascalCase */
|
|
39
|
+
pascal(value: string): string {
|
|
40
|
+
return value
|
|
41
|
+
.replace(/[-_\s]+(.)/g, (_, c) => c.toUpperCase())
|
|
42
|
+
.replace(/^(.)/, (_, c) => c.toUpperCase())
|
|
43
|
+
},
|
|
44
|
+
|
|
45
|
+
/** Convert to Title Case */
|
|
46
|
+
title(value: string): string {
|
|
47
|
+
return value
|
|
48
|
+
.replace(/[-_]+/g, ' ')
|
|
49
|
+
.replace(/\b\w/g, (c) => c.toUpperCase())
|
|
50
|
+
},
|
|
51
|
+
|
|
52
|
+
/** Convert to Headline (Title Case with spaces before capitals) */
|
|
53
|
+
headline(value: string): string {
|
|
54
|
+
return Str.title(Str.snake(value).replace(/_/g, ' '))
|
|
55
|
+
},
|
|
56
|
+
|
|
57
|
+
/** Generate a URL-friendly slug */
|
|
58
|
+
slug(value: string, separator = '-'): string {
|
|
59
|
+
return value
|
|
60
|
+
.normalize('NFD')
|
|
61
|
+
.replace(/[\u0300-\u036f]/g, '')
|
|
62
|
+
.toLowerCase()
|
|
63
|
+
.replace(/[^a-z0-9\s-]/g, '')
|
|
64
|
+
.trim()
|
|
65
|
+
.replace(/[\s-]+/g, separator)
|
|
66
|
+
},
|
|
67
|
+
|
|
68
|
+
/** Simple English pluralization */
|
|
69
|
+
plural(value: string): string {
|
|
70
|
+
if (value.endsWith('y') && !/[aeiou]y$/i.test(value)) {
|
|
71
|
+
return value.slice(0, -1) + 'ies'
|
|
72
|
+
}
|
|
73
|
+
if (/(?:s|sh|ch|x|z)$/i.test(value)) return value + 'es'
|
|
74
|
+
if (value.endsWith('f')) return value.slice(0, -1) + 'ves'
|
|
75
|
+
if (value.endsWith('fe')) return value.slice(0, -2) + 'ves'
|
|
76
|
+
return value + 's'
|
|
77
|
+
},
|
|
78
|
+
|
|
79
|
+
/** Simple English singularization */
|
|
80
|
+
singular(value: string): string {
|
|
81
|
+
if (value.endsWith('ies')) return value.slice(0, -3) + 'y'
|
|
82
|
+
if (value.endsWith('ves')) return value.slice(0, -3) + 'f'
|
|
83
|
+
if (value.endsWith('ses') || value.endsWith('shes') || value.endsWith('ches') || value.endsWith('xes') || value.endsWith('zes')) {
|
|
84
|
+
return value.slice(0, -2)
|
|
85
|
+
}
|
|
86
|
+
if (value.endsWith('s') && !value.endsWith('ss')) return value.slice(0, -1)
|
|
87
|
+
return value
|
|
88
|
+
},
|
|
89
|
+
|
|
90
|
+
/** Generate a random alphanumeric string */
|
|
91
|
+
random(length = 16): string {
|
|
92
|
+
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'
|
|
93
|
+
const bytes = crypto.getRandomValues(new Uint8Array(length))
|
|
94
|
+
return Array.from(bytes, (b) => chars[b % chars.length]).join('')
|
|
95
|
+
},
|
|
96
|
+
|
|
97
|
+
/** Generate a UUID v4 */
|
|
98
|
+
uuid(): string {
|
|
99
|
+
return crypto.randomUUID()
|
|
100
|
+
},
|
|
101
|
+
|
|
102
|
+
/** Generate a ULID (Universally Unique Lexicographically Sortable Identifier) */
|
|
103
|
+
ulid(): string {
|
|
104
|
+
const ENCODING = '0123456789ABCDEFGHJKMNPQRSTVWXYZ'
|
|
105
|
+
let now = Date.now()
|
|
106
|
+
let time = ''
|
|
107
|
+
for (let i = 0; i < 10; i++) {
|
|
108
|
+
time = ENCODING[now % 32]! + time
|
|
109
|
+
now = Math.floor(now / 32)
|
|
110
|
+
}
|
|
111
|
+
let random = ''
|
|
112
|
+
const bytes = crypto.getRandomValues(new Uint8Array(16))
|
|
113
|
+
for (let i = 0; i < 16; i++) {
|
|
114
|
+
random += ENCODING[bytes[i]! % 32]
|
|
115
|
+
}
|
|
116
|
+
return time + random
|
|
117
|
+
},
|
|
118
|
+
|
|
119
|
+
/** Mask a string, showing only the last N characters */
|
|
120
|
+
mask(value: string, character = '*', index = 0, length?: number): string {
|
|
121
|
+
const len = length ?? value.length - index
|
|
122
|
+
const start = value.slice(0, index)
|
|
123
|
+
const masked = character.repeat(Math.max(0, len))
|
|
124
|
+
const end = value.slice(index + len)
|
|
125
|
+
return start + masked + end
|
|
126
|
+
},
|
|
127
|
+
|
|
128
|
+
/** Truncate a string to a given length with an ellipsis */
|
|
129
|
+
truncate(value: string, length: number, end = '...'): string {
|
|
130
|
+
if (value.length <= length) return value
|
|
131
|
+
return value.slice(0, length - end.length) + end
|
|
132
|
+
},
|
|
133
|
+
|
|
134
|
+
/** Truncate at the nearest word boundary */
|
|
135
|
+
words(value: string, count: number, end = '...'): string {
|
|
136
|
+
const w = value.split(/\s+/)
|
|
137
|
+
if (w.length <= count) return value
|
|
138
|
+
return w.slice(0, count).join(' ') + end
|
|
139
|
+
},
|
|
140
|
+
|
|
141
|
+
/** Check if the string contains a substring */
|
|
142
|
+
contains(haystack: string, needle: string | string[]): boolean {
|
|
143
|
+
const needles = Array.isArray(needle) ? needle : [needle]
|
|
144
|
+
return needles.some((n) => haystack.includes(n))
|
|
145
|
+
},
|
|
146
|
+
|
|
147
|
+
/** Check if string starts with any of the given values */
|
|
148
|
+
startsWith(value: string, prefix: string | string[]): boolean {
|
|
149
|
+
const prefixes = Array.isArray(prefix) ? prefix : [prefix]
|
|
150
|
+
return prefixes.some((p) => value.startsWith(p))
|
|
151
|
+
},
|
|
152
|
+
|
|
153
|
+
/** Check if string ends with any of the given values */
|
|
154
|
+
endsWith(value: string, suffix: string | string[]): boolean {
|
|
155
|
+
const suffixes = Array.isArray(suffix) ? suffix : [suffix]
|
|
156
|
+
return suffixes.some((s) => value.endsWith(s))
|
|
157
|
+
},
|
|
158
|
+
|
|
159
|
+
/** Get the portion before the first occurrence of a delimiter */
|
|
160
|
+
before(value: string, search: string): string {
|
|
161
|
+
const idx = value.indexOf(search)
|
|
162
|
+
return idx === -1 ? value : value.slice(0, idx)
|
|
163
|
+
},
|
|
164
|
+
|
|
165
|
+
/** Get the portion before the last occurrence of a delimiter */
|
|
166
|
+
beforeLast(value: string, search: string): string {
|
|
167
|
+
const idx = value.lastIndexOf(search)
|
|
168
|
+
return idx === -1 ? value : value.slice(0, idx)
|
|
169
|
+
},
|
|
170
|
+
|
|
171
|
+
/** Get the portion after the first occurrence of a delimiter */
|
|
172
|
+
after(value: string, search: string): string {
|
|
173
|
+
const idx = value.indexOf(search)
|
|
174
|
+
return idx === -1 ? value : value.slice(idx + search.length)
|
|
175
|
+
},
|
|
176
|
+
|
|
177
|
+
/** Get the portion after the last occurrence of a delimiter */
|
|
178
|
+
afterLast(value: string, search: string): string {
|
|
179
|
+
const idx = value.lastIndexOf(search)
|
|
180
|
+
return idx === -1 ? value : value.slice(idx + search.length)
|
|
181
|
+
},
|
|
182
|
+
|
|
183
|
+
/** Get the portion between two delimiters */
|
|
184
|
+
between(value: string, start: string, end: string): string {
|
|
185
|
+
return Str.before(Str.after(value, start), end)
|
|
186
|
+
},
|
|
187
|
+
|
|
188
|
+
/** Check if string matches a wildcard pattern (* for any) */
|
|
189
|
+
is(pattern: string, value: string): boolean {
|
|
190
|
+
const regex = new RegExp(
|
|
191
|
+
'^' + pattern.replace(/\*/g, '.*').replace(/\?/g, '.') + '$',
|
|
192
|
+
)
|
|
193
|
+
return regex.test(value)
|
|
194
|
+
},
|
|
195
|
+
|
|
196
|
+
/** Extract an excerpt around a phrase */
|
|
197
|
+
excerpt(text: string, phrase: string, options?: { radius?: number; omission?: string }): string {
|
|
198
|
+
const radius = options?.radius ?? 100
|
|
199
|
+
const omission = options?.omission ?? '...'
|
|
200
|
+
const idx = text.toLowerCase().indexOf(phrase.toLowerCase())
|
|
201
|
+
if (idx === -1) return Str.truncate(text, radius * 2 + phrase.length, omission)
|
|
202
|
+
|
|
203
|
+
const start = Math.max(0, idx - radius)
|
|
204
|
+
const end = Math.min(text.length, idx + phrase.length + radius)
|
|
205
|
+
let result = text.slice(start, end)
|
|
206
|
+
if (start > 0) result = omission + result
|
|
207
|
+
if (end < text.length) result = result + omission
|
|
208
|
+
return result
|
|
209
|
+
},
|
|
210
|
+
|
|
211
|
+
/** Generate a secure random password */
|
|
212
|
+
password(length = 32): string {
|
|
213
|
+
const lower = 'abcdefghijklmnopqrstuvwxyz'
|
|
214
|
+
const upper = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'
|
|
215
|
+
const digits = '0123456789'
|
|
216
|
+
const symbols = '!@#$%^&*()-_=+[]{}|;:,.<>?'
|
|
217
|
+
const all = lower + upper + digits + symbols
|
|
218
|
+
const bytes = crypto.getRandomValues(new Uint8Array(length))
|
|
219
|
+
// Ensure at least one of each category
|
|
220
|
+
const required = [
|
|
221
|
+
lower[bytes[0]! % lower.length]!,
|
|
222
|
+
upper[bytes[1]! % upper.length]!,
|
|
223
|
+
digits[bytes[2]! % digits.length]!,
|
|
224
|
+
symbols[bytes[3]! % symbols.length]!,
|
|
225
|
+
]
|
|
226
|
+
const rest = Array.from(bytes.slice(4, length), (b) => all[b % all.length])
|
|
227
|
+
const chars = [...required, ...rest]
|
|
228
|
+
// Shuffle
|
|
229
|
+
for (let i = chars.length - 1; i > 0; i--) {
|
|
230
|
+
const j = bytes[i % bytes.length]! % (i + 1)
|
|
231
|
+
;[chars[i], chars[j]] = [chars[j]!, chars[i]!]
|
|
232
|
+
}
|
|
233
|
+
return chars.join('')
|
|
234
|
+
},
|
|
235
|
+
|
|
236
|
+
/** Pad both sides of a string to a given length */
|
|
237
|
+
padBoth(value: string, length: number, pad = ' '): string {
|
|
238
|
+
const diff = length - value.length
|
|
239
|
+
if (diff <= 0) return value
|
|
240
|
+
const left = Math.floor(diff / 2)
|
|
241
|
+
const right = diff - left
|
|
242
|
+
return pad.repeat(left) + value + pad.repeat(right)
|
|
243
|
+
},
|
|
244
|
+
|
|
245
|
+
/** Repeat a string N times */
|
|
246
|
+
repeat(value: string, times: number): string {
|
|
247
|
+
return value.repeat(times)
|
|
248
|
+
},
|
|
249
|
+
|
|
250
|
+
/** Replace the first occurrence */
|
|
251
|
+
replaceFirst(value: string, search: string, replace: string): string {
|
|
252
|
+
const idx = value.indexOf(search)
|
|
253
|
+
if (idx === -1) return value
|
|
254
|
+
return value.slice(0, idx) + replace + value.slice(idx + search.length)
|
|
255
|
+
},
|
|
256
|
+
|
|
257
|
+
/** Replace the last occurrence */
|
|
258
|
+
replaceLast(value: string, search: string, replace: string): string {
|
|
259
|
+
const idx = value.lastIndexOf(search)
|
|
260
|
+
if (idx === -1) return value
|
|
261
|
+
return value.slice(0, idx) + replace + value.slice(idx + search.length)
|
|
262
|
+
},
|
|
263
|
+
|
|
264
|
+
/** Reverse a string (Unicode-safe) */
|
|
265
|
+
reverse(value: string): string {
|
|
266
|
+
return [...value].reverse().join('')
|
|
267
|
+
},
|
|
268
|
+
|
|
269
|
+
/** Count words in a string */
|
|
270
|
+
wordCount(value: string): number {
|
|
271
|
+
return value.trim().split(/\s+/).filter(Boolean).length
|
|
272
|
+
},
|
|
273
|
+
|
|
274
|
+
/** Wrap a string with a given string */
|
|
275
|
+
wrap(value: string, before: string, after?: string): string {
|
|
276
|
+
return before + value + (after ?? before)
|
|
277
|
+
},
|
|
278
|
+
|
|
279
|
+
/** Unwrap a string (remove wrapping characters) */
|
|
280
|
+
unwrap(value: string, before: string, after?: string): string {
|
|
281
|
+
const a = after ?? before
|
|
282
|
+
let result = value
|
|
283
|
+
if (result.startsWith(before)) result = result.slice(before.length)
|
|
284
|
+
if (result.endsWith(a)) result = result.slice(0, -a.length)
|
|
285
|
+
return result
|
|
286
|
+
},
|
|
287
|
+
|
|
288
|
+
/** Ensure a string starts with a given prefix */
|
|
289
|
+
start(value: string, prefix: string): string {
|
|
290
|
+
return value.startsWith(prefix) ? value : prefix + value
|
|
291
|
+
},
|
|
292
|
+
|
|
293
|
+
/** Ensure a string ends with a given suffix */
|
|
294
|
+
finish(value: string, suffix: string): string {
|
|
295
|
+
return value.endsWith(suffix) ? value : value + suffix
|
|
296
|
+
},
|
|
297
|
+
|
|
298
|
+
/** Check if a string is a valid UUID */
|
|
299
|
+
isUuid(value: string): boolean {
|
|
300
|
+
return /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(value)
|
|
301
|
+
},
|
|
302
|
+
|
|
303
|
+
/** Check if a string is a valid ULID */
|
|
304
|
+
isUlid(value: string): boolean {
|
|
305
|
+
return /^[0-9A-HJKMNP-TV-Z]{26}$/i.test(value)
|
|
306
|
+
},
|
|
307
|
+
|
|
308
|
+
/** Create a fluent Stringable wrapper */
|
|
309
|
+
of(value: string): Stringable {
|
|
310
|
+
return new Stringable(value)
|
|
311
|
+
},
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
// ── Fluent Stringable wrapper ─────────────────────────────────────
|
|
315
|
+
|
|
316
|
+
/**
|
|
317
|
+
* Fluent string manipulation — chain operations without temp variables.
|
|
318
|
+
*
|
|
319
|
+
* @example
|
|
320
|
+
* ```ts
|
|
321
|
+
* Str.of('hello world')
|
|
322
|
+
* .title()
|
|
323
|
+
* .slug()
|
|
324
|
+
* .toString() // 'hello-world'
|
|
325
|
+
* ```
|
|
326
|
+
*/
|
|
327
|
+
export class Stringable {
|
|
328
|
+
constructor(private value: string) {}
|
|
329
|
+
|
|
330
|
+
toString(): string { return this.value }
|
|
331
|
+
valueOf(): string { return this.value }
|
|
332
|
+
get length(): number { return this.value.length }
|
|
333
|
+
|
|
334
|
+
// ── Transforms (return new Stringable) ──────────────────────────
|
|
335
|
+
|
|
336
|
+
upper(): Stringable { return new Stringable(this.value.toUpperCase()) }
|
|
337
|
+
lower(): Stringable { return new Stringable(this.value.toLowerCase()) }
|
|
338
|
+
trim(): Stringable { return new Stringable(this.value.trim()) }
|
|
339
|
+
ltrim(chars?: string): Stringable {
|
|
340
|
+
if (!chars) return new Stringable(this.value.trimStart())
|
|
341
|
+
const regex = new RegExp(`^[${chars.replace(/[-[\]{}()*+?.,\\^$|#\s]/g, '\\$&')}]+`)
|
|
342
|
+
return new Stringable(this.value.replace(regex, ''))
|
|
343
|
+
}
|
|
344
|
+
rtrim(chars?: string): Stringable {
|
|
345
|
+
if (!chars) return new Stringable(this.value.trimEnd())
|
|
346
|
+
const regex = new RegExp(`[${chars.replace(/[-[\]{}()*+?.,\\^$|#\s]/g, '\\$&')}]+$`)
|
|
347
|
+
return new Stringable(this.value.replace(regex, ''))
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
camel(): Stringable { return new Stringable(Str.camel(this.value)) }
|
|
351
|
+
snake(): Stringable { return new Stringable(Str.snake(this.value)) }
|
|
352
|
+
kebab(): Stringable { return new Stringable(Str.kebab(this.value)) }
|
|
353
|
+
pascal(): Stringable { return new Stringable(Str.pascal(this.value)) }
|
|
354
|
+
title(): Stringable { return new Stringable(Str.title(this.value)) }
|
|
355
|
+
headline(): Stringable { return new Stringable(Str.headline(this.value)) }
|
|
356
|
+
slug(separator = '-'): Stringable { return new Stringable(Str.slug(this.value, separator)) }
|
|
357
|
+
plural(): Stringable { return new Stringable(Str.plural(this.value)) }
|
|
358
|
+
singular(): Stringable { return new Stringable(Str.singular(this.value)) }
|
|
359
|
+
|
|
360
|
+
mask(character = '*', index = 0, length?: number): Stringable {
|
|
361
|
+
return new Stringable(Str.mask(this.value, character, index, length))
|
|
362
|
+
}
|
|
363
|
+
truncate(length: number, end?: string): Stringable {
|
|
364
|
+
return new Stringable(Str.truncate(this.value, length, end))
|
|
365
|
+
}
|
|
366
|
+
words(count: number, end?: string): Stringable {
|
|
367
|
+
return new Stringable(Str.words(this.value, count, end))
|
|
368
|
+
}
|
|
369
|
+
padBoth(length: number, pad?: string): Stringable {
|
|
370
|
+
return new Stringable(Str.padBoth(this.value, length, pad))
|
|
371
|
+
}
|
|
372
|
+
padLeft(length: number, pad = ' '): Stringable {
|
|
373
|
+
return new Stringable(this.value.padStart(length, pad))
|
|
374
|
+
}
|
|
375
|
+
padRight(length: number, pad = ' '): Stringable {
|
|
376
|
+
return new Stringable(this.value.padEnd(length, pad))
|
|
377
|
+
}
|
|
378
|
+
repeat(times: number): Stringable { return new Stringable(this.value.repeat(times)) }
|
|
379
|
+
reverse(): Stringable { return new Stringable(Str.reverse(this.value)) }
|
|
380
|
+
replace(search: string | RegExp, replacement: string): Stringable {
|
|
381
|
+
return new Stringable(this.value.replace(search, replacement))
|
|
382
|
+
}
|
|
383
|
+
replaceAll(search: string, replacement: string): Stringable {
|
|
384
|
+
return new Stringable(this.value.replaceAll(search, replacement))
|
|
385
|
+
}
|
|
386
|
+
replaceFirst(search: string, replacement: string): Stringable {
|
|
387
|
+
return new Stringable(Str.replaceFirst(this.value, search, replacement))
|
|
388
|
+
}
|
|
389
|
+
replaceLast(search: string, replacement: string): Stringable {
|
|
390
|
+
return new Stringable(Str.replaceLast(this.value, search, replacement))
|
|
391
|
+
}
|
|
392
|
+
append(suffix: string): Stringable { return new Stringable(this.value + suffix) }
|
|
393
|
+
prepend(prefix: string): Stringable { return new Stringable(prefix + this.value) }
|
|
394
|
+
wrap(before: string, after?: string): Stringable {
|
|
395
|
+
return new Stringable(Str.wrap(this.value, before, after))
|
|
396
|
+
}
|
|
397
|
+
unwrap(before: string, after?: string): Stringable {
|
|
398
|
+
return new Stringable(Str.unwrap(this.value, before, after))
|
|
399
|
+
}
|
|
400
|
+
start(prefix: string): Stringable { return new Stringable(Str.start(this.value, prefix)) }
|
|
401
|
+
finish(suffix: string): Stringable { return new Stringable(Str.finish(this.value, suffix)) }
|
|
402
|
+
substr(start: number, length?: number): Stringable {
|
|
403
|
+
return new Stringable(length !== undefined ? this.value.slice(start, start + length) : this.value.slice(start))
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
before(search: string): Stringable { return new Stringable(Str.before(this.value, search)) }
|
|
407
|
+
beforeLast(search: string): Stringable { return new Stringable(Str.beforeLast(this.value, search)) }
|
|
408
|
+
after(search: string): Stringable { return new Stringable(Str.after(this.value, search)) }
|
|
409
|
+
afterLast(search: string): Stringable { return new Stringable(Str.afterLast(this.value, search)) }
|
|
410
|
+
between(start: string, end: string): Stringable { return new Stringable(Str.between(this.value, start, end)) }
|
|
411
|
+
|
|
412
|
+
// ── Conditionals ────────────────────────────────────────────────
|
|
413
|
+
|
|
414
|
+
/** Apply a callback only when condition is true */
|
|
415
|
+
when(condition: boolean, callback: (s: Stringable) => Stringable): Stringable {
|
|
416
|
+
return condition ? callback(this) : this
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
/** Apply a callback unless condition is true */
|
|
420
|
+
unless(condition: boolean, callback: (s: Stringable) => Stringable): Stringable {
|
|
421
|
+
return condition ? this : callback(this)
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
/** Pipe through a custom function */
|
|
425
|
+
pipe(callback: (value: string) => string): Stringable {
|
|
426
|
+
return new Stringable(callback(this.value))
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
/** Inspect the value (pass through, for debugging) */
|
|
430
|
+
tap(callback: (value: string) => void): Stringable {
|
|
431
|
+
callback(this.value)
|
|
432
|
+
return this
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
// ── Predicates ──────────────────────────────────────────────────
|
|
436
|
+
|
|
437
|
+
contains(needle: string | string[]): boolean { return Str.contains(this.value, needle) }
|
|
438
|
+
startsWith(prefix: string | string[]): boolean { return Str.startsWith(this.value, prefix) }
|
|
439
|
+
endsWith(suffix: string | string[]): boolean { return Str.endsWith(this.value, suffix) }
|
|
440
|
+
is(pattern: string): boolean { return Str.is(pattern, this.value) }
|
|
441
|
+
isEmpty(): boolean { return this.value.length === 0 }
|
|
442
|
+
isNotEmpty(): boolean { return this.value.length > 0 }
|
|
443
|
+
isUuid(): boolean { return Str.isUuid(this.value) }
|
|
444
|
+
isUlid(): boolean { return Str.isUlid(this.value) }
|
|
445
|
+
|
|
446
|
+
// ── Extraction ──────────────────────────────────────────────────
|
|
447
|
+
|
|
448
|
+
wordCount(): number { return Str.wordCount(this.value) }
|
|
449
|
+
split(separator: string | RegExp, limit?: number): string[] { return this.value.split(separator, limit) }
|
|
450
|
+
match(pattern: RegExp): RegExpMatchArray | null { return this.value.match(pattern) }
|
|
451
|
+
matchAll(pattern: RegExp): string[] {
|
|
452
|
+
return [...this.value.matchAll(pattern)].map((m) => m[0])
|
|
453
|
+
}
|
|
454
|
+
excerpt(phrase: string, options?: { radius?: number; omission?: string }): Stringable {
|
|
455
|
+
return new Stringable(Str.excerpt(this.value, phrase, options))
|
|
456
|
+
}
|
|
457
|
+
}
|