@ix-xs/node-comfort 1.2.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 (78) hide show
  1. package/CHANGELOG.md +340 -0
  2. package/README.md +137 -548
  3. package/index.js +80 -35
  4. package/package.json +161 -33
  5. package/src/Arr.js +1050 -418
  6. package/src/Async.js +817 -0
  7. package/src/Cache.js +433 -0
  8. package/src/Checker.js +1202 -539
  9. package/src/Cli.js +1293 -0
  10. package/src/Color.js +556 -0
  11. package/src/Crypto.js +1082 -0
  12. package/src/Emitter.js +356 -0
  13. package/src/Env.js +689 -0
  14. package/src/FS.js +1626 -894
  15. package/src/Func.js +823 -356
  16. package/src/Http.js +700 -0
  17. package/src/Id.js +568 -200
  18. package/src/Logger.js +963 -378
  19. package/src/Num.js +965 -360
  20. package/src/Obj.js +1077 -379
  21. package/src/SQLite.js +1629 -1005
  22. package/src/Schema.js +2101 -0
  23. package/src/Str.js +1209 -432
  24. package/src/Sys.js +654 -0
  25. package/src/Time.js +1533 -440
  26. package/src/Utils.js +342 -325
  27. package/src/errors.js +335 -0
  28. package/src/internal/shared.js +415 -0
  29. package/types/index.d.ts +735 -526
  30. package/types/src/Arr.d.ts +533 -145
  31. package/types/src/Arr.d.ts.map +1 -1
  32. package/types/src/Async.d.ts +372 -0
  33. package/types/src/Async.d.ts.map +1 -0
  34. package/types/src/Cache.d.ts +221 -0
  35. package/types/src/Cache.d.ts.map +1 -0
  36. package/types/src/Checker.d.ts +651 -232
  37. package/types/src/Checker.d.ts.map +1 -1
  38. package/types/src/Cli.d.ts +664 -0
  39. package/types/src/Cli.d.ts.map +1 -0
  40. package/types/src/Color.d.ts +300 -0
  41. package/types/src/Color.d.ts.map +1 -0
  42. package/types/src/Crypto.d.ts +620 -0
  43. package/types/src/Crypto.d.ts.map +1 -0
  44. package/types/src/Emitter.d.ts +189 -0
  45. package/types/src/Emitter.d.ts.map +1 -0
  46. package/types/src/Env.d.ts +412 -0
  47. package/types/src/Env.d.ts.map +1 -0
  48. package/types/src/FS.d.ts +717 -238
  49. package/types/src/FS.d.ts.map +1 -1
  50. package/types/src/Func.d.ts +508 -135
  51. package/types/src/Func.d.ts.map +1 -1
  52. package/types/src/Http.d.ts +444 -0
  53. package/types/src/Http.d.ts.map +1 -0
  54. package/types/src/Id.d.ts +199 -66
  55. package/types/src/Id.d.ts.map +1 -1
  56. package/types/src/Logger.d.ts +414 -46
  57. package/types/src/Logger.d.ts.map +1 -1
  58. package/types/src/Num.d.ts +514 -137
  59. package/types/src/Num.d.ts.map +1 -1
  60. package/types/src/Obj.d.ts +453 -104
  61. package/types/src/Obj.d.ts.map +1 -1
  62. package/types/src/SQLite.d.ts +754 -308
  63. package/types/src/SQLite.d.ts.map +1 -1
  64. package/types/src/Schema.d.ts +1189 -0
  65. package/types/src/Schema.d.ts.map +1 -0
  66. package/types/src/Str.d.ts +617 -166
  67. package/types/src/Str.d.ts.map +1 -1
  68. package/types/src/Sys.d.ts +314 -0
  69. package/types/src/Sys.d.ts.map +1 -0
  70. package/types/src/Time.d.ts +759 -204
  71. package/types/src/Time.d.ts.map +1 -1
  72. package/types/src/Utils.d.ts +158 -102
  73. package/types/src/Utils.d.ts.map +1 -1
  74. package/types/src/errors.d.ts +316 -0
  75. package/types/src/errors.d.ts.map +1 -0
  76. package/types/src/internal/shared.d.ts +123 -0
  77. package/types/src/internal/shared.d.ts.map +1 -0
  78. package/types/index.d.ts.map +0 -1
package/src/Str.js CHANGED
@@ -1,452 +1,1229 @@
1
+ "use strict";
2
+
1
3
  /**
2
- * String utilities.
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.
3
8
  *
4
- * A curated set of the string helpers you end up rewriting in every project:
5
- * case conversion, slugs, truncation, padding, template rendering, and more.
6
- * Zero dependencies, Unicode-aware where it matters.
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, 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"`.
29
+ */
30
+
31
+ /**
32
+ * Options for `template()`.
33
+ * @typedef {object} TemplateOptions
34
+ * @property {string} [open] Opening delimiter. Defaults to `"{"`.
35
+ * @property {string} [close] Closing delimiter. Defaults to `"}"`.
36
+ * @property {string} [fallback] Used for missing values. Without it, their placeholders stay as they are.
7
37
  */
8
38
 
