@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.
- package/CHANGELOG.md +190 -0
- package/LICENSE +21 -0
- package/README.md +210 -748
- package/index.js +84 -13
- package/package.json +201 -21
- package/src/Arr.js +901 -0
- package/src/Async.js +585 -0
- package/src/Cache.js +329 -0
- package/src/Checker.js +979 -188
- package/src/Cli.js +1034 -0
- package/src/Color.js +415 -0
- package/src/Crypto.js +707 -0
- package/src/Emitter.js +289 -0
- package/src/Env.js +555 -0
- package/src/FS.js +1483 -768
- package/src/Func.js +692 -0
- package/src/Http.js +538 -0
- package/src/Id.js +418 -0
- package/src/Logger.js +739 -307
- package/src/Num.js +849 -0
- package/src/Obj.js +904 -0
- package/src/SQLite.js +1308 -994
- package/src/Schema.js +1586 -0
- package/src/Str.js +999 -0
- package/src/Sys.js +511 -0
- package/src/Time.js +1323 -0
- package/src/Utils.js +321 -325
- package/src/errors.js +292 -0
- package/types/index.d.ts +693 -120
- package/types/src/Arr.d.ts +562 -0
- package/types/src/Arr.d.ts.map +1 -0
- package/types/src/Async.d.ts +321 -0
- package/types/src/Async.d.ts.map +1 -0
- package/types/src/Cache.d.ts +195 -0
- package/types/src/Cache.d.ts.map +1 -0
- package/types/src/Checker.d.ts +609 -81
- package/types/src/Checker.d.ts.map +1 -1
- package/types/src/Cli.d.ts +582 -0
- package/types/src/Cli.d.ts.map +1 -0
- package/types/src/Color.d.ts +294 -0
- package/types/src/Color.d.ts.map +1 -0
- package/types/src/Crypto.d.ts +534 -0
- package/types/src/Crypto.d.ts.map +1 -0
- package/types/src/Emitter.d.ts +170 -0
- package/types/src/Emitter.d.ts.map +1 -0
- package/types/src/Env.d.ts +382 -0
- package/types/src/Env.d.ts.map +1 -0
- package/types/src/FS.d.ts +709 -201
- package/types/src/FS.d.ts.map +1 -1
- package/types/src/Func.d.ts +455 -0
- package/types/src/Func.d.ts.map +1 -0
- package/types/src/Http.d.ts +374 -0
- package/types/src/Http.d.ts.map +1 -0
- package/types/src/Id.d.ts +200 -0
- package/types/src/Id.d.ts.map +1 -0
- package/types/src/Logger.d.ts +374 -24
- package/types/src/Logger.d.ts.map +1 -1
- package/types/src/Num.d.ts +578 -0
- package/types/src/Num.d.ts.map +1 -0
- package/types/src/Obj.d.ts +475 -0
- package/types/src/Obj.d.ts.map +1 -0
- package/types/src/SQLite.d.ts +647 -305
- package/types/src/SQLite.d.ts.map +1 -1
- package/types/src/Schema.d.ts +940 -0
- package/types/src/Schema.d.ts.map +1 -0
- package/types/src/Str.d.ts +696 -0
- package/types/src/Str.d.ts.map +1 -0
- package/types/src/Sys.d.ts +269 -0
- package/types/src/Sys.d.ts.map +1 -0
- package/types/src/Time.d.ts +664 -0
- package/types/src/Time.d.ts.map +1 -0
- package/types/src/Utils.d.ts +154 -102
- package/types/src/Utils.d.ts.map +1 -1
- package/types/src/errors.d.ts +292 -0
- package/types/src/errors.d.ts.map +1 -0
- package/tsconfig.json +0 -18
- 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
|
+
* // "<a href="x">Tom & Jerry</a>"
|
|
356
|
+
*
|
|
357
|
+
* @param {unknown} input
|
|
358
|
+
* @returns {string}
|
|
359
|
+
*/
|
|
360
|
+
function escapeHTML(input) {
|
|
361
|
+
const map = /** @type {Record<string, string>} */ ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" });
|
|
362
|
+
return String(input).replace(/[&<>"']/g, (c) => map[c]);
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
/**
|
|
366
|
+
* Decodes HTML entities: named ones like `&` and ` `, and numeric
|
|
367
|
+
* ones like `é`.
|
|
368
|
+
*
|
|
369
|
+
* @example
|
|
370
|
+
* str.unescapeHTML("Tom & Jerry 😀"); // "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
|
+
};
|