stringx-js
Version:
A comprehensive JavaScript library for string, number, and array manipulation inspired by Laravel. Includes 95+ string methods, 25+ number formatters, and 60 array utilities with fluent chaining, dot notation, and TypeScript support.
635 lines (567 loc) • 20.4 kB
JavaScript
import n2words from 'n2words';
/**
* Number utility class for formatting and manipulating numbers
* Inspired by Laravel's Number helper
*
* @class Number
*/
class Number {
/**
* The current default locale
* @private
*/
static #locale = 'en';
/**
* The current default currency
* @private
*/
static #currency = 'USD';
/**
* Format the given number according to the current locale
*
* @param {number} number - The number to format
* @param {number|null} precision - Exact decimal places (overrides maxPrecision)
* @param {number|null} maxPrecision - Maximum decimal places
* @param {string|null} locale - The locale to use (defaults to current locale)
* @returns {string} The formatted number
*
* @example
* Number.format(1234.567); // "1,234.567"
* Number.format(1234.567, 2); // "1,234.57"
* Number.format(1234.5, null, 2); // "1,234.5" (max 2 decimals, but only shows 1)
*/
static format(number, precision = null, maxPrecision = null, locale = null) {
const options = { useGrouping: true };
if (maxPrecision !== null) {
options.minimumFractionDigits = 0;
options.maximumFractionDigits = maxPrecision;
} else if (precision !== null) {
options.minimumFractionDigits = precision;
options.maximumFractionDigits = precision;
}
return new Intl.NumberFormat(locale || this.#locale, options).format(number);
}
/**
* Parse a string into a number according to the specified locale
*
* @param {string} string - The string to parse
* @param {string|null} locale - The locale to use for parsing
* @returns {number|null} The parsed number or null if invalid
*
* @example
* Number.parse("1,234.56"); // 1234.56 (en locale)
* Number.parse("10,123", "fr"); // 10.123 (fr locale, comma is decimal)
* Number.parse("invalid"); // null
*/
static parse(string, locale = null) {
if (!string || typeof string !== 'string') {
return null;
}
// Get locale-specific separators
const targetLocale = locale || this.#locale;
const separators = this.#getLocaleSeparators(targetLocale);
// Normalize the string by:
// 1. Remove thousands separators
// 2. Replace decimal separator with standard '.'
let normalized = string.trim();
// Remove thousands separator (including all types of spaces if not decimal separator)
if (separators.thousands) {
// Escape special regex characters
const escaped = separators.thousands.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
normalized = normalized.replace(new RegExp(escaped, 'g'), '');
}
// Also remove regular spaces and non-breaking spaces if they're not the decimal separator
if (separators.decimal !== ' ' && separators.decimal !== '\u00A0') {
normalized = normalized.replace(/[\s\u00A0]/g, '');
}
// Replace locale decimal separator with standard '.'
if (separators.decimal !== '.') {
normalized = normalized.replace(separators.decimal, '.');
}
// Parse the normalized string
const parsed = parseFloat(normalized);
return isNaN(parsed) ? null : parsed;
}
/**
* Get decimal and thousands separators for a locale
* @private
*/
static #getLocaleSeparators(locale) {
// Format a number to detect separators
const formatted = new Intl.NumberFormat(locale).format(1234.5);
// Extract decimal separator (character between 4 and 5)
const decimal = formatted.charAt(5) || '.';
// Extract thousands separator (character between 1 and 2)
const thousands = formatted.charAt(1);
return { decimal, thousands };
}
/**
* Parse a string into an integer
*
* @param {string} string - The string to parse
* @param {string|null} locale - The locale to use (for future compatibility)
* @returns {number|null} The parsed integer or null if invalid
*
* @example
* Number.parseInt("1,234"); // 1234
* Number.parseInt("123.99"); // 123
*/
static parseInt(string, locale = null) {
const parsed = this.parse(string, locale);
return parsed !== null ? Math.floor(parsed) : null;
}
/**
* Parse a string into a float
*
* @param {string} string - The string to parse
* @param {string|null} locale - The locale to use (for future compatibility)
* @returns {number|null} The parsed float or null if invalid
*
* @example
* Number.parseFloat("1,234.56"); // 1234.56
*/
static parseFloat(string, locale = null) {
return this.parse(string, locale);
}
/**
* Spell out the given number in the given locale
* Uses n2words library for multi-language support
*
* @param {number} number - The number to spell out
* @param {string|null} locale - The locale to use (e.g., 'en', 'fr', 'es', 'de', 'ar')
* @param {number|null} after - Only spell if number is greater than this
* @param {number|null} until - Only spell if number is less than this
* @returns {string} The spelled out number
*
* @example
* Number.spell(42); // "forty-two" (English)
* Number.spell(42, 'fr'); // "quarante-deux" (French)
* Number.spell(42, 'es'); // "cuarenta y dos" (Spanish)
* Number.spell(42, 'de'); // "zweiundvierzig" (German)
* Number.spell(100, null, 50); // "100" (greater than 'after' threshold)
*/
static spell(number, locale = null, after = null, until = null) {
// Check thresholds
if (after !== null && number <= after) {
return this.format(number, 0, null, locale);
}
if (until !== null && number >= until) {
return this.format(number, 0, null, locale);
}
// Use n2words for multi-language support
try {
const lang = this.#mapLocaleToN2wordsLang(locale || this.#locale);
return n2words(number, { lang });
} catch (error) {
// Fallback to English if locale not supported
return n2words(number, { lang: 'en' });
}
}
/**
* Convert the given number to ordinal form (1st, 2nd, 3rd, etc.)
*
* @param {number} number - The number to convert
* @param {string|null} locale - The locale to use
* @returns {string} The ordinal number
*
* @example
* Number.ordinal(1); // "1st"
* Number.ordinal(22); // "22nd"
* Number.ordinal(103); // "103rd"
*/
static ordinal(number, locale = null) {
// Try to use Intl.PluralRules for locale-aware ordinals
try {
const pr = new Intl.PluralRules(locale || this.#locale, { type: 'ordinal' });
const rule = pr.select(number);
const suffixes = {
'one': 'st',
'two': 'nd',
'few': 'rd',
'other': 'th'
};
return `${number}${suffixes[rule] || 'th'}`;
} catch (e) {
// Fallback for older environments
return this.#basicOrdinal(number);
}
}
/**
* Spell out the ordinal form (first, second, third, etc.)
* Note: Full locale support for ordinals is limited. English is fully supported.
*
* @param {number} number - The number to spell out
* @param {string|null} locale - The locale to use
* @returns {string} The spelled ordinal
*
* @example
* Number.spellOrdinal(1); // "first"
* Number.spellOrdinal(22); // "twenty-second"
* Number.spellOrdinal(42, 'fr'); // "quarante-deuxième" (French, if supported)
*/
static spellOrdinal(number, locale = null) {
const targetLocale = locale || this.#locale;
const lang = this.#mapLocaleToN2wordsLang(targetLocale);
// For English, use our basic implementation since n2words doesn't support ordinals properly
if (lang === 'en') {
return this.#basicOrdinalSpelled(number);
}
// For other languages, try n2words with ordinal flag
// Note: Support varies by language
try {
return n2words(number, { lang, ordinal: true });
} catch (error) {
// Fallback to basic English ordinal
return this.#basicOrdinalSpelled(number);
}
}
/**
* Convert the given number to its percentage equivalent
*
* @param {number} number - The number to convert (e.g., 50 for 50%)
* @param {number} precision - Decimal places
* @param {number|null} maxPrecision - Maximum decimal places
* @param {string|null} locale - The locale to use
* @returns {string} The formatted percentage
*
* @example
* Number.percentage(50); // "50%"
* Number.percentage(66.666, 2); // "66.67%"
*/
static percentage(number, precision = 0, maxPrecision = null, locale = null) {
const options = { style: 'percent' };
if (maxPrecision !== null) {
options.minimumFractionDigits = 0;
options.maximumFractionDigits = maxPrecision;
} else {
options.minimumFractionDigits = precision;
options.maximumFractionDigits = precision;
}
return new Intl.NumberFormat(locale || this.#locale, options).format(number / 100);
}
/**
* Convert the given number to its currency equivalent
*
* @param {number} number - The number to format
* @param {string} currency - The currency code (e.g., 'USD', 'EUR')
* @param {string|null} locale - The locale to use
* @param {number|null} precision - Decimal places
* @returns {string} The formatted currency
*
* @example
* Number.currency(1234.56); // "$1,234.56"
* Number.currency(1234.56, 'EUR'); // "€1,234.56"
* Number.currency(1234.5, 'USD', null, 2); // "$1,234.50"
*/
static currency(number, currency = '', locale = null, precision = null) {
const options = {
style: 'currency',
currency: currency || this.#currency
};
if (precision !== null) {
options.minimumFractionDigits = precision;
options.maximumFractionDigits = precision;
}
return new Intl.NumberFormat(locale || this.#locale, options).format(number);
}
/**
* Convert the given number to its file size equivalent
*
* @param {number} bytes - The number of bytes
* @param {number} precision - Decimal places
* @param {number|null} maxPrecision - Maximum decimal places
* @returns {string} The formatted file size
*
* @example
* Number.fileSize(1024); // "1 KB"
* Number.fileSize(1536, 2); // "1.50 KB"
* Number.fileSize(1024 * 1024 * 5); // "5 MB"
*/
static fileSize(bytes, precision = 0, maxPrecision = null) {
const units = ['B', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB'];
let i = 0;
for (i = 0; (bytes / 1024) > 0.9 && (i < units.length - 1); i++) {
bytes /= 1024;
}
return `${this.format(bytes, precision, maxPrecision)} ${units[i]}`;
}
/**
* Convert the number to its human-readable equivalent (abbreviated)
*
* @param {number} number - The number to abbreviate
* @param {number} precision - Decimal places
* @param {number|null} maxPrecision - Maximum decimal places
* @returns {string} The abbreviated number
*
* @example
* Number.abbreviate(1000); // "1K"
* Number.abbreviate(1500000); // "1.5M"
* Number.abbreviate(2500000000); // "2.5B"
*/
static abbreviate(number, precision = 0, maxPrecision = null) {
return this.forHumans(number, precision, maxPrecision, true);
}
/**
* Convert the number to its human-readable equivalent
*
* @param {number} number - The number to convert
* @param {number} precision - Decimal places
* @param {number|null} maxPrecision - Maximum decimal places
* @param {boolean} abbreviate - Use abbreviated format (K, M, B) instead of full words
* @returns {string} The human-readable number
*
* @example
* Number.forHumans(1000); // "1 thousand"
* Number.forHumans(1500000); // "1.5 million"
* Number.forHumans(1000, 0, null, true); // "1K"
*/
static forHumans(number, precision = 0, maxPrecision = null, abbreviate = false) {
const units = abbreviate ? {
3: 'K',
6: 'M',
9: 'B',
12: 'T',
15: 'Q'
} : {
3: ' thousand',
6: ' million',
9: ' billion',
12: ' trillion',
15: ' quadrillion'
};
return this.#summarize(number, precision, maxPrecision, units);
}
/**
* Clamp the given number between the given minimum and maximum
*
* @param {number} number - The number to clamp
* @param {number} min - Minimum value
* @param {number} max - Maximum value
* @returns {number} The clamped number
*
* @example
* Number.clamp(5, 1, 10); // 5
* Number.clamp(0, 1, 10); // 1
* Number.clamp(15, 1, 10); // 10
*/
static clamp(number, min, max) {
return Math.min(Math.max(number, min), max);
}
/**
* Split the given number into pairs of min/max values
*
* @param {number} to - The maximum value
* @param {number} by - The step size
* @param {number} start - The starting value
* @param {number} offset - Offset for the upper bound
* @returns {Array<[number, number]>} Array of [min, max] pairs
*
* @example
* Number.pairs(10, 3); // [[0, 2], [3, 5], [6, 8], [9, 10]]
* Number.pairs(10, 5, 0, 0); // [[0, 5], [5, 10], [10, 10]]
*/
static pairs(to, by, start = 0, offset = 1) {
const output = [];
for (let lower = start; lower <= to; lower += by) {
let upper = lower + by - offset;
if (upper > to) {
upper = to;
}
output.push([lower, upper]);
}
return output;
}
/**
* Remove any trailing zero digits after the decimal point
*
* @param {number} number - The number to trim
* @returns {number} The trimmed number
*
* @example
* Number.trim(1.50); // 1.5
* Number.trim(1.00); // 1
* Number.trim(1.230); // 1.23
*/
static trim(number) {
return parseFloat(number.toString());
}
/**
* Execute the given callback using the given locale
*
* @param {string} locale - The locale to use
* @param {Function} callback - The callback to execute
* @returns {*} The callback result
*
* @example
* Number.withLocale('de-DE', () => Number.format(1234.56)); // "1.234,56"
*/
static withLocale(locale, callback) {
const previousLocale = this.#locale;
this.#locale = locale;
try {
return callback();
} finally {
this.#locale = previousLocale;
}
}
/**
* Execute the given callback using the given currency
*
* @param {string} currency - The currency code
* @param {Function} callback - The callback to execute
* @returns {*} The callback result
*
* @example
* Number.withCurrency('EUR', () => Number.currency(100)); // "€100.00"
*/
static withCurrency(currency, callback) {
const previousCurrency = this.#currency;
this.#currency = currency;
try {
return callback();
} finally {
this.#currency = previousCurrency;
}
}
/**
* Set the default locale
*
* @param {string} locale - The locale to set
*
* @example
* Number.useLocale('fr-FR');
*/
static useLocale(locale) {
this.#locale = locale;
}
/**
* Set the default currency
*
* @param {string} currency - The currency code to set
*
* @example
* Number.useCurrency('GBP');
*/
static useCurrency(currency) {
this.#currency = currency;
}
/**
* Get the default locale
*
* @returns {string} The current default locale
*
* @example
* Number.defaultLocale(); // "en"
*/
static defaultLocale() {
return this.#locale;
}
/**
* Get the default currency
*
* @returns {string} The current default currency
*
* @example
* Number.defaultCurrency(); // "USD"
*/
static defaultCurrency() {
return this.#currency;
}
// Private helper methods
/**
* Basic ordinal suffix generator
* @private
*/
static #basicOrdinal(number) {
const j = number % 10;
const k = number % 100;
if (j === 1 && k !== 11) {
return `${number}st`;
}
if (j === 2 && k !== 12) {
return `${number}nd`;
}
if (j === 3 && k !== 13) {
return `${number}rd`;
}
return `${number}th`;
}
/**
* Map locale string to n2words language code
* @private
*/
static #mapLocaleToN2wordsLang(locale) {
// Extract base language (e.g., 'en' from 'en-US', 'fr' from 'fr-FR')
const baseLang = locale.split('-')[0].toLowerCase();
// Map to n2words supported languages
const langMap = {
'en': 'en',
'fr': 'fr',
'es': 'es',
'de': 'de',
'ar': 'ar',
'pt': 'pt',
'it': 'it',
'ru': 'ru',
'pl': 'pl',
'uk': 'uk',
'tr': 'tr',
'nl': 'nl',
'id': 'id',
'ko': 'ko',
'vi': 'vi',
'zh': 'zh'
};
return langMap[baseLang] || 'en';
}
/**
* Basic spelled ordinal (English only fallback)
* @private
*/
static #basicOrdinalSpelled(number) {
const ones = ['zeroth', 'first', 'second', 'third', 'fourth', 'fifth', 'sixth', 'seventh', 'eighth', 'ninth'];
const teens = ['tenth', 'eleventh', 'twelfth', 'thirteenth', 'fourteenth', 'fifteenth', 'sixteenth', 'seventeenth', 'eighteenth', 'nineteenth'];
const tens = ['', '', 'twentieth', 'thirtieth', 'fortieth', 'fiftieth', 'sixtieth', 'seventieth', 'eightieth', 'ninetieth'];
const tensPrefix = ['', '', 'twenty', 'thirty', 'forty', 'fifty', 'sixty', 'seventy', 'eighty', 'ninety'];
if (number < 10) {
return ones[number];
}
if (number < 20) {
return teens[number - 10];
}
if (number < 100) {
const ten = Math.floor(number / 10);
const one = number % 10;
if (one === 0) {
return tens[ten];
}
return tensPrefix[ten] + '-' + ones[one];
}
// For larger numbers, use numeric ordinal
return this.ordinal(number, locale);
}
/**
* Summarize a number with units
* @private
*/
static #summarize(number, precision = 0, maxPrecision = null, units = {}) {
// Handle zero
if (number === 0) {
return precision > 0 ? this.format(0, precision, maxPrecision) : '0';
}
// Handle negative
if (number < 0) {
return '-' + this.#summarize(Math.abs(number), precision, maxPrecision, units);
}
// Handle very large numbers (>= 1 quadrillion)
if (number >= 1e15) {
const unitValues = Object.values(units);
return this.#summarize(number / 1e15, precision, maxPrecision, units) + unitValues[unitValues.length - 1];
}
const numberExponent = Math.floor(Math.log10(number));
const displayExponent = numberExponent - (numberExponent % 3);
const scaled = number / Math.pow(10, displayExponent);
return (this.format(scaled, precision, maxPrecision) + (units[displayExponent] || '')).trim();
}
}
export default Number;