@ix-xs/node-comfort 2.0.0 → 2.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (77) hide show
  1. package/CHANGELOG.md +150 -0
  2. package/README.md +32 -30
  3. package/index.js +3 -3
  4. package/package.json +8 -7
  5. package/src/Arr.js +222 -58
  6. package/src/Async.js +322 -90
  7. package/src/Cache.js +148 -44
  8. package/src/Checker.js +257 -26
  9. package/src/Cli.js +404 -145
  10. package/src/Color.js +224 -83
  11. package/src/Crypto.js +481 -106
  12. package/src/Emitter.js +104 -37
  13. package/src/Env.js +176 -42
  14. package/src/FS.js +203 -60
  15. package/src/Func.js +226 -76
  16. package/src/Http.js +311 -149
  17. package/src/Id.js +281 -96
  18. package/src/Logger.js +302 -78
  19. package/src/Num.js +182 -60
  20. package/src/Obj.js +340 -125
  21. package/src/SQLite.js +426 -105
  22. package/src/Schema.js +602 -87
  23. package/src/Str.js +316 -86
  24. package/src/Sys.js +197 -54
  25. package/src/Time.js +572 -317
  26. package/src/Utils.js +62 -41
  27. package/src/errors.js +54 -11
  28. package/src/internal/shared.js +415 -0
  29. package/types/index.d.ts +59 -17
  30. package/types/src/Arr.d.ts +78 -0
  31. package/types/src/Arr.d.ts.map +1 -1
  32. package/types/src/Async.d.ts +52 -1
  33. package/types/src/Async.d.ts.map +1 -1
  34. package/types/src/Cache.d.ts +33 -7
  35. package/types/src/Cache.d.ts.map +1 -1
  36. package/types/src/Checker.d.ts +155 -2
  37. package/types/src/Checker.d.ts.map +1 -1
  38. package/types/src/Cli.d.ts +97 -15
  39. package/types/src/Cli.d.ts.map +1 -1
  40. package/types/src/Color.d.ts +7 -1
  41. package/types/src/Color.d.ts.map +1 -1
  42. package/types/src/Crypto.d.ts +99 -13
  43. package/types/src/Crypto.d.ts.map +1 -1
  44. package/types/src/Emitter.d.ts +36 -17
  45. package/types/src/Emitter.d.ts.map +1 -1
  46. package/types/src/Env.d.ts +31 -1
  47. package/types/src/Env.d.ts.map +1 -1
  48. package/types/src/FS.d.ts +36 -5
  49. package/types/src/FS.d.ts.map +1 -1
  50. package/types/src/Func.d.ts +147 -19
  51. package/types/src/Func.d.ts.map +1 -1
  52. package/types/src/Http.d.ts +113 -43
  53. package/types/src/Http.d.ts.map +1 -1
  54. package/types/src/Id.d.ts +60 -8
  55. package/types/src/Id.d.ts.map +1 -1
  56. package/types/src/Logger.d.ts +53 -2
  57. package/types/src/Logger.d.ts.map +1 -1
  58. package/types/src/Num.d.ts +48 -7
  59. package/types/src/Num.d.ts.map +1 -1
  60. package/types/src/Obj.d.ts +67 -4
  61. package/types/src/Obj.d.ts.map +1 -1
  62. package/types/src/SQLite.d.ts +123 -14
  63. package/types/src/SQLite.d.ts.map +1 -1
  64. package/types/src/Schema.d.ts +256 -7
  65. package/types/src/Schema.d.ts.map +1 -1
  66. package/types/src/Str.d.ts +49 -1
  67. package/types/src/Str.d.ts.map +1 -1
  68. package/types/src/Sys.d.ts +51 -6
  69. package/types/src/Sys.d.ts.map +1 -1
  70. package/types/src/Time.d.ts +130 -34
  71. package/types/src/Time.d.ts.map +1 -1
  72. package/types/src/Utils.d.ts +15 -11
  73. package/types/src/Utils.d.ts.map +1 -1
  74. package/types/src/errors.d.ts +27 -3
  75. package/types/src/errors.d.ts.map +1 -1
  76. package/types/src/internal/shared.d.ts +123 -0
  77. package/types/src/internal/shared.d.ts.map +1 -0
