UNPKG

camelcase

Version:

Convert a dash/dot/underscore/space separated string to camelCase or PascalCase: `foo-bar` → `fooBar`

225 lines (189 loc) 7.53 kB
const UPPERCASE = /[\p{Lu}]/u; const LOWERCASE = /[\p{Ll}]/u; const LEADING_CAPITAL = /^[\p{Lu}](?![\p{Lu}])/u; const SEPARATORS = /[_.\- ]+/; // The |$ alternative allows matching at end-of-string, capturing empty string // This enables NUMBERS_AND_IDENTIFIER to match digits at string end (e.g., "test123") const IDENTIFIER = /([\p{Alpha}\p{N}_]|$)/u; const LEADING_SEPARATORS = new RegExp('^' + SEPARATORS.source); const SEPARATORS_AND_IDENTIFIER = new RegExp(SEPARATORS.source + IDENTIFIER.source, 'gu'); const NUMBERS_AND_IDENTIFIER = new RegExp(String.raw`\d+` + IDENTIFIER.source, 'gu'); const preserveCamelCase = (string, toLowerCase, toUpperCase, preserveConsecutiveUppercase) => { let isLastCharLower = false; let isLastCharUpper = false; let isLastLastCharUpper = false; let isLastLastCharPreserved = false; for (let index = 0; index < string.length; index++) { const character = string[index]; // Was the character 3 positions back inserted as a separator? // Prevents excessive separators by checking if we recently inserted one // index - 3 accounts for: current character, inserted separator, previous character // Default true for early positions activates the preserveConsecutiveUppercase guard isLastLastCharPreserved = index > 2 ? string[index - 3] === '-' : true; if (isLastCharLower && UPPERCASE.test(character)) { // FooBar → Foo-Bar (insert separator before uppercase) string = string.slice(0, index) + '-' + string.slice(index); isLastCharLower = false; isLastLastCharUpper = isLastCharUpper; isLastCharUpper = true; index++; } else if ( isLastCharUpper && isLastLastCharUpper && LOWERCASE.test(character) && (!isLastLastCharPreserved || preserveConsecutiveUppercase) ) { // FOOBar → FOO-Bar string = string.slice(0, index - 1) + '-' + string.slice(index - 1); isLastLastCharUpper = isLastCharUpper; isLastCharUpper = false; isLastCharLower = true; } else { isLastCharLower = toLowerCase(character) === character && toUpperCase(character) !== character; isLastLastCharUpper = isLastCharUpper; isLastCharUpper = toUpperCase(character) === character && toLowerCase(character) !== character; } } return string; }; const preserveConsecutiveUppercase = (input, toLowerCase) => input.replace(LEADING_CAPITAL, match => toLowerCase(match)); const processWithCasePreservation = (input, toLowerCase, preserveConsecutiveUppercase) => { let result = ''; let previousWasNumber = false; let previousWasUppercase = false; // Convert input to array for lookahead capability const characters = [...input]; for (let index = 0; index < characters.length; index++) { const character = characters[index]; const isUpperCase = UPPERCASE.test(character); const nextCharIsUpperCase = index + 1 < characters.length && UPPERCASE.test(characters[index + 1]); if (previousWasNumber && /[\p{Alpha}]/u.test(character)) { // Letter after number - preserve original case result += character; previousWasNumber = false; previousWasUppercase = isUpperCase; } else if (preserveConsecutiveUppercase && isUpperCase && (previousWasUppercase || nextCharIsUpperCase)) { // Part of consecutive uppercase sequence when preserveConsecutiveUppercase is true - keep it result += character; previousWasUppercase = true; } else if (/\d/.test(character)) { // Number - keep as-is and track it result += character; previousWasNumber = true; previousWasUppercase = false; } else if (SEPARATORS.test(character)) { // Separator - keep as-is and maintain previousWasNumber state result += character; previousWasUppercase = false; } else { // Regular character - lowercase it result += toLowerCase(character); previousWasNumber = false; previousWasUppercase = false; } } return result; }; /** Core post-processing: - Collapses separators and uppercases the following identifier character. - Optionally uppercases the identifier following a numeric sequence. Two-pass strategy prevents conflicts: 1. NUMBERS_AND_IDENTIFIER: handles digit-to-letter transitions 2. SEPARATORS_AND_IDENTIFIER: handles separator-to-identifier transitions Example: "b2b_registration" with capitalizeAfterNumber: true - Pass 1: "2b" matches, next char is "_" (separator), so don't capitalize → "b2b_registration" - Pass 2: "_r" matches, replace with "R" → "b2bRegistration" */ const postProcess = (input, toUpperCase, {capitalizeAfterNumber}) => { const transformNumericIdentifier = capitalizeAfterNumber ? (match, identifier, offset, string) => { const nextCharacter = string.charAt(offset + match.length); // If the numeric+identifier run is immediately followed by a separator, // treat it as a continued token and do not force a new word. if (SEPARATORS.test(nextCharacter)) { return match; } // Only uppercase the identifier part (not the digits) for efficiency return identifier ? match.slice(0, -identifier.length) + toUpperCase(identifier) : match; } // When false: numbers do not create a word boundary. : match => match; return input .replaceAll(NUMBERS_AND_IDENTIFIER, transformNumericIdentifier) .replaceAll( SEPARATORS_AND_IDENTIFIER, (_, identifier) => toUpperCase(identifier), ); }; export default function camelCase(input, options) { if (!(typeof input === 'string' || Array.isArray(input))) { throw new TypeError('Expected the input to be `string | string[]`'); } options = { pascalCase: false, preserveConsecutiveUppercase: false, capitalizeAfterNumber: true, ...options, }; if (Array.isArray(input)) { input = input .map(element => element.trim()) .filter(element => element.length > 0) .join('-'); } else { input = input.trim(); } if (input.length === 0) { return ''; } // Preserve leading _ and $ as they have semantic meaning const leadingPrefix = input.match(/^[_$]*/)[0]; input = input.slice(leadingPrefix.length); if (input.length === 0) { return leadingPrefix; } const toLowerCase = options.locale === false ? string => string.toLowerCase() : string => string.toLocaleLowerCase(options.locale); const toUpperCase = options.locale === false ? string => string.toUpperCase() : string => string.toLocaleUpperCase(options.locale); if (input.length === 1) { if (SEPARATORS.test(input)) { return leadingPrefix; } return leadingPrefix + (options.pascalCase ? toUpperCase(input) : toLowerCase(input)); } const hasUpperCase = input !== toLowerCase(input); if (hasUpperCase) { input = preserveCamelCase( input, toLowerCase, toUpperCase, options.preserveConsecutiveUppercase, ); } // Strip leading separators eagerly so they do not affect word detection input = input.replace(LEADING_SEPARATORS, ''); // Normalize base casing while preserving intended consecutive uppers if (options.capitalizeAfterNumber) { // Standard behavior - lowercase everything (or preserve consecutive uppercase) input = options.preserveConsecutiveUppercase ? preserveConsecutiveUppercase(input, toLowerCase) : toLowerCase(input); } else { // Preserve case after numbers (processWithCasePreservation handles preserveConsecutiveUppercase internally) input = processWithCasePreservation(input, toLowerCase, options.preserveConsecutiveUppercase); } if (options.pascalCase && input.length > 0) { input = toUpperCase(input[0]) + input.slice(1); } return leadingPrefix + postProcess(input, toUpperCase, options); }