9
39
  /**
10
- * Splits a string into its "words", handling camelCase, PascalCase,
11
- * snake_case, kebab-case, spaces and digits.
12
- * @private
13
- * @param {string} str
40
+ * Options for `wrap()`.
41
+ * @typedef {object} WrapOptions
42
+ * @property {number} [width] Maximum visible characters per line. Defaults to `80`.
43
+ * @property {string} [indent] Added at the start of every line.
44
+ * @property {boolean} [cut] Break words longer than `width` instead of letting them overflow.
45
+ * @property {string} [newline] Line separator. Defaults to `"\n"`.
46
+ */
47
+
48
+ /**
49
+ * Options for `mask()`.
50
+ * @typedef {object} MaskOptions
51
+ * @property {number} [start] Characters left visible at the start. Defaults to `0`.
52
+ * @property {number} [end] Characters left visible at the end. Defaults to `4`.
53
+ * @property {string} [char] Mask character. Defaults to `"*"`.
54
+ * @property {boolean} [keepSpaces] Leave spaces and dashes visible, nice for card numbers.
55
+ */
56
+
57
+ /**
58
+ * The word forms for `plural()`. Only `other` is required; add the others
59
+ * when your language needs them.
60
+ * @typedef {object} PluralForms
61
+ * @property {string} other The general plural, like `"files"`.
62
+ * @property {string} [zero] Used for `0` in any language, like `"no files"`.
63
+ * @property {string} [one] The singular, like `"file"`. French also uses it for 0.
64
+ * @property {string} [two] Dual form (Arabic, Hebrew, Slovenian...).
65
+ * @property {string} [few] "Few" form (Polish, Czech, Russian...).
66
+ * @property {string} [many] "Many" form (Polish, Russian, Arabic...).
67
+ */
68
+
69
+ /**
70
+ * Options for `plural()`.
71
+ * @typedef {object} PluralOptions
72
+ * @property {string} [locale] Language whose plural rules apply. Defaults to `"en"`.
73
+ * @property {boolean} [includeCount] Put the formatted count before the word. Defaults to `true`.
74
+ * @property {boolean} [ordinal] Use ordinal rules (1st, 2nd, 3rd).
75
+ */
76
+
77
+ /**
78
+ * Options for `compare()`.
79
+ * @typedef {object} CompareOptions
80
+ * @property {string} [locale] Language used for alphabetical order. Defaults to the system's.
81
+ * @property {boolean} [numeric] Sort numbers naturally, `"file2"` before `"file10"`. Defaults to `true`.
82
+ * @property {"base"|"accent"|"case"|"variant"} [sensitivity] Which differences count. `"base"` ignores accents and case. Defaults to `"variant"`.
83
+ */
84
+
85
+ /**
86
+ * Options for `closest()`.
87
+ * @typedef {object} ClosestOptions
88
+ * @property {number} [threshold] Minimum similarity (0 to 1) to accept a match. Defaults to `0.4`.
89
+ * @property {boolean} [caseSensitive] Take case into account.
90
+ */
91
+
92
+ /**
93
+ * Options for `random()`.
94
+ * @typedef {object} RandomStringOptions
95
+ * @property {string} [charset] Characters to pick from. Defaults to letters and digits.
96
+ */
97
+
98
+ const shared = require("./internal/shared.js");
99
+
100
+ const { intl, graphemes: _graphemes, graphemeCount: _count } = shared;
101
+
102
+ /** @type {Intl.Collator | undefined} */
103
+ let _defaultCollator;
104
+
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" };
122
+
123
+ /** Letters that Unicode normalization doesn't decompose. */
124
+ const _SPECIAL_LATIN = /** @type {Record<string, string>} */ ({
125
+ ß: "ss", ẞ: "SS", æ: "ae", Æ: "AE", œ: "oe", Œ: "OE", ø: "o", Ø: "O",
126
+ đ: "d", Đ: "D", ð: "d", Ð: "D", ł: "l", Ł: "L", þ: "th", Þ: "TH", ħ: "h", Ħ: "H", ı: "i",
127
+ });
128
+
129
+ /**
130
+ * @param {unknown} input
14
131
  * @returns {string[]}
15
132
  */