package/src/Str.js CHANGED
@@ -24,7 +24,8 @@
24
24
  * Options for `truncate()`.
25
25
  * @typedef {object} TruncateOptions
26
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.
27
+ * @property {boolean} [words] Cut at the previous space rather than inside a word, when the end is what gets cut.
28
+ * @property {"end" | "middle" | "start"} [position] Where the text is cut. `"middle"` keeps both ends, which suits paths and ids. Defaults to `"end"`.
28
29
  */
29
30
 
30
31
  /**
@@ -94,22 +95,30 @@
94
95
  * @property {string} [charset] Characters to pick from. Defaults to letters and digits.
95
96
  */
96
97
 
97
- // Intl objects are expensive to build, so they are created on first use.
98
- /** @type {Intl.Segmenter | undefined} */
99
- let _segmenter;
98
+ const shared = require("./internal/shared.js");
99
+
100
+ const { intl, graphemes: _graphemes, graphemeCount: _count } = shared;
101
+
100
102
  /** @type {Intl.Collator | undefined} */
101
103
  let _defaultCollator;
102
104
 
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
- };
105
+ const WORD = /[\p{L}\p{N}]+/gu;
106
+ const COMBINING_MARKS = /[\u0300-\u036f]/g;
107
+ const SPECIAL_LATIN = /[\u00df\u1e9e\u00e6\u00c6\u0153\u0152\u00f8\u00d8\u0111\u0110\u00f0\u00d0\u0142\u0141\u00fe\u00de\u0127\u0126\u0131]/g;
108
+ const HTML_UNSAFE = /[&<>"']/;
109
+ const HTML_UNSAFE_ALL = /[&<>"']/g;
110
+ const HTML_ESCAPES = /** @type {Record<string, string>} */ ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" });
111
+ const HTML_ENTITIES = /** @type {Record<string, string>} */ ({
112
+ amp: "&", lt: "<", gt: ">", quot: '"', apos: "'", nbsp: "\u00a0",
113
+ copy: "\u00a9", reg: "\u00ae", trade: "\u2122", hellip: "\u2026", mdash: "\u2014", ndash: "\u2013",
114
+ lsquo: "\u2018", rsquo: "\u2019", ldquo: "\u201c", rdquo: "\u201d", laquo: "\u00ab", raquo: "\u00bb",
115
+ euro: "\u20ac", pound: "\u00a3", yen: "\u00a5", cent: "\u00a2", deg: "\u00b0", middot: "\u00b7",
116
+ bull: "\u2022", times: "\u00d7", divide: "\u00f7", sect: "\u00a7", para: "\u00b6",
117
+ });
118
+ const TEMPLATE_DEFAULT = /\{\s*([\w$.-]+)\s*\}/g;
119
+ /** @type {Map<string, RegExp>} */
120
+ const _templateRegexes = new Map();
121
+ const ORDINAL = { type: "ordinal" };
113
122
 
114
123
  /** Letters that Unicode normalization doesn't decompose. */
115
124
  const _SPECIAL_LATIN = /** @type {Record<string, string>} */ ({
@@ -130,8 +139,96 @@ const _words = (input) =>
130
139
  .split(/\s+/)
131
140
  .filter(Boolean);
132
141
 
142
+ /**
143
+ * Length of the first character in UTF-16 units: 2 for letters outside the
144
+ * basic plane, like mathematical alphabets.
145
+ * @param {string} text
146
+ */
147
+ const _headLength = (text) => ((text.codePointAt(0) ?? 0) > 0xffff ? 2 : 1);
148
+
133
149
  /** @param {string} word */
134
- const _upperFirst = (word) => word.charAt(0).toUpperCase() + word.slice(1);
150
+ const _upperFirst = (word) => {
151
+ const n = _headLength(word);
152
+ return word.slice(0, n).toUpperCase() + word.slice(n);
153
+ };
154
+
155
+ /**
156
+ * Repeats `char` to fill `count` visible characters.
157
+ * @param {string} char
158
+ * @param {number} count
159
+ * @returns {string}
160
+ */
161
+ const _padding = (char, count) => {
162
+ if (!(count > 0) || !char) return "";
163
+ if (char.length === 1) return char.repeat(count);
164
+ const units = _graphemes(char);
165
+ let out = "";
166
+ for (let i = 0; i < count; i++) out += units[i % units.length];
167
+ return out;
168
+ };
169
+
170
+ /**
171
+ * Reads a template value. Members inherited from `Object.prototype`
172
+ * (`constructor`, `toString`...) are not data.
173
+ * @param {any} value
174
+ * @param {string} key
175
+ * @returns {unknown}
176
+ */
177
+ const _templateValue = (value, key) => {
178
+ const object = Object(value);
179
+ if (!(key in object)) return undefined;
180
+ if (!Object.prototype.hasOwnProperty.call(object, key) && key in Object.prototype) return undefined;
181
+ const found = object[key];
182
+ return typeof found === "function" ? undefined : found;
183
+ };
184
+
185
+ /** One row of the edit-distance table, reused between calls. */
186
+ let _row = new Uint32Array(64);
187
+
188
+ /**
189
+ * Levenshtein distance between two sequences of characters.
190
+ * @param {ArrayLike<string>} s
191
+ * @param {ArrayLike<string>} t
192
+ * @returns {number}
193
+ */
194
+ const _distance = (s, t) => {
195
+ let sLength = s.length;
196
+ let tLength = t.length;
197
+ let start = 0;
198
+ while (start < sLength && start < tLength && s[start] === t[start]) start++;
199
+ while (sLength > start && tLength > start && s[sLength - 1] === t[tLength - 1]) {
200
+ sLength--;
201
+ tLength--;
202
+ }
203
+ sLength -= start;
204
+ tLength -= start;
205
+ if (sLength === 0) return tLength;
206
+ if (tLength === 0) return sLength;
207
+ if (_row.length <= tLength) _row = new Uint32Array(tLength * 2);
208
+ const row = _row;
209
+ for (let j = 0; j <= tLength; j++) row[j] = j;
210
+ for (let i = 1; i <= sLength; i++) {
211
+ const char = s[start + i - 1];
212
+ let diagonal = row[0];
213
+ row[0] = i;
214
+ for (let j = 1; j <= tLength; j++) {
215
+ const above = row[j];
216
+ let best = char === t[start + j - 1] ? diagonal : diagonal + 1;
217
+ if (above + 1 < best) best = above + 1;
218
+ if (row[j - 1] + 1 < best) best = row[j - 1] + 1;
219
+ row[j] = best;
220
+ diagonal = above;
221
+ }
222
+ }
223
+ return row[tLength];
224
+ };
225
+
226
+ /**
227
+ * A string as a sequence of visible characters. ASCII strings are already one.
228
+ * @param {string} text
229
+ * @returns {ArrayLike<string>}
230
+ */
231
+ const _units = (text) => (shared.ASCII.test(text) ? text : _graphemes(text));
135
232
 
136
233
  /** @param {string} value */
137
234
  const _escapeRegExp = (value) => value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
@@ -147,7 +244,60 @@ const _escapeRegExp = (value) => value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
147
244
  */
148
245
  function capitalize(input) {
149
246
  const s = String(input);
150
- return s ? s.charAt(0).toUpperCase() + s.slice(1).toLowerCase() : s;
247
+ if (!s) return s;
248
+ const n = _headLength(s);
249
+ return s.slice(0, n).toUpperCase() + s.slice(n).toLowerCase();
250
+ }
251
+
252
+ /**
253
+ * Uppercases the first character and leaves the rest alone.
254
+ *
255
+ * @example
256
+ * str.upperFirst("userName"); // "UserName"
257
+ *
258
+ * @param {unknown} input
259
+ * @returns {string}
260
+ */
261
+ function upperFirst(input) {
262
+ return _upperFirst(String(input));
263
+ }
264
+
265
+ /**
266
+ * Lowercases the first character and leaves the rest alone.
267
+ *
268
+ * @example
269
+ * str.lowerFirst("UserName"); // "userName"
270
+ *
271
+ * @param {unknown} input
272
+ * @returns {string}
273
+ */
274
+ function lowerFirst(input) {
275
+ const s = String(input);
276
+ const n = _headLength(s);
277
+ return s.slice(0, n).toLowerCase() + s.slice(n);
278
+ }
279
+
280
+ /**
281
+ * Turns an identifier into a label people can read. Acronyms stay as they are.
282
+ *
283
+ * @example
284
+ * str.humanize("user_first_name"); // "User first name"
285
+ * str.humanize("createdAt"); // "Created at"
286
+ * str.humanize("userID"); // "User ID"
287
+ *
288
+ * @param {unknown} input
289
+ * @returns {string}
290
+ */
291
+ function humanize(input) {
292
+ const list = _words(input);
293
+ const shouting = list.every((word) => word === word.toUpperCase());
294
+ return list
295
+ .map((word, i) => {
296
+ if (!shouting && word.length > 1 && word === word.toUpperCase() && /\p{Lu}/u.test(word)) return word;
297
+ const lower = word.toLowerCase();
298
+ return i === 0 ? _upperFirst(lower) : lower;
299
+ })
300
+ .join(" ");
151
301
  }
152
302
 
153
303
  /**
@@ -286,10 +436,12 @@ function words(input) {
286
436
  * @returns {string}
287
437
  */
288
438
  function deburr(input) {
289
- return String(input)
439
+ const s = String(input);
440
+ if (shared.ASCII.test(s)) return s;
441
+ return s
290
442
  .normalize("NFKD")
291
- .replace(/[̀-ͯ]/g, "")
292
- .replace(/[ßẞæÆœŒøØđĐðÐłŁþÞħĦı]/g, (c) => _SPECIAL_LATIN[c] ?? c);
443
+ .replace(COMBINING_MARKS, "")
444
+ .replace(SPECIAL_LATIN, (c) => _SPECIAL_LATIN[c] ?? c);
293
445
  }
294
446
 
295
447
  /**
@@ -306,18 +458,21 @@ function deburr(input) {
306
458
  */
307
459
  function slugify(input, options = {}) {
308
460
  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})+$`), "");
461
+ let text = deburr(input);
462
+ if (options.lower ?? true) text = text.toLowerCase();
463
+ const list = text.match(WORD);
464
+ if (!list) return "";
465
+ const slug = list.join(separator);
466
+ const max = options.maxLength;
467
+ if (max === undefined || slug.length <= max) return slug;
468
+ // Keep whole words while they fit; a first word that is too long is cut.
469
+ let length = 0;
470
+ for (let i = 0; i < list.length; i++) {
471
+ const next = length + (i ? separator.length : 0) + list[i].length;
472
+ if (next > max) break;
473
+ length = next;
319
474
  }
320
- return slug;
475
+ return slug.slice(0, length || Math.max(0, max));
321
476
  }
322
477
 
323
478
  /**
@@ -344,7 +499,7 @@ function squish(input) {
344
499
  * @returns {string}
345
500
  */
346
501
  function stripTags(input) {
347
- return String(input).replace(/<\/?[^>]+(>|$)/g, "");
502
+ return String(input).replace(/<!--[\s\S]*?(?:-->|$)|<[/!]?[a-zA-Z][^>]*(?:>|$)/g, "");
348
503
  }
349
504
 
350
505
  /**
@@ -358,8 +513,8 @@ function stripTags(input) {
358
513
  * @returns {string}
359
514
  */
360
515
  function escapeHTML(input) {
361
- const map = /** @type {Record<string, string>} */ ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" });
362
- return String(input).replace(/[&<>"']/g, (c) => map[c]);
516
+ const s = String(input);
517
+ return HTML_UNSAFE.test(s) ? s.replace(HTML_UNSAFE_ALL, (c) => HTML_ESCAPES[c]) : s;
363
518
  }
364
519
 
365
520
  /**
@@ -373,12 +528,16 @@ function escapeHTML(input) {
373
528
  * @returns {string}
374
529
  */
375
530
  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;
531
+ const s = String(input);
532
+ if (!s.includes("&")) return s;
533
+ return s.replace(/&(#x[0-9a-f]+|#\d+|[a-z][a-z0-9]*);/gi, (match, entity) => {
534
+ if (entity.charCodeAt(0) === 35) {
535
+ const hex = entity.charCodeAt(1) === 120 || entity.charCodeAt(1) === 88;
536
+ const code = parseInt(entity.slice(hex ? 2 : 1), hex ? 16 : 10);
537
+ // Zero, out-of-range numbers and lone surrogates are not characters: leave them as written.
538
+ return code === 0 || code > 0x10ffff || (code >= 0xd800 && code <= 0xdfff) || Number.isNaN(code) ? match : String.fromCodePoint(code);
539
+ }
540
+ return HTML_ENTITIES[entity] ?? HTML_ENTITIES[entity.toLowerCase()] ?? match;
382
541
  });
383
542
  }
384
543
 
@@ -407,7 +566,7 @@ function escapeRegExp(input) {
407
566
  * @returns {number}
408
567
  */
409
568
  function length(input) {
410
- return _graphemes(String(input)).length;
569
+ return _count(String(input));
411
570
  }
412
571
 
413
572
  /**
@@ -463,10 +622,20 @@ function count(input, search) {
463
622
  * @returns {string}
464
623
  */
465
624
  function truncate(input, length, options = {}) {
625
+ const s = String(input);
626
+ // A string never has more visible characters than UTF-16 units.
627
+ if (s.length <= length) return s;
628
+ const chars = _graphemes(s);
629
+ if (chars.length <= length) return s;
466
630
  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);
631
+ const keep = length - _count(omission);
632
+ // No room for anything but the omission, and maybe not all of it.
633
+ if (keep <= 0) return _graphemes(omission).slice(0, Math.max(0, length)).join("");
634
+ if (options.position === "start") return omission + chars.slice(chars.length - keep).join("").replace(/^\s+/, "");
635
+ if (options.position === "middle") {
636
+ const head = Math.ceil(keep / 2);
637
+ return chars.slice(0, head).join("") + omission + chars.slice(chars.length - (keep - head)).join("");
638
+ }
470
639
  let sliced = chars.slice(0, keep).join("");
471
640
  if (options.words) {
472
641
  const nextIsSpace = /\s/.test(chars[keep] ?? " ");
@@ -476,6 +645,32 @@ function truncate(input, length, options = {}) {
476
645
  return sliced.replace(/\s+$/, "") + omission;
477
646
  }
478
647
 
648
+ /**
649
+ * Splits text into pieces of `size` visible characters. The last one can be
650
+ * shorter.
651
+ *
652
+ * @example
653
+ * str.chunk("4242424242424242", 4).join(" "); // "4242 4242 4242 4242"
654
+ *
655
+ * @param {unknown} input
656
+ * @param {number} size
657
+ * @returns {string[]}
658
+ */
659
+ function chunk(input, size) {
660
+ const n = Math.floor(size);
661
+ /** @type {string[]} */
662
+ const out = [];
663
+ if (!(n >= 1)) return out;
664
+ const s = String(input);
665
+ if (shared.ASCII.test(s)) {
666
+ for (let i = 0; i < s.length; i += n) out.push(s.slice(i, i + n));
667
+ return out;
668
+ }
669
+ const chars = _graphemes(s);
670
+ for (let i = 0; i < chars.length; i += n) out.push(chars.slice(i, i + n).join(""));
671
+ return out;
672
+ }
673
+
479
674
  /**
480
675
  * Returns the text between `start` and the next `end`, or `undefined` if a
481
676
  * marker is missing.
@@ -538,7 +733,8 @@ function lines(input) {
538
733
  * @returns {string}
539
734
  */
540
735
  function padStart(input, length, char = " ") {
541
- return String(input).padStart(length, char);
736
+ const s = String(input);
737
+ return _padding(char, length - _count(s)) + s;
542
738
  }
543
739
 
544
740
  /**
@@ -553,7 +749,8 @@ function padStart(input, length, char = " ") {
553
749
  * @returns {string}
554
750
  */
555
751
  function padEnd(input, length, char = " ") {
556
- return String(input).padEnd(length, char);
752
+ const s = String(input);
753
+ return s + _padding(char, length - _count(s));
557
754
  }
558
755
 
559
756
  /**
@@ -569,11 +766,10 @@ function padEnd(input, length, char = " ") {
569
766
  */
570
767
  function center(input, length, char = " ") {
571
768
  const s = String(input);
572
- const visible = _graphemes(s).length;
573
- if (visible >= length || !char) return s;
574
- const total = length - visible;
769
+ const total = length - _count(s);
770
+ if (!(total > 0) || !char) return s;
575
771
  const left = Math.floor(total / 2);
576
- return char.repeat(left) + s + char.repeat(total - left);
772
+ return _padding(char, left) + s + _padding(char, total - left);
577
773
  }
578
774
 
579
775
  /**
@@ -620,8 +816,13 @@ function dedent(input, ...values) {
620
816
  const all = text.replace(/\r\n?/g, "\n").split("\n");
621
817
  if (all.length && all[0].trim() === "") all.shift();
622
818
  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;
819
+ let min = Infinity;
820
+ for (const line of all) {
821
+ if (!line.trim()) continue;
822
+ const indentation = /^[ \t]*/.exec(line)?.[0].length ?? 0;
823
+ if (indentation < min) min = indentation;
824
+ }
825
+ if (min === Infinity || min === 0) return all.join("\n");
625
826
  return all.map((line) => line.slice(min)).join("\n");
626
827
  }
627
828
 
@@ -646,28 +847,38 @@ function wrap(input, options = {}) {
646
847
  const out = [];
647
848
  for (const paragraph of String(input).split(/\r\n|\r|\n/)) {
648
849
  let line = "";
649
- for (let word of paragraph.split(/\s+/).filter(Boolean)) {
650
- while (opts.cut && _graphemes(word).length > width) {
850
+ let lineWidth = 0;
851
+ for (let word of paragraph.split(/\s+/)) {
852
+ if (!word) continue;
853
+ let wordWidth = _count(word);
854
+ if (opts.cut && wordWidth > width) {
651
855
  if (line) {
652
856
  out.push(line);
653
857
  line = "";
858
+ lineWidth = 0;
654
859
  }
655
860
  const chars = _graphemes(word);
656
- out.push(chars.slice(0, width).join(""));
657
- word = chars.slice(width).join("");
861
+ let start = 0;
862
+ while (chars.length - start > width) {
863
+ out.push(chars.slice(start, start + width).join(""));
864
+ start += width;
865
+ }
866
+ word = chars.slice(start).join("");
867
+ wordWidth = chars.length - start;
868
+ if (!word) continue;
658
869
  }
659
- if (!word) continue;
660
- const candidate = line ? `${line} ${word}` : word;
661
- if (_graphemes(candidate).length > width && line) {
870
+ if (line && lineWidth + 1 + wordWidth > width) {
662
871
  out.push(line);
663
872
  line = word;
873
+ lineWidth = wordWidth;
664
874
  } else {
665
- line = candidate;
875
+ lineWidth += (line ? 1 : 0) + wordWidth;
876
+ line = line ? `${line} ${word}` : word;
666
877
  }
667
878
  }
668
879
  out.push(line);
669
880
  }
670
- return out.map((l) => prefix + l).join(newline);
881
+ return prefix ? out.map((l) => prefix + l).join(newline) : out.join(newline);
671
882
  }
672
883
 
673
884
  /**
@@ -746,10 +957,10 @@ function initials(input, max = 2) {
746
957
  function plural(value, forms, options = {}) {
747
958
  const locale = options.locale ?? "en";
748
959
  if (value === 0 && forms.zero !== undefined) return forms.zero;
749
- const category = new Intl.PluralRules(locale, { type: options.ordinal ? "ordinal" : "cardinal" }).select(value);
960
+ const category = intl.plural(locale, options.ordinal ? ORDINAL : undefined).select(value);
750
961
  const word = forms[category] ?? forms.other;
751
962
  if (options.includeCount === false) return word;
752
- return `${new Intl.NumberFormat(locale).format(value)} ${word}`;
963
+ return `${intl.number(locale).format(value)} ${word}`;
753
964
  }
754
965
 
755
966
  /**
@@ -768,13 +979,30 @@ function plural(value, forms, options = {}) {
768
979
  * @returns {string}
769
980
  */
770
981
  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");
982
+ let regex = TEMPLATE_DEFAULT;
983
+ if (options.open !== undefined || options.close !== undefined) {
984
+ const open = options.open ?? "{";
985
+ const close = options.close ?? "}";
986
+ const key = `${open}\u0000${close}`;
987
+ const cached = _templateRegexes.get(key);
988
+ if (cached) regex = cached;
989
+ else {
990
+ regex = new RegExp(`${_escapeRegExp(open)}\\s*([\\w$.-]+)\\s*${_escapeRegExp(close)}`, "g");
991
+ if (_templateRegexes.size >= 50) _templateRegexes.clear();
992
+ _templateRegexes.set(key, regex);
993
+ }
994
+ }
774
995
  return String(input).replace(regex, (match, key) => {
775
- /** @type {any} */
996
+ /** @type {unknown} */
776
997
  let value = data;
777
- for (const part of String(key).split(".")) value = value == null ? undefined : value[part];
998
+ if (key.includes(".")) {
999
+ for (const part of key.split(".")) {
1000
+ if (value === undefined || value === null) break;
1001
+ value = _templateValue(value, part);
1002
+ }
1003
+ } else if (value !== undefined && value !== null) {
1004
+ value = _templateValue(value, key);
1005
+ }
778
1006
  if (value === undefined || value === null) return options.fallback ?? match;
779
1007
  return String(value);
780
1008
  });
@@ -856,7 +1084,7 @@ function removeSuffix(input, suffix) {
856
1084
  function compare(a, b, options = {}) {
857
1085
  const collator =
858
1086
  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" })
1087
+ ? intl.collator(options.locale, { numeric: options.numeric ?? true, sensitivity: options.sensitivity ?? "variant" })
860
1088
  : (_defaultCollator ??= new Intl.Collator(undefined, { numeric: true, sensitivity: "variant" }));
861
1089
  return collator.compare(String(a), String(b));
862
1090
  }
@@ -872,20 +1100,9 @@ function compare(a, b, options = {}) {
872
1100
  * @returns {number}
873
1101
  */
874
1102
  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];
1103
+ const x = String(a);
1104
+ const y = String(b);
1105
+ return x === y ? 0 : _distance(_units(x), _units(y));
889
1106
  }
890
1107
 
891
1108
  /**
@@ -899,8 +1116,13 @@ function levenshtein(a, b) {
899
1116
  * @returns {number}
900
1117
  */
901
1118
  function similarity(a, b) {
902
- const longest = Math.max(length(a), length(b));
903
- return longest === 0 ? 1 : 1 - levenshtein(a, b) / longest;
1119
+ const x = String(a);
1120
+ const y = String(b);
1121
+ if (x === y) return 1;
1122
+ const s = _units(x);
1123
+ const t = _units(y);
1124
+ const longest = Math.max(s.length, t.length);
1125
+ return longest === 0 ? 1 : 1 - _distance(s, t) / longest;
904
1126
  }
905
1127
 
906
1128
  /**
@@ -919,11 +1141,15 @@ function similarity(a, b) {
919
1141
  function closest(input, candidates, options = {}) {
920
1142
  const threshold = options.threshold ?? 0.4;
921
1143
  const norm = (/** @type {string} */ s) => (options.caseSensitive ? s : s.toLowerCase());
922
- const needle = norm(String(input));
1144
+ const needle = _units(norm(String(input)));
923
1145
  let best;
924
1146
  let bestScore = -1;
925
1147
  for (const candidate of candidates) {
926
- const score = similarity(needle, norm(candidate));
1148
+ const units = _units(norm(candidate));
1149
+ const longest = Math.max(needle.length, units.length);
1150
+ // The length gap alone bounds the score: skip candidates that can't win.
1151
+ if (longest > 0 && 1 - Math.abs(needle.length - units.length) / longest <= bestScore) continue;
1152
+ const score = longest === 0 ? 1 : 1 - _distance(needle, units) / longest;
927
1153
  if (score > bestScore) {
928
1154
  bestScore = score;
929
1155
  best = candidate;
@@ -953,6 +1179,9 @@ function random(size = 16, options = {}) {
953
1179
 
954
1180
  module.exports = {
955
1181
  capitalize,
1182
+ upperFirst,
1183
+ lowerFirst,
1184
+ humanize,
956
1185
  titleCase,
957
1186
  sentenceCase,
958
1187
  camelCase,
@@ -973,6 +1202,7 @@ module.exports = {
973
1202
  byteLength,
974
1203
  count,
975
1204
  truncate,
1205
+ chunk,
976
1206
  between,
977
1207
  splitOnce,
978
1208
  lines,