@ix-xs/node-comfort 1.1.31 → 1.2.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/src/Id.js ADDED
@@ -0,0 +1,235 @@
1
+ const crypto = require("node:crypto");
2
+
3
+ /**
4
+ * ID, token & hashing utilities.
5
+ *
6
+ * Cryptographically-secure identifiers (UUID v4, ULID, nanoid-style tokens),
7
+ * hashing helpers and constant-time comparison • all built on Node's native
8
+ * `node:crypto`, with zero external dependencies.
9
+ */
10
+
11
+ const NANOID_ALPHABET = "useandom-26T198340PX75pxJACKVERYMINDBUSHWOLF_GQZbfghjklqvwyzrict";
12
+ const BASE58_ALPHABET = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz";
13
+ const ULID_ALPHABET = "0123456789ABCDEFGHJKMNPQRSTVWXYZ"; // Crockford base32
14
+
15
+ /**
16
+ * Per-prefix counters for {@link module:Id.seq}.
17
+ * @private
18
+ * @type {Record<string, number>}
19
+ */
20
+ const _seqCounters = {};
21
+
22
+ /**
23
+ * Fills a buffer with cryptographically secure random bytes.
24
+ * @private
25
+ * @param {number} size
26
+ * @returns {Buffer}
27
+ */
28
+ const _randomBytes = (size) => crypto.randomBytes(size);
29
+
30
+ /**
31
+ * Encodes a buffer as Base58 using the Bitcoin alphabet.
32
+ * @private
33
+ * @param {Buffer} buffer - Bytes to encode.
34
+ * @returns {string} The Base58 string.
35
+ */
36
+ const _toBase58 = (buffer) => {
37
+ if (buffer.length === 0) return "";
38
+ const digits = [0];
39
+ for (const byte of buffer) {
40
+ let carry = byte;
41
+ for (let i = 0; i < digits.length; i++) {
42
+ carry += digits[i] << 8;
43
+ digits[i] = carry % 58;
44
+ carry = Math.floor(carry / 58);
45
+ }
46
+ while (carry > 0) {
47
+ digits.push(carry % 58);
48
+ carry = Math.floor(carry / 58);
49
+ }
50
+ }
51
+ let str = "";
52
+ for (let i = 0; buffer[i] === 0 && i < buffer.length - 1; i++) str += BASE58_ALPHABET[0];
53
+ for (let i = digits.length - 1; i >= 0; i--) str += BASE58_ALPHABET[digits[i]];
54
+ return str;
55
+ };
56
+
57
+ module.exports = {
58
+ /**
59
+ * Generates a random UUID v4.
60
+ *
61
+ * @example
62
+ * nodeComfort.id.uuid(); // "3f2504e0-4f89-41d3-9a0c-0305e82c3301"
63
+ *
64
+ * @returns {string} A UUID v4 string.
65
+ */
66
+ uuid() {
67
+ return crypto.randomUUID();
68
+ },
69
+
70
+ /**
71
+ * Generates a URL-safe, collision-resistant ID (nanoid-compatible alphabet).
72
+ *
73
+ * Uses secure randomness with unbiased rejection sampling.
74
+ *
75
+ * @example
76
+ * nodeComfort.id.nano(); // "V1StGXR8_Z5jdHi6B-myT"
77
+ * nodeComfort.id.nano(10); // "IRFa-VaY2b"
78
+ *
79
+ * @param {number} [size=21] - Desired length.
80
+ * @param {string} [alphabet] - Custom alphabet.
81
+ * @returns {string} The generated ID.
82
+ */
83
+ nano(size = 21, alphabet = NANOID_ALPHABET) {
84
+ const len = alphabet.length;
85
+ // Bitmask for unbiased sampling.
86
+ const mask = (2 << Math.floor(Math.log2(len - 1))) - 1;
87
+ const step = Math.ceil((1.6 * mask * size) / len);
88
+ let id = "";
89
+ while (id.length < size) {
90
+ const bytes = _randomBytes(step);
91
+ for (let i = 0; i < step && id.length < size; i++) {
92
+ const index = bytes[i] & mask;
93
+ if (index < len) id += alphabet[index];
94
+ }
95
+ }
96
+ return id;
97
+ },
98
+
99
+ /**
100
+ * Generates a ULID • a lexicographically-sortable, timestamp-prefixed ID.
101
+ *
102
+ * ULIDs sort by creation time and are a great primary-key alternative to UUIDs.
103
+ *
104
+ * @example
105
+ * nodeComfort.id.ulid(); // "01ARZ3NDEKTSV4RRFFQ69G5FAV"
106
+ *
107
+ * @param {number} [time=Date.now()] - Timestamp in ms to encode.
108
+ * @returns {string} A 26-character ULID.
109
+ */
110
+ ulid(time = Date.now()) {
111
+ let timeChars = "";
112
+ let t = time;
113
+ for (let i = 0; i < 10; i++) {
114
+ timeChars = ULID_ALPHABET[t % 32] + timeChars;
115
+ t = Math.floor(t / 32);
116
+ }
117
+
118
+ const bytes = _randomBytes(16);
119
+ let randChars = "";
120
+ for (let i = 0; i < 16; i++) {
121
+ randChars += ULID_ALPHABET[bytes[i] % 32];
122
+ }
123
+ return timeChars + randChars;
124
+ },
125
+
126
+ /**
127
+ * Generates a cryptographically-secure random token.
128
+ *
129
+ * @example
130
+ * nodeComfort.id.token(); // 32-byte hex string
131
+ * nodeComfort.id.token(16, "base64url"); // 16 bytes, URL-safe base64
132
+ *
133
+ * @param {number} [bytes=32] - Number of random bytes.
134
+ * @param {"hex"|"base64"|"base64url"|"base58"} [encoding="hex"] - Output encoding.
135
+ * @returns {string} The token.
136
+ */
137
+ token(bytes = 32, encoding = "hex") {
138
+ const buffer = _randomBytes(bytes);
139
+ if (encoding === "base58") return _toBase58(buffer);
140
+ return buffer.toString(encoding);
141
+ },
142
+
143
+ /**
144
+ * Generates a short, human-friendly random code (uppercase + digits by default).
145
+ *
146
+ * Ambiguous characters (`0/O`, `1/I`) are excluded, making it suitable for
147
+ * coupons, OTP-like codes and invite links.
148
+ *
149
+ * @example
150
+ * nodeComfort.id.code(); // "K7QF9X"
151
+ * nodeComfort.id.code(4, "1234"); // "3142"
152
+ *
153
+ * @param {number} [length=6] - Number of characters.
154
+ * @param {string} [alphabet="ABCDEFGHJKLMNPQRSTUVWXYZ23456789"] - Characters to pick from.
155
+ * @returns {string} The random code.
156
+ */
157
+ code(length = 6, alphabet = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789") {
158
+ const bytes = _randomBytes(length);
159
+ let out = "";
160
+ for (let i = 0; i < length; i++) {
161
+ out += alphabet[bytes[i] % alphabet.length];
162
+ }
163
+ return out;
164
+ },
165
+
166
+ /**
167
+ * Hashes a value with a chosen algorithm.
168
+ *
169
+ * @example
170
+ * nodeComfort.id.hash("hello"); // sha256 hex
171
+ * nodeComfort.id.hash("hello", { algorithm: "md5" });
172
+ *
173
+ * @param {string|Buffer} value - Data to hash.
174
+ * @param {{ algorithm?: string, encoding?: "hex"|"base64"|"base64url" }} [options] - Hash options.
175
+ * @returns {string} The digest.
176
+ */
177
+ hash(value, options = {}) {
178
+ const algorithm = options.algorithm ?? "sha256";
179
+ const encoding = options.encoding ?? "hex";
180
+ return crypto.createHash(algorithm).update(value).digest(encoding);
181
+ },
182
+
183
+ /**
184
+ * Computes an HMAC signature for a value using a secret key.
185
+ *
186
+ * @example
187
+ * nodeComfort.id.hmac("payload", "secret"); // sha256 HMAC hex
188
+ *
189
+ * @param {string|Buffer} value - Data to sign.
190
+ * @param {string|Buffer} secret - Secret key.
191
+ * @param {{ algorithm?: string, encoding?: "hex"|"base64"|"base64url" }} [options] - HMAC options.
192
+ * @returns {string} The signature.
193
+ */
194
+ hmac(value, secret, options = {}) {
195
+ const algorithm = options.algorithm ?? "sha256";
196
+ const encoding = options.encoding ?? "hex";
197
+ return crypto.createHmac(algorithm, secret).update(value).digest(encoding);
198
+ },
199
+
200
+ /**
201
+ * Compares two strings/buffers in constant time to prevent timing attacks.
202
+ *
203
+ * Use this when comparing secrets, tokens, or signatures.
204
+ *
205
+ * @example
206
+ * if (nodeComfort.id.safeEqual(provided, expected)) { ... }
207
+ *
208
+ * @param {string|Buffer} a - First value.
209
+ * @param {string|Buffer} b - Second value.
210
+ * @returns {boolean} True if equal.
211
+ */
212
+ safeEqual(a, b) {
213
+ const bufA = Buffer.isBuffer(a) ? a : Buffer.from(String(a));
214
+ const bufB = Buffer.isBuffer(b) ? b : Buffer.from(String(b));
215
+ if (bufA.length !== bufB.length) return false;
216
+ return crypto.timingSafeEqual(bufA, bufB);
217
+ },
218
+
219
+ /**
220
+ * Returns a monotonically-increasing counter ID prefixed with an optional label.
221
+ *
222
+ * Useful for readable, unique keys within a single process run.
223
+ *
224
+ * @example
225
+ * nodeComfort.id.seq("user"); // "user-1"
226
+ * nodeComfort.id.seq("user"); // "user-2"
227
+ *
228
+ * @param {string} [prefix=""] - Optional prefix.
229
+ * @returns {string} The sequential ID.
230
+ */
231
+ seq(prefix = "") {
232
+ _seqCounters[prefix] = (_seqCounters[prefix] ?? 0) + 1;
233
+ return prefix ? `${prefix}-${_seqCounters[prefix]}` : String(_seqCounters[prefix]);
234
+ },
235
+ };
package/src/Logger.js CHANGED
@@ -220,6 +220,24 @@ const _indent = () => {
220
220
  return " ".repeat(_groups.length);
221
221
  };
