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.
1,443 lines (1,229 loc) • 43.8 kB
JavaScript
import { v4 as uuidv4, v7 as uuidv7, validate as validateUuid } from 'uuid';
import Stringable from './Stringable.js';
/**
* String helper class inspired by Laravel's Str helper
*/
class Str {
// Static caches for performance
static snakeCache = {};
static camelCache = {};
static studlyCache = {};
static uuidFactory = null;
static ulidFactory = null;
static randomStringFactory = null;
/**
* Return a new Stringable object for fluent string manipulation.
*/
static of(string) {
return new Stringable(string);
}
/**
* Return the remainder of a string after the first occurrence of a given value.
*/
static after(subject, search) {
if (search === '') return subject;
const parts = subject.split(search);
return parts.length > 1 ? parts.slice(1).join(search) : subject;
}
/**
* Return the remainder of a string after the last occurrence of a given value.
*/
static afterLast(subject, search) {
if (search === '') return subject;
const position = subject.lastIndexOf(search);
if (position === -1) return subject;
return subject.substring(position + search.length);
}
/**
* Get the portion of a string before the first occurrence of a given value.
*/
static before(subject, search) {
if (search === '') return subject;
const position = subject.indexOf(search);
return position === -1 ? subject : subject.substring(0, position);
}
/**
* Get the portion of a string before the last occurrence of a given value.
*/
static beforeLast(subject, search) {
if (search === '') return subject;
const position = subject.lastIndexOf(search);
return position === -1 ? subject : subject.substring(0, position);
}
/**
* Get the portion of a string between two given values.
*/
static between(subject, from, to) {
if (from === '' || to === '') return subject;
return this.beforeLast(this.after(subject, from), to);
}
/**
* Get the smallest possible portion of a string between two given values.
*/
static betweenFirst(subject, from, to) {
if (from === '' || to === '') return subject;
return this.before(this.after(subject, from), to);
}
/**
* Convert a value to camel case.
*/
static camel(value) {
if (this.camelCache[value]) {
return this.camelCache[value];
}
// Remove special characters and convert to camel case
const result = value
.replace(/[-_\s]+(.)?/g, (_, c) => c ? c.toUpperCase() : '')
.replace(/^(.)/, (_, c) => c.toLowerCase());
this.camelCache[value] = result;
return result;
}
/**
* Get the character at the specified index.
*/
static charAt(subject, index) {
const length = subject.length;
if (index < 0 ? index < -length : index > length - 1) {
return false;
}
return index < 0 ? subject.charAt(length + index) : subject.charAt(index);
}
/**
* Remove the given string(s) if it exists at the start of the haystack.
*/
static chopStart(subject, needle) {
const needles = Array.isArray(needle) ? needle : [needle];
for (const n of needles) {
if (subject.startsWith(n)) {
return subject.substring(n.length);
}
}
return subject;
}
/**
* Remove the given string(s) if it exists at the end of the haystack.
*/
static chopEnd(subject, needle) {
const needles = Array.isArray(needle) ? needle : [needle];
for (const n of needles) {
if (subject.endsWith(n)) {
return subject.substring(0, subject.length - n.length);
}
}
return subject;
}
/**
* Determine if a given string contains a given substring.
*/
static contains(haystack, needles, ignoreCase = false) {
if (!haystack) return false;
const needleArray = Array.isArray(needles) ? needles : [needles];
const searchHaystack = ignoreCase ? haystack.toLowerCase() : haystack;
for (let needle of needleArray) {
const searchNeedle = ignoreCase ? needle.toLowerCase() : needle;
if (searchNeedle !== '' && searchHaystack.includes(searchNeedle)) {
return true;
}
}
return false;
}
/**
* Determine if a given string contains all array values.
*/
static containsAll(haystack, needles, ignoreCase = false) {
const needleArray = Array.isArray(needles) ? needles : [needles];
for (const needle of needleArray) {
if (!this.contains(haystack, needle, ignoreCase)) {
return false;
}
}
return true;
}
/**
* Determine if a given string doesn't contain a given substring.
*/
static doesntContain(haystack, needles, ignoreCase = false) {
return !this.contains(haystack, needles, ignoreCase);
}
/**
* Determine if a given string ends with a given substring.
*/
static endsWith(haystack, needles) {
if (!haystack) return false;
const needleArray = Array.isArray(needles) ? needles : [needles];
for (const needle of needleArray) {
if (needle !== '' && haystack.endsWith(needle)) {
return true;
}
}
return false;
}
/**
* Determine if a given string doesn't end with a given substring.
*/
static doesntEndWith(haystack, needles) {
return !this.endsWith(haystack, needles);
}
/**
* Cap a string with a single instance of a given value.
*/
static finish(value, cap) {
const quotedCap = cap.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const regex = new RegExp(`(?:${quotedCap})+$`);
return value.replace(regex, '') + cap;
}
/**
* Determine if a given string matches a given pattern.
*/
static is(pattern, value, ignoreCase = false) {
const patterns = Array.isArray(pattern) ? pattern : [pattern];
for (let p of patterns) {
// Exact match
if (p === '*' || p === value) return true;
if (ignoreCase && p.toLowerCase() === value.toLowerCase()) {
return true;
}
// Convert pattern to regex
const regexPattern = p
.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
.replace(/\\\*/g, '.*');
const flags = ignoreCase ? 'i' : '';
const regex = new RegExp(`^${regexPattern}$`, flags);
if (regex.test(value)) return true;
}
return false;
}
/**
* Determine if a given string is 7 bit ASCII.
*/
static isAscii(value) {
return /^[\x00-\x7F]*$/.test(value);
}
/**
* Determine if a given value is valid JSON.
*/
static isJson(value) {
if (typeof value !== 'string') return false;
try {
JSON.parse(value);
return true;
} catch {
return false;
}
}
/**
* Determine if a given value is a valid URL.
*/
static isUrl(value, protocols = []) {
if (typeof value !== 'string') return false;
try {
const url = new URL(value);
if (protocols.length === 0) return true;
return protocols.includes(url.protocol.replace(':', ''));
} catch {
return false;
}
}
/**
* Determine if a given value is a valid UUID.
*/
static isUuid(value) {
if (typeof value !== 'string') return false;
return validateUuid(value);
}
/**
* Determine if a given value is a valid ULID.
*/
static isUlid(value) {
if (typeof value !== 'string') return false;
// ULID is 26 characters, base32 encoded
return /^[0123456789ABCDEFGHJKMNPQRSTVWXYZ]{26}$/.test(value);
}
/**
* Convert a string to kebab case.
*/
static kebab(value) {
return this.snake(value, '-');
}
/**
* Return the length of the given string.
*/
static length(value) {
// JavaScript strings are UTF-16, but we can count Unicode code points
return Array.from(value).length;
}
/**
* Limit the number of characters in a string.
*/
static limit(value, limit = 100, end = '...') {
if (value.length <= limit) return value;
return value.substring(0, limit).trimEnd() + end;
}
/**
* Convert the given string to lower-case.
*/
static lower(value) {
return value.toLowerCase();
}
/**
* Limit the number of words in a string.
*/
static words(value, words = 100, end = '...') {
const matches = value.match(/\S+/g) || [];
if (matches.length <= words) return value;
return matches.slice(0, words).join(' ') + end;
}
/**
* Masks a portion of a string with a repeated character.
*/
static mask(string, character, index, length = null) {
if (character === '') return string;
const stringArray = Array.from(string);
const strLength = stringArray.length;
let startIndex = index < 0 ? Math.max(0, strLength + index) : index;
let maskLength = length === null ? strLength - startIndex : length;
if (startIndex >= strLength) return string;
const segment = stringArray.slice(startIndex, startIndex + maskLength);
const maskChar = Array.from(character)[0];
const masked = maskChar.repeat(segment.length);
return stringArray.slice(0, startIndex).join('') +
masked +
stringArray.slice(startIndex + segment.length).join('');
}
/**
* Get the string matching the given pattern.
*/
static match(pattern, subject) {
const matches = subject.match(pattern);
if (!matches) return '';
return matches[1] !== undefined ? matches[1] : matches[0];
}
/**
* Determine if a given string matches a given pattern.
*/
static isMatch(pattern, value) {
const patterns = Array.isArray(pattern) ? pattern : [pattern];
for (const p of patterns) {
if (new RegExp(p).test(value)) return true;
}
return false;
}
/**
* Get all strings matching the given pattern.
*/
static matchAll(pattern, subject) {
const regex = new RegExp(pattern, 'g');
const matches = [];
let match;
while ((match = regex.exec(subject)) !== null) {
matches.push(match[1] !== undefined ? match[1] : match[0]);
}
return matches;
}
/**
* Pad both sides of a string with another.
*/
static padBoth(value, length, pad = ' ') {
const strLength = value.length;
if (strLength >= length) return value;
const padLength = length - strLength;
const leftPad = Math.floor(padLength / 2);
const rightPad = padLength - leftPad;
return pad.repeat(leftPad).substring(0, leftPad) +
value +
pad.repeat(rightPad).substring(0, rightPad);
}
/**
* Pad the left side of a string with another.
*/
static padLeft(value, length, pad = ' ') {
return value.padStart(length, pad);
}
/**
* Pad the right side of a string with another.
*/
static padRight(value, length, pad = ' ') {
return value.padEnd(length, pad);
}
/**
* Convert a string to Pascal case (StudlyCase).
*/
static pascal(value) {
return this.studly(value);
}
/**
* Generate a random, secure string.
*/
static random(length = 16) {
if (this.randomStringFactory) {
return this.randomStringFactory(length);
}
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
let result = '';
// Use crypto for secure randomness in Node.js or browser
if (typeof crypto !== 'undefined' && crypto.getRandomValues) {
const values = new Uint32Array(length);
crypto.getRandomValues(values);
for (let i = 0; i < length; i++) {
result += chars[values[i] % chars.length];
}
} else {
// Fallback for environments without crypto
for (let i = 0; i < length; i++) {
result += chars[Math.floor(Math.random() * chars.length)];
}
}
return result;
}
/**
* Remove all non-numeric characters from a string.
*/
static numbers(value) {
return value.replace(/[^0-9]/g, '');
}
/**
* Replace a given value in the string.
*/
static replace(search, replace, subject, caseSensitive = true) {
if (Array.isArray(subject)) {
return subject.map(s => this.replace(search, replace, s, caseSensitive));
}
if (caseSensitive) {
if (Array.isArray(search)) {
search.forEach((s, i) => {
const r = Array.isArray(replace) ? replace[i] : replace;
subject = subject.split(s).join(r);
});
return subject;
}
return subject.split(search).join(replace);
} else {
const regex = new RegExp(search, 'gi');
return subject.replace(regex, replace);
}
}
/**
* Replace the first occurrence of a given value in the string.
*/
static replaceFirst(search, replace, subject) {
if (search === '') return subject;
const position = subject.indexOf(search);
if (position === -1) return subject;
return subject.substring(0, position) + replace + subject.substring(position + search.length);
}
/**
* Replace the last occurrence of a given value in the string.
*/
static replaceLast(search, replace, subject) {
if (search === '') return subject;
const position = subject.lastIndexOf(search);
if (position === -1) return subject;
return subject.substring(0, position) + replace + subject.substring(position + search.length);
}
/**
* Remove any occurrence of the given string in the subject.
*/
static remove(search, subject, caseSensitive = true) {
return this.replace(search, '', subject, caseSensitive);
}
/**
* Reverse the given string.
*/
static reverse(value) {
return Array.from(value).reverse().join('');
}
/**
* Begin a string with a single instance of a given value.
*/
static start(value, prefix) {
const quotedPrefix = prefix.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const regex = new RegExp(`^(?:${quotedPrefix})+`);
return prefix + value.replace(regex, '');
}
/**
* Convert the given string to upper-case.
*/
static upper(value) {
return value.toUpperCase();
}
/**
* Convert the given string to title case.
*/
static title(value) {
return value.replace(/\w\S*/g, txt =>
txt.charAt(0).toUpperCase() + txt.substr(1).toLowerCase()
);
}
/**
* Convert the given string to title case for each word.
*/
static headline(value) {
const parts = value.split(/[\s_-]+/);
return parts
.map(part => this.title(part))
.join(' ');
}
/**
* Match the case of the target string to the case of the original string.
* @private
*/
static matchCase(target, original) {
if (!original || !target) return target;
// Check if original is all uppercase
if (original === original.toUpperCase() && original !== original.toLowerCase()) {
return target.toUpperCase();
}
// Check if original starts with uppercase (Title Case)
if (original[0] === original[0].toUpperCase() && original[0] !== original[0].toLowerCase()) {
return target.charAt(0).toUpperCase() + target.slice(1).toLowerCase();
}
// Default to lowercase
return target.toLowerCase();
}
/**
* Get the singular form of an English word.
*/
static singular(value) {
if (!value || value.length === 0) return value;
const lower = value.toLowerCase();
// Uncountable words - return as-is
const uncountable = [
'equipment', 'information', 'rice', 'money', 'species',
'series', 'fish', 'sheep', 'moose', 'deer', 'news',
'pants', 'scissors', 'trousers', 'glasses', 'police',
];
if (uncountable.includes(lower)) {
return value;
}
// Irregular singulars
const irregular = {
'men': 'man',
'women': 'woman',
'children': 'child',
'teeth': 'tooth',
'feet': 'foot',
'people': 'person',
'mice': 'mouse',
'geese': 'goose',
'oxen': 'ox',
'criteria': 'criterion',
'phenomena': 'phenomenon',
'analyses': 'analysis',
'bases': 'basis',
'diagnoses': 'diagnosis',
'theses': 'thesis'
};
if (irregular[lower]) {
return this.matchCase(irregular[lower], value);
}
// Words ending in -ves
if (lower.endsWith('ves')) {
if (lower.endsWith('ives')) {
return this.matchCase(value.slice(0, -4) + 'ife', value); // knives -> knife (remove 'ives', add 'ife')
}
return this.matchCase(value.slice(0, -3) + 'f', value); // wolves -> wolf
}
// Words ending in -ies
if (lower.endsWith('ies') && lower.length > 3) {
return this.matchCase(value.slice(0, -3) + 'y', value); // cities -> city
}
// Words ending in -i (Latin/Greek)
if (lower.endsWith('i') && lower.length > 2) {
return this.matchCase(value.slice(0, -1) + 'us', value); // radii -> radius
}
// Words ending in -ses (Greek plurals: -is becomes -es)
// Only for specific Greek patterns: -ises, -yses, -ases
if (lower.endsWith('ses') && lower.length > 4) {
if (lower.endsWith('ises') || lower.endsWith('yses') || lower.endsWith('ases')) {
return this.matchCase(value.slice(0, -2) + 'is', value); // crises -> crisis, analyses -> analysis
}
}
// Words ending in -a (Latin/Greek)
if (lower.endsWith('a') && lower.length > 2 && !lower.endsWith('ia')) {
return this.matchCase(value.slice(0, -1) + 'on', value); // criteria -> criterion
}
// Words ending in -es
if (lower.endsWith('es') && lower.length > 3) {
// Check for specific patterns that take -es
if (lower.endsWith('sses') || lower.endsWith('xes') || lower.endsWith('zes') ||
lower.endsWith('shes') || lower.endsWith('ches')) {
return this.matchCase(value.slice(0, -2), value); // classes -> class, boxes -> box, dishes -> dish
}
// For words ending in -oes
if (lower.endsWith('oes')) {
return this.matchCase(value.slice(0, -2), value); // heroes -> hero, potatoes -> potato
}
// For words ending in -ives
if (lower.endsWith('ives')) {
return this.matchCase(value.slice(0, -3) + 'ife', value); // knives -> knife (already handled above)
}
// Otherwise just remove -s (houses -> house)
return this.matchCase(value.slice(0, -1), value);
}
// Words ending in -s (but not -ss)
if (lower.endsWith('s') && !lower.endsWith('ss') && lower.length > 1) {
return this.matchCase(value.slice(0, -1), value); // cats -> cat
}
return value;
}
/**
* Get the plural form of an English word.
*/
static plural(value, count = 2) {
if (count === 1) return value;
if (!value || value.length === 0) return value;
const lower = value.toLowerCase();
// Uncountable words - return as-is
const uncountable = [
'equipment', 'information', 'rice', 'money', 'species',
'series', 'fish', 'sheep', 'moose', 'deer', 'news',
'pants', 'scissors', 'trousers', 'glasses', 'police', 'related', 'recommended'
];
if (uncountable.includes(lower)) {
return value;
}
// Irregular plurals
const irregular = {
'man': 'men',
'woman': 'women',
'child': 'children',
'tooth': 'teeth',
'foot': 'feet',
'person': 'people',
'mouse': 'mice',
'goose': 'geese',
'ox': 'oxen',
'criterion': 'criteria',
'phenomenon': 'phenomena',
'analysis': 'analyses',
'basis': 'bases',
'diagnosis': 'diagnoses',
'thesis': 'theses'
};
if (irregular[lower]) {
return this.matchCase(irregular[lower], value);
}
// Words ending in -f or -fe
if (lower.endsWith('f')) {
return this.matchCase(value.slice(0, -1) + 'ves', value); // wolf -> wolves
}
if (lower.endsWith('fe')) {
return this.matchCase(value.slice(0, -2) + 'ves', value); // knife -> knives
}
// Words ending in -o
if (lower.endsWith('o')) {
// Words that take -oes
const oesWords = ['hero', 'potato', 'tomato', 'echo', 'torpedo', 'veto'];
if (oesWords.includes(lower)) {
return this.matchCase(value + 'es', value);
}
// Words ending in vowel + o OR words like 'photo', 'piano' take just -s
if (/[aeiou]o$/.test(lower) || ['photo', 'piano', 'solo', 'halo', 'portfolio'].includes(lower)) {
return this.matchCase(value + 's', value); // radio -> radios, photo -> photos
}
// Most other -o words take -es
return this.matchCase(value + 'es', value); // hero -> heroes
}
// Words ending in -us (Latin)
if (lower.endsWith('us')) {
return this.matchCase(value.slice(0, -2) + 'i', value); // radius -> radii
}
// Words ending in -is (Greek)
if (lower.endsWith('is')) {
return this.matchCase(value.slice(0, -2) + 'es', value); // crisis -> crises
}
// Words ending in -on (Greek)
if (lower.endsWith('on') && ['criterion', 'phenomenon'].includes(lower)) {
return this.matchCase(value.slice(0, -2) + 'a', value); // criterion -> criteria
}
// Words ending in -y
if (lower.endsWith('y')) {
// Consonant + y: -y -> -ies
if (!/[aeiou]y$/.test(lower)) {
return this.matchCase(value.slice(0, -1) + 'ies', value); // city -> cities
}
// Vowel + y: just add -s
return this.matchCase(value + 's', value); // day -> days
}
// Words ending in -s, -ss, -sh, -ch, -x, -z
if (['s', 'x', 'z'].some(ending => lower.endsWith(ending)) ||
['ss', 'sh', 'ch'].some(ending => lower.endsWith(ending))) {
return this.matchCase(value + 'es', value); // class -> classes, box -> boxes
}
// Default: just add -s
return this.matchCase(value + 's', value); // car -> cars
}
/**
* Generate a URL friendly "slug" from a given string.
*/
static slug(title, separator = '-') {
// Handle camelCase by adding spaces before uppercase letters
let slug = title.replace(/([a-z])([A-Z])/g, '$1 $2');
// Convert to lowercase and replace special characters
slug = slug.toLowerCase()
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, ''); // Remove diacritics
// Replace non-alphanumeric characters with separator
slug = slug.replace(/[^a-z0-9]+/g, separator);
// Remove leading/trailing separators
slug = slug.replace(new RegExp(`^${separator}+|${separator}+$`, 'g'), '');
return slug;
}
/**
* Convert a string to snake case.
*/
static snake(value, delimiter = '_') {
const key = value + delimiter;
if (this.snakeCache[key]) {
return this.snakeCache[key];
}
// Handle special cases
if (!/[A-Z]/.test(value)) {
return value;
}
const result = value
.replace(/([A-Z]+)([A-Z][a-z])/g, '$1' + delimiter + '$2')
.replace(/([a-z\d])([A-Z])/g, '$1' + delimiter + '$2')
.replace(/[-\s]+/g, delimiter)
.toLowerCase();
this.snakeCache[key] = result;
return result;
}
/**
* Remove whitespace from both ends of a string.
*/
static trim(value, charlist = null) {
if (charlist === null) {
return value.trim();
}
const chars = charlist.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
return value.replace(new RegExp(`^[${chars}]+|[${chars}]+$`, 'g'), '');
}
/**
* Remove whitespace from the beginning of a string.
*/
static ltrim(value, charlist = null) {
if (charlist === null) {
return value.trimStart();
}
const chars = charlist.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
return value.replace(new RegExp(`^[${chars}]+`, 'g'), '');
}
/**
* Remove whitespace from the end of a string.
*/
static rtrim(value, charlist = null) {
if (charlist === null) {
return value.trimEnd();
}
const chars = charlist.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
return value.replace(new RegExp(`[${chars}]+$`, 'g'), '');
}
/**
* Remove all "extra" blank space from the given string.
*/
static squish(value) {
return this.trim(value).replace(/\s+/g, ' ');
}
/**
* Determine if a given string starts with a given substring.
*/
static startsWith(haystack, needles) {
if (!haystack) return false;
const needleArray = Array.isArray(needles) ? needles : [needles];
for (const needle of needleArray) {
if (needle !== '' && haystack.startsWith(needle)) {
return true;
}
}
return false;
}
/**
* Determine if a given string doesn't start with a given substring.
*/
static doesntStartWith(haystack, needles) {
return !this.startsWith(haystack, needles);
}
/**
* Convert a value to studly caps case.
*/
static studly(value) {
if (this.studlyCache[value]) {
return this.studlyCache[value];
}
const result = value
.replace(/[-_\s]+(.)?/g, (_, c) => c ? c.toUpperCase() : '')
.replace(/^(.)/, (_, c) => c.toUpperCase());
this.studlyCache[value] = result;
return result;
}
/**
* Returns the portion of the string specified by the start and length parameters.
*/
static substr(string, start, length = null) {
const str = Array.from(string);
if (start < 0) {
start = Math.max(0, str.length + start);
}
if (length === null) {
return str.slice(start).join('');
}
if (length < 0) {
return str.slice(start, str.length + length).join('');
}
return str.slice(start, start + length).join('');
}
/**
* Returns the number of substring occurrences.
*/
static substrCount(haystack, needle, offset = 0, length = null) {
let str = haystack;
if (offset !== 0 || length !== null) {
str = this.substr(haystack, offset, length);
}
return (str.match(new RegExp(needle.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'g')) || []).length;
}
/**
* Make a string's first character lowercase.
*/
static lcfirst(string) {
return string.charAt(0).toLowerCase() + string.slice(1);
}
/**
* Make a string's first character uppercase.
*/
static ucfirst(string) {
return string.charAt(0).toUpperCase() + string.slice(1);
}
/**
* Split a string into pieces by uppercase characters.
*/
static ucsplit(string) {
return string.split(/(?=[A-Z])/).filter(s => s.length > 0);
}
/**
* Get the number of words a string contains.
*/
static wordCount(string) {
return (string.match(/\S+/g) || []).length;
}
/**
* Wrap the string with the given strings.
*/
static wrap(value, before, after = null) {
return before + value + (after !== null ? after : before);
}
/**
* Unwrap the string with the given strings.
*/
static unwrap(value, before, after = null) {
after = after !== null ? after : before;
if (this.startsWith(value, before)) {
value = this.substr(value, this.length(before));
}
if (this.endsWith(value, after)) {
value = this.substr(value, 0, -this.length(after));
}
return value;
}
/**
* Generate a UUID (version 4).
*/
static uuid() {
return this.uuidFactory ? this.uuidFactory() : uuidv4();
}
/**
* Generate a UUID (version 7).
*/
static uuid7() {
return this.uuidFactory ? this.uuidFactory() : uuidv7();
}
/**
* Generate a ULID.
*/
static ulid() {
if (this.ulidFactory) {
return this.ulidFactory();
}
// Simple ULID implementation
// ULID format: 10 characters timestamp + 16 characters randomness
const ENCODING = '0123456789ABCDEFGHJKMNPQRSTVWXYZ'; // Crockford's Base32
const ENCODING_LEN = ENCODING.length;
const TIME_LEN = 10;
const RANDOM_LEN = 16;
const now = Date.now();
let ulid = '';
// Encode timestamp
let time = now;
for (let i = TIME_LEN - 1; i >= 0; i--) {
const mod = time % ENCODING_LEN;
ulid = ENCODING.charAt(mod) + ulid;
time = (time - mod) / ENCODING_LEN;
}
// Add randomness
for (let i = 0; i < RANDOM_LEN; i++) {
ulid += ENCODING.charAt(Math.floor(Math.random() * ENCODING_LEN));
}
return ulid;
}
/**
* Set the callable that will be used to generate UUIDs.
*/
static createUuidsUsing(factory = null) {
this.uuidFactory = factory;
}
/**
* Set the callable that will be used to generate ULIDs.
*/
static createUlidsUsing(factory = null) {
this.ulidFactory = factory;
}
/**
* Set the callable that will be used to generate random strings.
*/
static createRandomStringsUsing(factory = null) {
this.randomStringFactory = factory;
}
/**
* Indicate that random strings should be created normally.
*/
static createRandomStringsNormally() {
this.randomStringFactory = null;
}
/**
* Indicate that UUIDs should be created normally.
*/
static createUuidsNormally() {
this.uuidFactory = null;
}
/**
* Indicate that ULIDs should be created normally.
*/
static createUlidsNormally() {
this.ulidFactory = null;
}
/**
* Transliterate a string to its closest ASCII representation.
*/
static ascii(value, language = 'en') {
// Simple ASCII conversion by removing diacritics
return value.normalize('NFD').replace(/[\u0300-\u036f]/g, '');
}
/**
* Transliterate a string to its closest ASCII representation.
* Uses Unicode normalization for accents and handles common ligatures.
*
* @param {string} string - The string to transliterate
* @param {string|null} unknown - Character to use for untranslatable characters
* @param {boolean|null} strict - If true, replace all non-ASCII with unknown character
* @returns {string}
* @example
* Str.transliterate('Café'); // 'Cafe'
* Str.transliterate('Übermensch'); // 'Ubermensch'
* Str.transliterate('naïve'); // 'naive'
* Str.transliterate('Æon'); // 'AEon'
* Str.transliterate('straße'); // 'strasse'
*/
static transliterate(string, unknown = '?', strict = false) {
// Decompose and drop combining marks (accents) using NFKD for compatibility
let asciiish = string
.normalize('NFKD')
.replace(/[\u0300-\u036f]/g, '') // Remove diacritics
.replace(/ß/g, 'ss') // German sharp s
.replace(/æ/g, 'ae') // Latin ligatures
.replace(/Æ/g, 'AE')
.replace(/œ/g, 'oe')
.replace(/Œ/g, 'OE');
// If strict, replace anything still non-ASCII
if (strict) {
asciiish = asciiish.replace(/[^\x00-\x7F]/g, unknown ?? '');
}
return asciiish;
}
/**
* Extracts an excerpt from text that matches the first instance of a phrase.
*/
static excerpt(text, phrase = '', options = {}) {
const radius = options.radius || 100;
const omission = options.omission || '...';
if (!phrase) {
return text.length > radius ? text.substring(0, radius).trim() + omission : text;
}
const position = text.toLowerCase().indexOf(phrase.toLowerCase());
if (position === -1) return null;
const start = Math.max(0, position - radius);
const end = Math.min(text.length, position + phrase.length + radius);
let excerpt = text.substring(start, end).trim();
if (start > 0) excerpt = omission + excerpt;
if (end < text.length) excerpt = excerpt + omission;
return excerpt;
}
/**
* Generate a secure password.
*/
static password(length = 32, letters = true, numbers = true, symbols = true, spaces = false) {
const options = [];
if (letters) {
options.push('abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ');
}
if (numbers) {
options.push('0123456789');
}
if (symbols) {
options.push('!@#$%^&*()_+-=[]{}|;:,.<>?');
}
if (spaces) {
options.push(' ');
}
const allChars = options.join('');
let password = '';
// Use crypto for secure randomness
if (typeof crypto !== 'undefined' && crypto.getRandomValues) {
const values = new Uint32Array(length);
crypto.getRandomValues(values);
for (let i = 0; i < length; i++) {
password += allChars[values[i] % allChars.length];
}
} else {
for (let i = 0; i < length; i++) {
password += allChars[Math.floor(Math.random() * allChars.length)];
}
}
return password;
}
/**
* Find the position of the first occurrence of a substring.
*/
static position(haystack, needle, offset = 0) {
const position = haystack.indexOf(needle, offset);
return position === -1 ? false : position;
}
/**
* Replace a given value in the string sequentially with an array.
*/
static replaceArray(search, replace, subject) {
const replaceArray = Array.isArray(replace) ? replace : [replace];
const segments = subject.split(search);
let result = segments[0];
for (let i = 1; i < segments.length; i++) {
const replacement = replaceArray[i - 1] !== undefined ? replaceArray[i - 1] : search;
result += replacement + segments[i];
}
return result;
}
/**
* Replace the first occurrence of the given value if it appears at the start.
*/
static replaceStart(search, replace, subject) {
if (search === '') return subject;
if (this.startsWith(subject, search)) {
return this.replaceFirst(search, replace, subject);
}
return subject;
}
/**
* Replace the last occurrence of a given value if it appears at the end.
*/
static replaceEnd(search, replace, subject) {
if (search === '') return subject;
if (this.endsWith(subject, search)) {
return this.replaceLast(search, replace, subject);
}
return subject;
}
/**
* Replace the patterns matching the given regular expression.
*/
static replaceMatches(pattern, replace, subject, limit = -1) {
if (typeof replace === 'function') {
return subject.replace(pattern, replace);
}
if (limit === -1) {
return subject.replace(pattern, replace);
}
let count = 0;
return subject.replace(pattern, (match) => {
if (limit !== -1 && count >= limit) return match;
count++;
return replace;
});
}
/**
* Convert the given string to APA-style title case.
*/
static apa(value) {
if (value.trim() === '') return value;
const minorWords = [
'and', 'as', 'but', 'for', 'if', 'nor', 'or', 'so', 'yet', 'a', 'an',
'the', 'at', 'by', 'in', 'of', 'off', 'on', 'per', 'to', 'up', 'via'
];
const words = value.split(/\s+/);
return words.map((word, index) => {
const lowerWord = word.toLowerCase();
// Always capitalize first word
if (index === 0) {
return this.ucfirst(lowerWord);
}
// Check if word contains hyphen
if (word.includes('-')) {
return word.split('-').map(part =>
minorWords.includes(part.toLowerCase()) && part.length <= 3 ?
part.toLowerCase() : this.ucfirst(part.toLowerCase())
).join('-');
}
// Minor words in lowercase (unless they're long)
if (minorWords.includes(lowerWord) && lowerWord.length <= 3) {
return lowerWord;
}
return this.ucfirst(lowerWord);
}).join(' ');
}
/**
* Pluralize the last word of an English, studly caps case string.
*/
static pluralStudly(value, count = 2) {
const parts = value.split(/(?=[A-Z])/);
const lastWord = parts.pop();
return parts.join('') + this.plural(lastWord, count);
}
/**
* Pluralize the last word of an English, Pascal case string.
*/
static pluralPascal(value, count = 2) {
return this.pluralStudly(value, count);
}
/**
* Replace text within a portion of a string.
*/
static substrReplace(string, replace, offset = 0, length = null) {
const stringArray = Array.from(string);
if (length === null) {
length = stringArray.length;
}
const before = stringArray.slice(0, offset).join('');
const after = stringArray.slice(offset + length).join('');
return before + replace + after;
}
/**
* Swap multiple keywords in a string with other keywords.
*/
static swap(map, subject) {
// Sort keys by length (longest first) to avoid partial replacements
const sortedKeys = Object.keys(map).sort((a, b) => b.length - a.length);
let result = subject;
for (const key of sortedKeys) {
const escapedKey = key.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
result = result.replace(new RegExp(escapedKey, 'g'), map[key]);
}
return result;
}
/**
* Wrap a string to a given number of characters.
*/
static wordWrap(string, characters = 75, breakStr = '\n', cutLongWords = false) {
if (characters <= 0) return string;
const words = string.split(' ');
let lines = [];
let currentLine = '';
for (const word of words) {
if (cutLongWords && word.length > characters) {
// If current line has content, push it
if (currentLine) {
lines.push(currentLine.trim());
currentLine = '';
}
// Split long word
for (let i = 0; i < word.length; i += characters) {
lines.push(word.substring(i, i + characters));
}
} else {
const testLine = currentLine ? currentLine + ' ' + word : word;
if (testLine.length <= characters) {
currentLine = testLine;
} else {
if (currentLine) {
lines.push(currentLine.trim());
}
currentLine = word;
}
}
}
if (currentLine) {
lines.push(currentLine.trim());
}
return lines.join(breakStr);
}
/**
* Convert case of a string using different modes.
*/
static convertCase(string, mode = 'lower', encoding = 'UTF-8') {
switch (mode) {
case 'upper':
return string.toUpperCase();
case 'lower':
return string.toLowerCase();
case 'title':
return this.title(string);
default:
return string.toLowerCase();
}
}
/**
* Remove all strings from the casing caches.
*/
static flushCache() {
this.snakeCache = {};
this.camelCache = {};
this.studlyCache = {};
}
/**
* Convert the given string to Base64 encoding.
*/
static toBase64(string) {
if (typeof Buffer !== 'undefined') {
return Buffer.from(string).toString('base64');
}
return btoa(string);
}
/**
* Decode the given Base64 encoded string.
*/
static fromBase64(string, strict = false) {
try {
if (typeof Buffer !== 'undefined') {
return Buffer.from(string, 'base64').toString('utf8');
}
return atob(string);
} catch (e) {
return strict ? false : '';
}
}
/**
* Take the first or last {limit} characters of a string.
*/
static take(string, limit) {
if (limit < 0) {
return this.substr(string, limit);
}
return this.substr(string, 0, limit);
}
/**
* Repeat the given string.
*/
static repeat(string, times) {
return string.repeat(times);
}
/**
* Replace consecutive instances of a given character with a single character.
*/
static deduplicate(string, characters = ' ') {
const chars = Array.isArray(characters) ? characters : [characters];
let result = string;
for (const char of chars) {
const escapedChar = char.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
result = result.replace(new RegExp(`${escapedChar}+`, 'g'), char);
}
return result;
}
}
export default Str;