@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.
- package/CHANGELOG.md +340 -0
- package/README.md +137 -548
- package/index.js +80 -35
- package/package.json +161 -33
- package/src/Arr.js +1050 -418
- package/src/Async.js +817 -0
- package/src/Cache.js +433 -0
- package/src/Checker.js +1202 -539
- package/src/Cli.js +1293 -0
- package/src/Color.js +556 -0
- package/src/Crypto.js +1082 -0
- package/src/Emitter.js +356 -0
- package/src/Env.js +689 -0
- package/src/FS.js +1626 -894
- package/src/Func.js +823 -356
- package/src/Http.js +700 -0
- package/src/Id.js +568 -200
- package/src/Logger.js +963 -378
- package/src/Num.js +965 -360
- package/src/Obj.js +1077 -379
- package/src/SQLite.js +1629 -1005
- package/src/Schema.js +2101 -0
- package/src/Str.js +1209 -432
- package/src/Sys.js +654 -0
- package/src/Time.js +1533 -440
- package/src/Utils.js +342 -325
- package/src/errors.js +335 -0
- package/src/internal/shared.js +415 -0
- package/types/index.d.ts +735 -526
- package/types/src/Arr.d.ts +533 -145
- package/types/src/Arr.d.ts.map +1 -1
- package/types/src/Async.d.ts +372 -0
- package/types/src/Async.d.ts.map +1 -0
- package/types/src/Cache.d.ts +221 -0
- package/types/src/Cache.d.ts.map +1 -0
- package/types/src/Checker.d.ts +651 -232
- package/types/src/Checker.d.ts.map +1 -1
- package/types/src/Cli.d.ts +664 -0
- package/types/src/Cli.d.ts.map +1 -0
- package/types/src/Color.d.ts +300 -0
- package/types/src/Color.d.ts.map +1 -0
- package/types/src/Crypto.d.ts +620 -0
- package/types/src/Crypto.d.ts.map +1 -0
- package/types/src/Emitter.d.ts +189 -0
- package/types/src/Emitter.d.ts.map +1 -0
- package/types/src/Env.d.ts +412 -0
- package/types/src/Env.d.ts.map +1 -0
- package/types/src/FS.d.ts +717 -238
- package/types/src/FS.d.ts.map +1 -1
- package/types/src/Func.d.ts +508 -135
- package/types/src/Func.d.ts.map +1 -1
- package/types/src/Http.d.ts +444 -0
- package/types/src/Http.d.ts.map +1 -0
- package/types/src/Id.d.ts +199 -66
- package/types/src/Id.d.ts.map +1 -1
- package/types/src/Logger.d.ts +414 -46
- package/types/src/Logger.d.ts.map +1 -1
- package/types/src/Num.d.ts +514 -137
- package/types/src/Num.d.ts.map +1 -1
- package/types/src/Obj.d.ts +453 -104
- package/types/src/Obj.d.ts.map +1 -1
- package/types/src/SQLite.d.ts +754 -308
- package/types/src/SQLite.d.ts.map +1 -1
- package/types/src/Schema.d.ts +1189 -0
- package/types/src/Schema.d.ts.map +1 -0
- package/types/src/Str.d.ts +617 -166
- package/types/src/Str.d.ts.map +1 -1
- package/types/src/Sys.d.ts +314 -0
- package/types/src/Sys.d.ts.map +1 -0
- package/types/src/Time.d.ts +759 -204
- package/types/src/Time.d.ts.map +1 -1
- package/types/src/Utils.d.ts +158 -102
- package/types/src/Utils.d.ts.map +1 -1
- package/types/src/errors.d.ts +316 -0
- package/types/src/errors.d.ts.map +1 -0
- package/types/src/internal/shared.d.ts +123 -0
- package/types/src/internal/shared.d.ts.map +1 -0
- 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
|
|
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
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
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
|
-
*
|
|
11
|
-
*
|
|
12
|
-
* @
|
|
13
|
-
* @
|
|
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>} */ ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" });
|
|
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 = (
|
|
17
|
-
|
|
18
|
-
.replace(/(
|
|
19
|
-
.replace(/(
|
|
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
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
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
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
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
|
+
* // "<a href="x">Tom & Jerry</a>"
|
|
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 `&` and ` `, and numeric
|
|
522
|
+
* ones like `é`.
|
|
523
|
+
*
|
|
524
|
+
* @example
|
|
525
|
+
* str.unescapeHTML("Tom & Jerry 😀"); // "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
|
|
324
|
-
}
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
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
|
|
398
|
-
const
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
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
|
-
|
|
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
|
|
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
|
};
|