millas 0.2.13 → 0.2.15

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.
Files changed (88) hide show
  1. package/package.json +6 -3
  2. package/src/admin/Admin.js +107 -1027
  3. package/src/admin/AdminAuth.js +1 -1
  4. package/src/admin/ViewContext.js +1 -1
  5. package/src/admin/handlers/ActionHandler.js +103 -0
  6. package/src/admin/handlers/ApiHandler.js +113 -0
  7. package/src/admin/handlers/AuthHandler.js +76 -0
  8. package/src/admin/handlers/ExportHandler.js +70 -0
  9. package/src/admin/handlers/InlineHandler.js +71 -0
  10. package/src/admin/handlers/PageHandler.js +351 -0
  11. package/src/admin/resources/AdminResource.js +22 -1
  12. package/src/admin/static/SelectFilter2.js +34 -0
  13. package/src/admin/static/actions.js +201 -0
  14. package/src/admin/static/admin.css +7 -0
  15. package/src/admin/static/change_form.js +585 -0
  16. package/src/admin/static/core.js +128 -0
  17. package/src/admin/static/login.js +76 -0
  18. package/src/admin/static/vendor/bi/bootstrap-icons.min.css +5 -0
  19. package/src/admin/static/vendor/bi/fonts/bootstrap-icons.woff +0 -0
  20. package/src/admin/static/vendor/bi/fonts/bootstrap-icons.woff2 +0 -0
  21. package/src/admin/static/vendor/jquery.min.js +2 -0
  22. package/src/admin/views/layouts/base.njk +30 -113
  23. package/src/admin/views/pages/detail.njk +10 -9
  24. package/src/admin/views/pages/form.njk +4 -4
  25. package/src/admin/views/pages/list.njk +11 -193
  26. package/src/admin/views/pages/login.njk +19 -64
  27. package/src/admin/views/partials/form-field.njk +1 -1
  28. package/src/admin/views/partials/form-scripts.njk +4 -478
  29. package/src/admin/views/partials/form-widget.njk +10 -10
  30. package/src/ai/AITokenBudget.js +1 -1
  31. package/src/auth/Auth.js +112 -3
  32. package/src/auth/AuthMiddleware.js +18 -15
  33. package/src/auth/Hasher.js +15 -43
  34. package/src/cli.js +3 -0
  35. package/src/commands/call.js +190 -0
  36. package/src/commands/createsuperuser.js +3 -4
  37. package/src/commands/key.js +97 -0
  38. package/src/commands/make.js +16 -2
  39. package/src/commands/new.js +16 -1
  40. package/src/commands/serve.js +5 -5
  41. package/src/console/Command.js +337 -0
  42. package/src/console/CommandLoader.js +165 -0
  43. package/src/console/index.js +6 -0
  44. package/src/container/AppInitializer.js +48 -1
  45. package/src/container/Application.js +3 -1
  46. package/src/container/HttpServer.js +0 -1
  47. package/src/container/MillasConfig.js +48 -0
  48. package/src/controller/Controller.js +13 -11
  49. package/src/core/docs.js +6 -0
  50. package/src/core/foundation.js +8 -0
  51. package/src/core/http.js +20 -10
  52. package/src/core/validation.js +58 -27
  53. package/src/docs/Docs.js +268 -0
  54. package/src/docs/DocsServiceProvider.js +80 -0
  55. package/src/docs/SchemaInferrer.js +131 -0
  56. package/src/docs/handlers/ApiHandler.js +305 -0
  57. package/src/docs/handlers/PageHandler.js +47 -0
  58. package/src/docs/index.js +13 -0
  59. package/src/docs/resources/ApiResource.js +402 -0
  60. package/src/docs/static/docs.css +723 -0
  61. package/src/docs/static/docs.js +1181 -0
  62. package/src/encryption/Encrypter.js +381 -0
  63. package/src/facades/Auth.js +5 -2
  64. package/src/facades/Crypt.js +166 -0
  65. package/src/facades/Docs.js +43 -0
  66. package/src/facades/Mail.js +1 -1
  67. package/src/http/MillasRequest.js +7 -31
  68. package/src/http/RequestContext.js +11 -7
  69. package/src/http/SecurityBootstrap.js +24 -2
  70. package/src/http/Shape.js +168 -0
  71. package/src/http/adapters/ExpressAdapter.js +9 -5
  72. package/src/middleware/CorsMiddleware.js +3 -0
  73. package/src/middleware/ThrottleMiddleware.js +10 -7
  74. package/src/orm/model/Model.js +20 -2
  75. package/src/providers/EncryptionServiceProvider.js +66 -0
  76. package/src/router/MiddlewareRegistry.js +79 -54
  77. package/src/router/Route.js +9 -4
  78. package/src/router/RouteEntry.js +91 -0
  79. package/src/router/Router.js +71 -1
  80. package/src/scaffold/maker.js +138 -1
  81. package/src/scaffold/templates.js +12 -0
  82. package/src/serializer/Serializer.js +239 -0
  83. package/src/support/Str.js +1080 -0
  84. package/src/validation/BaseValidator.js +45 -5
  85. package/src/validation/Validator.js +67 -61
  86. package/src/validation/types.js +490 -0
  87. package/src/middleware/AuthMiddleware.js +0 -46
  88. package/src/middleware/MiddlewareRegistry.js +0 -106