16
- const _words = (str) => {
17
- return String(str)
18
- .replace(/([a-z0-9])([A-Z])/g, "$1 $2")
19
- .replace(/([A-Z]+)([A-Z][a-z])/g, "$1 $2")
20
- .replace(/[_\-.]+/g, " ")
133
+ const _words = (input) =>
134
+ String(input)
135
+ .replace(/(\p{Ll}|\p{N})(\p{Lu})/gu, "$1 $2")
136
+ .replace(/(\p{Lu}+)(\p{Lu}\p{Ll})/gu, "$1 $2")
21
137
  .replace(/[^\p{L}\p{N}]+/gu, " ")
22
138
  .trim()
23
139
  .split(/\s+/)
24
140
  .filter(Boolean);
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
+
149
+ /** @param {string} word */
150
+ const _upperFirst = (word) => {
151
+ const n = _headLength(word);
152
+ return word.slice(0, n).toUpperCase() + word.slice(n);
25
153
  };
26
154
 
27
- module.exports = {
28
- /**
29
- * Capitalizes the first character of a string and lowercases the rest.
30
- *
31
- * @example
32
- * nodeComfort.str.capitalize("hELLO"); // "Hello"
33
- *
34
- * @param {string} str - Input string.
35
- * @returns {string} The capitalized string.
36
- */
37
- capitalize(str) {
38
- str = String(str);
39
- if (!str) return str;
40
- return str.charAt(0).toUpperCase() + str.slice(1).toLowerCase();
41
- },
42
-
43
- /**
44
- * Uppercases the first character of each word (Title Case), leaving the rest untouched.
45
- *
46
- * @example
47
- * nodeComfort.str.titleCase("the quick brown fox"); // "The Quick Brown Fox"
48
- *
49
- * @param {string} str - Input string.
50
- * @returns {string} The title-cased string.
51
- */
52
- titleCase(str) {
53
- return String(str).replace(/\b\p{L}/gu, (c) => c.toUpperCase());
54
- },
55
-
56
- /**
57
- * Converts a string to `camelCase`.
58
- *
59
- * @example
60
- * nodeComfort.str.camelCase("hello world"); // "helloWorld"
61
- * nodeComfort.str.camelCase("foo-bar_baz"); // "fooBarBaz"
62
- *
63
- * @param {string} str - Input string.
64
- * @returns {string} The camelCased string.
65
- */
66
- camelCase(str) {
67
- return _words(str)
68
- .map((word, i) =>
69
- i === 0
70
- ? word.toLowerCase()
71
- : word.charAt(0).toUpperCase() + word.slice(1).toLowerCase(),
72
- )
73
- .join("");
74
- },
75
-
76
- /**
77
- * Converts a string to `PascalCase`.
78
- *
79
- * @example
80
- * nodeComfort.str.pascalCase("hello world"); // "HelloWorld"
81
- *
82
- * @param {string} str - Input string.
83
- * @returns {string} The PascalCased string.
84
- */
85
- pascalCase(str) {
86
- return _words(str)
87
- .map((word) => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase())
88
- .join("");
89
- },
90
-
91
- /**
92
- * Converts a string to `snake_case`.
93
- *
94
- * @example
95
- * nodeComfort.str.snakeCase("helloWorld"); // "hello_world"
96
- *
97
- * @param {string} str - Input string.
98
- * @returns {string} The snake_cased string.
99
- */
100
- snakeCase(str) {
101
- return _words(str).map((word) => word.toLowerCase()).join("_");
102
- },
103
-
104
- /**
105
- * Converts a string to `kebab-case`.
106
- *
107
- * @example
108
- * nodeComfort.str.kebabCase("helloWorld"); // "hello-world"
109
- *
110
- * @param {string} str - Input string.
111
- * @returns {string} The kebab-cased string.
112
- */
113
- kebabCase(str) {
114
- return _words(str).map((word) => word.toLowerCase()).join("-");
115
- },
116
-
117
- /**
118
- * Converts a string to `CONSTANT_CASE`.
119
- *
120
- * @example
121
- * nodeComfort.str.constantCase("helloWorld"); // "HELLO_WORLD"
122
- *
123
- * @param {string} str - Input string.
124
- * @returns {string} The CONSTANT_CASED string.
125
- */
126
- constantCase(str) {
127
- return _words(str).map((word) => word.toUpperCase()).join("_");
128
- },
129
-
130
- /**
131
- * Converts a string into a URL-friendly slug.
132
- *
133
- * Accents/diacritics are stripped, non-alphanumeric runs become the separator.
134
- *
135
- * @example
136
- * nodeComfort.str.slugify("Héllo, World!"); // "hello-world"
137
- * nodeComfort.str.slugify("A B C", { separator: "_" }); // "a_b_c"
138
- *
139
- * @param {string} str - Input string.
140
- * @param {{ separator?: string, lower?: boolean }} [options] - Slug options.
141
- * @returns {string} The slug.
142
- */
143
- slugify(str, options = {}) {
144
- const separator = options.separator ?? "-";
145
- const lower = options.lower ?? true;
146
- const result = String(str)
147
- .normalize("NFKD")
148
- .replace(/[̀-ͯ]/g, "")
149
- .replace(/[^\p{L}\p{N}]+/gu, separator)
150
- .replace(new RegExp(`\\${separator}{2,}`, "g"), separator)
151
- .replace(new RegExp(`^\\${separator}|\\${separator}$`, "g"), "");
152
- return lower ? result.toLowerCase() : result;
153
- },
154
-
155
- /**
156
- * Truncates a string to a maximum length, appending an ellipsis if cut.
157
- *
158
- * @example
159
- * nodeComfort.str.truncate("Hello world", 8); // "Hello…"
160
- * nodeComfort.str.truncate("Hello world", 8, { omission: "..." }); // "Hello..."
161
- *
162
- * @param {string} str - Input string.
163
- * @param {number} length - Maximum length of the result (including the omission).
164
- * @param {{ omission?: string, words?: boolean }} [options] - Truncation options.
165
- * @returns {string} The truncated string.
166
- */
167
- truncate(str, length, options = {}) {
168
- str = String(str);
169
- const omission = options.omission ?? "…";
170
- if (str.length <= length) return str;
171
-
172
- const cut = Math.max(0, length - omission.length);
173
- let sliced = str.slice(0, cut);
174
-
175
- if (options.words) {
176
- const lastSpace = sliced.lastIndexOf(" ");
177
- if (lastSpace > 0) sliced = sliced.slice(0, lastSpace);
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;
178
221
  }
179
- return sliced.replace(/\s+$/, "") + omission;
180
- },
181
-
182
- /**
183
- * Pads a string on the left (start) to a target length.
184
- *
185
- * @param {string} str - Input string.
186
- * @param {number} length - Target total length.
187
- * @param {string} [char=" "] - Padding character(s).
188
- * @returns {string} The padded string.
189
- */
190
- padStart(str, length, char = " ") {
191
- return String(str).padStart(length, char);
192
- },
193
-
194
- /**
195
- * Pads a string on the right (end) to a target length.
196
- *
197
- * @param {string} str - Input string.
198
- * @param {number} length - Target total length.
199
- * @param {string} [char=" "] - Padding character(s).
200
- * @returns {string} The padded string.
201
- */
202
- padEnd(str, length, char = " ") {
203
- return String(str).padEnd(length, char);
204
- },
205
-
206
- /**
207
- * Centers a string within a target length by padding both sides.
208
- *
209
- * @example
210
- * nodeComfort.str.center("hi", 6); // " hi "
211
- * nodeComfort.str.center("hi", 6, "*"); // "**hi**"
212
- *
213
- * @param {string} str - Input string.
214
- * @param {number} length - Target total length.
215
- * @param {string} [char=" "] - Padding character.
216
- * @returns {string} The centered string.
217
- */
218
- center(str, length, char = " ") {
219
- str = String(str);
220
- if (str.length >= length) return str;
221
- const total = length - str.length;
222
- const left = Math.floor(total / 2);
223
- const right = total - left;
224
- return char.repeat(left) + str + char.repeat(right);
225
- },
226
-
227
- /**
228
- * Reverses a string (Unicode code-point aware, so emojis stay intact).
229
- *
230
- * @example
231
- * nodeComfort.str.reverse("abc"); // "cba"
232
- *
233
- * @param {string} str - Input string.
234
- * @returns {string} The reversed string.
235
- */
236
- reverse(str) {
237
- return [...String(str)].reverse().join("");
238
- },
239
-
240
- /**
241
- * Collapses all runs of whitespace into single spaces and trims the ends.
242
- *
243
- * @example
244
- * nodeComfort.str.squish(" hello world \n"); // "hello world"
245
- *
246
- * @param {string} str - Input string.
247
- * @returns {string} The normalized string.
248
- */
249
- squish(str) {
250
- return String(str).replace(/\s+/g, " ").trim();
251
- },
252
-
253
- /**
254
- * Removes all HTML tags from a string.
255
- *
256
- * @example
257
- * nodeComfort.str.stripTags("<b>Hi</b> there"); // "Hi there"
258
- *
259
- * @param {string} str - Input string.
260
- * @returns {string} The string with tags removed.
261
- */
262
- stripTags(str) {
263
- return String(str).replace(/<\/?[^>]+(>|$)/g, "");
264
- },
265
-
266
- /**
267
- * Escapes HTML-sensitive characters (`& < > " '`).
268
- *
269
- * @example
270
- * nodeComfort.str.escapeHTML('<a href="x">'); // "&lt;a href=&quot;x&quot;&gt;"
271
- *
272
- * @param {string} str - Input string.
273
- * @returns {string} The escaped string.
274
- */
275
- escapeHTML(str) {
276
- const map = { "&": "&amp;", "<": "&lt;", ">": "&gt;", "\"": "&quot;", "'": "&#39;" };
277
- return String(str).replace(/[&<>"']/g, (c) => map[c]);
278
- },
279
-
280
- /**
281
- * Reverses {@link escapeHTML}: turns HTML entities back into characters.
282
- *
283
- * @param {string} str - Input string.
284
- * @returns {string} The unescaped string.
285
- */
286
- unescapeHTML(str) {
287
- const map = { "&amp;": "&", "&lt;": "<", "&gt;": ">", "&quot;": "\"", "&#39;": "'", "&#x27;": "'" };
288
- return String(str).replace(/&(amp|lt|gt|quot|#39|#x27);/g, (m) => map[m]);
289
- },
290
-
291
- /**
292
- * Escapes a string for safe use inside a `RegExp`.
293
- *
294
- * @example
295
- * new RegExp(nodeComfort.str.escapeRegExp("a.b")); // matches literal "a.b"
296
- *
297
- * @param {string} str - Input string.
298
- * @returns {string} The escaped string.
299
- */
300
- escapeRegExp(str) {
301
- return String(str).replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
302
- },
303
-
304
- /**
305
- * Counts the number of non-overlapping occurrences of a substring.
306
- *
307
- * @example
308
- * nodeComfort.str.count("banana", "a"); // 3
309
- *
310
- * @param {string} str - Input string.
311
- * @param {string} sub - Substring to count.
312
- * @returns {number} Number of occurrences.
313
- */
314
- count(str, sub) {
315
- str = String(str);
316
- if (!sub) return 0;
317
- let count = 0;
318
- let pos = str.indexOf(sub);
319
- while (pos !== -1) {
320
- count++;
321
- pos = str.indexOf(sub, pos + sub.length);
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));
232
+
233
+ /** @param {string} value */
234
+ const _escapeRegExp = (value) => value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
235
+
236
+ /**
237
+ * Uppercases the first character and lowercases the rest.
238
+ *
239
+ * @example
240
+ * str.capitalize("hELLO wORLD"); // "Hello world"
241
+ *
242
+ * @param {unknown} input
243
+ * @returns {string}
244
+ */
245
+ function capitalize(input) {
246
+ const s = String(input);
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(" ");
301
+ }
302
+
303
+ /**
304
+ * Uppercases the first letter of each word and leaves the rest alone.
305
+ *
306
+ * @example
307
+ * str.titleCase("the quick brown fox"); // "The Quick Brown Fox"
308
+ * str.titleCase("élodie o'neil"); // "Élodie O'neil"
309
+ *
310
+ * @param {unknown} input
311
+ * @returns {string}
312
+ */
313
+ function titleCase(input) {
314
+ return String(input).replace(/(^|[^\p{L}\p{N}'’])(\p{L})/gu, (_, before, letter) => before + letter.toUpperCase());
315
+ }
316
+
317
+ /**
318
+ * Lowercases everything, then capitalizes the start of each sentence.
319
+ *
320
+ * @example
321
+ * str.sentenceCase("hELLO WORLD. how ARE you?"); // "Hello world. How are you?"
322
+ *
323
+ * @param {unknown} input
324
+ * @returns {string}
325
+ */
326
+ function sentenceCase(input) {
327
+ return String(input)
328
+ .toLowerCase()
329
+ .replace(/(^\s*|[.!?]\s+)(\p{L})/gu, (_, before, letter) => before + letter.toUpperCase());
330
+ }
331
+
332
+ /**
333
+ * Converts to `camelCase`.
334
+ *
335
+ * @example
336
+ * str.camelCase("user_first-name"); // "userFirstName"
337
+ * str.camelCase("XMLHttpRequest"); // "xmlHttpRequest"
338
+ *
339
+ * @param {unknown} input
340
+ * @returns {string}
341
+ */
342
+ function camelCase(input) {
343
+ return _words(input)
344
+ .map((word, i) => (i === 0 ? word.toLowerCase() : _upperFirst(word.toLowerCase())))
345
+ .join("");
346
+ }
347
+
348
+ /**
349
+ * Converts to `PascalCase`.
350
+ *
351
+ * @example
352
+ * str.pascalCase("hello world"); // "HelloWorld"
353
+ *
354
+ * @param {unknown} input
355
+ * @returns {string}
356
+ */
357
+ function pascalCase(input) {
358
+ return _words(input).map((word) => _upperFirst(word.toLowerCase())).join("");
359
+ }
360
+
361
+ /**
362
+ * Converts to `snake_case`.
363
+ *
364
+ * @example
365
+ * str.snakeCase("helloWorld"); // "hello_world"
366
+ *
367
+ * @param {unknown} input
368
+ * @returns {string}
369
+ */
370
+ function snakeCase(input) {
371
+ return _words(input).map((word) => word.toLowerCase()).join("_");
372
+ }
373
+
374
+ /**
375
+ * Converts to `kebab-case`.
376
+ *
377
+ * @example
378
+ * str.kebabCase("helloWorld"); // "hello-world"
379
+ *
380
+ * @param {unknown} input
381
+ * @returns {string}
382
+ */
383
+ function kebabCase(input) {
384
+ return _words(input).map((word) => word.toLowerCase()).join("-");
385
+ }
386
+
387
+ /**
388
+ * Converts to `CONSTANT_CASE`.
389
+ *
390
+ * @example
391
+ * str.constantCase("helloWorld"); // "HELLO_WORLD"
392
+ *
393
+ * @param {unknown} input
394
+ * @returns {string}
395
+ */
396
+ function constantCase(input) {
397
+ return _words(input).map((word) => word.toUpperCase()).join("_");
398
+ }
399
+
400
+ /**
401
+ * Converts to `dot.case`.
402
+ *
403
+ * @example
404
+ * str.dotCase("userFirstName"); // "user.first.name"
405
+ *
406
+ * @param {unknown} input
407
+ * @returns {string}
408
+ */
409
+ function dotCase(input) {
410
+ return _words(input).map((word) => word.toLowerCase()).join(".");
411
+ }
412
+
413
+ /**
414
+ * Splits text into words. Understands camelCase, snake_case, kebab-case,
415
+ * dots, spaces and punctuation.
416
+ *
417
+ * @example
418
+ * str.words("helloWorld-foo_bar"); // ["hello", "World", "foo", "bar"]
419
+ *
420
+ * @param {unknown} input
421
+ * @returns {string[]}
422
+ */
423
+ function words(input) {
424
+ return _words(input);
425
+ }
426
+
427
+ /**
428
+ * Removes accents (`é` becomes `e`) and spells out special Latin letters
429
+ * (`ß` becomes `ss`, `æ` becomes `ae`).
430
+ *
431
+ * @example
432
+ * str.deburr("Crème brûlée"); // "Creme brulee"
433
+ * str.deburr("Straße"); // "Strasse"
434
+ *
435
+ * @param {unknown} input
436
+ * @returns {string}
437
+ */
438
+ function deburr(input) {
439
+ const s = String(input);
440
+ if (shared.ASCII.test(s)) return s;
441
+ return s
442
+ .normalize("NFKD")
443
+ .replace(COMBINING_MARKS, "")
444
+ .replace(SPECIAL_LATIN, (c) => _SPECIAL_LATIN[c] ?? c);
445
+ }
446
+
447
+ /**
448
+ * Turns text into a URL slug: no accents, lowercase, words joined by dashes.
449
+ *
450
+ * @example
451
+ * str.slugify("Héllo, World!"); // "hello-world"
452
+ * str.slugify("Crème Brûlée", { separator: "_" }); // "creme_brulee"
453
+ * str.slugify("A very long title", { maxLength: 8 }); // "a-very"
454
+ *
455
+ * @param {unknown} input
456
+ * @param {SlugifyOptions} [options]
457
+ * @returns {string} The slug, possibly empty.
458
+ */
459
+ function slugify(input, options = {}) {
460
+ const separator = options.separator ?? "-";
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;
474
+ }
475
+ return slug.slice(0, length || Math.max(0, max));
476
+ }
477
+
478
+ /**
479
+ * Collapses runs of whitespace into single spaces and trims the ends.
480
+ *
481
+ * @example
482
+ * str.squish(" hello \n\t world "); // "hello world"
483
+ *
484
+ * @param {unknown} input
485
+ * @returns {string}
486
+ */
487
+ function squish(input) {
488
+ return String(input).replace(/\s+/g, " ").trim();
489
+ }
490
+
491
+ /**
492
+ * Removes HTML tags and keeps the text. This is not a sanitizer: to show
493
+ * untrusted text in HTML, use `escapeHTML()`.
494
+ *
495
+ * @example
496
+ * str.stripTags("<p>Hello <b>world</b></p>"); // "Hello world"
497
+ *
498
+ * @param {unknown} input
499
+ * @returns {string}
500
+ */
501
+ function stripTags(input) {
502
+ return String(input).replace(/<!--[\s\S]*?(?:-->|$)|<[/!]?[a-zA-Z][^>]*(?:>|$)/g, "");
503
+ }
504
+
505
+ /**
506
+ * Escapes `& < > " '` so the text is safe inside HTML content and attributes.
507
+ *
508
+ * @example
509
+ * str.escapeHTML('<a href="x">Tom & Jerry</a>');
510
+ * // "&lt;a href=&quot;x&quot;&gt;Tom &amp; Jerry&lt;/a&gt;"
511
+ *
512
+ * @param {unknown} input
513
+ * @returns {string}
514
+ */
515
+ function escapeHTML(input) {
516
+ const s = String(input);
517
+ return HTML_UNSAFE.test(s) ? s.replace(HTML_UNSAFE_ALL, (c) => HTML_ESCAPES[c]) : s;
518
+ }
519
+
520
+ /**
521
+ * Decodes HTML entities: named ones like `&amp;` and `&nbsp;`, and numeric
522
+ * ones like `&#233;`.
523
+ *
524
+ * @example
525
+ * str.unescapeHTML("Tom &amp; Jerry &#x1F600;"); // "Tom & Jerry 😀"
526
+ *
527
+ * @param {unknown} input
528
+ * @returns {string}
529
+ */
530
+ function unescapeHTML(input) {
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);
322
539
  }
323
- return count;
324
- },
325
-
326
- /**
327
- * Ensures a string starts with a given prefix (adds it only if missing).
328
- *
329
- * @example
330
- * nodeComfort.str.ensurePrefix("example.com", "https://"); // "https://example.com"
331
- *
332
- * @param {string} str - Input string.
333
- * @param {string} prefix - Prefix to ensure.
334
- * @returns {string} The string with the prefix guaranteed.
335
- */
336
- ensurePrefix(str, prefix) {
337
- str = String(str);
338
- return str.startsWith(prefix) ? str : prefix + str;
339
- },
340
-
341
- /**
342
- * Ensures a string ends with a given suffix (adds it only if missing).
343
- *
344
- * @example
345
- * nodeComfort.str.ensureSuffix("path", "/"); // "path/"
346
- *
347
- * @param {string} str - Input string.
348
- * @param {string} suffix - Suffix to ensure.
349
- * @returns {string} The string with the suffix guaranteed.
350
- */
351
- ensureSuffix(str, suffix) {
352
- str = String(str);
353
- return str.endsWith(suffix) ? str : str + suffix;
354
- },
355
-
356
- /**
357
- * Removes a prefix from a string if present.
358
- *
359
- * @param {string} str - Input string.
360
- * @param {string} prefix - Prefix to remove.
361
- * @returns {string} The string without the prefix.
362
- */
363
- removePrefix(str, prefix) {
364
- str = String(str);
365
- return str.startsWith(prefix) ? str.slice(prefix.length) : str;
366
- },
367
-
368
- /**
369
- * Removes a suffix from a string if present.
370
- *
371
- * @param {string} str - Input string.
372
- * @param {string} suffix - Suffix to remove.
373
- * @returns {string} The string without the suffix.
374
- */
375
- removeSuffix(str, suffix) {
376
- str = String(str);
377
- return suffix && str.endsWith(suffix) ? str.slice(0, -suffix.length) : str;
378
- },
379
-
380
- /**
381
- * Renders a template, replacing `{key}` placeholders with values from `data`.
382
- *
383
- * Missing keys are left as-is by default. Nested paths (`{user.name}`) are supported.
384
- *
385
- * @example
386
- * nodeComfort.str.template("Hi {name}, you have {count} msgs", { name: "Jo", count: 3 });
387
- * // "Hi Jo, you have 3 msgs"
388
- *
389
- * @param {string} str - Template string.
390
- * @param {Record<string, any>} data - Replacement values.
391
- * @param {{ open?: string, close?: string, fallback?: string }} [options] - Delimiters / fallback.
392
- * @returns {string} The rendered string.
393
- */
394
- template(str, data, options = {}) {
540
+ return HTML_ENTITIES[entity] ?? HTML_ENTITIES[entity.toLowerCase()] ?? match;
541
+ });
542
+ }
543
+
544
+ /**
545
+ * Escapes regex special characters so the text matches literally.
546
+ *
547
+ * @example
548
+ * new RegExp(str.escapeRegExp("1+1=2?")).test("1+1=2?"); // true
549
+ *
550
+ * @param {unknown} input
551
+ * @returns {string}
552
+ */
553
+ function escapeRegExp(input) {
554
+ return _escapeRegExp(String(input));
555
+ }
556
+
557
+ /**
558
+ * Counts visible characters. Unlike `.length`, an emoji or an accented
559
+ * letter counts as one.
560
+ *
561
+ * @example
562
+ * "👨‍👩‍👧".length; // 8
563
+ * str.length("👨‍👩‍👧"); // 1
564
+ *
565
+ * @param {unknown} input
566
+ * @returns {number}
567
+ */
568
+ function length(input) {
569
+ return _count(String(input));
570
+ }
571
+
572
+ /**
573
+ * Size of the text in bytes once encoded, UTF-8 by default. Useful when a
574
+ * limit is in bytes, like a database column or an HTTP header.
575
+ *
576
+ * @example
577
+ * str.byteLength("é"); // 2
578
+ * str.byteLength("😀"); // 4
579
+ *
580
+ * @param {unknown} input
581
+ * @param {BufferEncoding} [encoding="utf8"]
582
+ * @returns {number}
583
+ */
584
+ function byteLength(input, encoding = "utf8") {
585
+ return Buffer.byteLength(String(input), encoding);
586
+ }
587
+
588
+ /**
589
+ * Counts how many times `search` appears, without overlaps.
590
+ *
591
+ * @example
592
+ * str.count("banana", "a"); // 3
593
+ * str.count("aaaa", "aa"); // 2
594
+ *
595
+ * @param {unknown} input
596
+ * @param {string} search An empty string gives `0`.
597
+ * @returns {number}
598
+ */
599
+ function count(input, search) {
600
+ const s = String(input);
601
+ if (!search) return 0;
602
+ let total = 0;
603
+ let pos = s.indexOf(search);
604
+ while (pos !== -1) {
605
+ total++;
606
+ pos = s.indexOf(search, pos + search.length);
607
+ }
608
+ return total;
609
+ }
610
+
611
+ /**
612
+ * Shortens text to `length` visible characters, ending with `…` when cut.
613
+ *
614
+ * @example
615
+ * str.truncate("Hello world", 8); // "Hello w…"
616
+ * str.truncate("Hello world", 8, { omission: "..." }); // "Hello..."
617
+ * str.truncate("Hello big world", 12, { words: true }); // "Hello big…"
618
+ *
619
+ * @param {unknown} input
620
+ * @param {number} length Maximum length, omission included.
621
+ * @param {TruncateOptions} [options]
622
+ * @returns {string}
623
+ */
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;
630
+ const omission = options.omission ?? "…";
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
+ }
639
+ let sliced = chars.slice(0, keep).join("");
640
+ if (options.words) {
641
+ const nextIsSpace = /\s/.test(chars[keep] ?? " ");
642
+ const lastSpace = sliced.search(/\s\S*$/);
643
+ if (!nextIsSpace && lastSpace > 0) sliced = sliced.slice(0, lastSpace);
644
+ }
645
+ return sliced.replace(/\s+$/, "") + omission;
646
+ }
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
+
674
+ /**
675
+ * Returns the text between `start` and the next `end`, or `undefined` if a
676
+ * marker is missing.
677
+ *
678
+ * @example
679
+ * str.between("Hello [world]!", "[", "]"); // "world"
680
+ *
681
+ * @param {unknown} input
682
+ * @param {string} start
683
+ * @param {string} end
684
+ * @returns {string | undefined}
685
+ */
686
+ function between(input, start, end) {
687
+ const s = String(input);
688
+ const from = s.indexOf(start);
689
+ if (from === -1) return undefined;
690
+ const to = s.indexOf(end, from + start.length);
691
+ return to === -1 ? undefined : s.slice(from + start.length, to);
692
+ }
693
+
694
+ /**
695
+ * Splits at the first occurrence of `separator` only.
696
+ *
697
+ * @example
698
+ * str.splitOnce("key=value=more", "="); // ["key", "value=more"]
699
+ * str.splitOnce("novalue", "="); // ["novalue", undefined]
700
+ *
701
+ * @param {unknown} input
702
+ * @param {string} separator
703
+ * @returns {[string, string | undefined]}
704
+ */
705
+ function splitOnce(input, separator) {
706
+ const s = String(input);
707
+ const index = s.indexOf(separator);
708
+ return index === -1 ? [s, undefined] : [s.slice(0, index), s.slice(index + separator.length)];
709
+ }
710
+
711
+ /**
712
+ * Splits text into lines, whatever the line endings.
713
+ *
714
+ * @example
715
+ * str.lines("a\r\nb\nc"); // ["a", "b", "c"]
716
+ *
717
+ * @param {unknown} input
718
+ * @returns {string[]}
719
+ */
720
+ function lines(input) {
721
+ return String(input).split(/\r\n|\r|\n/);
722
+ }
723
+
724
+ /**
725
+ * Pads the start until the text is `length` characters long.
726
+ *
727
+ * @example
728
+ * str.padStart("7", 3, "0"); // "007"
729
+ *
730
+ * @param {unknown} input
731
+ * @param {number} length
732
+ * @param {string} [char=" "]
733
+ * @returns {string}
734
+ */
735
+ function padStart(input, length, char = " ") {
736
+ const s = String(input);
737
+ return _padding(char, length - _count(s)) + s;
738
+ }
739
+
740
+ /**
741
+ * Pads the end until the text is `length` characters long.
742
+ *
743
+ * @example
744
+ * str.padEnd("ab", 5, "."); // "ab..."
745
+ *
746
+ * @param {unknown} input
747
+ * @param {number} length
748
+ * @param {string} [char=" "]
749
+ * @returns {string}
750
+ */
751
+ function padEnd(input, length, char = " ") {
752
+ const s = String(input);
753
+ return s + _padding(char, length - _count(s));
754
+ }
755
+
756
+ /**
757
+ * Centers text within `length` characters. An odd leftover goes to the right.
758
+ *
759
+ * @example
760
+ * str.center("hi", 7, "*"); // "**hi***"
761
+ *
762
+ * @param {unknown} input
763
+ * @param {number} length
764
+ * @param {string} [char=" "]
765
+ * @returns {string}
766
+ */
767
+ function center(input, length, char = " ") {
768
+ const s = String(input);
769
+ const total = length - _count(s);
770
+ if (!(total > 0) || !char) return s;
771
+ const left = Math.floor(total / 2);
772
+ return _padding(char, left) + s + _padding(char, total - left);
773
+ }
774
+
775
+ /**
776
+ * Indents every non-empty line.
777
+ *
778
+ * @example
779
+ * str.indent("a\nb", 2); // " a\n b"
780
+ * str.indent("a\nb", "> "); // "> a\n> b"
781
+ *
782
+ * @param {unknown} input
783
+ * @param {number | string} [prefix=2] A number of spaces, or the prefix itself.
784
+ * @returns {string}
785
+ */
786
+ function indent(input, prefix = 2) {
787
+ const pad = typeof prefix === "number" ? " ".repeat(prefix) : prefix;
788
+ return String(input).replace(/^(?=.)/gm, pad);
789
+ }
790
+
791
+ /**
792
+ * Removes the indentation shared by all lines, and blank first and last
793
+ * lines. Works as a template tag, so multi-line strings can follow your
794
+ * code's indentation.
795
+ *
796
+ * @example
797
+ * const sql = str.dedent`
798
+ * SELECT *
799
+ * FROM users
800
+ * WHERE id = ${id}
801
+ * `;
802
+ * // "SELECT *\n FROM users\n WHERE id = 42"
803
+ *
804
+ * @param {unknown} input
805
+ * @param {...unknown} values
806
+ * @returns {string}
807
+ */
808
+ function dedent(input, ...values) {
809
+ let text;
810
+ if (Array.isArray(input) && Object.prototype.hasOwnProperty.call(input, "raw")) {
811
+ const strings = /** @type {TemplateStringsArray} */ (/** @type {unknown} */ (input));
812
+ text = strings.reduce((acc, part, i) => acc + part + (i < values.length ? String(values[i]) : ""), "");
813
+ } else {
814
+ text = String(input);
815
+ }
816
+ const all = text.replace(/\r\n?/g, "\n").split("\n");
817
+ if (all.length && all[0].trim() === "") all.shift();
818
+ if (all.length && all[all.length - 1].trim() === "") all.pop();
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");
826
+ return all.map((line) => line.slice(min)).join("\n");
827
+ }
828
+
829
+ /**
830
+ * Wraps text so no line is longer than `width` visible characters. Existing
831
+ * line breaks are kept.
832
+ *
833
+ * @example
834
+ * str.wrap("The quick brown fox jumps over the lazy dog", 16);
835
+ * // "The quick brown\nfox jumps over\nthe lazy dog"
836
+ *
837
+ * @param {unknown} input
838
+ * @param {WrapOptions | number} [options] Options, or just the width.
839
+ * @returns {string}
840
+ */
841
+ function wrap(input, options = {}) {
842
+ const opts = typeof options === "number" ? { width: options } : options;
843
+ const width = Math.max(1, opts.width ?? 80);
844
+ const prefix = opts.indent ?? "";
845
+ const newline = opts.newline ?? "\n";
846
+ /** @type {string[]} */
847
+ const out = [];
848
+ for (const paragraph of String(input).split(/\r\n|\r|\n/)) {
849
+ let line = "";
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) {
855
+ if (line) {
856
+ out.push(line);
857
+ line = "";
858
+ lineWidth = 0;
859
+ }
860
+ const chars = _graphemes(word);
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;
869
+ }
870
+ if (line && lineWidth + 1 + wordWidth > width) {
871
+ out.push(line);
872
+ line = word;
873
+ lineWidth = wordWidth;
874
+ } else {
875
+ lineWidth += (line ? 1 : 0) + wordWidth;
876
+ line = line ? `${line} ${word}` : word;
877
+ }
878
+ }
879
+ out.push(line);
880
+ }
881
+ return prefix ? out.map((l) => prefix + l).join(newline) : out.join(newline);
882
+ }
883
+
884
+ /**
885
+ * Reverses text without breaking emoji or accents.
886
+ *
887
+ * @example
888
+ * str.reverse("añ👍🏽"); // "👍🏽ña"
889
+ *
890
+ * @param {unknown} input
891
+ * @returns {string}
892
+ */
893
+ function reverse(input) {
894
+ return _graphemes(String(input)).reverse().join("");
895
+ }
896
+
897
+ /**
898
+ * Hides part of a string, like a card number or a token.
899
+ *
900
+ * @example
901
+ * str.mask("4242424242424242"); // "************4242"
902
+ * str.mask("4242 4242 4242 4242", { keepSpaces: true }); // "**** **** **** 4242"
903
+ * str.mask("secret-token", { start: 2, end: 2, char: "•" }); // "se••••••••en"
904
+ *
905
+ * @param {unknown} input
906
+ * @param {MaskOptions} [options]
907
+ * @returns {string}
908
+ */
909
+ function mask(input, options = {}) {
910
+ const chars = _graphemes(String(input));
911
+ const start = Math.max(0, options.start ?? 0);
912
+ const end = Math.max(0, options.end ?? 4);
913
+ const char = options.char ?? "*";
914
+ if (start + end >= chars.length) return chars.join("");
915
+ return chars
916
+ .map((c, i) => {
917
+ if (i < start || i >= chars.length - end) return c;
918
+ if (options.keepSpaces && /[\s-]/.test(c)) return c;
919
+ return char;
920
+ })
921
+ .join("");
922
+ }
923
+
924
+ /**
925
+ * The initials of a name, in uppercase.
926
+ *
927
+ * @example
928
+ * str.initials("Ada Lovelace"); // "AL"
929
+ * str.initials("jean-luc picard", 3); // "JLP"
930
+ *
931
+ * @param {unknown} input
932
+ * @param {number} [max=2] Maximum number of letters.
933
+ * @returns {string}
934
+ */
935
+ function initials(input, max = 2) {
936
+ return _words(input)
937
+ .slice(0, max)
938
+ .map((word) => _graphemes(word)[0].toUpperCase())
939
+ .join("");
940
+ }
941
+
942
+ /**
943
+ * Picks the right plural for a count, following the rules of the language,
944
+ * and puts the formatted count in front.
945
+ *
946
+ * @example
947
+ * str.plural(1, { one: "file", other: "files" }); // "1 file"
948
+ * str.plural(1234, { one: "item", other: "items" }); // "1,234 items"
949
+ * str.plural(0, { zero: "no files", one: "file", other: "files" }); // "no files"
950
+ * str.plural(0, { one: "fichier", other: "fichiers" }, { locale: "fr" }); // "0 fichier"
951
+ *
952
+ * @param {number} value
953
+ * @param {PluralForms} forms
954
+ * @param {PluralOptions} [options]
955
+ * @returns {string}
956
+ */
957
+ function plural(value, forms, options = {}) {
958
+ const locale = options.locale ?? "en";
959
+ if (value === 0 && forms.zero !== undefined) return forms.zero;
960
+ const category = intl.plural(locale, options.ordinal ? ORDINAL : undefined).select(value);
961
+ const word = forms[category] ?? forms.other;
962
+ if (options.includeCount === false) return word;
963
+ return `${intl.number(locale).format(value)} ${word}`;
964
+ }
965
+
966
+ /**
967
+ * Fills `{placeholders}` with values. Dotted paths like `{user.name}` work,
968
+ * and missing values are left alone unless you give a `fallback`.
969
+ *
970
+ * @example
971
+ * str.template("Hi {name}, you have {count} messages", { name: "Jo", count: 3 });
972
+ * // "Hi Jo, you have 3 messages"
973
+ * str.template("Hello {{ user.name }}", { user: { name: "Ada" } }, { open: "{{", close: "}}" });
974
+ * // "Hello Ada"
975
+ *
976
+ * @param {unknown} input
977
+ * @param {Record<string, any>} data
978
+ * @param {TemplateOptions} [options]
979
+ * @returns {string}
980
+ */
981
+ function template(input, data, options = {}) {
982
+ let regex = TEMPLATE_DEFAULT;
983
+ if (options.open !== undefined || options.close !== undefined) {
395
984
  const open = options.open ?? "{";
396
985
  const close = options.close ?? "}";
397
- const esc = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
398
- const regex = new RegExp(`${esc(open)}\\s*([\\w.]+)\\s*${esc(close)}`, "g");
399
- return String(str).replace(regex, (match, key) => {
400
- const value = key.split(".").reduce((acc, k) => (acc == null ? acc : acc[k]), data);
401
- if (value === undefined || value === null) {
402
- return options.fallback !== undefined ? options.fallback : match;
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
+ }
995
+ return String(input).replace(regex, (match, key) => {
996
+ /** @type {unknown} */
997
+ let value = data;
998
+ if (key.includes(".")) {
999
+ for (const part of key.split(".")) {
1000
+ if (value === undefined || value === null) break;
1001
+ value = _templateValue(value, part);
403
1002
  }
404
- return String(value);
405
- });
406
- },
407
-
408
- /**
409
- * Splits a string into an array of "words" (camelCase / snake / kebab aware).
410
- *
411
- * @example
412
- * nodeComfort.str.words("helloWorld-foo_bar"); // ["hello", "World", "foo", "bar"]
413
- *
414
- * @param {string} str - Input string.
415
- * @returns {string[]} The words.
416
- */
417
- words(str) {
418
- return _words(str);
419
- },
420
-
421
- /**
422
- * Returns the visible length of a string in code points (emoji-safe).
423
- *
424
- * @example
425
- * "😀".length; // 2
426
- * nodeComfort.str.length("😀"); // 1
427
- *
428
- * @param {string} str - Input string.
429
- * @returns {number} The number of code points.
430
- */
431
- length(str) {
432
- return [...String(str)].length;
433
- },
434
-
435
- /**
436
- * Generates a random alphanumeric string of a given length.
437
- *
438
- * Note: uses `Math.random` and is **not** cryptographically secure.
439
- * For tokens/IDs use `nodeComfort.id.token()` instead.
440
- *
441
- * @param {number} [length=16] - Desired length.
442
- * @param {string} [charset] - Characters to pick from.
443
- * @returns {string} The random string.
444
- */
445
- random(length = 16, charset = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789") {
446
- let out = "";
447
- for (let i = 0; i < length; i++) {
448
- out += charset.charAt(Math.floor(Math.random() * charset.length));
1003
+ } else if (value !== undefined && value !== null) {
1004
+ value = _templateValue(value, key);
449
1005
  }
450
- return out;
451
- },
1006
+ if (value === undefined || value === null) return options.fallback ?? match;
1007
+ return String(value);
1008
+ });
1009
+ }
1010
+
1011
+ /**
1012
+ * Adds `prefix` unless the text already starts with it.
1013
+ *
1014
+ * @example
1015
+ * str.ensurePrefix("example.com", "https://"); // "https://example.com"
1016
+ *
1017
+ * @param {unknown} input
1018
+ * @param {string} prefix
1019
+ * @returns {string}
1020
+ */
1021
+ function ensurePrefix(input, prefix) {
1022
+ const s = String(input);
1023
+ return s.startsWith(prefix) ? s : prefix + s;
1024
+ }
1025
+
1026
+ /**
1027
+ * Adds `suffix` unless the text already ends with it.
1028
+ *
1029
+ * @example
1030
+ * str.ensureSuffix("path/to", "/"); // "path/to/"
1031
+ *
1032
+ * @param {unknown} input
1033
+ * @param {string} suffix
1034
+ * @returns {string}
1035
+ */
1036
+ function ensureSuffix(input, suffix) {
1037
+ const s = String(input);
1038
+ return s.endsWith(suffix) ? s : s + suffix;
1039
+ }
1040
+
1041
+ /**
1042
+ * Removes `prefix` if the text starts with it.
1043
+ *
1044
+ * @example
1045
+ * str.removePrefix("https://example.com", "https://"); // "example.com"
1046
+ *
1047
+ * @param {unknown} input
1048
+ * @param {string} prefix
1049
+ * @returns {string}
1050
+ */
1051
+ function removePrefix(input, prefix) {
1052
+ const s = String(input);
1053
+ return prefix && s.startsWith(prefix) ? s.slice(prefix.length) : s;
1054
+ }
1055
+
1056
+ /**
1057
+ * Removes `suffix` if the text ends with it.
1058
+ *
1059
+ * @example
1060
+ * str.removeSuffix("report.pdf", ".pdf"); // "report"
1061
+ *
1062
+ * @param {unknown} input
1063
+ * @param {string} suffix
1064
+ * @returns {string}
1065
+ */
1066
+ function removeSuffix(input, suffix) {
1067
+ const s = String(input);
1068
+ return suffix && s.endsWith(suffix) ? s.slice(0, -suffix.length) : s;
1069
+ }
1070
+
1071
+ /**
1072
+ * Compares two strings the way people sort them: numbers in natural order
1073
+ * and accents in the right place. Pass it straight to `sort()`.
1074
+ *
1075
+ * @example
1076
+ * ["file10", "file2", "File1"].sort(str.compare); // ["File1", "file2", "file10"]
1077
+ * str.compare("a", "A", { sensitivity: "base" }); // 0
1078
+ *
1079
+ * @param {unknown} a
1080
+ * @param {unknown} b
1081
+ * @param {CompareOptions} [options]
1082
+ * @returns {number} Negative if `a` comes first, positive if `b` does, `0` if equal.
1083
+ */
1084
+ function compare(a, b, options = {}) {
1085
+ const collator =
1086
+ options && typeof options === "object" && (options.locale || options.sensitivity || options.numeric === false)
1087
+ ? intl.collator(options.locale, { numeric: options.numeric ?? true, sensitivity: options.sensitivity ?? "variant" })
1088
+ : (_defaultCollator ??= new Intl.Collator(undefined, { numeric: true, sensitivity: "variant" }));
1089
+ return collator.compare(String(a), String(b));
1090
+ }
1091
+
1092
+ /**
1093
+ * The number of single-character edits needed to turn `a` into `b`.
1094
+ *
1095
+ * @example
1096
+ * str.levenshtein("kitten", "sitting"); // 3
1097
+ *
1098
+ * @param {unknown} a
1099
+ * @param {unknown} b
1100
+ * @returns {number}
1101
+ */
1102
+ function levenshtein(a, b) {
1103
+ const x = String(a);
1104
+ const y = String(b);
1105
+ return x === y ? 0 : _distance(_units(x), _units(y));
1106
+ }
1107
+
1108
+ /**
1109
+ * How alike two strings are, from `0` (nothing in common) to `1` (identical).
1110
+ *
1111
+ * @example
1112
+ * str.similarity("hello", "hallo"); // 0.8
1113
+ *
1114
+ * @param {unknown} a
1115
+ * @param {unknown} b
1116
+ * @returns {number}
1117
+ */
1118
+ function similarity(a, b) {
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;
1126
+ }
1127
+
1128
+ /**
1129
+ * Finds the candidate closest to `input`. Made for "Did you mean...?"
1130
+ * suggestions.
1131
+ *
1132
+ * @example
1133
+ * str.closest("instal", ["install", "uninstall", "list"]); // "install"
1134
+ * str.closest("xyz", ["install", "list"]); // undefined
1135
+ *
1136
+ * @param {unknown} input
1137
+ * @param {Iterable<string>} candidates
1138
+ * @param {ClosestOptions} [options]
1139
+ * @returns {string | undefined} `undefined` when nothing is close enough.
1140
+ */
1141
+ function closest(input, candidates, options = {}) {
1142
+ const threshold = options.threshold ?? 0.4;
1143
+ const norm = (/** @type {string} */ s) => (options.caseSensitive ? s : s.toLowerCase());
1144
+ const needle = _units(norm(String(input)));
1145
+ let best;
1146
+ let bestScore = -1;
1147
+ for (const candidate of candidates) {
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;
1153
+ if (score > bestScore) {
1154
+ bestScore = score;
1155
+ best = candidate;
1156
+ }
1157
+ }
1158
+ return bestScore >= threshold ? best : undefined;
1159
+ }
1160
+
1161
+ /**
1162
+ * A random string. It relies on `Math.random`, so don't use it for secrets:
1163
+ * `nc.id.token()` and `nc.id.nano()` are made for that.
1164
+ *
1165
+ * @example
1166
+ * str.random(); // "aZ3kP9qL0xYb7TcW"
1167
+ * str.random(6, { charset: "0123456789" }); // "402917"
1168
+ *
1169
+ * @param {number} [size=16]
1170
+ * @param {RandomStringOptions | string} [options] Options, or the charset itself.
1171
+ * @returns {string}
1172
+ */
1173
+ function random(size = 16, options = {}) {
1174
+ const charset = typeof options === "string" ? options : options.charset ?? "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
1175
+ let out = "";
1176
+ for (let i = 0; i < size; i++) out += charset.charAt(Math.floor(Math.random() * charset.length));
1177
+ return out;
1178
+ }
1179
+
1180
+ module.exports = {
1181
+ capitalize,
1182
+ upperFirst,
1183
+ lowerFirst,
1184
+ humanize,
1185
+ titleCase,
1186
+ sentenceCase,
1187
+ camelCase,
1188
+ pascalCase,
1189
+ snakeCase,
1190
+ kebabCase,
1191
+ constantCase,
1192
+ dotCase,
1193
+ words,
1194
+ deburr,
1195
+ slugify,
1196
+ squish,
1197
+ stripTags,
1198
+ escapeHTML,
1199
+ unescapeHTML,
1200
+ escapeRegExp,
1201
+ length,
1202
+ byteLength,
1203
+ count,
1204
+ truncate,
1205
+ chunk,
1206
+ between,
1207
+ splitOnce,
1208
+ lines,
1209
+ padStart,
1210
+ padEnd,
1211
+ center,
1212
+ indent,
1213
+ dedent,
1214
+ wrap,
1215
+ reverse,
1216
+ mask,
1217
+ initials,
1218
+ plural,
1219
+ template,
1220
+ ensurePrefix,
1221
+ ensureSuffix,
1222
+ removePrefix,
1223
+ removeSuffix,
1224
+ compare,
1225
+ levenshtein,
1226
+ similarity,
1227
+ closest,
1228
+ random,
452
1229
  };