@ix-xs/node-comfort 1.1.31 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (77) hide show
  1. package/CHANGELOG.md +190 -0
  2. package/LICENSE +21 -0
  3. package/README.md +210 -748
  4. package/index.js +84 -13
  5. package/package.json +201 -21
  6. package/src/Arr.js +901 -0
  7. package/src/Async.js +585 -0
  8. package/src/Cache.js +329 -0
  9. package/src/Checker.js +979 -188
  10. package/src/Cli.js +1034 -0
  11. package/src/Color.js +415 -0
  12. package/src/Crypto.js +707 -0
  13. package/src/Emitter.js +289 -0
  14. package/src/Env.js +555 -0
  15. package/src/FS.js +1483 -768
  16. package/src/Func.js +692 -0
  17. package/src/Http.js +538 -0
  18. package/src/Id.js +418 -0
  19. package/src/Logger.js +739 -307
  20. package/src/Num.js +849 -0
  21. package/src/Obj.js +904 -0
  22. package/src/SQLite.js +1308 -994
  23. package/src/Schema.js +1586 -0
  24. package/src/Str.js +999 -0
  25. package/src/Sys.js +511 -0
  26. package/src/Time.js +1323 -0
  27. package/src/Utils.js +321 -325
  28. package/src/errors.js +292 -0
  29. package/types/index.d.ts +693 -120
  30. package/types/src/Arr.d.ts +562 -0
  31. package/types/src/Arr.d.ts.map +1 -0
  32. package/types/src/Async.d.ts +321 -0
  33. package/types/src/Async.d.ts.map +1 -0
  34. package/types/src/Cache.d.ts +195 -0
  35. package/types/src/Cache.d.ts.map +1 -0
  36. package/types/src/Checker.d.ts +609 -81
  37. package/types/src/Checker.d.ts.map +1 -1
  38. package/types/src/Cli.d.ts +582 -0
  39. package/types/src/Cli.d.ts.map +1 -0
  40. package/types/src/Color.d.ts +294 -0
  41. package/types/src/Color.d.ts.map +1 -0
  42. package/types/src/Crypto.d.ts +534 -0
  43. package/types/src/Crypto.d.ts.map +1 -0
  44. package/types/src/Emitter.d.ts +170 -0
  45. package/types/src/Emitter.d.ts.map +1 -0
  46. package/types/src/Env.d.ts +382 -0
  47. package/types/src/Env.d.ts.map +1 -0
  48. package/types/src/FS.d.ts +709 -201
  49. package/types/src/FS.d.ts.map +1 -1
  50. package/types/src/Func.d.ts +455 -0
  51. package/types/src/Func.d.ts.map +1 -0
  52. package/types/src/Http.d.ts +374 -0
  53. package/types/src/Http.d.ts.map +1 -0
  54. package/types/src/Id.d.ts +200 -0
  55. package/types/src/Id.d.ts.map +1 -0
  56. package/types/src/Logger.d.ts +374 -24
  57. package/types/src/Logger.d.ts.map +1 -1
  58. package/types/src/Num.d.ts +578 -0
  59. package/types/src/Num.d.ts.map +1 -0
  60. package/types/src/Obj.d.ts +475 -0
  61. package/types/src/Obj.d.ts.map +1 -0
  62. package/types/src/SQLite.d.ts +647 -305
  63. package/types/src/SQLite.d.ts.map +1 -1
  64. package/types/src/Schema.d.ts +940 -0
  65. package/types/src/Schema.d.ts.map +1 -0
  66. package/types/src/Str.d.ts +696 -0
  67. package/types/src/Str.d.ts.map +1 -0
  68. package/types/src/Sys.d.ts +269 -0
  69. package/types/src/Sys.d.ts.map +1 -0
  70. package/types/src/Time.d.ts +664 -0
  71. package/types/src/Time.d.ts.map +1 -0
  72. package/types/src/Utils.d.ts +154 -102
  73. package/types/src/Utils.d.ts.map +1 -1
  74. package/types/src/errors.d.ts +292 -0
  75. package/types/src/errors.d.ts.map +1 -0
  76. package/tsconfig.json +0 -18
  77. package/types/index.d.ts.map +0 -1
