@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/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
+ }