nhb-toolbox
Version:
A versatile collection of smart, efficient, and reusable utility functions and classes for everyday development needs.
187 lines (186 loc) • 7.71 kB
JavaScript
import { LOWERCASE } from './constants.js';
/**
* * Converts a string to a specified case format with advanced handling for word boundaries, punctuation, acronyms, and Unicode characters.
*
* @remarks
* - This function is Unicode-aware, treats non-alphanumeric characters (spaces, underscores, dots, slashes, etc.) as word boundaries, and optionally preserves internal acronyms.
* - `Title Case` formatting respects small words such as prepositions, articles, conjunctions, and auxiliary verbs (defined in `LOWERCASE`) — these are not capitalized unless they appear at the start or end of the string.
* - Leading and trailing punctuation (non-letter/number characters) is preserved in the result.
*
* @param value - The input string to convert. Can contain letters, numbers, punctuation,
* spaces, underscores, dashes, etc.
* @param format - The target case format:
* - `'camelCase'` → camelCase (e.g., `myVariableName`)
* - `'PascalCase'` → PascalCase (e.g., `MyVariableName`)
* - `'snake_case'` → snake_case (e.g., `my_variable_name`)
* - `'kebab-case'` → kebab-case (e.g., `my-variable-name`)
* - `'Title Case'` → Title Case (e.g., `My Variable Name`)
* - `'lowercase'` → all lowercase [ It is recommended to use built-in string method `string.toLowerCase()` ]
* - `'UPPERCASE'` → all uppercase [ It is recommended to use built-in string method `string.toUpperCase()` ]
* @param options - Optional configuration options for more control.
*
* @returns The converted string, with leading/trailing punctuation preserved.
*
* @example
* // Basic usage
* convertStringCase('my-example_string', 'camelCase');
* // Returns: 'myExampleString'
*
* convertStringCase('my-example_string', 'snake_case');
* // Returns: 'my_example_string'
*
* convertStringCase('my-example_string', 'kebab-case');
* // Returns: 'my-example-string'
*
* convertStringCase('my example string', 'Title Case');
* // Returns: 'My Example String'
*
* convertStringCase('My example String', 'lowercase');
* // Returns: 'my example string'
*
* convertStringCase('my example string', 'UPPERCASE');
* // Returns: 'MY EXAMPLE STRING'
*
* @example
* // Preserve acronyms
* convertStringCase('get API response', 'camelCase', { preserveAcronyms: true });
* // Returns: 'getAPIResponse'
*
* convertStringCase('get API response', 'PascalCase', { preserveAcronyms: true });
* // Returns: 'GetAPIResponse'
*
* convertStringCase('the API of things', 'Title Case', { preserveAcronyms: true });
* // Returns: 'The API of Things'
*
* @example
* // Leading/trailing punctuation is preserved
* convertStringCase('++hello_world++', 'PascalCase');
* // Returns: '++HelloWorld++'
*
* @example
* // Dashes are preserved in Title Case
* convertStringCase('xml-http_request', 'Title Case');
* // Returns: 'Xml-http Request'
*
* @example
* // Empty string returns empty
* convertStringCase('', 'camelCase');
* // Returns: ''
*
* @example
* // Single token is capitalized properly
* convertStringCase('api', 'PascalCase');
* // Returns: 'Api'
*/
export function convertStringCase(value, format, options) {
if (!value || typeof value !== 'string')
return '';
const { preserveAcronyms = false } = options ?? {};
// Unicode-aware regexes
const LEADING_PUNCTUATION = /^[^\p{L}\p{N}\s]+/u;
const TRAILING_PUNCTUATION = /[^\p{L}\p{N}\s]+$/u;
const SEPARATOR = format === 'Title Case' ? /[^\p{L}\p{N}-]+/gu : /[^\p{L}\p{N}]+/gu;
const CAMEL_BOUNDARY = /(?<=[\p{Ll}\p{N}])(?=\p{Lu})|(?<=[\p{Lu}])(?=\p{Lu}[\p{Ll}])/u;
const LETTER_NUMBER_BOUNDARY = /(?<=[\p{L}])(?=\p{N})|(?<=[\p{N}])(?=\p{L})/u;
// preserve leading/trailing punctuation (but not spaces)
const start = value.match(LEADING_PUNCTUATION)?.[0] ?? '';
const end = value.match(TRAILING_PUNCTUATION)?.[0] ?? '';
// core trimmed of leading/trailing punctuation (but keep internal separators)
const core = value
.replace(/^[^\p{L}\p{N}\s]+|[^\p{L}\p{N}\s]+$/gu, '')
.trim();
const lowerCase = (s) => s.toLowerCase();
const isAcronym = (s) => s.length >= 2 && /^[\p{Lu}]+$/u.test(s);
const capitalize = (s) => s.length === 0 ?
''
: s.charAt(0).toUpperCase().concat(s.slice(1).toLowerCase());
// Tokenize into logical words:
// 1) split on explicit separators (space, underscore, dash, punctuation)
// 2) if result is single token, try splitting camel/pascal boundaries
// 3) also try splitting letter<->number boundaries
let tokens = core.length > 0 ? core.split(SEPARATOR).filter(Boolean) : [];
if (tokens.length <= 1 && core.length > 0) {
if (CAMEL_BOUNDARY.test(core)) {
tokens = core.split(CAMEL_BOUNDARY).filter(Boolean);
}
else if (LETTER_NUMBER_BOUNDARY.test(core)) {
tokens = core.split(LETTER_NUMBER_BOUNDARY).filter(Boolean);
}
else {
tokens = [core];
}
}
if (tokens.length === 0) {
// nothing meaningful to do — return only punctuation (if any)
return start.concat(end);
}
// Title-case small words set for fast lookup
const smallSet = new Set(LOWERCASE);
switch (format) {
case 'camelCase': {
const middle = tokens
.slice(1)
.map((token) => {
if (preserveAcronyms && isAcronym(token)) {
return token; // preserve acronym as-is
}
return capitalize(token);
})
.join('');
return start.concat(lowerCase(tokens[0]).concat(middle), end);
}
case 'PascalCase': {
const body = tokens
.map((token) => {
if (preserveAcronyms && isAcronym(token))
return token;
return capitalize(token);
})
.join('');
return start.concat(body, end);
}
case 'snake_case': {
const body = tokens.map((token) => lowerCase(token)).join('_');
return start.concat(body, end);
}
case 'kebab-case': {
const body = tokens.map((token) => lowerCase(token)).join('-');
return start.concat(body, end);
}
case 'Title Case': {
const title = tokens
.map((token, idx, self) => {
const tokenLower = token.toLowerCase();
// keep small words lowercase unless first or last
if (idx !== 0 &&
idx !== self.length - 1 &&
smallSet.has(tokenLower)) {
return tokenLower;
}
// If preserveAcronyms is enabled, preserve acronym-like subparts inside hyphenated tokens.
// Example: "XML-HTTP" -> ["XML","HTTP"] -> preserved -> "XML-HTTP"
if (preserveAcronyms && token.includes('-')) {
return token
.split('-')
.map((sub) => isAcronym(sub) ? sub : capitalize(sub))
.join('-');
}
if (preserveAcronyms && isAcronym(token)) {
return token.split('-');
}
return capitalize(token);
})
.join(' ');
return start.concat(title, end);
}
case 'lowercase': {
return start.concat(core.toLowerCase(), end);
}
case 'UPPERCASE': {
return start.concat(core.toUpperCase(), end);
}
default: {
return start.concat(core, end);
}
}
}