222
222
 
223
+ /**
224
+ * Coerces any value to a string for the level helpers (info/warn/...).
225
+ * @private
226
+ * @param {*} content - Value to stringify.
227
+ * @returns {string} The string representation.
228
+ */
229
+ const _stringify = (content) => {
230
+ if (typeof content === "string") return content;
231
+ if (typeof content === "object" && content !== null) {
232
+ try {
233
+ return JSON.stringify(content, null, 2);
234
+ } catch {
235
+ return String(content);
236
+ }
237
+ }
238
+ return String(content);
239
+ };
240
+
223
241
  module.exports = {
224
242
  /**
225
243
  * Enables or disables timestamp in logs
@@ -284,6 +302,59 @@ module.exports = {
284
302
  return /** @type {Logger} */ (this);
285
303
  },
286
304
 
305
+ /**
306
+ * Logs an informational message, prefixed with a cyan `ℹ INFO` label.
307
+ * @param {*} content - Content to print.
308
+ * @returns {Logger} The same Logger instance (for chaining).
309
+ */
310
+ info(content) {
311
+ return this.log(`<% cyan bold ℹ INFO %> ${_stringify(content)}`);
312
+ },
313
+
314
+ /**
315
+ * Logs a success message, prefixed with a green `✔ OK` label.
316
+ * @param {*} content - Content to print.
317
+ * @returns {Logger} The same Logger instance (for chaining).
318
+ */
319
+ success(content) {
320
+ return this.log(`<% greenBright bold ✔ OK %> ${_stringify(content)}`);
321
+ },
322
+
323
+ /**
324
+ * Logs a warning, prefixed with a yellow `⚠ WARN` label (writes to stderr).
325
+ * @param {*} content - Content to print.
326
+ * @returns {Logger} The same Logger instance (for chaining).
327
+ */
328
+ warn(content) {
329
+ return this.log(`<% yellowBright bold ⚠ WARN %> ${_stringify(content)}`);
330
+ },
331
+
332
+ /**
333
+ * Logs an error, prefixed with a red `✖ ERROR` label.
334
+ *
335
+ * Accepts an `Error` and prints its stack when available.
336
+ * @param {*} content - Content or Error to print.
337
+ * @returns {Logger} The same Logger instance (for chaining).
338
+ */
339
+ error(content) {
340
+ const text =
341
+ content instanceof Error ? content.stack ?? content.message : _stringify(content);
342
+ return this.log(`<% redBright bold ✖ ERROR %> ${text}`);
343
+ },
344
+
345
+ /**
346
+ * Logs a debug message (only when `DEBUG`/`NODE_DEBUG` env is set), prefixed
347
+ * with a gray `● DEBUG` label.
348
+ * @param {*} content - Content to print.
349
+ * @returns {Logger} The same Logger instance (for chaining).
350
+ */
351
+ debug(content) {
352
+ if (!process.env.DEBUG && !process.env.NODE_DEBUG) {
353
+ return /** @type {Logger} */ (this);
354
+ }
355
+ return this.log(`<% gray bold ● DEBUG %> <% gray ${_stringify(content)} %>`);
356
+ },
357
+
287
358
  /**
288
359
  * Start a log group (displays a label and adds indentation)
289
360
  * @param {string} label - The group name to display
package/src/Num.js ADDED
@@ -0,0 +1,366 @@
1
+ /**
2
+ * Number & math utilities.
3
+ *
4
+ * Clamping, rounding, ranges, human-friendly formatting (bytes, durations,
5
+ * ordinals, abbreviations) and safe parsing • the stuff `Math` leaves out.
6
+ */
7
+ module.exports = {
8
+ /**
9
+ * Clamps a number between a minimum and maximum.
10
+ *
11
+ * @example
12
+ * nodeComfort.num.clamp(15, 0, 10); // 10
13
+ * nodeComfort.num.clamp(-3, 0, 10); // 0
14
+ *
15
+ * @param {number} value - Value to clamp.
16
+ * @param {number} min - Lower bound.
17
+ * @param {number} max - Upper bound.
18
+ * @returns {number} The clamped value.
19
+ */
20
+ clamp(value, min, max) {
21
+ return Math.min(Math.max(value, min), max);
22
+ },
23
+
24
+ /**
25
+ * Checks whether a number is within an inclusive `[min, max]` range.
26
+ *
27
+ * @param {number} value - Value to test.
28
+ * @param {number} min - Lower bound.
29
+ * @param {number} max - Upper bound.
30
+ * @returns {boolean} True if within range.
31
+ */
32
+ inRange(value, min, max) {
33
+ return value >= min && value <= max;
34
+ },
35
+
36
+ /**
37
+ * Rounds a number to a given number of decimal places (avoids float artifacts).
38
+ *
39
+ * @example
40
+ * nodeComfort.num.round(1.005, 2); // 1.01
41
+ *
42
+ * @param {number} value - Value to round.
43
+ * @param {number} [decimals=0] - Number of decimal places.
44
+ * @returns {number} The rounded number.
45
+ */
46
+ round(value, decimals = 0) {
47
+ const factor = 10 ** decimals;
48
+ return Math.round((value + Number.EPSILON) * factor) / factor;
49
+ },
50
+
51
+ /**
52
+ * Rounds a number down to a given number of decimal places.
53
+ *
54
+ * @param {number} value - Value to round.
55
+ * @param {number} [decimals=0] - Number of decimal places.
56
+ * @returns {number} The floored number.
57
+ */
58
+ floor(value, decimals = 0) {
59
+ const factor = 10 ** decimals;
60
+ return Math.floor(value * factor) / factor;
61
+ },
62
+
63
+ /**
64
+ * Rounds a number up to a given number of decimal places.
65
+ *
66
+ * @param {number} value - Value to round.
67
+ * @param {number} [decimals=0] - Number of decimal places.
68
+ * @returns {number} The ceiled number.
69
+ */
70
+ ceil(value, decimals = 0) {
71
+ const factor = 10 ** decimals;
72
+ return Math.ceil(value * factor) / factor;
73
+ },
74
+
75
+ /**
76
+ * Linearly interpolates between `start` and `end` by ratio `t` (0..1).
77
+ *
78
+ * @example
79
+ * nodeComfort.num.lerp(0, 100, 0.5); // 50
80
+ *
81
+ * @param {number} start - Start value.
82
+ * @param {number} end - End value.
83
+ * @param {number} t - Interpolation factor (usually 0..1).
84
+ * @returns {number} The interpolated value.
85
+ */
86
+ lerp(start, end, t) {
87
+ return start + (end - start) * t;
88
+ },
89
+
90
+ /**
91
+ * Re-maps a number from one range to another.
92
+ *
93
+ * @example
94
+ * nodeComfort.num.mapRange(5, 0, 10, 0, 100); // 50
95
+ *
96
+ * @param {number} value - Value to map.
97
+ * @param {number} inMin - Input range minimum.
98
+ * @param {number} inMax - Input range maximum.
99
+ * @param {number} outMin - Output range minimum.
100
+ * @param {number} outMax - Output range maximum.
101
+ * @returns {number} The mapped value.
102
+ */
103
+ mapRange(value, inMin, inMax, outMin, outMax) {
104
+ if (inMax === inMin) return outMin;
105
+ return ((value - inMin) * (outMax - outMin)) / (inMax - inMin) + outMin;
106
+ },
107
+
108
+ /**
109
+ * Returns a random number (float) in `[min, max)`.
110
+ *
111
+ * Not cryptographically secure. For secure randomness use `nodeComfort.id`.
112
+ *
113
+ * @param {number} [min=0] - Lower bound (inclusive).
114
+ * @param {number} [max=1] - Upper bound (exclusive).
115
+ * @returns {number} A random float.
116
+ */
117
+ random(min = 0, max = 1) {
118
+ return Math.random() * (max - min) + min;
119
+ },
120
+
121
+ /**
122
+ * Returns a random integer in `[min, max]` (both inclusive).
123
+ *
124
+ * @example
125
+ * nodeComfort.num.randomInt(1, 6); // dice roll
126
+ *
127
+ * @param {number} min - Lower bound (inclusive).
128
+ * @param {number} max - Upper bound (inclusive).
129
+ * @returns {number} A random integer.
130
+ */
131
+ randomInt(min, max) {
132
+ min = Math.ceil(min);
133
+ max = Math.floor(max);
134
+ return Math.floor(Math.random() * (max - min + 1)) + min;
135
+ },
136
+
137
+ /**
138
+ * Sums all numbers in an array.
139
+ *
140
+ * @param {number[]} values - Array of numbers.
141
+ * @returns {number} The sum (0 for an empty array).
142
+ */
143
+ sum(values) {
144
+ return values.reduce((acc, n) => acc + n, 0);
145
+ },
146
+
147
+ /**
148
+ * Returns the arithmetic mean (average) of an array of numbers.
149
+ *
150
+ * @param {number[]} values - Array of numbers.
151
+ * @returns {number} The average (0 for an empty array).
152
+ */
153
+ average(values) {
154
+ return values.length ? this.sum(values) / values.length : 0;
155
+ },
156
+
157
+ /**
158
+ * Returns the median value of an array of numbers.
159
+ *
160
+ * @param {number[]} values - Array of numbers.
161
+ * @returns {number} The median (0 for an empty array).
162
+ */
163
+ median(values) {
164
+ if (!values.length) return 0;
165
+ const sorted = [...values].sort((a, b) => a - b);
166
+ const mid = Math.floor(sorted.length / 2);
167
+ return sorted.length % 2 ? sorted[mid] : (sorted[mid - 1] + sorted[mid]) / 2;
168
+ },
169
+
170
+ /**
171
+ * Returns the smallest number in an array.
172
+ *
173
+ * @param {number[]} values - Array of numbers.
174
+ * @returns {number|undefined} The minimum, or undefined if empty.
175
+ */
176
+ min(values) {
177
+ return values.length ? Math.min(...values) : undefined;
178
+ },
179
+
180
+ /**
181
+ * Returns the largest number in an array.
182
+ *
183
+ * @param {number[]} values - Array of numbers.
184
+ * @returns {number|undefined} The maximum, or undefined if empty.
185
+ */
186
+ max(values) {
187
+ return values.length ? Math.max(...values) : undefined;
188
+ },
189
+
190
+ /**
191
+ * Computes what percentage `value` is of `total`.
192
+ *
193
+ * @example
194
+ * nodeComfort.num.percent(25, 200); // 12.5
195
+ *
196
+ * @param {number} value - Partial value.
197
+ * @param {number} total - Total value.
198
+ * @param {number} [decimals=2] - Decimal places to keep.
199
+ * @returns {number} The percentage.
200
+ */
201
+ percent(value, total, decimals = 2) {
202
+ if (!total) return 0;
203
+ return this.round((value / total) * 100, decimals);
204
+ },
205
+
206
+ /**
207
+ * Formats a byte count into a human-readable string (KB, MB, GB, ...).
208
+ *
209
+ * By default uses binary sizing (base 1024) with familiar `KB`/`MB` labels.
210
+ * Set `binary: false` for SI sizing (base 1000), or `iec: true` for
211
+ * strict `KiB`/`MiB` labels.
212
+ *
213
+ * @example
214
+ * nodeComfort.num.formatBytes(1536); // "1.5 KB"
215
+ * nodeComfort.num.formatBytes(1073741824); // "1 GB"
216
+ * nodeComfort.num.formatBytes(1000, { binary: false }); // "1 KB"
217
+ * nodeComfort.num.formatBytes(1024, { iec: true }); // "1 KiB"
218
+ *
219
+ * @param {number} bytes - Number of bytes.
220
+ * @param {{ decimals?: number, binary?: boolean, iec?: boolean }} [options] - Formatting options.
221
+ * @returns {string} The human-readable size.
222
+ */
223
+ formatBytes(bytes, options = {}) {
224
+ if (!Number.isFinite(bytes) || bytes === 0) return "0 B";
225
+ const decimals = options.decimals ?? 1;
226
+ const base = options.binary === false ? 1000 : 1024;
227
+ const units = options.iec
228
+ ? ["B", "KiB", "MiB", "GiB", "TiB", "PiB", "EiB", "ZiB", "YiB"]
229
+ : ["B", "KB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB"];
230
+ const sign = bytes < 0 ? "-" : "";
231
+ bytes = Math.abs(bytes);
232
+ const i = Math.min(Math.floor(Math.log(bytes) / Math.log(base)), units.length - 1);
233
+ return `${sign}${this.round(bytes / base ** i, decimals)} ${units[i]}`;
234
+ },
235
+
236
+ /**
237
+ * Formats a large number with a compact suffix (K, M, B, T).
238
+ *
239
+ * @example
240
+ * nodeComfort.num.abbreviate(1500); // "1.5K"
241
+ * nodeComfort.num.abbreviate(2_400_000); // "2.4M"
242
+ *
243
+ * @param {number} value - Number to abbreviate.
244
+ * @param {number} [decimals=1] - Decimal places.
245
+ * @returns {string} The abbreviated number.
246
+ */
247
+ abbreviate(value, decimals = 1) {
248
+ const sign = value < 0 ? "-" : "";
249
+ value = Math.abs(value);
250
+ const units = [
251
+ { v: 1e12, s: "T" },
252
+ { v: 1e9, s: "B" },
253
+ { v: 1e6, s: "M" },
254
+ { v: 1e3, s: "K" },
255
+ ];
256
+ for (const { v, s } of units) {
257
+ if (value >= v) return `${sign}${this.round(value / v, decimals)}${s}`;
258
+ }
259
+ return `${sign}${value}`;
260
+ },
261
+
262
+ /**
263
+ * Inserts thousands separators into a number.
264
+ *
265
+ * @example
266
+ * nodeComfort.num.thousands(1234567); // "1,234,567"
267
+ * nodeComfort.num.thousands(1234.5, " "); // "1 234.5"
268
+ *
269
+ * @param {number} value - Number to format.
270
+ * @param {string} [separator=","] - Thousands separator.
271
+ * @returns {string} The formatted number.
272
+ */
273
+ thousands(value, separator = ",") {
274
+ const [int, dec] = String(value).split(".");
275
+ const withSep = int.replace(/\B(?=(\d{3})+(?!\d))/g, separator);
276
+ return dec ? `${withSep}.${dec}` : withSep;
277
+ },
278
+
279
+ /**
280
+ * Returns the English ordinal suffix for a number ("st", "nd", "rd", "th").
281
+ *
282
+ * @example
283
+ * nodeComfort.num.ordinal(1); // "1st"
284
+ * nodeComfort.num.ordinal(22); // "22nd"
285
+ *
286
+ * @param {number} value - The number.
287
+ * @returns {string} The number with its ordinal suffix.
288
+ */
289
+ ordinal(value) {
290
+ const abs = Math.abs(value) % 100;
291
+ const suffix =
292
+ abs >= 11 && abs <= 13
293
+ ? "th"
294
+ : ["th", "st", "nd", "rd"][Math.abs(value) % 10] || "th";
295
+ return `${value}${suffix}`;
296
+ },
297
+
298
+ /**
299
+ * Parses a value into a number, returning a fallback when it isn't numeric.
300
+ *
301
+ * @example
302
+ * nodeComfort.num.parse("42px"); // 42
303
+ * nodeComfort.num.parse("nope", 0); // 0
304
+ *
305
+ * @param {*} value - Value to parse.
306
+ * @param {number} [fallback=NaN] - Value returned when parsing fails.
307
+ * @returns {number} The parsed number or the fallback.
308
+ */
309
+ parse(value, fallback = NaN) {
310
+ if (typeof value === "number") return Number.isNaN(value) ? fallback : value;
311
+ const parsed = parseFloat(String(value).replace(/[^0-9.eE+-]/g, ""));
312
+ return Number.isNaN(parsed) ? fallback : parsed;
313
+ },
314
+
315
+ /**
316
+ * Checks whether a number is even.
317
+ * @param {number} value - Number to check.
318
+ * @returns {boolean} True if even.
319
+ */
320
+ isEven(value) {
321
+ return value % 2 === 0;
322
+ },
323
+
324
+ /**
325
+ * Checks whether a number is odd.
326
+ * @param {number} value - Number to check.
327
+ * @returns {boolean} True if odd.
328
+ */
329
+ isOdd(value) {
330
+ return Math.abs(value % 2) === 1;
331
+ },
332
+
333
+ /**
334
+ * Returns an array of numbers from `start` to `end`.
335
+ *
336
+ * `end` is exclusive. Supports a custom step and descending ranges.
337
+ *
338
+ * @example
339
+ * nodeComfort.num.range(4); // [0, 1, 2, 3]
340
+ * nodeComfort.num.range(1, 5); // [1, 2, 3, 4]
341
+ * nodeComfort.num.range(0, 10, 2); // [0, 2, 4, 6, 8]
342
+ * nodeComfort.num.range(5, 0); // [5, 4, 3, 2, 1]
343
+ *
344
+ * @param {number} start - Start (or end if it's the only argument).
345
+ * @param {number} [end] - End (exclusive).
346
+ * @param {number} [step] - Step size (defaults to 1 or -1 based on direction).
347
+ * @returns {number[]} The generated range.
348
+ */
349
+ range(start, end, step) {
350
+ if (end === undefined) {
351
+ end = start;
352
+ start = 0;
353
+ }
354
+ const descending = end < start;
355
+ step = step ?? (descending ? -1 : 1);
356
+ if (step === 0) return [];
357
+
358
+ const result = [];
359
+ if (step > 0) {
360
+ for (let i = start; i < end; i += step) result.push(i);
361
+ } else {
362
+ for (let i = start; i > end; i += step) result.push(i);
363
+ }
364
+ return result;
365
+ },
366
+ };