diff --git a/eslint.config.js b/eslint.config.js index a9bff8986..6cea14bdf 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -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?$`, + }, ], }, }, diff --git a/packages/utils/src/index.ts b/packages/utils/src/index.ts index 7c8fd8556..52df761d8 100644 --- a/packages/utils/src/index.ts +++ b/packages/utils/src/index.ts @@ -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, @@ -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'; diff --git a/packages/utils/src/lib/case-conversion.type.test.ts b/packages/utils/src/lib/case-conversion.type.test.ts new file mode 100644 index 000000000..230e59a59 --- /dev/null +++ b/packages/utils/src/lib/case-conversion.type.test.ts @@ -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>(); + + // @ts-expect-error Numbers should not be transformed + assertType>(); + }); + + // ✅ 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>(); + + // @ts-expect-error Numbers should not be transformed + assertType>(); + }); + + // ✅ Edge Cases + it('Edge cases for case conversions', () => { + expectTypeOf>().toEqualTypeOf<''>(); + expectTypeOf>().toEqualTypeOf<''>(); + + // @ts-expect-error Ensures no spaces allowed in input + assertType>(); + + // @ts-expect-error Ensures no mixed case with dashes + assertType>(); + }); +}); +/* eslint-enable vitest/expect-expect */ diff --git a/packages/utils/src/lib/case-conversions.ts b/packages/utils/src/lib/case-conversions.ts new file mode 100644 index 000000000..589b65b34 --- /dev/null +++ b/packages/utils/src/lib/case-conversions.ts @@ -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( + string: T, +): KebabCaseToCamelCase { + return string + .split('-') + .map((segment, index) => (index === 0 ? segment : capitalize(segment))) + .join('') as KebabCaseToCamelCase; +} + +/** + * Converts a camelCase string to kebab-case. + * @param input - The camelCase string to convert. + * @returns The kebab-case string. + */ +export function camelCaseToKebabCase( + input: T, +): CamelCaseToKebabCase { + 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; +} + +/** + * 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(text: T): Capitalize { + return `${text.charAt(0).toLocaleUpperCase()}${text.slice(1).toLowerCase()}` as Capitalize; +} diff --git a/packages/utils/src/lib/case-conversions.unit.test.ts b/packages/utils/src/lib/case-conversions.unit.test.ts new file mode 100644 index 000000000..8fdbdbba9 --- /dev/null +++ b/packages/utils/src/lib/case-conversions.unit.test.ts @@ -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(''); + }); +}); diff --git a/packages/utils/src/lib/text-formats/table.ts b/packages/utils/src/lib/text-formats/table.ts index 6cfb4842c..6ca339f16 100644 --- a/packages/utils/src/lib/text-formats/table.ts +++ b/packages/utils/src/lib/text-formats/table.ts @@ -5,7 +5,7 @@ import type { TableColumnObject, TableColumnPrimitive, } from '@code-pushup/models'; -import { capitalize } from '../transform.js'; +import { capitalize } from '../case-conversions.js'; export function rowToStringArray({ rows, columns = [] }: Table): string[][] { if (Array.isArray(rows.at(0)) && typeof columns.at(0) === 'object') { diff --git a/packages/utils/src/lib/transform.ts b/packages/utils/src/lib/transform.ts index d3943a0c4..315c7d030 100644 --- a/packages/utils/src/lib/transform.ts +++ b/packages/utils/src/lib/transform.ts @@ -123,12 +123,6 @@ export function toJsonLines(json: T[]) { return json.map(item => JSON.stringify(item)).join('\n'); } -export function capitalize(text: T): Capitalize { - return `${text.charAt(0).toLocaleUpperCase()}${text.slice( - 1, - )}` as Capitalize; -} - export function toNumberPrecision( value: number, decimalPlaces: number, diff --git a/packages/utils/src/lib/transform.unit.test.ts b/packages/utils/src/lib/transform.unit.test.ts index 1478f8d0d..6716d1e83 100644 --- a/packages/utils/src/lib/transform.unit.test.ts +++ b/packages/utils/src/lib/transform.unit.test.ts @@ -1,6 +1,5 @@ import { describe, expect, it } from 'vitest'; import { - capitalize, countOccurrences, deepClone, distinct, @@ -273,20 +272,6 @@ describe('JSON lines format', () => { }); }); -describe('capitalize', () => { - it('should transform the first string letter to upper case', () => { - expect(capitalize('code PushUp')).toBe('Code PushUp'); - }); - - it('should leave the first string letter in upper case', () => { - expect(capitalize('Code PushUp')).toBe('Code PushUp'); - }); - - it('should accept empty string', () => { - expect(capitalize('')).toBe(''); - }); -}); - describe('toNumberPrecision', () => { it.each([ [12.1, 0, 12], diff --git a/packages/utils/src/lib/types.ts b/packages/utils/src/lib/types.ts index 03b53ea77..5cbf40ac8 100644 --- a/packages/utils/src/lib/types.ts +++ b/packages/utils/src/lib/types.ts @@ -15,3 +15,15 @@ export type WithRequired = Prettify< >; export type Prettify = { [K in keyof T]: T[K] }; + +export type CamelCaseToKebabCase = + T extends `${infer First}${infer Rest}` + ? Rest extends Uncapitalize + ? `${Lowercase}${CamelCaseToKebabCase}` + : `${Lowercase}-${CamelCaseToKebabCase}` + : T; + +export type KebabCaseToCamelCase = + T extends `${infer First}-${infer Rest}` + ? `${First}${Capitalize>}` + : T;