@@ -0,0 +1,1080 @@
1
+ 'use strict';
2
+
3
+ const crypto = require('crypto');
4
+
5
+ /**
6
+ * Str
7
+ *
8
+ * Fluent string manipulation utility.
9
+ *
10
+ * ── Static API (chainable via Str.of()) ──────────────────────────────────────
11
+ *
12
+ * Str.camel('hello_world') → 'helloWorld'
13
+ * Str.snake('HelloWorld') → 'hello_world'
14
+ * Str.kebab('Hello World') → 'hello-world'
15
+ * Str.pascal('hello world') → 'HelloWorld'
16
+ * Str.title('hello world') → 'Hello World'
17
+ * Str.slug('Hello World!') → 'hello-world'
18
+ * Str.plural('apple') → 'apples'
19
+ * Str.singular('apples') → 'apple'
20
+ * Str.ucfirst('hello') → 'Hello'
21
+ * Str.lcfirst('Hello') → 'hello'
22
+ * Str.limit('Hello World', 5) → 'Hello...'
23
+ * Str.words('Hello World', 1) → 'Hello...'
24
+ * Str.truncate('Hello World', 5) → 'Hello...'
25
+ * Str.contains('hello world', 'lo') → true
26
+ * Str.containsAll('hello world', ['hello','world']) → true
27
+ * Str.startsWith('hello', 'he') → true
28
+ * Str.endsWith('hello', 'lo') → true
29
+ * Str.is('foo*', 'foobar') → true (glob pattern)
30
+ * Str.isUuid('...') → true
31
+ * Str.isUrl('https://...') → true
32
+ * Str.isEmail('a@b.com') → true
33
+ * Str.isJson('{}') → true
34
+ * Str.isAscii('hello') → true
35
+ * Str.isEmpty('') → true
36
+ * Str.isNotEmpty('x') → true
37
+ * Str.uuid() → 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'
38
+ * Str.random(16) → random alphanumeric string
39
+ * Str.pad('5', 3, '0', 'left') → '005'
40
+ * Str.padLeft('5', 3, '0') → '005'
41
+ * Str.padRight('5', 3, '0') → '500'
42
+ * Str.padBoth('5', 5, '-') → '--5--'
43
+ * Str.repeat('ab', 3) → 'ababab'
44
+ * Str.reverse('hello') → 'olleh'
45
+ * Str.wordCount('hello world') → 2
46
+ * Str.length('hello') → 5
47
+ * Str.substr('hello', 1, 3) → 'ell'
48
+ * Str.before('hello world', ' ') → 'hello'
49
+ * Str.beforeLast('a/b/c', '/') → 'a/b'
50
+ * Str.after('hello world', ' ') → 'world'
51
+ * Str.afterLast('a/b/c', '/') → 'c'
52
+ * Str.between('(hello)', '(', ')') → 'hello'
53
+ * Str.betweenFirst('(a)(b)', '(', ')') → 'a'
54
+ * Str.replace('hello', 'l', 'r') → 'herro'
55
+ * Str.replaceFirst('aaa', 'a', 'b') → 'baa'
56
+ * Str.replaceLast('aaa', 'a', 'b') → 'aab'
57
+ * Str.replaceArray('?', ['a','b'], '? and ?') → 'a and b'
58
+ * Str.remove('hello world', 'l') → 'heo word'
59
+ * Str.squish(' hello world ') → 'hello world'
60
+ * Str.wrap('hello', '"') → '"hello"'
61
+ * Str.unwrap('"hello"', '"') → 'hello'
62
+ * Str.mask('password123', '*', 4) → 'pass*******'
63
+ * Str.excerpt('hello world foo', 'world', { radius: 3 }) → '...lo world fo...'
64
+ * Str.headline('hello_world foo') → 'Hello World Foo'
65
+ * Str.swap({ Hello: 'Hi' }, 'Hello World') → 'Hi World'
66
+ * Str.of('hello world') → FluentString instance
67
+ *
68
+ * ── Fluent API (Str.of()) ──────────────────────────────────────────────────
69
+ *
70
+ * Str.of('hello world')
71
+ * .title()
72
+ * .replace('World', 'Millas')
73
+ * .append('!')
74
+ * .toString()
75
+ * // → 'Hello Millas!'
76
+ */
77
+
78
+ // ── Irregular plurals ─────────────────────────────────────────────────────────
79
+
80
+ const IRREGULAR_PLURALS = new Map([
81
+ ['child', 'children'], ['person', 'people'], ['man', 'men'],
82
+ ['woman', 'women'], ['tooth', 'teeth'], ['foot', 'feet'],
83
+ ['mouse', 'mice'], ['goose', 'geese'], ['ox', 'oxen'],
84
+ ['leaf', 'leaves'], ['knife', 'knives'], ['wife', 'wives'],
85
+ ['life', 'lives'], ['wolf', 'wolves'], ['shelf', 'shelves'],
86
+ ['half', 'halves'], ['self', 'selves'], ['elf', 'elves'],
87
+ ['loaf', 'loaves'], ['calf', 'calves'], ['wharf', 'wharves'],
88
+ ['thesis', 'theses'], ['crisis', 'crises'], ['axis', 'axes'],
89
+ ['analysis','analyses'], ['basis', 'bases'], ['datum', 'data'],
90
+ ['medium', 'media'], ['index', 'indices'], ['matrix', 'matrices'],
91
+ ['vertex', 'vertices'], ['appendix','appendices'],['radius', 'radii'],
92
+ ['nucleus', 'nuclei'], ['cactus', 'cacti'], ['fungus', 'fungi'],
93
+ ['syllabus','syllabi'], ['formula', 'formulae'], ['alumna', 'alumnae'],
94
+ ['alumnus', 'alumni'], ['quiz', 'quizzes'], ['ox', 'oxen'],
95
+ ['echo', 'echoes'], ['embargo', 'embargoes'], ['hero', 'heroes'],
96
+ ['potato', 'potatoes'], ['tomato', 'tomatoes'], ['torpedo','torpedoes'],
97
+ ['veto', 'vetoes'], ['buffalo', 'buffaloes'],
98
+ ]);
99
+
100
+ const UNCOUNTABLE = new Set([
101
+ 'sheep', 'fish', 'deer', 'moose', 'series', 'species', 'money', 'rice',
102
+ 'information', 'equipment', 'news', 'music', 'furniture', 'luggage',
103
+ 'software', 'hardware', 'data', 'feedback', 'knowledge', 'traffic',
104
+ 'research', 'advice', 'progress', 'water', 'weather', 'aircraft',
105
+ 'offspring', 'pokemon', 'bison', 'buffalo', 'cod', 'elk', 'salmon', 'trout',
106
+ ]);
107
+
108
+ // ── Core implementation ───────────────────────────────────────────────────────
109
+
110
+ class Str {
111
+
112
+ // ── Case conversion ────────────────────────────────────────────────────────
113
+
114
+ /**
115
+ * Convert to camelCase.
116
+ * Str.camel('hello_world') → 'helloWorld'
117
+ * Str.camel('Hello World') → 'helloWorld'
118
+ */
119
+ static camel(str) {
120
+ return Str._words(str)
121
+ .map((w, i) => i === 0 ? w.toLowerCase() : Str.ucfirst(w.toLowerCase()))
122
+ .join('');
123
+ }
124
+
125
+ /**
126
+ * Convert to PascalCase.
127
+ * Str.pascal('hello world') → 'HelloWorld'
128
+ */
129
+ static pascal(str) {
130
+ return Str._words(str)
131
+ .map(w => Str.ucfirst(w.toLowerCase()))
132
+ .join('');
133
+ }
134
+
135
+ /**
136
+ * Convert to snake_case.
137
+ * Str.snake('helloWorld') → 'hello_world'
138
+ * Str.snake('Hello World') → 'hello_world'
139
+ */
140
+ static snake(str, delimiter = '_') {
141
+ return Str._words(str)
142
+ .map(w => w.toLowerCase())
143
+ .join(delimiter);
144
+ }
145
+
146
+ /**
147
+ * Convert to kebab-case.
148
+ * Str.kebab('helloWorld') → 'hello-world'
149
+ */
150
+ static kebab(str) {
151
+ return Str.snake(str, '-');
152
+ }
153
+
154
+ /**
155
+ * Convert to Title Case.
156
+ * Str.title('hello world') → 'Hello World'
157
+ */
158
+ static title(str) {
159
+ return String(str).replace(/\w\S*/g, w => Str.ucfirst(w.toLowerCase()));
160
+ }
161
+
162
+ /**
163
+ * Convert to Headline Case — splits on separators, numbers, case boundaries.
164
+ * Str.headline('hello_world foo-bar') → 'Hello World Foo Bar'
165
+ * Str.headline('emailAddress') → 'Email Address'
166
+ */
167
+ static headline(str) {
168
+ return Str._words(str)
169
+ .map(w => Str.ucfirst(w.toLowerCase()))
170
+ .join(' ');
171
+ }
172
+
173
+ /**
174
+ * Uppercase first character.
175
+ * Str.ucfirst('hello') → 'Hello'
176
+ */
177
+ static ucfirst(str) {
178
+ str = String(str);
179
+ return str.charAt(0).toUpperCase() + str.slice(1);
180
+ }
181
+
182
+ /**
183
+ * Lowercase first character.
184
+ * Str.lcfirst('Hello') → 'hello'
185
+ */
186
+ static lcfirst(str) {
187
+ str = String(str);
188
+ return str.charAt(0).toLowerCase() + str.slice(1);
189
+ }
190
+
191
+ /**
192
+ * Convert to URL-friendly slug.
193
+ * Str.slug('Hello World!') → 'hello-world'
194
+ * Str.slug('Hello World', '_') → 'hello_world'
195
+ */
196
+ static slug(str, separator = '-') {
197
+ return String(str)
198
+ .normalize('NFD')
199
+ .replace(/[\u0300-\u036f]/g, '') // strip diacritics
200
+ .toLowerCase()
201
+ .replace(/[^a-z0-9\s-_]/g, '')
202
+ .trim()
203
+ .replace(/[\s-_]+/g, separator);
204
+ }
205
+
206
+ // ── Pluralization / singularization ────────────────────────────────────────
207
+
208
+ /**
209
+ * Return the plural form of a word.
210
+ * Str.plural('apple') → 'apples'
211
+ * Str.plural('child') → 'children'
212
+ * Str.plural('apple', 1) → 'apple' (count-aware)
213
+ */
214
+ static plural(word, count = 2) {
215
+ word = String(word);
216
+ if (Math.abs(count) === 1) return word;
217
+
218
+ const lower = word.toLowerCase();
219
+ if (UNCOUNTABLE.has(lower)) return word;
220
+
221
+ // Check irregulars (preserve original casing pattern)
222
+ for (const [singular, plural] of IRREGULAR_PLURALS) {
223
+ if (lower === singular) return _matchCase(word, plural);
224
+ if (lower === plural) return word;
225
+ }
226
+
227
+ // Suffix rules (ordered most-specific → least-specific)
228
+ if (/(quiz)$/i.test(word)) return word.replace(/(quiz)$/i, '$1zes');
229
+ if (/^(oxen)$/i.test(word)) return word;
230
+ if (/^(ox)$/i.test(word)) return word + 'en';
231
+ if (/([m|l])ice$/i.test(word)) return word;
232
+ if (/([m|l])ouse$/i.test(word)) return word.replace(/([m|l])ouse$/i, '$1ice');
233
+ if (/(pea)s$/i.test(word)) return word;
234
+ if (/(pe)ople$/i.test(word)) return word;
235
+ if (/(matr|vert|append)(ix|ices)$/i.test(word)) return word;
236
+ if (/(matr|vert|append)ix$/i.test(word)) return word.replace(/ix$/i, 'ices');
237
+ if (/(x|ch|ss|sh)$/i.test(word)) return word + 'es';
238
+ if (/([^aeiouy]|qu)ies$/i.test(word)) return word;
239
+ if (/([^aeiouy]|qu)y$/i.test(word)) return word.replace(/y$/i, 'ies');
240
+ if (/(hive|tive)s?$/i.test(word)) return word.replace(/s?$/i, 's');
241
+ if (/([lr])ves$/i.test(word)) return word;
242
+ if (/([^f])ves$/i.test(word)) return word;
243
+ if (/([^aeiouy])fe$/i.test(word)) return word.replace(/fe$/i, 'ves');
244
+ if (/([lr])f$/i.test(word)) return word.replace(/f$/i, 'ves');
245
+ if (/sis$/i.test(word)) return word.replace(/sis$/i, 'ses');
246
+ if (/([ti])a$/i.test(word)) return word;
247
+ if (/([ti])um$/i.test(word)) return word.replace(/um$/i, 'a');
248
+ if (/(buffal|tomat|potat)o$/i.test(word)) return word + 'es';
249
+ if (/(bu|mis|gas)ses$/i.test(word)) return word;
250
+ if (/(bus)$/i.test(word)) return word + 'es';
251
+ if (/(alias|status)$/i.test(word)) return word + 'es';
252
+ if (/(ax|test)is$/i.test(word)) return word + 'es';
253
+ if (/s$/i.test(word)) return word;
254
+ return word + 's';
255
+ }
256
+
257
+ /**
258
+ * Return the singular form of a word.
259
+ * Str.singular('apples') → 'apple'
260
+ * Str.singular('children') → 'child'
261
+ */
262
+ static singular(word) {
263
+ word = String(word);
264
+ const lower = word.toLowerCase();
265
+ if (UNCOUNTABLE.has(lower)) return word;
266
+
267
+ // Check irregular plurals
268
+ for (const [singular, plural] of IRREGULAR_PLURALS) {
269
+ if (lower === plural) return _matchCase(word, singular);
270
+ if (lower === singular) return word;
271
+ }
272
+
273
+ // Suffix rules
274
+ if (/(quiz)zes$/i.test(word)) return word.replace(/(quiz)zes$/i, '$1');
275
+ if (/(matr)ices$/i.test(word)) return word.replace(/(matr)ices$/i, '$1ix');
276
+ if (/(vert|ind)ices$/i.test(word)) return word.replace(/(vert|ind)ices$/i, '$1ex');
277
+ if (/^(ox)en/i.test(word)) return word.replace(/^(ox)en/i, '$1');
278
+ if (/(alias|status)es$/i.test(word)) return word.replace(/(alias|status)es$/i, '$1');
279
+ if (/(ax|cris|test)es$/i.test(word)) return word.replace(/(ax|cris|test)es$/i, '$1is');
280
+ if (/(shoe)s$/i.test(word)) return word.replace(/(shoe)s$/i, '$1');
281
+ if (/(o)es$/i.test(word)) return word.replace(/(o)es$/i, '$1');
282
+ if (/(bus)es$/i.test(word)) return word.replace(/(bus)es$/i, '$1');
283
+ if (/([m|l])ice$/i.test(word)) return word.replace(/([m|l])ice$/i, '$1ouse');
284
+ if (/(x|ch|ss|sh)es$/i.test(word)) return word.replace(/(x|ch|ss|sh)es$/i, '$1');
285
+ if (/(m)ovies$/i.test(word)) return word.replace(/(m)ovies$/i, '$1ovie');
286
+ if (/(s)eries$/i.test(word)) return word.replace(/(s)eries$/i, '$1eries');
287
+ if (/([^aeiouy]|qu)ies$/i.test(word)) return word.replace(/([^aeiouy]|qu)ies$/i, '$1y');
288
+ if (/([lr])ves$/i.test(word)) return word.replace(/([lr])ves$/i, '$1f');
289
+ if (/(thi|shea|lea)ves$/i.test(word)) return word.replace(/(thi|shea|lea)ves$/i, '$1f');
290
+ if (/(s)taves$/i.test(word)) return word.replace(/(s)taves$/i, '$1taff');
291
+ if (/(hive)s$/i.test(word)) return word.replace(/(hive)s$/i, '$1');
292
+ if (/(dr|l|wh)ives$/i.test(word)) return word.replace(/(dr|l|wh)ives$/i, '$1ife');
293
+ if (/([^f])ves$/i.test(word)) return word.replace(/([^f])ves$/i, '$1fe');
294
+ if (/(^analy)(sis|ses)$/i.test(word)) return word.replace(/(^analy)(sis|ses)$/i, '$1sis');
295
+ if (/((a)naly|(b)a|(d)iagno|(p)arenthe|(p)rogno|(s)ynop|(t)he)(sis|ses)$/i.test(word))
296
+ return word.replace(/ses$/i, 'sis');
297
+ if (/(ta|ra)$/i.test(word) && !/(sta|na)ta$/i.test(word))
298
+ return word.replace(/a$/i, 'um');
299
+ if (/(database)s$/i.test(word)) return word.replace(/(database)s$/i, '$1');
300
+ if (/s$/i.test(word)) return word.replace(/s$/i, '');
301
+ return word;
302
+ }
303
+
304
+ /**
305
+ * Pluralize if count !== 1.
306
+ * Str.pluralStudly('UserPost', 2) → 'UserPosts'
307
+ */
308
+ static pluralStudly(str, count = 2) {
309
+ const words = Str._words(str);
310
+ const last = words[words.length - 1];
311
+ words[words.length - 1] = Str.plural(last, count);
312
+ return words.map(w => Str.ucfirst(w.toLowerCase())).join('');
313
+ }
314
+
315
+ // ── Truncation / limiting ──────────────────────────────────────────────────
316
+
317
+ /**
318
+ * Limit a string to a number of characters.
319
+ * Str.limit('Hello World', 5) → 'Hello...'
320
+ * Str.limit('Hello World', 5, ' →') → 'Hello →'
321
+ */
322
+ static limit(str, limit = 100, end = '...') {
323
+ str = String(str);
324
+ if (str.length <= limit) return str;
325
+ return str.slice(0, limit) + end;
326
+ }
327
+
328
+ /** Alias for limit() */
329
+ static truncate(str, limit = 100, end = '...') {
330
+ return Str.limit(str, limit, end);
331
+ }
332
+
333
+ /**
334
+ * Limit a string to a number of words.
335
+ * Str.words('Hello World Foo', 2) → 'Hello World...'
336
+ */
337
+ static words(str, words = 100, end = '...') {
338
+ str = String(str);
339
+ const arr = str.trim().split(/\s+/);
340
+ if (arr.length <= words) return str;
341
+ return arr.slice(0, words).join(' ') + end;
342
+ }
343
+
344
+ /**
345
+ * Extract an excerpt around a phrase.
346
+ * Str.excerpt('This is a long string', 'long', { radius: 5 })
347
+ * → '...is a long stri...'
348
+ */
349
+ static excerpt(str, phrase = '', { radius = 100, omission = '...' } = {}) {
350
+ str = String(str);
351
+ if (!phrase) return Str.limit(str, radius * 2, omission);
352
+ const idx = str.toLowerCase().indexOf(phrase.toLowerCase());
353
+ if (idx === -1) return Str.limit(str, radius * 2, omission);
354
+ const start = Math.max(0, idx - radius);
355
+ const end = Math.min(str.length, idx + phrase.length + radius);
356
+ return (start > 0 ? omission : '') + str.slice(start, end) + (end < str.length ? omission : '');
357
+ }
358
+
359
+ // ── Padding ────────────────────────────────────────────────────────────────
360
+
361
+ /**
362
+ * Pad a string.
363
+ * Str.pad('5', 3, '0', 'left') → '005'
364
+ * Str.pad('5', 3, '0', 'right') → '500'
365
+ * Str.pad('5', 5, '-', 'both') → '--5--'
366
+ */
367
+ static pad(str, length, pad = ' ', position = 'right') {
368
+ str = String(str);
369
+ if (str.length >= length) return str;
370
+ const needed = length - str.length;
371
+ if (position === 'left') return pad.repeat(Math.ceil(needed / pad.length)).slice(0, needed) + str;
372
+ if (position === 'both') {
373
+ const lPad = Math.floor(needed / 2);
374
+ const rPad = needed - lPad;
375
+ return pad.repeat(Math.ceil(lPad / pad.length)).slice(0, lPad) + str +
376
+ pad.repeat(Math.ceil(rPad / pad.length)).slice(0, rPad);
377
+ }
378
+ return str + pad.repeat(Math.ceil(needed / pad.length)).slice(0, needed);
379
+ }
380
+
381
+ /** Pad left (start). Str.padLeft('5', 3, '0') → '005' */
382
+ static padLeft(str, length, pad = ' ') { return Str.pad(str, length, pad, 'left'); }
383
+
384
+ /** Pad right (end). Str.padRight('5', 3, '0') → '500' */
385
+ static padRight(str, length, pad = ' ') { return Str.pad(str, length, pad, 'right'); }
386
+
387
+ /** Pad both sides. Str.padBoth('5', 5, '-') → '--5--' */
388
+ static padBoth(str, length, pad = ' ') { return Str.pad(str, length, pad, 'both'); }
389
+
390
+ // ── Search / detection ─────────────────────────────────────────────────────
391
+
392
+ /**
393
+ * Determine if a string contains a given substring (or any from an array).
394
+ * Str.contains('hello world', 'world') → true
395
+ * Str.contains('hello world', ['hello','world']) → true
396
+ */
397
+ static contains(haystack, needles, caseSensitive = true) {
398
+ haystack = String(haystack);
399
+ const h = caseSensitive ? haystack : haystack.toLowerCase();
400
+ if (Array.isArray(needles)) {
401
+ return needles.some(n => {
402
+ const needle = caseSensitive ? String(n) : String(n).toLowerCase();
403
+ return needle !== '' && h.includes(needle);
404
+ });
405
+ }
406
+ const needle = caseSensitive ? String(needles) : String(needles).toLowerCase();
407
+ return needle !== '' && h.includes(needle);
408
+ }
409
+
410
+ /**
411
+ * Determine if a string contains all given substrings.
412
+ * Str.containsAll('hello world', ['hello', 'world']) → true
413
+ */
414
+ static containsAll(haystack, needles, caseSensitive = true) {
415
+ return needles.every(n => Str.contains(haystack, n, caseSensitive));
416
+ }
417
+
418
+ /**
419
+ * Determine if a string starts with a given substring (or any from an array).
420
+ * Str.startsWith('hello', 'he') → true
421
+ * Str.startsWith('hello', ['he','wo']) → true
422
+ */
423
+ static startsWith(haystack, needles) {
424
+ haystack = String(haystack);
425
+ if (Array.isArray(needles)) return needles.some(n => String(n) !== '' && haystack.startsWith(String(n)));
426
+ return String(needles) !== '' && haystack.startsWith(String(needles));
427
+ }
428
+
429
+ /**
430
+ * Determine if a string ends with a given substring (or any from an array).
431
+ * Str.endsWith('hello', 'lo') → true
432
+ * Str.endsWith('hello', ['lo','he']) → true
433
+ */
434
+ static endsWith(haystack, needles) {
435
+ haystack = String(haystack);
436
+ if (Array.isArray(needles)) return needles.some(n => String(n) !== '' && haystack.endsWith(String(n)));
437
+ return String(needles) !== '' && haystack.endsWith(String(needles));
438
+ }
439
+
440
+ /**
441
+ * Test a string against a pattern — supports * wildcards.
442
+ * Str.is('foo*', 'foobar') → true
443
+ * Str.is('*.js', 'app.js') → true
444
+ * Str.is(['*.js','*.ts'], 'app.ts') → true
445
+ */
446
+ static is(pattern, value) {
447
+ value = String(value);
448
+ if (Array.isArray(pattern)) return pattern.some(p => Str.is(p, value));
449
+ if (pattern === value) return true;
450
+ const regex = new RegExp('^' + String(pattern).replace(/[.+^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '.*') + '$');
451
+ return regex.test(value);
452
+ }
453
+
454
+ // ── Type checks ────────────────────────────────────────────────────────────
455
+
456
+ /** Determine if a string is a valid UUID v4. */
457
+ static isUuid(str) {
458
+ return /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(String(str));
459
+ }
460
+
461
+ /** Determine if a string is a valid URL. */
462
+ static isUrl(str) {
463
+ try { new URL(String(str)); return true; } catch { return false; }
464
+ }
465
+
466
+ /** Determine if a string is a valid email address. */
467
+ static isEmail(str) {
468
+ return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(String(str));
469
+ }
470
+
471
+ /** Determine if a string is valid JSON. */
472
+ static isJson(str) {
473
+ try { JSON.parse(String(str)); return true; } catch { return false; }
474
+ }
475
+
476
+ /** Determine if a string contains only ASCII characters. */
477
+ static isAscii(str) {
478
+ return /^[\x00-\x7F]*$/.test(String(str));
479
+ }
480
+
481
+ /** Determine if a string is empty or whitespace-only. */
482
+ static isEmpty(str) {
483
+ return String(str).trim() === '';
484
+ }
485
+
486
+ /** Determine if a string is not empty. */
487
+ static isNotEmpty(str) {
488
+ return !Str.isEmpty(str);
489
+ }
490
+
491
+ /** Determine if a string contains only alphabetical characters. */
492
+ static isAlpha(str) {
493
+ return /^[a-zA-Z]+$/.test(String(str));
494
+ }
495
+
496
+ /** Determine if a string contains only alphanumeric characters. */
497
+ static isAlphanumeric(str) {
498
+ return /^[a-zA-Z0-9]+$/.test(String(str));
499
+ }
500
+
501
+ /** Determine if a string contains only numeric characters. */
502
+ static isNumeric(str) {
503
+ return /^-?\d+(\.\d+)?$/.test(String(str).trim());
504
+ }
505
+
506
+ // ── Extraction ─────────────────────────────────────────────────────────────
507
+
508
+ /**
509
+ * Return everything before the first occurrence of a value.
510
+ * Str.before('hello world', ' ') → 'hello'
511
+ */
512
+ static before(subject, search) {
513
+ subject = String(subject); search = String(search);
514
+ if (search === '') return subject;
515
+ const idx = subject.indexOf(search);
516
+ return idx === -1 ? subject : subject.slice(0, idx);
517
+ }
518
+
519
+ /**
520
+ * Return everything before the last occurrence of a value.
521
+ * Str.beforeLast('a/b/c', '/') → 'a/b'
522
+ */
523
+ static beforeLast(subject, search) {
524
+ subject = String(subject); search = String(search);
525
+ if (search === '') return subject;
526
+ const idx = subject.lastIndexOf(search);
527
+ return idx === -1 ? subject : subject.slice(0, idx);
528
+ }
529
+
530
+ /**
531
+ * Return everything after the first occurrence of a value.
532
+ * Str.after('hello world', ' ') → 'world'
533
+ */
534
+ static after(subject, search) {
535
+ subject = String(subject); search = String(search);
536
+ if (search === '') return subject;
537
+ const idx = subject.indexOf(search);
538
+ return idx === -1 ? subject : subject.slice(idx + search.length);
539
+ }
540
+
541
+ /**
542
+ * Return everything after the last occurrence of a value.
543
+ * Str.afterLast('a/b/c', '/') → 'c'
544
+ */
545
+ static afterLast(subject, search) {
546
+ subject = String(subject); search = String(search);
547
+ if (search === '') return subject;
548
+ const idx = subject.lastIndexOf(search);
549
+ return idx === -1 ? subject : subject.slice(idx + search.length);
550
+ }
551
+
552
+ /**
553
+ * Return the portion of a string between two values.
554
+ * Str.between('[hello]', '[', ']') → 'hello'
555
+ */
556
+ static between(subject, from, to) {
557
+ if (from === '' || to === '') return subject;
558
+ return Str.beforeLast(Str.after(subject, from), to);
559
+ }
560
+
561
+ /**
562
+ * Return the smallest portion of a string between two values.
563
+ * Str.betweenFirst('[a][b]', '[', ']') → 'a'
564
+ */
565
+ static betweenFirst(subject, from, to) {
566
+ if (from === '' || to === '') return subject;
567
+ return Str.before(Str.after(subject, from), to);
568
+ }
569
+
570
+ /**
571
+ * Return a substring.
572
+ * Str.substr('hello', 1, 3) → 'ell'
573
+ */
574
+ static substr(str, start, length) {
575
+ str = String(str);
576
+ return length !== undefined ? str.slice(start, start + length) : str.slice(start);
577
+ }
578
+
579
+ // ── Replacement ────────────────────────────────────────────────────────────
580
+
581
+ /**
582
+ * Replace all occurrences of a search string (or array).
583
+ * Str.replace('hello', 'l', 'r') → 'herro'
584
+ * Str.replace(['a','b'], ['x','y'], 'ab') → 'xy'
585
+ */
586
+ static replace(search, replace, subject, caseSensitive = true) {
587
+ if (Array.isArray(search)) {
588
+ let result = String(subject);
589
+ search.forEach((s, i) => {
590
+ const r = Array.isArray(replace) ? (replace[i] ?? '') : replace;
591
+ result = Str.replace(s, r, result, caseSensitive);
592
+ });
593
+ return result;
594
+ }
595
+ subject = String(subject);
596
+ const flags = caseSensitive ? 'g' : 'gi';
597
+ return subject.replace(new RegExp(_escapeRegex(String(search)), flags), String(replace));
598
+ }
599
+
600
+ /**
601
+ * Replace the first occurrence.
602
+ * Str.replaceFirst('aaa', 'a', 'b') → 'baa'
603
+ */
604
+ static replaceFirst(search, replace, subject) {
605
+ subject = String(subject); search = String(search);
606
+ if (search === '') return subject;
607
+ const idx = subject.indexOf(search);
608
+ return idx === -1 ? subject : subject.slice(0, idx) + String(replace) + subject.slice(idx + search.length);
609
+ }
610
+
611
+ /**
612
+ * Replace the last occurrence.
613
+ * Str.replaceLast('aaa', 'a', 'b') → 'aab'
614
+ */
615
+ static replaceLast(search, replace, subject) {
616
+ subject = String(subject); search = String(search);
617
+ if (search === '') return subject;
618
+ const idx = subject.lastIndexOf(search);
619
+ return idx === -1 ? subject : subject.slice(0, idx) + String(replace) + subject.slice(idx + search.length);
620
+ }
621
+
622
+ /**
623
+ * Replace sequential placeholders with an array of values.
624
+ * Str.replaceArray('?', ['a', 'b'], '? and ?') → 'a and b'
625
+ */
626
+ static replaceArray(search, replace, subject) {
627
+ subject = String(subject);
628
+ const arr = [...replace];
629
+ return subject.split(String(search)).reduce((acc, part, i) => {
630
+ return i === 0 ? part : acc + (arr.shift() ?? search) + part;
631
+ });
632
+ }
633
+
634
+ /**
635
+ * Remove all occurrences of a search string.
636
+ * Str.remove('hello world', 'l') → 'heo word'
637
+ * Str.remove('hello world', ['l','o']) → 'he wrd'
638
+ */
639
+ static remove(search, subject, caseSensitive = true) {
640
+ if (Array.isArray(search)) {
641
+ let result = String(subject);
642
+ search.forEach(s => { result = Str.remove(s, result, caseSensitive); });
643
+ return result;
644
+ }
645
+ return Str.replace(search, '', subject, caseSensitive);
646
+ }
647
+
648
+ /**
649
+ * Swap multiple keywords in a string.
650
+ * Str.swap({ Hello: 'Hi', World: 'Earth' }, 'Hello World') → 'Hi Earth'
651
+ */
652
+ static swap(map, subject) {
653
+ subject = String(subject);
654
+ const keys = Object.keys(map).sort((a, b) => b.length - a.length); // longest first
655
+ let result = subject;
656
+ for (const key of keys) {
657
+ result = Str.replace(key, map[key], result);
658
+ }
659
+ return result;
660
+ }
661
+
662
+ // ── Padding / wrapping ─────────────────────────────────────────────────────
663
+
664
+ /**
665
+ * Wrap a string with another string (or start/end separately).
666
+ * Str.wrap('hello', '"') → '"hello"'
667
+ * Str.wrap('hello', '(', ')') → '(hello)'
668
+ */
669
+ static wrap(str, before, after) {
670
+ return String(before) + String(str) + String(after ?? before);
671
+ }
672
+
673
+ /**
674
+ * Unwrap a string — remove wrapping characters if present.
675
+ * Str.unwrap('"hello"', '"') → 'hello'
676
+ * Str.unwrap('(hello)', '(', ')') → 'hello'
677
+ */
678
+ static unwrap(str, before, after) {
679
+ str = String(str);
680
+ const end = after ?? before;
681
+ if (str.startsWith(String(before)) && str.endsWith(String(end))) {
682
+ return str.slice(String(before).length, str.length - String(end).length);
683
+ }
684
+ return str;
685
+ }
686
+
687
+ // ── Whitespace ─────────────────────────────────────────────────────────────
688
+
689
+ /**
690
+ * Remove excess whitespace and trim.
691
+ * Str.squish(' hello world ') → 'hello world'
692
+ */
693
+ static squish(str) {
694
+ return String(str).trim().replace(/\s+/g, ' ');
695
+ }
696
+
697
+ // ── Masking ────────────────────────────────────────────────────────────────
698
+
699
+ /**
700
+ * Mask a portion of a string with a repeated character.
701
+ * Str.mask('password123', '*', 4) → 'pass*******'
702
+ * Str.mask('password123', '*', 0, 4) → '****word123'
703
+ * Str.mask('password123', '*', -4) → 'passwor****'
704
+ */
705
+ static mask(str, character, index, length) {
706
+ str = String(str);
707
+ character = String(character).charAt(0) || '*';
708
+ const len = str.length;
709
+
710
+ // Normalise index (supports negative)
711
+ const start = index < 0 ? Math.max(0, len + index) : Math.min(index, len);
712
+ const maskLen = length !== undefined
713
+ ? Math.min(length, len - start)
714
+ : len - start;
715
+
716
+ return str.slice(0, start) + character.repeat(maskLen) + str.slice(start + maskLen);
717
+ }
718
+
719
+ // ── Generation ─────────────────────────────────────────────────────────────
720
+
721
+ /**
722
+ * Generate a UUID v4.
723
+ * Str.uuid() → '110e8400-e29b-41d4-a716-446655440000'
724
+ */
725
+ static uuid() {
726
+ return crypto.randomUUID
727
+ ? crypto.randomUUID()
728
+ : 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, c => {
729
+ const r = Math.random() * 16 | 0;
730
+ return (c === 'x' ? r : (r & 0x3 | 0x8)).toString(16);
731
+ });
732
+ }
733
+
734
+ /**
735
+ * Generate a random alphanumeric string.
736
+ * Str.random(16) → 'aB3kL9mNpQrStUvW'
737
+ */
738
+ static random(length = 16) {
739
+ const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
740
+ const bytes = crypto.randomBytes(length);
741
+ return Array.from(bytes, b => chars[b % chars.length]).join('');
742
+ }
743
+
744
+ /**
745
+ * Generate a random string of the given length using only lowercase + numbers.
746
+ * Useful for tokens, codes, slugs.
747
+ * Str.randomToken(8) → 'a3k9mpqr'
748
+ */
749
+ static randomToken(length = 32) {
750
+ const chars = 'abcdefghijklmnopqrstuvwxyz0123456789';
751
+ const bytes = crypto.randomBytes(length);
752
+ return Array.from(bytes, b => chars[b % chars.length]).join('');
753
+ }
754
+
755
+ // ── Misc ───────────────────────────────────────────────────────────────────
756
+
757
+ /**
758
+ * Repeat a string n times.
759
+ * Str.repeat('ab', 3) → 'ababab'
760
+ */
761
+ static repeat(str, times) {
762
+ return String(str).repeat(times);
763
+ }
764
+
765
+ /**
766
+ * Reverse a string.
767
+ * Str.reverse('hello') → 'olleh'
768
+ */
769
+ static reverse(str) {
770
+ return String(str).split('').reverse().join('');
771
+ }
772
+
773
+ /**
774
+ * Count the number of words.
775
+ * Str.wordCount('hello world') → 2
776
+ */
777
+ static wordCount(str) {
778
+ return String(str).trim().split(/\s+/).filter(Boolean).length;
779
+ }
780
+
781
+ /**
782
+ * Return the length of a string.
783
+ * Str.length('hello') → 5
784
+ */
785
+ static length(str) {
786
+ return String(str).length;
787
+ }
788
+
789
+ /**
790
+ * Convert a string to uppercase.
791
+ */
792
+ static upper(str) {
793
+ return String(str).toUpperCase();
794
+ }
795
+
796
+ /**
797
+ * Convert a string to lowercase.
798
+ */
799
+ static lower(str) {
800
+ return String(str).toLowerCase();
801
+ }
802
+
803
+ /**
804
+ * Finish a string with a single instance of a given value.
805
+ * Str.finish('path/', '/') → 'path/'
806
+ * Str.finish('path', '/') → 'path/'
807
+ */
808
+ static finish(str, cap) {
809
+ str = String(str); cap = String(cap);
810
+ return str.endsWith(cap) ? str : str + cap;
811
+ }
812
+
813
+ /**
814
+ * Begin a string with a single instance of a given value.
815
+ * Str.start('/path', '/') → '/path'
816
+ * Str.start('path', '/') → '/path'
817
+ */
818
+ static start(str, prefix) {
819
+ str = String(str); prefix = String(prefix);
820
+ return str.startsWith(prefix) ? str : prefix + str;
821
+ }
822
+
823
+ /**
824
+ * Determine if two strings match case-insensitively.
825
+ */
826
+ static equalsIgnoreCase(a, b) {
827
+ return String(a).toLowerCase() === String(b).toLowerCase();
828
+ }
829
+
830
+ /**
831
+ * Convert a string to its ASCII representation (remove non-ASCII).
832
+ * Str.ascii('héllo') → 'hello'
833
+ */
834
+ static ascii(str) {
835
+ return String(str)
836
+ .normalize('NFD')
837
+ .replace(/[\u0300-\u036f]/g, '')
838
+ .replace(/[^\x00-\x7F]/g, '');
839
+ }
840
+
841
+ /**
842
+ * Return a fluent string builder wrapping the given value.
843
+ * Str.of('hello world').title().append('!').toString()
844
+ */
845
+ static of(str) {
846
+ return new FluentString(str);
847
+ }
848
+
849
+ // ── Internal helpers ───────────────────────────────────────────────────────
850
+
851
+ /**
852
+ * Split a string into words — handles camelCase, PascalCase,
853
+ * snake_case, kebab-case, spaces and numbers.
854
+ * @private
855
+ */
856
+ static _words(str) {
857
+ return String(str)
858
+ .replace(/([a-z])([A-Z])/g, '$1 $2') // camelCase → camel Case
859
+ .replace(/([A-Z]+)([A-Z][a-z])/g, '$1 $2') // ABCDef → ABC Def
860
+ .replace(/[-_]+/g, ' ')
861
+ .trim()
862
+ .split(/\s+/)
863
+ .filter(Boolean);
864
+ }
865
+ }
866
+
867
+ // ── Fluent string builder ─────────────────────────────────────────────────────
868
+
869
+ /**
870
+ * FluentString
871
+ *
872
+ * Immutable fluent wrapper around a string value.
873
+ * Every method returns a new FluentString — the original is never mutated.
874
+ *
875
+ * Str.of('hello world')
876
+ * .title()
877
+ * .replace('World', 'Millas')
878
+ * .append('!')
879
+ * .toString()
880
+ * // → 'Hello Millas!'
881
+ */
882
+ class FluentString {
883
+ constructor(value = '') {
884
+ this._value = String(value);
885
+ }
886
+
887
+ // ── Case ────────────────────────────────────────────────────────────────────
888
+ camel() { return new FluentString(Str.camel(this._value)); }
889
+ pascal() { return new FluentString(Str.pascal(this._value)); }
890
+ snake(sep) { return new FluentString(Str.snake(this._value, sep)); }
891
+ kebab() { return new FluentString(Str.kebab(this._value)); }
892
+ title() { return new FluentString(Str.title(this._value)); }
893
+ headline() { return new FluentString(Str.headline(this._value)); }
894
+ upper() { return new FluentString(Str.upper(this._value)); }
895
+ lower() { return new FluentString(Str.lower(this._value)); }
896
+ ucfirst() { return new FluentString(Str.ucfirst(this._value)); }
897
+ lcfirst() { return new FluentString(Str.lcfirst(this._value)); }
898
+ slug(sep) { return new FluentString(Str.slug(this._value, sep)); }
899
+ ascii() { return new FluentString(Str.ascii(this._value)); }
900
+
901
+ // ── Plural / singular ───────────────────────────────────────────────────────
902
+ plural(count) { return new FluentString(Str.plural(this._value, count)); }
903
+ singular() { return new FluentString(Str.singular(this._value)); }
904
+
905
+ // ── Truncation ─────────────────────────────────────────────────────────────
906
+ limit(n, end) { return new FluentString(Str.limit(this._value, n, end)); }
907
+ words(n, end) { return new FluentString(Str.words(this._value, n, end)); }
908
+ truncate(n, end) { return new FluentString(Str.truncate(this._value, n, end)); }
909
+ excerpt(phrase, opts) { return new FluentString(Str.excerpt(this._value, phrase, opts)); }
910
+
911
+ // ── Padding ────────────────────────────────────────────────────────────────
912
+ pad(len, ch, pos) { return new FluentString(Str.pad(this._value, len, ch, pos)); }
913
+ padLeft(len, ch) { return new FluentString(Str.padLeft(this._value, len, ch)); }
914
+ padRight(len, ch) { return new FluentString(Str.padRight(this._value, len, ch)); }
915
+ padBoth(len, ch) { return new FluentString(Str.padBoth(this._value, len, ch)); }
916
+
917
+ // ── Extraction ─────────────────────────────────────────────────────────────
918
+ before(s) { return new FluentString(Str.before(this._value, s)); }
919
+ beforeLast(s) { return new FluentString(Str.beforeLast(this._value, s)); }
920
+ after(s) { return new FluentString(Str.after(this._value, s)); }
921
+ afterLast(s) { return new FluentString(Str.afterLast(this._value, s)); }
922
+ between(a, b) { return new FluentString(Str.between(this._value, a, b)); }
923
+ betweenFirst(a, b) { return new FluentString(Str.betweenFirst(this._value, a, b)); }
924
+ substr(s, l) { return new FluentString(Str.substr(this._value, s, l)); }
925
+
926
+ // ── Replacement ────────────────────────────────────────────────────────────
927
+ replace(search, rep, cs) { return new FluentString(Str.replace(search, rep, this._value, cs)); }
928
+ replaceFirst(s, r) { return new FluentString(Str.replaceFirst(s, r, this._value)); }
929
+ replaceLast(s, r) { return new FluentString(Str.replaceLast(s, r, this._value)); }
930
+ replaceArray(s, arr) { return new FluentString(Str.replaceArray(s, arr, this._value)); }
931
+ remove(s, cs) { return new FluentString(Str.remove(s, this._value, cs)); }
932
+ swap(map) { return new FluentString(Str.swap(map, this._value)); }
933
+
934
+ // ── Append / prepend ───────────────────────────────────────────────────────
935
+ /** Append one or more strings. Str.of('hello').append(' ', 'world') → 'hello world' */
936
+ append(...parts) { return new FluentString(this._value + parts.join('')); }
937
+ /** Prepend one or more strings. Str.of('world').prepend('hello ') → 'hello world' */
938
+ prepend(...parts) { return new FluentString(parts.join('') + this._value); }
939
+ /** Finish with a cap character. */
940
+ finish(cap) { return new FluentString(Str.finish(this._value, cap)); }
941
+ /** Ensure starts with a prefix. */
942
+ start(prefix) { return new FluentString(Str.start(this._value, prefix)); }
943
+
944
+ // ── Whitespace ─────────────────────────────────────────────────────────────
945
+ trim(chars) { return new FluentString(chars ? _trimChars(this._value, chars) : this._value.trim()); }
946
+ ltrim(chars) { return new FluentString(chars ? _ltrimChars(this._value, chars) : this._value.trimStart()); }
947
+ rtrim(chars) { return new FluentString(chars ? _rtrimChars(this._value, chars) : this._value.trimEnd()); }
948
+ squish() { return new FluentString(Str.squish(this._value)); }
949
+
950
+ // ── Masking ────────────────────────────────────────────────────────────────
951
+ mask(char, idx, len) { return new FluentString(Str.mask(this._value, char, idx, len)); }
952
+
953
+ // ── Wrapping ───────────────────────────────────────────────────────────────
954
+ wrap(before, after) { return new FluentString(Str.wrap(this._value, before, after)); }
955
+ unwrap(before, after) { return new FluentString(Str.unwrap(this._value, before, after)); }
956
+
957
+ // ── Misc ───────────────────────────────────────────────────────────────────
958
+ repeat(n) { return new FluentString(Str.repeat(this._value, n)); }
959
+ reverse() { return new FluentString(Str.reverse(this._value)); }
960
+
961
+ // ── Checks — return primitives, not fluent ─────────────────────────────────
962
+ contains(n, cs) { return Str.contains(this._value, n, cs); }
963
+ containsAll(arr, cs) { return Str.containsAll(this._value, arr, cs); }
964
+ startsWith(n) { return Str.startsWith(this._value, n); }
965
+ endsWith(n) { return Str.endsWith(this._value, n); }
966
+ is(pattern) { return Str.is(pattern, this._value); }
967
+ isUuid() { return Str.isUuid(this._value); }
968
+ isUrl() { return Str.isUrl(this._value); }
969
+ isEmail() { return Str.isEmail(this._value); }
970
+ isJson() { return Str.isJson(this._value); }
971
+ isAscii() { return Str.isAscii(this._value); }
972
+ isEmpty() { return Str.isEmpty(this._value); }
973
+ isNotEmpty() { return Str.isNotEmpty(this._value); }
974
+ isAlpha() { return Str.isAlpha(this._value); }
975
+ isAlphanumeric() { return Str.isAlphanumeric(this._value); }
976
+ isNumeric() { return Str.isNumeric(this._value); }
977
+ equalsIgnoreCase(b) { return Str.equalsIgnoreCase(this._value, b); }
978
+ wordCount() { return Str.wordCount(this._value); }
979
+ length() { return Str.length(this._value); }
980
+
981
+ // ── Tap / pipe ─────────────────────────────────────────────────────────────
982
+
983
+ /**
984
+ * Apply a callback and return the FluentString unchanged.
985
+ * Useful for side-effects (logging, debugging) mid-chain.
986
+ * Str.of('hello').tap(s => console.log(s.toString())).upper()
987
+ */
988
+ tap(callback) {
989
+ callback(this);
990
+ return this;
991
+ }
992
+
993
+ /**
994
+ * Pass the FluentString through a callback and return the result.
995
+ * The callback can return a string or FluentString.
996
+ * Str.of('hello').pipe(s => s.upper().append('!'))
997
+ */
998
+ pipe(callback) {
999
+ const result = callback(this);
1000
+ return result instanceof FluentString ? result : new FluentString(String(result));
1001
+ }
1002
+
1003
+ /**
1004
+ * Apply a callback only when the condition is truthy.
1005
+ * Str.of('hello').when(true, s => s.upper()) → FluentString('HELLO')
1006
+ * Str.of('hello').when(false, s => s.upper()) → FluentString('hello')
1007
+ */
1008
+ when(condition, callback, otherwise) {
1009
+ const cond = typeof condition === 'function' ? condition(this) : condition;
1010
+ if (cond) {
1011
+ const result = callback(this);
1012
+ return result instanceof FluentString ? result : this;
1013
+ }
1014
+ if (otherwise) {
1015
+ const result = otherwise(this);
1016
+ return result instanceof FluentString ? result : this;
1017
+ }
1018
+ return this;
1019
+ }
1020
+
1021
+ /**
1022
+ * Apply a callback only when the string is empty.
1023
+ */
1024
+ whenEmpty(callback) {
1025
+ return this.when(this.isEmpty(), callback);
1026
+ }
1027
+
1028
+ /**
1029
+ * Apply a callback only when the string is not empty.
1030
+ */
1031
+ whenNotEmpty(callback) {
1032
+ return this.when(this.isNotEmpty(), callback);
1033
+ }
1034
+
1035
+ // ── Conversion ─────────────────────────────────────────────────────────────
1036
+
1037
+ /** Get the raw string value. */
1038
+ toString() { return this._value; }
1039
+
1040
+ /** Get the raw string value (alias). */
1041
+ value() { return this._value; }
1042
+
1043
+ /** JSON serialization returns the raw string. */
1044
+ toJSON() { return this._value; }
1045
+
1046
+ /** Allow implicit string coercion. */
1047
+ [Symbol.toPrimitive](hint) {
1048
+ return hint === 'number' ? +this._value : this._value;
1049
+ }
1050
+ }
1051
+
1052
+ // ── Private helpers ───────────────────────────────────────────────────────────
1053
+
1054
+ function _escapeRegex(str) {
1055
+ return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
1056
+ }
1057
+
1058
+ /** Match the casing pattern of `original` onto `target`. */
1059
+ function _matchCase(original, target) {
1060
+ if (original === original.toUpperCase()) return target.toUpperCase();
1061
+ if (original === original.toLowerCase()) return target.toLowerCase();
1062
+ if (original[0] === original[0].toUpperCase()) return Str.ucfirst(target.toLowerCase());
1063
+ return target;
1064
+ }
1065
+
1066
+ function _trimChars(str, chars) {
1067
+ return _ltrimChars(_rtrimChars(str, chars), chars);
1068
+ }
1069
+ function _ltrimChars(str, chars) {
1070
+ const escaped = _escapeRegex(chars);
1071
+ return str.replace(new RegExp(`^[${escaped}]+`), '');
1072
+ }
1073
+ function _rtrimChars(str, chars) {
1074
+ const escaped = _escapeRegex(chars);
1075
+ return str.replace(new RegExp(`[${escaped}]+$`), '');
1076
+ }
1077
+
1078
+ // ── Exports ───────────────────────────────────────────────────────────────────
1079
+
1080
+ module.exports = { Str, FluentString };