UNPKG

camelcase

Version:

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

237 lines (200 loc) • 9.42 kB
const UPPERCASE = /\p{Uppercase_Letter}/v; const LOWERCASE = /\p{Lowercase_Letter}/v; const ALPHABETIC = /\p{Alphabetic}/v; // A decimal digit in any script, not only ASCII like `\d`. Other \p{Number} characters, such as superscripts and Roman numerals, are not digit runs. const DIGIT = /\p{Decimal_Number}/v; const LEADING_CAPITAL = /^\p{Uppercase_Letter}(?!\p{Uppercase_Letter})/v; const SEPARATORS = /[ \-._]+/v; const LEADING_PREFIX = /^[$_]*/v; // 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") // // A combining mark with no base letter is not an identifier, so `a-́b` comes back unchanged. A mark after a base letter is fine, so decomposed `café-bar` still gives `caféBar`. const IDENTIFIER = /(?<identifier>[\p{Alphabetic}\p{Number}_]|$)/v; const LEADING_SEPARATORS = new RegExp('^' + SEPARATORS.source, 'v'); // A long separator run followed by a rejecting character backtracks quadratically (see the input length note in the readme). An atomic run would change the result for inputs like `a-_-!`, where the trailing `_` is the identifier. // eslint-disable-next-line regexp/no-super-linear-move const SEPARATORS_AND_IDENTIFIER = new RegExp(SEPARATORS.source + IDENTIFIER.source, 'gv'); // False positive: `\p{Decimal_Number}` is a subset of the identifier class, so a rejecting suffix costs one backtrack, not one per digit. A run of millions of non-ASCII digits can overflow V8's regex stack and throw a RangeError (see the input length note in the readme). // eslint-disable-next-line regexp/no-super-linear-move const NUMBERS_AND_IDENTIFIER = new RegExp(String.raw`\p{Decimal_Number}+` + IDENTIFIER.source, 'gv'); const preserveCamelCase = (string, toLowerCase, toUpperCase, preserveConsecutiveUppercase) => { // Iterate code points, not code units. Letters outside the basic multilingual plane need a surrogate pair, so indexing the string directly would see two meaningless halves per letter. const characters = [...string]; let isLastCharLower = false; let isLastCharUpper = false; let isLastLastCharUpper = false; let isLastLastCharPreserved = false; for (let index = 0; index < characters.length; index++) { const character = characters[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 ? characters[index - 3] === '-' : true; if (isLastCharLower && UPPERCASE.test(character)) { // FooBar → Foo-Bar (insert separator before uppercase) characters.splice(index, 0, '-'); isLastCharLower = false; isLastLastCharUpper = isLastCharUpper; isLastCharUpper = true; index++; } else if ( isLastCharUpper && isLastLastCharUpper && LOWERCASE.test(character) && (!isLastLastCharPreserved || preserveConsecutiveUppercase) ) { // FOOBar → FOO-Bar characters.splice(index - 1, 0, '-'); isLastLastCharUpper = isLastCharUpper; isLastCharUpper = false; isLastCharLower = true; } else { isLastCharLower = toLowerCase(character) === character && toUpperCase(character) !== character; isLastLastCharUpper = isLastCharUpper; isLastCharUpper = toUpperCase(character) === character && toLowerCase(character) !== character; } } return characters.join(''); }; // Only a lone leading capital is lowercased. Cased characters that are not Uppercase_Letter (titlecase letters, Roman numerals) survive here, while the capitalizeAfterNumber: false path lowercases them. const preserveConsecutiveUppercase = (input, toLowerCase) => input.replace(LEADING_CAPITAL, match => toLowerCase(match)); const processWithCasePreservation = (input, toLowerCase, shouldPreserveConsecutiveUppercase) => { let result = ''; let wasNumberBefore = false; // Iterate code points, not code units (see preserveCamelCase) const characters = [...input]; for (let index = 0; index < characters.length; index++) { const character = characters[index]; const isUpperCase = UPPERCASE.test(character); // Mirrors LEADING_CAPITAL: a lone capital at the very start of the string is lowercased, even when preserveConsecutiveUppercase is on. const isLoneLeadingCapital = index === 0 && isUpperCase && !(index + 1 < characters.length && UPPERCASE.test(characters[index + 1])); if (wasNumberBefore && ALPHABETIC.test(character)) { // Letter after number - preserve original case result += character; wasNumberBefore = false; } else if (shouldPreserveConsecutiveUppercase && isUpperCase && !isLoneLeadingCapital) { // Mirror the capitalizeAfterNumber: true path, which keeps every capital except a lone leading one. result += character; } else if (DIGIT.test(character)) { // Number - keep as-is and track it result += character; wasNumberBefore = true; } else if (SEPARATORS.test(character)) { // Separator - keep as-is, and end any digit run, so the next letter is lowercased like any other. result += character; wasNumberBefore = false; } else { // Regular character - lowercase it. This goes one character at a time, so U+03A3 never becomes the final sigma, which depends on the surrounding characters. result += toLowerCase(character); wasNumberBefore = 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}) => { // When `capitalizeAfterNumber` is false, numbers do not create a word boundary, so pass 1 is skipped entirely. const withNumbers = capitalizeAfterNumber ? input.replaceAll(NUMBERS_AND_IDENTIFIER, (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; }) : input; return withNumbers.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(LEADING_PREFIX)[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) { // Read the first code point, not the first code unit, so astral letters get uppercased. const firstCharacter = String.fromCodePoint(input.codePointAt(0)); input = toUpperCase(firstCharacter) + input.slice(firstCharacter.length); } return leadingPrefix + postProcess(input, toUpperCase, options); }