package/src/Str.js ADDED
@@ -0,0 +1,999 @@
1
+ "use strict";
2
+
3
+ /**
4
+ * String helpers: case conversion, slugs, truncation, templates, wrapping,
5
+ * fuzzy matching, plurals. Nothing is mutated, any value is accepted, and
6
+ * lengths are counted the way you see them, so 👨‍👩‍👧 is one character and
7
+ * is never cut in half.
8
+ *
9
+ * @example
10
+ * str.slugify("Héllo, Wörld!"); // "hello-world"
11
+ * str.camelCase("user_first-name"); // "userFirstName"
12
+ * str.plural(3, { one: "file", other: "files" }); // "3 files"
13
+ */
14
+
15
+ /**
16
+ * Options for `slugify()`.
17
+ * @typedef {object} SlugifyOptions
18
+ * @property {string} [separator] Placed between words. Defaults to `"-"`.
19
+ * @property {boolean} [lower] Lowercase the result. Defaults to `true`.
20
+ * @property {number} [maxLength] Maximum length, cut on a word boundary when possible.
21
+ */
22
+
23
+ /**
24
+ * Options for `truncate()`.
25
+ * @typedef {object} TruncateOptions
26
+ * @property {string} [omission] Added where the text is cut, and counted in the length. Defaults to `"…"`.
27
+ * @property {boolean} [words] Cut at the previous space rather than inside a word.
28
+ */
29
+
30
+ /**
31
+ * Options for `template()`.
32
+ * @typedef {object} TemplateOptions
33
+ * @property {string} [open] Opening delimiter. Defaults to `"{"`.
34
+ * @property {string} [close] Closing delimiter. Defaults to `"}"`.
35
+ * @property {string} [fallback] Used for missing values. Without it, their placeholders stay as they are.
36
+ */
37
+
38
+ /**
39
+ * Options for `wrap()`.
40
+ * @typedef {object} WrapOptions
41
+ * @property {number} [width] Maximum visible characters per line. Defaults to `80`.
42
+ * @property {string} [indent] Added at the start of every line.
43
+ * @property {boolean} [cut] Break words longer than `width` instead of letting them overflow.
44
+ * @property {string} [newline] Line separator. Defaults to `"\n"`.
45
+ */
46
+
47
+ /**
48
+ * Options for `mask()`.
49
+ * @typedef {object} MaskOptions
50
+ * @property {number} [start] Characters left visible at the start. Defaults to `0`.
51
+ * @property {number} [end] Characters left visible at the end. Defaults to `4`.
52
+ * @property {string} [char] Mask character. Defaults to `"*"`.
53
+ * @property {boolean} [keepSpaces] Leave spaces and dashes visible, nice for card numbers.
54
+ */
55
+
56
+ /**
57
+ * The word forms for `plural()`. Only `other` is required; add the others
58
+ * when your language needs them.
59
+ * @typedef {object} PluralForms
60
+ * @property {string} other The general plural, like `"files"`.
61
+ * @property {string} [zero] Used for `0` in any language, like `"no files"`.
62
+ * @property {string} [one] The singular, like `"file"`. French also uses it for 0.
63
+ * @property {string} [two] Dual form (Arabic, Hebrew, Slovenian...).
64
+ * @property {string} [few] "Few" form (Polish, Czech, Russian...).
65
+ * @property {string} [many] "Many" form (Polish, Russian, Arabic...).
66
+ */
67
+
68
+ /**
69
+ * Options for `plural()`.
70
+ * @typedef {object} PluralOptions
71
+ * @property {string} [locale] Language whose plural rules apply. Defaults to `"en"`.
72
+ * @property {boolean} [includeCount] Put the formatted count before the word. Defaults to `true`.
73
+ * @property {boolean} [ordinal] Use ordinal rules (1st, 2nd, 3rd).
74
+ */
75
+
76
+ /**
77
+ * Options for `compare()`.
78
+ * @typedef {object} CompareOptions
79
+ * @property {string} [locale] Language used for alphabetical order. Defaults to the system's.
80
+ * @property {boolean} [numeric] Sort numbers naturally, `"file2"` before `"file10"`. Defaults to `true`.
81
+ * @property {"base"|"accent"|"case"|"variant"} [sensitivity] Which differences count. `"base"` ignores accents and case. Defaults to `"variant"`.
82
+ */
83
+
84
+ /**
85
+ * Options for `closest()`.
86
+ * @typedef {object} ClosestOptions
87
+ * @property {number} [threshold] Minimum similarity (0 to 1) to accept a match. Defaults to `0.4`.
88
+ * @property {boolean} [caseSensitive] Take case into account.
89
+ */
90
+
91
+ /**
92
+ * Options for `random()`.
93
+ * @typedef {object} RandomStringOptions
94
+ * @property {string} [charset] Characters to pick from. Defaults to letters and digits.
95
+ */
96
+
97
+ // Intl objects are expensive to build, so they are created on first use.
98
+ /** @type {Intl.Segmenter | undefined} */
99
+ let _segmenter;
100
+ /** @type {Intl.Collator | undefined} */
101
+ let _defaultCollator;
102
+
103
+ /**
104
+ * @param {string} input
105
+ * @returns {string[]}
106
+ */
107
+ const _graphemes = (input) => {
108
+ // Fast path: pure ASCII strings have one grapheme per UTF-16 unit.
109
+ if (/^[\x00-\x7f]*$/.test(input)) return input.split("");
110
+ _segmenter ??= new Intl.Segmenter(undefined, { granularity: "grapheme" });
111
+ return Array.from(_segmenter.segment(input), (s) => s.segment);
112
+ };
113
+
114
+ /** Letters that Unicode normalization doesn't decompose. */
115
+ const _SPECIAL_LATIN = /** @type {Record<string, string>} */ ({
116
+ ß: "ss", ẞ: "SS", æ: "ae", Æ: "AE", œ: "oe", Œ: "OE", ø: "o", Ø: "O",
117
+ đ: "d", Đ: "D", ð: "d", Ð: "D", ł: "l", Ł: "L", þ: "th", Þ: "TH", ħ: "h", Ħ: "H", ı: "i",
118
+ });
119
+
120
+ /**
121
+ * @param {unknown} input
122
+ * @returns {string[]}
123
+ */
124
+ const _words = (input) =>
125
+ String(input)
126
+ .replace(/(\p{Ll}|\p{N})(\p{Lu})/gu, "$1 $2")
127
+ .replace(/(\p{Lu}+)(\p{Lu}\p{Ll})/gu, "$1 $2")
128
+ .replace(/[^\p{L}\p{N}]+/gu, " ")
129
+ .trim()
130
+ .split(/\s+/)
131
+ .filter(Boolean);
132
+
133
+ /** @param {string} word */
134
+ const _upperFirst = (word) => word.charAt(0).toUpperCase() + word.slice(1);
135
+
136
+ /** @param {string} value */
137
+ const _escapeRegExp = (value) => value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
138
+
139
+ /**
140
+ * Uppercases the first character and lowercases the rest.
141
+ *
142
+ * @example
143
+ * str.capitalize("hELLO wORLD"); // "Hello world"
144
+ *
145
+ * @param {unknown} input
146
+ * @returns {string}
147
+ */
148
+ function capitalize(input) {
149
+ const s = String(input);
150
+ return s ? s.charAt(0).toUpperCase() + s.slice(1).toLowerCase() : s;
151
+ }
152
+
153
+ /**
154
+ * Uppercases the first letter of each word and leaves the rest alone.
155
+ *
156
+ * @example
157
+ * str.titleCase("the quick brown fox"); // "The Quick Brown Fox"
158
+ * str.titleCase("élodie o'neil"); // "Élodie O'neil"
159
+ *
160
+ * @param {unknown} input
161
+ * @returns {string}
162
+ */
163
+ function titleCase(input) {
164
+ return String(input).replace(/(^|[^\p{L}\p{N}'’])(\p{L})/gu, (_, before, letter) => before + letter.toUpperCase());
165
+ }
166
+
167
+ /**
168
+ * Lowercases everything, then capitalizes the start of each sentence.
169
+ *
170
+ * @example
171
+ * str.sentenceCase("hELLO WORLD. how ARE you?"); // "Hello world. How are you?"
172
+ *
173
+ * @param {unknown} input
174
+ * @returns {string}
175
+ */
176
+ function sentenceCase(input) {
177
+ return String(input)
178
+ .toLowerCase()
179
+ .replace(/(^\s*|[.!?]\s+)(\p{L})/gu, (_, before, letter) => before + letter.toUpperCase());
180
+ }
181
+
182
+ /**
183
+ * Converts to `camelCase`.
184
+ *
185
+ * @example
186
+ * str.camelCase("user_first-name"); // "userFirstName"
187
+ * str.camelCase("XMLHttpRequest"); // "xmlHttpRequest"
188
+ *
189
+ * @param {unknown} input
190
+ * @returns {string}
191
+ */
192
+ function camelCase(input) {
193
+ return _words(input)
194
+ .map((word, i) => (i === 0 ? word.toLowerCase() : _upperFirst(word.toLowerCase())))
195
+ .join("");
196
+ }
197
+
198
+ /**
199
+ * Converts to `PascalCase`.
200
+ *
201
+ * @example
202
+ * str.pascalCase("hello world"); // "HelloWorld"
203
+ *
204
+ * @param {unknown} input
205
+ * @returns {string}
206
+ */
207
+ function pascalCase(input) {
208
+ return _words(input).map((word) => _upperFirst(word.toLowerCase())).join("");
209
+ }
210
+
211
+ /**
212
+ * Converts to `snake_case`.
213
+ *
214
+ * @example
215
+ * str.snakeCase("helloWorld"); // "hello_world"
216
+ *
217
+ * @param {unknown} input
218
+ * @returns {string}
219
+ */
220
+ function snakeCase(input) {
221
+ return _words(input).map((word) => word.toLowerCase()).join("_");
222
+ }
223
+
224
+ /**
225
+ * Converts to `kebab-case`.
226
+ *
227
+ * @example
228
+ * str.kebabCase("helloWorld"); // "hello-world"
229
+ *
230
+ * @param {unknown} input
231
+ * @returns {string}
232
+ */
233
+ function kebabCase(input) {
234
+ return _words(input).map((word) => word.toLowerCase()).join("-");
235
+ }
236
+
237
+ /**
238
+ * Converts to `CONSTANT_CASE`.
239
+ *
240
+ * @example
241
+ * str.constantCase("helloWorld"); // "HELLO_WORLD"
242
+ *
243
+ * @param {unknown} input
244
+ * @returns {string}
245
+ */
246
+ function constantCase(input) {
247
+ return _words(input).map((word) => word.toUpperCase()).join("_");
248
+ }
249
+
250
+ /**
251
+ * Converts to `dot.case`.
252
+ *
253
+ * @example
254
+ * str.dotCase("userFirstName"); // "user.first.name"
255
+ *
256
+ * @param {unknown} input
257
+ * @returns {string}
258
+ */
259
+ function dotCase(input) {
260
+ return _words(input).map((word) => word.toLowerCase()).join(".");
261
+ }
262
+
263
+ /**
264
+ * Splits text into words. Understands camelCase, snake_case, kebab-case,
265
+ * dots, spaces and punctuation.
266
+ *
267
+ * @example
268
+ * str.words("helloWorld-foo_bar"); // ["hello", "World", "foo", "bar"]
269
+ *
270
+ * @param {unknown} input
271
+ * @returns {string[]}
272
+ */
273
+ function words(input) {
274
+ return _words(input);
275
+ }
276
+
277
+ /**
278
+ * Removes accents (`é` becomes `e`) and spells out special Latin letters
279
+ * (`ß` becomes `ss`, `æ` becomes `ae`).
280
+ *
281
+ * @example
282
+ * str.deburr("Crème brûlée"); // "Creme brulee"
283
+ * str.deburr("Straße"); // "Strasse"
284
+ *
285
+ * @param {unknown} input
286
+ * @returns {string}
287
+ */
288
+ function deburr(input) {
289
+ return String(input)
290
+ .normalize("NFKD")
291
+ .replace(/[̀-ͯ]/g, "")
292
+ .replace(/[ßẞæÆœŒøØđĐðÐłŁþÞħĦı]/g, (c) => _SPECIAL_LATIN[c] ?? c);
293
+ }
294
+
295
+ /**
296
+ * Turns text into a URL slug: no accents, lowercase, words joined by dashes.
297
+ *
298
+ * @example
299
+ * str.slugify("Héllo, World!"); // "hello-world"
300
+ * str.slugify("Crème Brûlée", { separator: "_" }); // "creme_brulee"
301
+ * str.slugify("A very long title", { maxLength: 8 }); // "a-very"
302
+ *
303
+ * @param {unknown} input
304
+ * @param {SlugifyOptions} [options]
305
+ * @returns {string} The slug, possibly empty.
306
+ */
307
+ function slugify(input, options = {}) {
308
+ const separator = options.separator ?? "-";
309
+ const sep = _escapeRegExp(separator);
310
+ let slug = deburr(input)
311
+ .replace(/[^\p{L}\p{N}]+/gu, separator)
312
+ .replace(new RegExp(`(?:${sep}){2,}`, "g"), separator)
313
+ .replace(new RegExp(`^(?:${sep})|(?:${sep})$`, "g"), "");
314
+ if (options.lower ?? true) slug = slug.toLowerCase();
315
+ if (options.maxLength !== undefined && slug.length > options.maxLength) {
316
+ const cut = slug.slice(0, options.maxLength);
317
+ const lastSep = separator ? cut.lastIndexOf(separator) : -1;
318
+ slug = (lastSep > 0 ? cut.slice(0, lastSep) : cut).replace(new RegExp(`(?:${sep})+$`), "");
319
+ }
320
+ return slug;
321
+ }
322
+
323
+ /**
324
+ * Collapses runs of whitespace into single spaces and trims the ends.
325
+ *
326
+ * @example
327
+ * str.squish(" hello \n\t world "); // "hello world"
328
+ *
329
+ * @param {unknown} input
330
+ * @returns {string}
331
+ */
332
+ function squish(input) {
333
+ return String(input).replace(/\s+/g, " ").trim();
334
+ }
335
+
336
+ /**
337
+ * Removes HTML tags and keeps the text. This is not a sanitizer: to show
338
+ * untrusted text in HTML, use `escapeHTML()`.
339
+ *
340
+ * @example
341
+ * str.stripTags("<p>Hello <b>world</b></p>"); // "Hello world"
342
+ *
343
+ * @param {unknown} input
344
+ * @returns {string}
345
+ */
346
+ function stripTags(input) {
347
+ return String(input).replace(/<\/?[^>]+(>|$)/g, "");
348
+ }
349
+
350
+ /**
351
+ * Escapes `& < > " '` so the text is safe inside HTML content and attributes.
352
+ *
353
+ * @example
354
+ * str.escapeHTML('<a href="x">Tom & Jerry</a>');
355
+ * // "&lt;a href=&quot;x&quot;&gt;Tom &amp; Jerry&lt;/a&gt;"
356
+ *
357
+ * @param {unknown} input
358
+ * @returns {string}
359
+ */
360
+ function escapeHTML(input) {
361
+ const map = /** @type {Record<string, string>} */ ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" });
362
+ return String(input).replace(/[&<>"']/g, (c) => map[c]);
363
+ }
364
+
365
+ /**
366
+ * Decodes HTML entities: named ones like `&amp;` and `&nbsp;`, and numeric
367
+ * ones like `&#233;`.
368
+ *
369
+ * @example
370
+ * str.unescapeHTML("Tom &amp; Jerry &#x1F600;"); // "Tom & Jerry 😀"
371
+ *
372
+ * @param {unknown} input
373
+ * @returns {string}
374
+ */
375
+ function unescapeHTML(input) {
376
+ const named = /** @type {Record<string, string>} */ ({ amp: "&", lt: "<", gt: ">", quot: '"', apos: "'", nbsp: " " });
377
+ return String(input).replace(/&(#x[0-9a-f]+|#\d+|amp|lt|gt|quot|apos|nbsp);/gi, (match, entity) => {
378
+ const lower = entity.toLowerCase();
379
+ if (lower.startsWith("#x")) return String.fromCodePoint(parseInt(lower.slice(2), 16));
380
+ if (lower.startsWith("#")) return String.fromCodePoint(parseInt(lower.slice(1), 10));
381
+ return named[lower] ?? match;
382
+ });
383
+ }
384
+
385
+ /**
386
+ * Escapes regex special characters so the text matches literally.
387
+ *
388
+ * @example
389
+ * new RegExp(str.escapeRegExp("1+1=2?")).test("1+1=2?"); // true
390
+ *
391
+ * @param {unknown} input
392
+ * @returns {string}
393
+ */
394
+ function escapeRegExp(input) {
395
+ return _escapeRegExp(String(input));
396
+ }
397
+
398
+ /**
399
+ * Counts visible characters. Unlike `.length`, an emoji or an accented
400
+ * letter counts as one.
401
+ *
402
+ * @example
403
+ * "👨‍👩‍👧".length; // 8
404
+ * str.length("👨‍👩‍👧"); // 1
405
+ *
406
+ * @param {unknown} input
407
+ * @returns {number}
408
+ */
409
+ function length(input) {
410
+ return _graphemes(String(input)).length;
411
+ }
412
+
413
+ /**
414
+ * Size of the text in bytes once encoded, UTF-8 by default. Useful when a
415
+ * limit is in bytes, like a database column or an HTTP header.
416
+ *
417
+ * @example
418
+ * str.byteLength("é"); // 2
419
+ * str.byteLength("😀"); // 4
420
+ *
421
+ * @param {unknown} input
422
+ * @param {BufferEncoding} [encoding="utf8"]
423
+ * @returns {number}
424
+ */
425
+ function byteLength(input, encoding = "utf8") {
426
+ return Buffer.byteLength(String(input), encoding);
427
+ }
428
+
429
+ /**
430
+ * Counts how many times `search` appears, without overlaps.
431
+ *
432
+ * @example
433
+ * str.count("banana", "a"); // 3
434
+ * str.count("aaaa", "aa"); // 2
435
+ *
436
+ * @param {unknown} input
437
+ * @param {string} search An empty string gives `0`.
438
+ * @returns {number}
439
+ */
440
+ function count(input, search) {
441
+ const s = String(input);
442
+ if (!search) return 0;
443
+ let total = 0;
444
+ let pos = s.indexOf(search);
445
+ while (pos !== -1) {
446
+ total++;
447
+ pos = s.indexOf(search, pos + search.length);
448
+ }
449
+ return total;
450
+ }
451
+
452
+ /**
453
+ * Shortens text to `length` visible characters, ending with `…` when cut.
454
+ *
455
+ * @example
456
+ * str.truncate("Hello world", 8); // "Hello w…"
457
+ * str.truncate("Hello world", 8, { omission: "..." }); // "Hello..."
458
+ * str.truncate("Hello big world", 12, { words: true }); // "Hello big…"
459
+ *
460
+ * @param {unknown} input
461
+ * @param {number} length Maximum length, omission included.
462
+ * @param {TruncateOptions} [options]
463
+ * @returns {string}
464
+ */
465
+ function truncate(input, length, options = {}) {
466
+ const omission = options.omission ?? "…";
467
+ const chars = _graphemes(String(input));
468
+ if (chars.length <= length) return chars.join("");
469
+ const keep = Math.max(0, length - _graphemes(omission).length);
470
+ let sliced = chars.slice(0, keep).join("");
471
+ if (options.words) {
472
+ const nextIsSpace = /\s/.test(chars[keep] ?? " ");
473
+ const lastSpace = sliced.search(/\s\S*$/);
474
+ if (!nextIsSpace && lastSpace > 0) sliced = sliced.slice(0, lastSpace);
475
+ }
476
+ return sliced.replace(/\s+$/, "") + omission;
477
+ }
478
+
479
+ /**
480
+ * Returns the text between `start` and the next `end`, or `undefined` if a
481
+ * marker is missing.
482
+ *
483
+ * @example
484
+ * str.between("Hello [world]!", "[", "]"); // "world"
485
+ *
486
+ * @param {unknown} input
487
+ * @param {string} start
488
+ * @param {string} end
489
+ * @returns {string | undefined}
490
+ */
491
+ function between(input, start, end) {
492
+ const s = String(input);
493
+ const from = s.indexOf(start);
494
+ if (from === -1) return undefined;
495
+ const to = s.indexOf(end, from + start.length);
496
+ return to === -1 ? undefined : s.slice(from + start.length, to);
497
+ }
498
+
499
+ /**
500
+ * Splits at the first occurrence of `separator` only.
501
+ *
502
+ * @example
503
+ * str.splitOnce("key=value=more", "="); // ["key", "value=more"]
504
+ * str.splitOnce("novalue", "="); // ["novalue", undefined]
505
+ *
506
+ * @param {unknown} input
507
+ * @param {string} separator
508
+ * @returns {[string, string | undefined]}
509
+ */
510
+ function splitOnce(input, separator) {
511
+ const s = String(input);
512
+ const index = s.indexOf(separator);
513
+ return index === -1 ? [s, undefined] : [s.slice(0, index), s.slice(index + separator.length)];
514
+ }
515
+
516
+ /**
517
+ * Splits text into lines, whatever the line endings.
518
+ *
519
+ * @example
520
+ * str.lines("a\r\nb\nc"); // ["a", "b", "c"]
521
+ *
522
+ * @param {unknown} input
523
+ * @returns {string[]}
524
+ */
525
+ function lines(input) {
526
+ return String(input).split(/\r\n|\r|\n/);
527
+ }
528
+
529
+ /**
530
+ * Pads the start until the text is `length` characters long.
531
+ *
532
+ * @example
533
+ * str.padStart("7", 3, "0"); // "007"
534
+ *
535
+ * @param {unknown} input
536
+ * @param {number} length
537
+ * @param {string} [char=" "]
538
+ * @returns {string}
539
+ */
540
+ function padStart(input, length, char = " ") {
541
+ return String(input).padStart(length, char);
542
+ }
543
+
544
+ /**
545
+ * Pads the end until the text is `length` characters long.
546
+ *
547
+ * @example
548
+ * str.padEnd("ab", 5, "."); // "ab..."
549
+ *
550
+ * @param {unknown} input
551
+ * @param {number} length
552
+ * @param {string} [char=" "]
553
+ * @returns {string}
554
+ */
555
+ function padEnd(input, length, char = " ") {
556
+ return String(input).padEnd(length, char);
557
+ }
558
+
559
+ /**
560
+ * Centers text within `length` characters. An odd leftover goes to the right.
561
+ *
562
+ * @example
563
+ * str.center("hi", 7, "*"); // "**hi***"
564
+ *
565
+ * @param {unknown} input
566
+ * @param {number} length
567
+ * @param {string} [char=" "]
568
+ * @returns {string}
569
+ */
570
+ function center(input, length, char = " ") {
571
+ const s = String(input);
572
+ const visible = _graphemes(s).length;
573
+ if (visible >= length || !char) return s;
574
+ const total = length - visible;
575
+ const left = Math.floor(total / 2);
576
+ return char.repeat(left) + s + char.repeat(total - left);
577
+ }
578
+
579
+ /**
580
+ * Indents every non-empty line.
581
+ *
582
+ * @example
583
+ * str.indent("a\nb", 2); // " a\n b"
584
+ * str.indent("a\nb", "> "); // "> a\n> b"
585
+ *
586
+ * @param {unknown} input
587
+ * @param {number | string} [prefix=2] A number of spaces, or the prefix itself.
588
+ * @returns {string}
589
+ */
590
+ function indent(input, prefix = 2) {
591
+ const pad = typeof prefix === "number" ? " ".repeat(prefix) : prefix;
592
+ return String(input).replace(/^(?=.)/gm, pad);
593
+ }
594
+
595
+ /**
596
+ * Removes the indentation shared by all lines, and blank first and last
597
+ * lines. Works as a template tag, so multi-line strings can follow your
598
+ * code's indentation.
599
+ *
600
+ * @example
601
+ * const sql = str.dedent`
602
+ * SELECT *
603
+ * FROM users
604
+ * WHERE id = ${id}
605
+ * `;
606
+ * // "SELECT *\n FROM users\n WHERE id = 42"
607
+ *
608
+ * @param {unknown} input
609
+ * @param {...unknown} values
610
+ * @returns {string}
611
+ */
612
+ function dedent(input, ...values) {
613
+ let text;
614
+ if (Array.isArray(input) && Object.prototype.hasOwnProperty.call(input, "raw")) {
615
+ const strings = /** @type {TemplateStringsArray} */ (/** @type {unknown} */ (input));
616
+ text = strings.reduce((acc, part, i) => acc + part + (i < values.length ? String(values[i]) : ""), "");
617
+ } else {
618
+ text = String(input);
619
+ }
620
+ const all = text.replace(/\r\n?/g, "\n").split("\n");
621
+ if (all.length && all[0].trim() === "") all.shift();
622
+ if (all.length && all[all.length - 1].trim() === "") all.pop();
623
+ const indents = all.filter((line) => line.trim()).map((line) => /^[ \t]*/.exec(line)?.[0].length ?? 0);
624
+ const min = indents.length ? Math.min(...indents) : 0;
625
+ return all.map((line) => line.slice(min)).join("\n");
626
+ }
627
+
628
+ /**
629
+ * Wraps text so no line is longer than `width` visible characters. Existing
630
+ * line breaks are kept.
631
+ *
632
+ * @example
633
+ * str.wrap("The quick brown fox jumps over the lazy dog", 16);
634
+ * // "The quick brown\nfox jumps over\nthe lazy dog"
635
+ *
636
+ * @param {unknown} input
637
+ * @param {WrapOptions | number} [options] Options, or just the width.
638
+ * @returns {string}
639
+ */
640
+ function wrap(input, options = {}) {
641
+ const opts = typeof options === "number" ? { width: options } : options;
642
+ const width = Math.max(1, opts.width ?? 80);
643
+ const prefix = opts.indent ?? "";
644
+ const newline = opts.newline ?? "\n";
645
+ /** @type {string[]} */
646
+ const out = [];
647
+ for (const paragraph of String(input).split(/\r\n|\r|\n/)) {
648
+ let line = "";
649
+ for (let word of paragraph.split(/\s+/).filter(Boolean)) {
650
+ while (opts.cut && _graphemes(word).length > width) {
651
+ if (line) {
652
+ out.push(line);
653
+ line = "";
654
+ }
655
+ const chars = _graphemes(word);
656
+ out.push(chars.slice(0, width).join(""));
657
+ word = chars.slice(width).join("");
658
+ }
659
+ if (!word) continue;
660
+ const candidate = line ? `${line} ${word}` : word;
661
+ if (_graphemes(candidate).length > width && line) {
662
+ out.push(line);
663
+ line = word;
664
+ } else {
665
+ line = candidate;
666
+ }
667
+ }
668
+ out.push(line);
669
+ }
670
+ return out.map((l) => prefix + l).join(newline);
671
+ }
672
+
673
+ /**
674
+ * Reverses text without breaking emoji or accents.
675
+ *
676
+ * @example
677
+ * str.reverse("añ👍🏽"); // "👍🏽ña"
678
+ *
679
+ * @param {unknown} input
680
+ * @returns {string}
681
+ */
682
+ function reverse(input) {
683
+ return _graphemes(String(input)).reverse().join("");
684
+ }
685
+
686
+ /**
687
+ * Hides part of a string, like a card number or a token.
688
+ *
689
+ * @example
690
+ * str.mask("4242424242424242"); // "************4242"
691
+ * str.mask("4242 4242 4242 4242", { keepSpaces: true }); // "**** **** **** 4242"
692
+ * str.mask("secret-token", { start: 2, end: 2, char: "•" }); // "se••••••••en"
693
+ *
694
+ * @param {unknown} input
695
+ * @param {MaskOptions} [options]
696
+ * @returns {string}
697
+ */
698
+ function mask(input, options = {}) {
699
+ const chars = _graphemes(String(input));
700
+ const start = Math.max(0, options.start ?? 0);
701
+ const end = Math.max(0, options.end ?? 4);
702
+ const char = options.char ?? "*";
703
+ if (start + end >= chars.length) return chars.join("");
704
+ return chars
705
+ .map((c, i) => {
706
+ if (i < start || i >= chars.length - end) return c;
707
+ if (options.keepSpaces && /[\s-]/.test(c)) return c;
708
+ return char;
709
+ })
710
+ .join("");
711
+ }
712
+
713
+ /**
714
+ * The initials of a name, in uppercase.
715
+ *
716
+ * @example
717
+ * str.initials("Ada Lovelace"); // "AL"
718
+ * str.initials("jean-luc picard", 3); // "JLP"
719
+ *
720
+ * @param {unknown} input
721
+ * @param {number} [max=2] Maximum number of letters.
722
+ * @returns {string}
723
+ */
724
+ function initials(input, max = 2) {
725
+ return _words(input)
726
+ .slice(0, max)
727
+ .map((word) => _graphemes(word)[0].toUpperCase())
728
+ .join("");
729
+ }
730
+
731
+ /**
732
+ * Picks the right plural for a count, following the rules of the language,
733
+ * and puts the formatted count in front.
734
+ *
735
+ * @example
736
+ * str.plural(1, { one: "file", other: "files" }); // "1 file"
737
+ * str.plural(1234, { one: "item", other: "items" }); // "1,234 items"
738
+ * str.plural(0, { zero: "no files", one: "file", other: "files" }); // "no files"
739
+ * str.plural(0, { one: "fichier", other: "fichiers" }, { locale: "fr" }); // "0 fichier"
740
+ *
741
+ * @param {number} value
742
+ * @param {PluralForms} forms
743
+ * @param {PluralOptions} [options]
744
+ * @returns {string}
745
+ */
746
+ function plural(value, forms, options = {}) {
747
+ const locale = options.locale ?? "en";
748
+ if (value === 0 && forms.zero !== undefined) return forms.zero;
749
+ const category = new Intl.PluralRules(locale, { type: options.ordinal ? "ordinal" : "cardinal" }).select(value);
750
+ const word = forms[category] ?? forms.other;
751
+ if (options.includeCount === false) return word;
752
+ return `${new Intl.NumberFormat(locale).format(value)} ${word}`;
753
+ }
754
+
755
+ /**
756
+ * Fills `{placeholders}` with values. Dotted paths like `{user.name}` work,
757
+ * and missing values are left alone unless you give a `fallback`.
758
+ *
759
+ * @example
760
+ * str.template("Hi {name}, you have {count} messages", { name: "Jo", count: 3 });
761
+ * // "Hi Jo, you have 3 messages"
762
+ * str.template("Hello {{ user.name }}", { user: { name: "Ada" } }, { open: "{{", close: "}}" });
763
+ * // "Hello Ada"
764
+ *
765
+ * @param {unknown} input
766
+ * @param {Record<string, any>} data
767
+ * @param {TemplateOptions} [options]
768
+ * @returns {string}
769
+ */
770
+ function template(input, data, options = {}) {
771
+ const open = _escapeRegExp(options.open ?? "{");
772
+ const close = _escapeRegExp(options.close ?? "}");
773
+ const regex = new RegExp(`${open}\\s*([\\w$.-]+)\\s*${close}`, "g");
774
+ return String(input).replace(regex, (match, key) => {
775
+ /** @type {any} */
776
+ let value = data;
777
+ for (const part of String(key).split(".")) value = value == null ? undefined : value[part];
778
+ if (value === undefined || value === null) return options.fallback ?? match;
779
+ return String(value);
780
+ });
781
+ }
782
+
783
+ /**
784
+ * Adds `prefix` unless the text already starts with it.
785
+ *
786
+ * @example
787
+ * str.ensurePrefix("example.com", "https://"); // "https://example.com"
788
+ *
789
+ * @param {unknown} input
790
+ * @param {string} prefix
791
+ * @returns {string}
792
+ */
793
+ function ensurePrefix(input, prefix) {
794
+ const s = String(input);
795
+ return s.startsWith(prefix) ? s : prefix + s;
796
+ }
797
+
798
+ /**
799
+ * Adds `suffix` unless the text already ends with it.
800
+ *
801
+ * @example
802
+ * str.ensureSuffix("path/to", "/"); // "path/to/"
803
+ *
804
+ * @param {unknown} input
805
+ * @param {string} suffix
806
+ * @returns {string}
807
+ */
808
+ function ensureSuffix(input, suffix) {
809
+ const s = String(input);
810
+ return s.endsWith(suffix) ? s : s + suffix;
811
+ }
812
+
813
+ /**
814
+ * Removes `prefix` if the text starts with it.
815
+ *
816
+ * @example
817
+ * str.removePrefix("https://example.com", "https://"); // "example.com"
818
+ *
819
+ * @param {unknown} input
820
+ * @param {string} prefix
821
+ * @returns {string}
822
+ */
823
+ function removePrefix(input, prefix) {
824
+ const s = String(input);
825
+ return prefix && s.startsWith(prefix) ? s.slice(prefix.length) : s;
826
+ }
827
+
828
+ /**
829
+ * Removes `suffix` if the text ends with it.
830
+ *
831
+ * @example
832
+ * str.removeSuffix("report.pdf", ".pdf"); // "report"
833
+ *
834
+ * @param {unknown} input
835
+ * @param {string} suffix
836
+ * @returns {string}
837
+ */
838
+ function removeSuffix(input, suffix) {
839
+ const s = String(input);
840
+ return suffix && s.endsWith(suffix) ? s.slice(0, -suffix.length) : s;
841
+ }
842
+
843
+ /**
844
+ * Compares two strings the way people sort them: numbers in natural order
845
+ * and accents in the right place. Pass it straight to `sort()`.
846
+ *
847
+ * @example
848
+ * ["file10", "file2", "File1"].sort(str.compare); // ["File1", "file2", "file10"]
849
+ * str.compare("a", "A", { sensitivity: "base" }); // 0
850
+ *
851
+ * @param {unknown} a
852
+ * @param {unknown} b
853
+ * @param {CompareOptions} [options]
854
+ * @returns {number} Negative if `a` comes first, positive if `b` does, `0` if equal.
855
+ */
856
+ function compare(a, b, options = {}) {
857
+ const collator =
858
+ options && typeof options === "object" && (options.locale || options.sensitivity || options.numeric === false)
859
+ ? new Intl.Collator(options.locale, { numeric: options.numeric ?? true, sensitivity: options.sensitivity ?? "variant" })
860
+ : (_defaultCollator ??= new Intl.Collator(undefined, { numeric: true, sensitivity: "variant" }));
861
+ return collator.compare(String(a), String(b));
862
+ }
863
+
864
+ /**
865
+ * The number of single-character edits needed to turn `a` into `b`.
866
+ *
867
+ * @example
868
+ * str.levenshtein("kitten", "sitting"); // 3
869
+ *
870
+ * @param {unknown} a
871
+ * @param {unknown} b
872
+ * @returns {number}
873
+ */
874
+ function levenshtein(a, b) {
875
+ const s = _graphemes(String(a));
876
+ const t = _graphemes(String(b));
877
+ if (!s.length) return t.length;
878
+ if (!t.length) return s.length;
879
+ let previous = Array.from({ length: t.length + 1 }, (_, i) => i);
880
+ for (let i = 1; i <= s.length; i++) {
881
+ const current = [i];
882
+ for (let j = 1; j <= t.length; j++) {
883
+ const cost = s[i - 1] === t[j - 1] ? 0 : 1;
884
+ current[j] = Math.min(previous[j] + 1, current[j - 1] + 1, previous[j - 1] + cost);
885
+ }
886
+ previous = current;
887
+ }
888
+ return previous[t.length];
889
+ }
890
+
891
+ /**
892
+ * How alike two strings are, from `0` (nothing in common) to `1` (identical).
893
+ *
894
+ * @example
895
+ * str.similarity("hello", "hallo"); // 0.8
896
+ *
897
+ * @param {unknown} a
898
+ * @param {unknown} b
899
+ * @returns {number}
900
+ */
901
+ function similarity(a, b) {
902
+ const longest = Math.max(length(a), length(b));
903
+ return longest === 0 ? 1 : 1 - levenshtein(a, b) / longest;
904
+ }
905
+
906
+ /**
907
+ * Finds the candidate closest to `input`. Made for "Did you mean...?"
908
+ * suggestions.
909
+ *
910
+ * @example
911
+ * str.closest("instal", ["install", "uninstall", "list"]); // "install"
912
+ * str.closest("xyz", ["install", "list"]); // undefined
913
+ *
914
+ * @param {unknown} input
915
+ * @param {Iterable<string>} candidates
916
+ * @param {ClosestOptions} [options]
917
+ * @returns {string | undefined} `undefined` when nothing is close enough.
918
+ */
919
+ function closest(input, candidates, options = {}) {
920
+ const threshold = options.threshold ?? 0.4;
921
+ const norm = (/** @type {string} */ s) => (options.caseSensitive ? s : s.toLowerCase());
922
+ const needle = norm(String(input));
923
+ let best;
924
+ let bestScore = -1;
925
+ for (const candidate of candidates) {
926
+ const score = similarity(needle, norm(candidate));
927
+ if (score > bestScore) {
928
+ bestScore = score;
929
+ best = candidate;
930
+ }
931
+ }
932
+ return bestScore >= threshold ? best : undefined;
933
+ }
934
+
935
+ /**
936
+ * A random string. It relies on `Math.random`, so don't use it for secrets:
937
+ * `nc.id.token()` and `nc.id.nano()` are made for that.
938
+ *
939
+ * @example
940
+ * str.random(); // "aZ3kP9qL0xYb7TcW"
941
+ * str.random(6, { charset: "0123456789" }); // "402917"
942
+ *
943
+ * @param {number} [size=16]
944
+ * @param {RandomStringOptions | string} [options] Options, or the charset itself.
945
+ * @returns {string}
946
+ */
947
+ function random(size = 16, options = {}) {
948
+ const charset = typeof options === "string" ? options : options.charset ?? "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
949
+ let out = "";
950
+ for (let i = 0; i < size; i++) out += charset.charAt(Math.floor(Math.random() * charset.length));
951
+ return out;
952
+ }
953
+
954
+ module.exports = {
955
+ capitalize,
956
+ titleCase,
957
+ sentenceCase,
958
+ camelCase,
959
+ pascalCase,
960
+ snakeCase,
961
+ kebabCase,
962
+ constantCase,
963
+ dotCase,
964
+ words,
965
+ deburr,
966
+ slugify,
967
+ squish,
968
+ stripTags,
969
+ escapeHTML,
970
+ unescapeHTML,
971
+ escapeRegExp,
972
+ length,
973
+ byteLength,
974
+ count,
975
+ truncate,
976
+ between,
977
+ splitOnce,
978
+ lines,
979
+ padStart,
980
+ padEnd,
981
+ center,
982
+ indent,
983
+ dedent,
984
+ wrap,
985
+ reverse,
986
+ mask,
987
+ initials,
988
+ plural,
989
+ template,
990
+ ensurePrefix,
991
+ ensureSuffix,
992
+ removePrefix,
993
+ removeSuffix,
994
+ compare,
995
+ levenshtein,
996
+ similarity,
997
+ closest,
998
+ random,
999
+ };