Skip to content
4 changes: 3 additions & 1 deletion eslint.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,9 @@ export default tseslint.config(
rules: {
'vitest/consistent-test-filename': [
'warn',
{ pattern: String.raw`.*\.(unit|integration|e2e)\.test\.[tj]sx?$` },
{
pattern: String.raw`.*\.(bench|type|unit|integration|e2e)\.test\.[tj]sx?$`,
},
],
},
},
Expand Down
9 changes: 8 additions & 1 deletion packages/utils/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -95,9 +95,15 @@ export {
formatReportScore,
} from './lib/reports/utils.js';
export { isSemver, normalizeSemver, sortSemvers } from './lib/semver.js';
export * from './lib/text-formats/index.js';
export {
camelCaseToKebabCase,
kebabCaseToCamelCase,
capitalize,
toSentenceCase,
toTitleCase,
} from './lib/case-conversions.js';
export * from './lib/text-formats/index.js';
export {
countOccurrences,
distinct,
factorOf,
Expand All @@ -121,6 +127,7 @@ export type {
ItemOrArray,
Prettify,
WithRequired,
CamelCaseToKebabCase,
} from './lib/types.js';
export { verboseUtils } from './lib/verbose-utils.js';
export { parseSchema, SchemaValidationError } from './lib/zod-validation.js';
63 changes: 63 additions & 0 deletions packages/utils/src/lib/case-conversion.type.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import { assertType, describe, expectTypeOf, it } from 'vitest';
import type { CamelCaseToKebabCase, KebabCaseToCamelCase } from './types.js';

/* eslint-disable vitest/expect-expect */
describe('CamelCaseToKebabCase', () => {
// ✅ CamelCase → kebab-case Type Tests

it('CamelCaseToKebabCase works correctly', () => {
expectTypeOf<
CamelCaseToKebabCase<'myTestString'>
>().toEqualTypeOf<'my-test-string'>();
expectTypeOf<
CamelCaseToKebabCase<'APIResponse'>
>().toEqualTypeOf<'a-p-i-response'>();
expectTypeOf<
CamelCaseToKebabCase<'myXMLParser'>
>().toEqualTypeOf<'my-x-m-l-parser'>();
expectTypeOf<
CamelCaseToKebabCase<'singleWord'>
>().toEqualTypeOf<'single-word'>();

// @ts-expect-error Ensures that non-camelCase strings do not pass
assertType<CamelCaseToKebabCase<'hello_world'>>();

// @ts-expect-error Numbers should not be transformed
assertType<CamelCaseToKebabCase<'version2Release'>>();
});

// ✅ kebab-case → CamelCase Type Tests
it('KebabCaseToCamelCase works correctly', () => {
expectTypeOf<
KebabCaseToCamelCase<'my-test-string'>
>().toEqualTypeOf<'myTestString'>();
expectTypeOf<
KebabCaseToCamelCase<'a-p-i-response'>
>().toEqualTypeOf<'aPIResponse'>();
expectTypeOf<
KebabCaseToCamelCase<'my-x-m-l-parser'>
>().toEqualTypeOf<'myXMLParser'>();
expectTypeOf<
KebabCaseToCamelCase<'single-word'>
>().toEqualTypeOf<'singleWord'>();

// @ts-expect-error Ensures that non-kebab-case inputs are not accepted
assertType<KebabCaseToCamelCase<'my Test String'>>();

// @ts-expect-error Numbers should not be transformed
assertType<KebabCaseToCamelCase<'version-2-release'>>();
});

// ✅ Edge Cases
it('Edge cases for case conversions', () => {
expectTypeOf<CamelCaseToKebabCase<''>>().toEqualTypeOf<''>();
expectTypeOf<KebabCaseToCamelCase<''>>().toEqualTypeOf<''>();

// @ts-expect-error Ensures no spaces allowed in input
assertType<CamelCaseToKebabCase<'this is not camelCase'>>();

// @ts-expect-error Ensures no mixed case with dashes
assertType<KebabCaseToCamelCase<'this-Is-Wrong'>>();
});
});
/* eslint-enable vitest/expect-expect */
101 changes: 101 additions & 0 deletions packages/utils/src/lib/case-conversions.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
import type { CamelCaseToKebabCase, KebabCaseToCamelCase } from './types.js';

/**
* Converts a kebab-case string to camelCase.
* @param string - The kebab-case string to convert.
* @returns The camelCase string.
*/
export function kebabCaseToCamelCase<T extends string>(
string: T,
): KebabCaseToCamelCase<T> {
return string
.split('-')
.map((segment, index) => (index === 0 ? segment : capitalize(segment)))
.join('') as KebabCaseToCamelCase<T>;
}

/**
* Converts a camelCase string to kebab-case.
* @param input - The camelCase string to convert.
* @returns The kebab-case string.
*/
export function camelCaseToKebabCase<T extends string>(
input: T,
): CamelCaseToKebabCase<T> {
return input
.replace(/([a-z])([A-Z])/g, '$1-$2') // Insert dash before uppercase letters
.replace(/([A-Z])([A-Z][a-z])/g, '$1-$2') // Handle consecutive uppercase letters
.toLowerCase() as CamelCaseToKebabCase<T>;
}

/**
* Converts a string to Title Case.
* - Capitalizes the first letter of each major word.
* - Keeps articles, conjunctions, and short prepositions in lowercase unless they are the first word.
*
* @param input - The string to convert.
* @returns The formatted title case string.
*/
export function toTitleCase(input: string): string {
const minorWords = new Set([
'a',
'an',
'the',
'and',
'or',
'but',
'for',
'nor',
'on',
'in',
'at',
'to',
'by',
'of',
]);

return input
.replace(/([a-z])([A-Z])/g, '$1 $2') // Split PascalCase & camelCase
.replace(/[_-]/g, ' ') // Replace kebab-case and snake_case with spaces
.replace(/(\d+)/g, ' $1 ') // Add spaces around numbers
.replace(/\s+/g, ' ') // Remove extra spaces
.trim()
.split(' ')
.map((word, index) => {
// Preserve uppercase acronyms (e.g., API, HTTP)
if (/^[A-Z]{2,}$/.test(word)) {
return word;
}

// Capitalize first word or non-minor words
if (index === 0 || !minorWords.has(word.toLowerCase())) {
return capitalize(word);
}
return word.toLowerCase();
})
.join(' ');
}

/**
* Converts a string to Sentence Case.
* - Capitalizes only the first letter of the sentence.
* - Retains case of proper nouns.
*
* @param input - The string to convert.
* @returns The formatted sentence case string.
*/
export function toSentenceCase(input: string): string {
return input
.replace(/([a-z])([A-Z])/g, '$1 $2') // Split PascalCase & camelCase
.replace(/[_-]/g, ' ') // Replace kebab-case and snake_case with spaces
.replace(/(\d+)/g, ' $1 ') // Add spaces around numbers
.replace(/\s+/g, ' ') // Remove extra spaces
.trim()
.toLowerCase()
.replace(/^(\w)/, match => match.toUpperCase()) // Capitalize first letter
.replace(/\b([A-Z]{2,})\b/g, match => match); // Preserve uppercase acronyms
}

export function capitalize<T extends string>(text: T): Capitalize<T> {

Check warning on line 99 in packages/utils/src/lib/case-conversions.ts

View workflow job for this annotation

GitHub Actions / Code PushUp

<✓> JSDoc coverage | Functions coverage

Missing functions documentation for capitalize
return `${text.charAt(0).toLocaleUpperCase()}${text.slice(1).toLowerCase()}` as Capitalize<T>;
}
190 changes: 190 additions & 0 deletions packages/utils/src/lib/case-conversions.unit.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,190 @@
import { describe, expect, it } from 'vitest';
import {
camelCaseToKebabCase,
capitalize,
kebabCaseToCamelCase,
toSentenceCase,
toTitleCase,
} from './case-conversions.js';

describe('capitalize', () => {
it('should transform the first string letter to upper case', () => {
expect(capitalize('code')).toBe('Code');
});

it('should lowercase all but the the first string letter', () => {
expect(capitalize('PushUp')).toBe('Pushup');
});

it('should leave the first string letter in upper case', () => {
expect(capitalize('Code')).toBe('Code');
});

it('should accept empty string', () => {
expect(capitalize('')).toBe('');
});
});

describe('kebabCaseToCamelCase', () => {
it('should convert simple kebab-case to camelCase', () => {
expect(kebabCaseToCamelCase('hello-world')).toBe('helloWorld');
});

it('should handle multiple hyphens', () => {
expect(kebabCaseToCamelCase('this-is-a-long-string')).toBe(
'thisIsALongString',
);
});

it('should preserve numbers', () => {
expect(kebabCaseToCamelCase('user-123-test')).toBe('user123Test');
});

it('should handle single word', () => {
expect(kebabCaseToCamelCase('hello')).toBe('hello');
});

it('should handle empty string', () => {
expect(kebabCaseToCamelCase('')).toBe('');
});
});

describe('camelCaseToKebabCase', () => {
it('should convert camelCase to kebab-case', () => {
expect(camelCaseToKebabCase('myTestString')).toBe('my-test-string');
});

it('should handle acronyms properly', () => {
expect(camelCaseToKebabCase('APIResponse')).toBe('api-response');
});

it('should handle consecutive uppercase letters correctly', () => {
expect(camelCaseToKebabCase('myXMLParser')).toBe('my-xml-parser');
});

it('should handle single-word camelCase', () => {
expect(camelCaseToKebabCase('singleWord')).toBe('single-word');
});

it('should not modify already kebab-case strings', () => {
expect(camelCaseToKebabCase('already-kebab')).toBe('already-kebab');
});

it('should not modify non-camelCase inputs', () => {
expect(camelCaseToKebabCase('not_camelCase')).toBe('not_camel-case');
});
});

describe('toTitleCase', () => {
it('should capitalize each word in a simple sentence', () => {
expect(toTitleCase('hello world')).toBe('Hello World');
});

it('should capitalize each word in a longer sentence', () => {
expect(toTitleCase('this is a title')).toBe('This Is a Title');
});

it('should convert PascalCase to title case', () => {
expect(toTitleCase('FormatToTitleCase')).toBe('Format to Title Case');
});

it('should convert camelCase to title case', () => {
expect(toTitleCase('thisIsTest')).toBe('This Is Test');
});

it('should convert kebab-case to title case', () => {
expect(toTitleCase('hello-world-example')).toBe('Hello World Example');
});

it('should convert snake_case to title case', () => {
expect(toTitleCase('snake_case_example')).toBe('Snake Case Example');
});

it('should capitalize a single word', () => {
expect(toTitleCase('hello')).toBe('Hello');
});

it('should handle numbers in words correctly', () => {
expect(toTitleCase('chapter1Introduction')).toBe('Chapter 1 Introduction');
});

it('should handle numbers in slugs correctly', () => {
expect(toTitleCase('version2Release')).toBe('Version 2 Release');
});

it('should handle acronyms properly', () => {
expect(toTitleCase('apiResponse')).toBe('Api Response');
});

it('should handle mixed-case inputs correctly', () => {
expect(toTitleCase('thisIs-mixed_CASE')).toBe('This Is Mixed CASE');
});

it('should not modify already formatted title case text', () => {
expect(toTitleCase('Hello World')).toBe('Hello World');
});

it('should return an empty string when given an empty input', () => {
expect(toTitleCase('')).toBe('');
});
});

describe('toSentenceCase', () => {
it('should convert a simple sentence to sentence case', () => {
expect(toSentenceCase('hello world')).toBe('Hello world');
});

it('should maintain a correctly formatted sentence', () => {
expect(toSentenceCase('This is a test')).toBe('This is a test');
});

it('should convert PascalCase to sentence case', () => {
expect(toSentenceCase('FormatToSentenceCase')).toBe(
'Format to sentence case',
);
});

it('should convert camelCase to sentence case', () => {
expect(toSentenceCase('thisIsTest')).toBe('This is test');
});

it('should convert kebab-case to sentence case', () => {
expect(toSentenceCase('hello-world-example')).toBe('Hello world example');
});

it('should convert snake_case to sentence case', () => {
expect(toSentenceCase('snake_case_example')).toBe('Snake case example');
});

it('should capitalize a single word', () => {
expect(toSentenceCase('hello')).toBe('Hello');
});

it('should handle numbers in words correctly', () => {
expect(toSentenceCase('chapter1Introduction')).toBe(
'Chapter 1 introduction',
);
});

it('should handle numbers in slugs correctly', () => {
expect(toSentenceCase('version2Release')).toBe('Version 2 release');
});

it('should handle acronyms properly', () => {
expect(toSentenceCase('apiResponse')).toBe('Api response');
});

it('should handle mixed-case inputs correctly', () => {
expect(toSentenceCase('thisIs-mixed_CASE')).toBe('This is mixed case');
});

it('should not modify already formatted sentence case text', () => {
expect(toSentenceCase('This is a normal sentence.')).toBe(
'This is a normal sentence.',
);
});

it('should return an empty string when given an empty input', () => {
expect(toSentenceCase('')).toBe('');
});
});
Loading
Loading