From c5d713a7b8e91170bf34012be79a55e0accbde91 Mon Sep 17 00:00:00 2001 From: Michael <michael.hladky@push-based.io> Date: Fri, 10 Jan 2025 19:49:00 +0100 Subject: [PATCH 01/11] feat(utils): add string helper --- packages/utils/src/index.ts | 7 ++ packages/utils/src/lib/string.ts | 58 ++++++++++++++++ packages/utils/src/lib/string.unit.test.ts | 81 ++++++++++++++++++++++ packages/utils/src/lib/types.ts | 7 ++ 4 files changed, 153 insertions(+) create mode 100644 packages/utils/src/lib/string.ts create mode 100644 packages/utils/src/lib/string.unit.test.ts diff --git a/packages/utils/src/index.ts b/packages/utils/src/index.ts index 58dd0dbe6..08e9ee4ec 100644 --- a/packages/utils/src/index.ts +++ b/packages/utils/src/index.ts @@ -95,6 +95,12 @@ export { formatReportScore, } from './lib/reports/utils.js'; export { isSemver, normalizeSemver, sortSemvers } from './lib/semver.js'; +export { + camelCaseToKebabCase, + kebabCaseToSentence, + kebabCaseToCamelCase, + camelCaseToSentence, +} from './lib/string.js'; export * from './lib/text-formats/index.js'; export { capitalize, @@ -121,6 +127,7 @@ export type { ItemOrArray, Prettify, WithRequired, + CamelCaseToKebabCase, } from './lib/types.js'; export { verboseUtils } from './lib/verbose-utils.js'; export { zodErrorMessageBuilder } from './lib/zod-validation.js'; diff --git a/packages/utils/src/lib/string.ts b/packages/utils/src/lib/string.ts new file mode 100644 index 000000000..d183d6377 --- /dev/null +++ b/packages/utils/src/lib/string.ts @@ -0,0 +1,58 @@ +import type { CamelCaseToKebabCase } 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: string) { + return string + .split('-') + .map((segment, index) => + index === 0 + ? segment + : segment.charAt(0).toUpperCase() + segment.slice(1), + ) + .join(''); +} + +/** + * Converts a camelCase string to kebab-case. + * @param string - The camelCase string to convert. + * @returns The kebab-case string. + */ +export function camelCaseToKebabCase<T extends string>( + string: T, +): CamelCaseToKebabCase<T> { + return string + .replace(/([A-Z])([A-Z][a-z])/g, '$1-$2') // Split between uppercase followed by uppercase+lowercase + .replace(/([a-z])([A-Z])/g, '$1-$2') // Split between lowercase followed by uppercase + .replace(/([A-Z]+)([A-Z][a-z])/g, '$1-$2') // Additional split for consecutive uppercase + .replace(/[\s_]+/g, '-') // Replace spaces and underscores with hyphens + .toLowerCase() as CamelCaseToKebabCase<T>; +} + +/** + * Formats a slug to a readable title. + * @param slug - The slug to format. + * @returns The formatted title. + */ +export function kebabCaseToSentence(slug: string = '') { + return slug + .replace(/-/g, ' ') + .replace(/\b\w/g, letter => letter.toUpperCase()); +} + +/** + * Formats a slug to a readable title. + * @param slug - The slug to format. + * @returns The formatted title. + */ +export function camelCaseToSentence(slug: string = '') { + return slug + .replace(/([A-Z])([A-Z][a-z])/g, '$1-$2') // Split between uppercase followed by uppercase+lowercase + .replace(/([a-z])([A-Z])/g, '$1-$2') // Split between lowercase followed by uppercase + .replace(/([A-Z]+)([A-Z][a-z])/g, '$1-$2') // Additional split for consecutive uppercase + .replace(/[\s_]+/g, ' ') // Replace spaces and underscores with hyphens + .replace(/\b\w/g, letter => letter.toUpperCase()); +} diff --git a/packages/utils/src/lib/string.unit.test.ts b/packages/utils/src/lib/string.unit.test.ts new file mode 100644 index 000000000..d7100c984 --- /dev/null +++ b/packages/utils/src/lib/string.unit.test.ts @@ -0,0 +1,81 @@ +import { + camelCaseToKebabCase, + kebabCaseToCamelCase, + kebabCaseToSentence, +} from './string.js'; + +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 simple camelCase to kebab-case', () => { + expect(camelCaseToKebabCase('helloWorld')).toBe('hello-world'); + }); + + it('should handle multiple capital letters', () => { + expect(camelCaseToKebabCase('thisIsALongString')).toBe( + 'this-is-a-long-string', + ); + }); + + it('should handle consecutive capital letters', () => { + expect(camelCaseToKebabCase('myXMLParser')).toBe('my-xml-parser'); + }); + + it('should handle spaces and underscores', () => { + expect(camelCaseToKebabCase('hello_world test')).toBe('hello-world-test'); + }); + + it('should handle single word', () => { + expect(camelCaseToKebabCase('hello')).toBe('hello'); + }); + + it('should handle empty string', () => { + expect(camelCaseToKebabCase('')).toBe(''); + }); +}); + +describe('kebabCaseToSentence', () => { + it('should convert simple slug to title case', () => { + expect(kebabCaseToSentence('hello-world')).toBe('Hello World'); + }); + + it('should handle multiple hyphens', () => { + expect(kebabCaseToSentence('this-is-a-title')).toBe('This Is A Title'); + }); + + it('should handle empty string', () => { + expect(kebabCaseToSentence()).toBe(''); + }); + + it('should handle single word', () => { + expect(kebabCaseToSentence('hello')).toBe('Hello'); + }); + + it('should handle numbers in slug', () => { + expect(kebabCaseToSentence('chapter-1-introduction')).toBe( + 'Chapter 1 Introduction', + ); + }); +}); diff --git a/packages/utils/src/lib/types.ts b/packages/utils/src/lib/types.ts index 03b53ea77..62360f9ab 100644 --- a/packages/utils/src/lib/types.ts +++ b/packages/utils/src/lib/types.ts @@ -15,3 +15,10 @@ export type WithRequired<T, K extends keyof T> = Prettify< >; export type Prettify<T> = { [K in keyof T]: T[K] }; + +export type CamelCaseToKebabCase<T extends string> = + T extends `${infer First}${infer Rest}` + ? Rest extends Uncapitalize<Rest> + ? `${Lowercase<First>}${CamelCaseToKebabCase<Rest>}` + : `${Lowercase<First>}-${CamelCaseToKebabCase<Rest>}` + : T; From 619be589a814d75ec8237b7e58b513c9e5962129 Mon Sep 17 00:00:00 2001 From: Michael <michael.hladky@push-based.io> Date: Fri, 10 Jan 2025 19:52:08 +0100 Subject: [PATCH 02/11] fix(utils): update docs --- packages/utils/src/lib/string.ts | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/packages/utils/src/lib/string.ts b/packages/utils/src/lib/string.ts index d183d6377..c476a534c 100644 --- a/packages/utils/src/lib/string.ts +++ b/packages/utils/src/lib/string.ts @@ -34,22 +34,22 @@ export function camelCaseToKebabCase<T extends string>( /** * Formats a slug to a readable title. - * @param slug - The slug to format. + * @param string - The slug to format. * @returns The formatted title. */ -export function kebabCaseToSentence(slug: string = '') { - return slug +export function kebabCaseToSentence(string = '') { + return string .replace(/-/g, ' ') .replace(/\b\w/g, letter => letter.toUpperCase()); } /** * Formats a slug to a readable title. - * @param slug - The slug to format. + * @param string - The slug to format. * @returns The formatted title. */ -export function camelCaseToSentence(slug: string = '') { - return slug +export function camelCaseToSentence(string = '') { + return string .replace(/([A-Z])([A-Z][a-z])/g, '$1-$2') // Split between uppercase followed by uppercase+lowercase .replace(/([a-z])([A-Z])/g, '$1-$2') // Split between lowercase followed by uppercase .replace(/([A-Z]+)([A-Z][a-z])/g, '$1-$2') // Additional split for consecutive uppercase From 90b78f9f0a5be223eb0a32179341e3089ac811e5 Mon Sep 17 00:00:00 2001 From: Michael <michael.hladky@push-based.io> Date: Thu, 30 Jan 2025 13:20:21 +0100 Subject: [PATCH 03/11] fix(utils): refine string helper --- packages/utils/src/lib/string.ts | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/packages/utils/src/lib/string.ts b/packages/utils/src/lib/string.ts index c476a534c..a20e7f7d6 100644 --- a/packages/utils/src/lib/string.ts +++ b/packages/utils/src/lib/string.ts @@ -33,23 +33,23 @@ export function camelCaseToKebabCase<T extends string>( } /** - * Formats a slug to a readable title. - * @param string - The slug to format. + * Formats a string to a readable title. + * @param stringToFormat - The string to format. * @returns The formatted title. */ -export function kebabCaseToSentence(string = '') { - return string +export function kebabCaseToSentence(stringToFormat: string) { + return stringToFormat .replace(/-/g, ' ') .replace(/\b\w/g, letter => letter.toUpperCase()); } /** - * Formats a slug to a readable title. - * @param string - The slug to format. + * Formats a string to a readable title. + * @param stringToFormat - The string to format. * @returns The formatted title. */ -export function camelCaseToSentence(string = '') { - return string +export function camelCaseToSentence(stringToFormat : string) { + return stringToFormat .replace(/([A-Z])([A-Z][a-z])/g, '$1-$2') // Split between uppercase followed by uppercase+lowercase .replace(/([a-z])([A-Z])/g, '$1-$2') // Split between lowercase followed by uppercase .replace(/([A-Z]+)([A-Z][a-z])/g, '$1-$2') // Additional split for consecutive uppercase From 15c4677f0341732f784610a2ff626e0af05c41fa Mon Sep 17 00:00:00 2001 From: Michael <michael.hladky@push-based.io> Date: Thu, 30 Jan 2025 13:33:25 +0100 Subject: [PATCH 04/11] fix(utils): refactor string helper --- packages/utils/src/index.ts | 2 +- packages/utils/src/lib/string.ts | 26 ++++++--- packages/utils/src/lib/string.unit.test.ts | 64 ++++++++++++++++++++-- 3 files changed, 79 insertions(+), 13 deletions(-) diff --git a/packages/utils/src/index.ts b/packages/utils/src/index.ts index 08e9ee4ec..acf952922 100644 --- a/packages/utils/src/index.ts +++ b/packages/utils/src/index.ts @@ -99,7 +99,7 @@ export { camelCaseToKebabCase, kebabCaseToSentence, kebabCaseToCamelCase, - camelCaseToSentence, + formatToSentenceCase, } from './lib/string.js'; export * from './lib/text-formats/index.js'; export { diff --git a/packages/utils/src/lib/string.ts b/packages/utils/src/lib/string.ts index a20e7f7d6..53a175752 100644 --- a/packages/utils/src/lib/string.ts +++ b/packages/utils/src/lib/string.ts @@ -44,15 +44,27 @@ export function kebabCaseToSentence(stringToFormat: string) { } /** - * Formats a string to a readable title. + * Converts a camelCase, PascalCase, kebab-case, or snake_case string into a readable sentence. + * It also ensures numbers are separated correctly from words. + * + * @example + * camelCaseToSentence('helloWorld') // 'Hello world' + * camelCaseToSentence('thisIsALongString') // 'This is a long string' + * camelCaseToSentence('my-1-word') // 'My 1 word' + * camelCaseToSentence('my_snake_case') // 'My snake case' + * camelCaseToSentence('PascalCaseExample') // 'Pascal case example' + * camelCaseToSentence('Chapter1Introduction') // 'Chapter 1 Introduction' + * * @param stringToFormat - The string to format. * @returns The formatted title. */ -export function camelCaseToSentence(stringToFormat : string) { +export function formatToSentenceCase(stringToFormat: string): string { return stringToFormat - .replace(/([A-Z])([A-Z][a-z])/g, '$1-$2') // Split between uppercase followed by uppercase+lowercase - .replace(/([a-z])([A-Z])/g, '$1-$2') // Split between lowercase followed by uppercase - .replace(/([A-Z]+)([A-Z][a-z])/g, '$1-$2') // Additional split for consecutive uppercase - .replace(/[\s_]+/g, ' ') // Replace spaces and underscores with hyphens - .replace(/\b\w/g, letter => letter.toUpperCase()); + .replace(/([a-z])([A-Z])/g, '$1 $2') // camelCase -> split before uppercase letters + .replace(/([A-Z])([A-Z][a-z])/g, '$1 $2') // PascalCase -> split uppercase sequences + .replace(/(\d+)([A-Za-z])/g, '$1 $2') // Separate numbers from letters (before a letter) + .replace(/([A-Za-z])(\d+)/g, '$1 $2') // Separate numbers from letters (after a letter) + .replace(/[-_]+/g, ' ') // Convert kebab-case and snake_case to spaces + .trim() // Remove leading/trailing spaces + .replace(/\b\w/g, char => char.toUpperCase()); // Capitalize first letter } diff --git a/packages/utils/src/lib/string.unit.test.ts b/packages/utils/src/lib/string.unit.test.ts index d7100c984..c53fdf09b 100644 --- a/packages/utils/src/lib/string.unit.test.ts +++ b/packages/utils/src/lib/string.unit.test.ts @@ -1,5 +1,6 @@ import { camelCaseToKebabCase, + formatToSentenceCase, kebabCaseToCamelCase, kebabCaseToSentence, } from './string.js'; @@ -57,7 +58,7 @@ describe('camelCaseToKebabCase', () => { }); describe('kebabCaseToSentence', () => { - it('should convert simple slug to title case', () => { + it('should convert simple kebab-case string to title case', () => { expect(kebabCaseToSentence('hello-world')).toBe('Hello World'); }); @@ -65,10 +66,6 @@ describe('kebabCaseToSentence', () => { expect(kebabCaseToSentence('this-is-a-title')).toBe('This Is A Title'); }); - it('should handle empty string', () => { - expect(kebabCaseToSentence()).toBe(''); - }); - it('should handle single word', () => { expect(kebabCaseToSentence('hello')).toBe('Hello'); }); @@ -79,3 +76,60 @@ describe('kebabCaseToSentence', () => { ); }); }); + +describe('formatToSentenceCase', () => { + it('should convert camelCase to sentence case', () => { + expect(formatToSentenceCase('helloWorld')).toBe('Hello World'); + expect(formatToSentenceCase('thisIsATitle')).toBe('This Is A Title'); + expect(formatToSentenceCase('myTestString')).toBe('My Test String'); + }); + + it('should convert PascalCase to sentence case', () => { + expect(formatToSentenceCase('HelloWorld')).toBe('Hello World'); + expect(formatToSentenceCase('FormatToSentenceCase')).toBe( + 'Format To Sentence Case', + ); + }); + + it('should handle strings with numbers correctly', () => { + expect(formatToSentenceCase('chapter1Introduction')).toBe( + 'Chapter 1 Introduction', + ); + expect(formatToSentenceCase('version2Release')).toBe('Version 2 Release'); + expect(formatToSentenceCase('test123String')).toBe('Test 123 String'); + }); + + it('should handle kebab-case and snake_case formats', () => { + expect(formatToSentenceCase('hello-world')).toBe('Hello World'); + expect(formatToSentenceCase('snake_case_example')).toBe( + 'Snake Case Example', + ); + }); + + it('should return capitalized single words', () => { + expect(formatToSentenceCase('hello')).toBe('Hello'); + expect(formatToSentenceCase('test')).toBe('Test'); + }); + + it('should handle acronyms properly', () => { + expect(formatToSentenceCase('APIResponse')).toBe('API Response'); + expect(formatToSentenceCase('HTTPError')).toBe('HTTP Error'); + }); + + it('should handle mixed case formats', () => { + expect(formatToSentenceCase('thisIs-mixed_CASE')).toBe( + 'This Is Mixed CASE', + ); + }); + + it('should return an empty string when given an empty input', () => { + expect(formatToSentenceCase('')).toBe(''); + }); + + it('should not modify already formatted sentences', () => { + expect(formatToSentenceCase('This is a sentence')).toBe( + 'This Is A Sentence', + ); + expect(formatToSentenceCase('Hello World')).toBe('Hello World'); + }); +}); From 28ade3713b98e7e9f07269e2850301afea3626c2 Mon Sep 17 00:00:00 2001 From: Michael <michael.hladky@push-based.io> Date: Thu, 30 Jan 2025 14:04:50 +0100 Subject: [PATCH 05/11] fix(utils): added title and sentence case functions --- packages/utils/src/index.ts | 2 +- packages/utils/src/lib/case-conversions.ts | 101 ++++++++++ .../src/lib/case-conversions.unit.test.ts | 172 ++++++++++++++++++ packages/utils/src/lib/string.ts | 70 ------- packages/utils/src/lib/string.unit.test.ts | 135 -------------- 5 files changed, 274 insertions(+), 206 deletions(-) create mode 100644 packages/utils/src/lib/case-conversions.ts create mode 100644 packages/utils/src/lib/case-conversions.unit.test.ts delete mode 100644 packages/utils/src/lib/string.ts delete mode 100644 packages/utils/src/lib/string.unit.test.ts diff --git a/packages/utils/src/index.ts b/packages/utils/src/index.ts index acf952922..d77a4fba9 100644 --- a/packages/utils/src/index.ts +++ b/packages/utils/src/index.ts @@ -100,7 +100,7 @@ export { kebabCaseToSentence, kebabCaseToCamelCase, formatToSentenceCase, -} from './lib/string.js'; +} from './lib/case-conversions.js'; export * from './lib/text-formats/index.js'; export { capitalize, diff --git a/packages/utils/src/lib/case-conversions.ts b/packages/utils/src/lib/case-conversions.ts new file mode 100644 index 000000000..0b7e36857 --- /dev/null +++ b/packages/utils/src/lib/case-conversions.ts @@ -0,0 +1,101 @@ +import type { CamelCaseToKebabCase } 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: string) { + return string + .split('-') + .map((segment, index) => + index === 0 + ? segment + : segment.charAt(0).toUpperCase() + segment.slice(1), + ) + .join(''); +} + +/** + * Converts a camelCase string to kebab-case. + * @param string - The camelCase string to convert. + * @returns The kebab-case string. + */ +export function camelCaseToKebabCase<T extends string>( + string: T, +): CamelCaseToKebabCase<T> { + return string + .replace(/([A-Z])([A-Z][a-z])/g, '$1-$2') // Split between uppercase followed by uppercase+lowercase + .replace(/([a-z])([A-Z])/g, '$1-$2') // Split between lowercase followed by uppercase + .replace(/([A-Z]+)([A-Z][a-z])/g, '$1-$2') // Additional split for consecutive uppercase + .replace(/[\s_]+/g, '-') // Replace spaces and underscores with hyphens + .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 word.charAt(0).toUpperCase() + word.slice(1).toLowerCase(); + } + 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 +} 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..e64b4e719 --- /dev/null +++ b/packages/utils/src/lib/case-conversions.unit.test.ts @@ -0,0 +1,172 @@ +import { + camelCaseToKebabCase, + kebabCaseToCamelCase, + toSentenceCase, + toTitleCase, +} from './case-conversions.js'; + +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 simple camelCase to kebab-case', () => { + expect(camelCaseToKebabCase('helloWorld')).toBe('hello-world'); + }); + + it('should handle multiple capital letters', () => { + expect(camelCaseToKebabCase('thisIsALongString')).toBe( + 'this-is-a-long-string', + ); + }); + + it('should handle consecutive capital letters', () => { + expect(camelCaseToKebabCase('myXMLParser')).toBe('my-xml-parser'); + }); + + it('should handle spaces and underscores', () => { + expect(camelCaseToKebabCase('hello_world test')).toBe('hello-world-test'); + }); + + it('should handle single word', () => { + expect(camelCaseToKebabCase('hello')).toBe('hello'); + }); + + it('should handle empty string', () => { + expect(camelCaseToKebabCase('')).toBe(''); + }); +}); + +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/string.ts b/packages/utils/src/lib/string.ts deleted file mode 100644 index 53a175752..000000000 --- a/packages/utils/src/lib/string.ts +++ /dev/null @@ -1,70 +0,0 @@ -import type { CamelCaseToKebabCase } 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: string) { - return string - .split('-') - .map((segment, index) => - index === 0 - ? segment - : segment.charAt(0).toUpperCase() + segment.slice(1), - ) - .join(''); -} - -/** - * Converts a camelCase string to kebab-case. - * @param string - The camelCase string to convert. - * @returns The kebab-case string. - */ -export function camelCaseToKebabCase<T extends string>( - string: T, -): CamelCaseToKebabCase<T> { - return string - .replace(/([A-Z])([A-Z][a-z])/g, '$1-$2') // Split between uppercase followed by uppercase+lowercase - .replace(/([a-z])([A-Z])/g, '$1-$2') // Split between lowercase followed by uppercase - .replace(/([A-Z]+)([A-Z][a-z])/g, '$1-$2') // Additional split for consecutive uppercase - .replace(/[\s_]+/g, '-') // Replace spaces and underscores with hyphens - .toLowerCase() as CamelCaseToKebabCase<T>; -} - -/** - * Formats a string to a readable title. - * @param stringToFormat - The string to format. - * @returns The formatted title. - */ -export function kebabCaseToSentence(stringToFormat: string) { - return stringToFormat - .replace(/-/g, ' ') - .replace(/\b\w/g, letter => letter.toUpperCase()); -} - -/** - * Converts a camelCase, PascalCase, kebab-case, or snake_case string into a readable sentence. - * It also ensures numbers are separated correctly from words. - * - * @example - * camelCaseToSentence('helloWorld') // 'Hello world' - * camelCaseToSentence('thisIsALongString') // 'This is a long string' - * camelCaseToSentence('my-1-word') // 'My 1 word' - * camelCaseToSentence('my_snake_case') // 'My snake case' - * camelCaseToSentence('PascalCaseExample') // 'Pascal case example' - * camelCaseToSentence('Chapter1Introduction') // 'Chapter 1 Introduction' - * - * @param stringToFormat - The string to format. - * @returns The formatted title. - */ -export function formatToSentenceCase(stringToFormat: string): string { - return stringToFormat - .replace(/([a-z])([A-Z])/g, '$1 $2') // camelCase -> split before uppercase letters - .replace(/([A-Z])([A-Z][a-z])/g, '$1 $2') // PascalCase -> split uppercase sequences - .replace(/(\d+)([A-Za-z])/g, '$1 $2') // Separate numbers from letters (before a letter) - .replace(/([A-Za-z])(\d+)/g, '$1 $2') // Separate numbers from letters (after a letter) - .replace(/[-_]+/g, ' ') // Convert kebab-case and snake_case to spaces - .trim() // Remove leading/trailing spaces - .replace(/\b\w/g, char => char.toUpperCase()); // Capitalize first letter -} diff --git a/packages/utils/src/lib/string.unit.test.ts b/packages/utils/src/lib/string.unit.test.ts deleted file mode 100644 index c53fdf09b..000000000 --- a/packages/utils/src/lib/string.unit.test.ts +++ /dev/null @@ -1,135 +0,0 @@ -import { - camelCaseToKebabCase, - formatToSentenceCase, - kebabCaseToCamelCase, - kebabCaseToSentence, -} from './string.js'; - -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 simple camelCase to kebab-case', () => { - expect(camelCaseToKebabCase('helloWorld')).toBe('hello-world'); - }); - - it('should handle multiple capital letters', () => { - expect(camelCaseToKebabCase('thisIsALongString')).toBe( - 'this-is-a-long-string', - ); - }); - - it('should handle consecutive capital letters', () => { - expect(camelCaseToKebabCase('myXMLParser')).toBe('my-xml-parser'); - }); - - it('should handle spaces and underscores', () => { - expect(camelCaseToKebabCase('hello_world test')).toBe('hello-world-test'); - }); - - it('should handle single word', () => { - expect(camelCaseToKebabCase('hello')).toBe('hello'); - }); - - it('should handle empty string', () => { - expect(camelCaseToKebabCase('')).toBe(''); - }); -}); - -describe('kebabCaseToSentence', () => { - it('should convert simple kebab-case string to title case', () => { - expect(kebabCaseToSentence('hello-world')).toBe('Hello World'); - }); - - it('should handle multiple hyphens', () => { - expect(kebabCaseToSentence('this-is-a-title')).toBe('This Is A Title'); - }); - - it('should handle single word', () => { - expect(kebabCaseToSentence('hello')).toBe('Hello'); - }); - - it('should handle numbers in slug', () => { - expect(kebabCaseToSentence('chapter-1-introduction')).toBe( - 'Chapter 1 Introduction', - ); - }); -}); - -describe('formatToSentenceCase', () => { - it('should convert camelCase to sentence case', () => { - expect(formatToSentenceCase('helloWorld')).toBe('Hello World'); - expect(formatToSentenceCase('thisIsATitle')).toBe('This Is A Title'); - expect(formatToSentenceCase('myTestString')).toBe('My Test String'); - }); - - it('should convert PascalCase to sentence case', () => { - expect(formatToSentenceCase('HelloWorld')).toBe('Hello World'); - expect(formatToSentenceCase('FormatToSentenceCase')).toBe( - 'Format To Sentence Case', - ); - }); - - it('should handle strings with numbers correctly', () => { - expect(formatToSentenceCase('chapter1Introduction')).toBe( - 'Chapter 1 Introduction', - ); - expect(formatToSentenceCase('version2Release')).toBe('Version 2 Release'); - expect(formatToSentenceCase('test123String')).toBe('Test 123 String'); - }); - - it('should handle kebab-case and snake_case formats', () => { - expect(formatToSentenceCase('hello-world')).toBe('Hello World'); - expect(formatToSentenceCase('snake_case_example')).toBe( - 'Snake Case Example', - ); - }); - - it('should return capitalized single words', () => { - expect(formatToSentenceCase('hello')).toBe('Hello'); - expect(formatToSentenceCase('test')).toBe('Test'); - }); - - it('should handle acronyms properly', () => { - expect(formatToSentenceCase('APIResponse')).toBe('API Response'); - expect(formatToSentenceCase('HTTPError')).toBe('HTTP Error'); - }); - - it('should handle mixed case formats', () => { - expect(formatToSentenceCase('thisIs-mixed_CASE')).toBe( - 'This Is Mixed CASE', - ); - }); - - it('should return an empty string when given an empty input', () => { - expect(formatToSentenceCase('')).toBe(''); - }); - - it('should not modify already formatted sentences', () => { - expect(formatToSentenceCase('This is a sentence')).toBe( - 'This Is A Sentence', - ); - expect(formatToSentenceCase('Hello World')).toBe('Hello World'); - }); -}); From 15057fc5023605c8077a0ebf66f14455899c8eba Mon Sep 17 00:00:00 2001 From: Michael <michael.hladky@push-based.io> Date: Thu, 30 Jan 2025 14:13:04 +0100 Subject: [PATCH 06/11] fix(utils): include PR feedback --- packages/utils/src/index.ts | 2 +- packages/utils/src/lib/case-conversions.ts | 14 +++++++++++--- .../utils/src/lib/case-conversions.unit.test.ts | 16 ++++++++++++++++ packages/utils/src/lib/text-formats/table.ts | 2 +- packages/utils/src/lib/transform.ts | 6 ------ packages/utils/src/lib/transform.unit.test.ts | 15 --------------- packages/utils/src/lib/types.ts | 5 +++++ 7 files changed, 34 insertions(+), 26 deletions(-) diff --git a/packages/utils/src/index.ts b/packages/utils/src/index.ts index d77a4fba9..1c6a4946a 100644 --- a/packages/utils/src/index.ts +++ b/packages/utils/src/index.ts @@ -103,7 +103,6 @@ export { } from './lib/case-conversions.js'; export * from './lib/text-formats/index.js'; export { - capitalize, countOccurrences, distinct, factorOf, @@ -131,3 +130,4 @@ export type { } from './lib/types.js'; export { verboseUtils } from './lib/verbose-utils.js'; export { zodErrorMessageBuilder } from './lib/zod-validation.js'; +export { capitalize } from './lib/case-conversions'; diff --git a/packages/utils/src/lib/case-conversions.ts b/packages/utils/src/lib/case-conversions.ts index 0b7e36857..bcb32ca7f 100644 --- a/packages/utils/src/lib/case-conversions.ts +++ b/packages/utils/src/lib/case-conversions.ts @@ -1,11 +1,13 @@ -import type { CamelCaseToKebabCase } from './types.js'; +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: string) { +export function kebabCaseToCamelCase<T extends string>( + string: T, +): KebabCaseToCamelCase<T> { return string .split('-') .map((segment, index) => @@ -13,7 +15,7 @@ export function kebabCaseToCamelCase(string: string) { ? segment : segment.charAt(0).toUpperCase() + segment.slice(1), ) - .join(''); + .join('') as KebabCaseToCamelCase<T>; } /** @@ -99,3 +101,9 @@ export function toSentenceCase(input: string): string { .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> { + return `${text.charAt(0).toLocaleUpperCase()}${text.slice( + 1, + )}` as Capitalize<T>; +} diff --git a/packages/utils/src/lib/case-conversions.unit.test.ts b/packages/utils/src/lib/case-conversions.unit.test.ts index e64b4e719..b60829055 100644 --- a/packages/utils/src/lib/case-conversions.unit.test.ts +++ b/packages/utils/src/lib/case-conversions.unit.test.ts @@ -1,10 +1,26 @@ +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 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('kebabCaseToCamelCase', () => { it('should convert simple kebab-case to camelCase', () => { expect(kebabCaseToCamelCase('hello-world')).toBe('helloWorld'); diff --git a/packages/utils/src/lib/text-formats/table.ts b/packages/utils/src/lib/text-formats/table.ts index 6cfb4842c..14d57df0c 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 '@code-pushup/utils'; 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<T>(json: T[]) { return json.map(item => JSON.stringify(item)).join('\n'); } -export function capitalize<T extends string>(text: T): Capitalize<T> { - return `${text.charAt(0).toLocaleUpperCase()}${text.slice( - 1, - )}` as Capitalize<T>; -} - 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 62360f9ab..5cbf40ac8 100644 --- a/packages/utils/src/lib/types.ts +++ b/packages/utils/src/lib/types.ts @@ -22,3 +22,8 @@ export type CamelCaseToKebabCase<T extends string> = ? `${Lowercase<First>}${CamelCaseToKebabCase<Rest>}` : `${Lowercase<First>}-${CamelCaseToKebabCase<Rest>}` : T; + +export type KebabCaseToCamelCase<T extends string> = + T extends `${infer First}-${infer Rest}` + ? `${First}${Capitalize<KebabCaseToCamelCase<Rest>>}` + : T; From fa7485b16efdfed43ca9916591a5a6a821f92c6e Mon Sep 17 00:00:00 2001 From: Michael <michael.hladky@push-based.io> Date: Thu, 30 Jan 2025 14:18:16 +0100 Subject: [PATCH 07/11] fix(utils): reuse capitalize --- packages/utils/src/index.ts | 4 ++-- packages/utils/src/lib/case-conversions.ts | 6 ++---- 2 files changed, 4 insertions(+), 6 deletions(-) diff --git a/packages/utils/src/index.ts b/packages/utils/src/index.ts index 704706889..52df761d8 100644 --- a/packages/utils/src/index.ts +++ b/packages/utils/src/index.ts @@ -97,10 +97,10 @@ export { export { isSemver, normalizeSemver, sortSemvers } from './lib/semver.js'; export { camelCaseToKebabCase, - kebabCaseToSentence, kebabCaseToCamelCase, - formatToSentenceCase, capitalize, + toSentenceCase, + toTitleCase, } from './lib/case-conversions.js'; export * from './lib/text-formats/index.js'; export { diff --git a/packages/utils/src/lib/case-conversions.ts b/packages/utils/src/lib/case-conversions.ts index bcb32ca7f..0a99d44cc 100644 --- a/packages/utils/src/lib/case-conversions.ts +++ b/packages/utils/src/lib/case-conversions.ts @@ -75,7 +75,7 @@ export function toTitleCase(input: string): string { // Capitalize first word or non-minor words if (index === 0 || !minorWords.has(word.toLowerCase())) { - return word.charAt(0).toUpperCase() + word.slice(1).toLowerCase(); + return capitalize(word); } return word.toLowerCase(); }) @@ -103,7 +103,5 @@ export function toSentenceCase(input: string): string { } export function capitalize<T extends string>(text: T): Capitalize<T> { - return `${text.charAt(0).toLocaleUpperCase()}${text.slice( - 1, - )}` as Capitalize<T>; + return `${text.charAt(0).toLocaleUpperCase()}${text.slice(1).toLowerCase()}` as Capitalize<T>; } From da37aebefeb1838dd8234abd2326c041bcf078ed Mon Sep 17 00:00:00 2001 From: Michael <michael.hladky@push-based.io> Date: Thu, 30 Jan 2025 14:29:12 +0100 Subject: [PATCH 08/11] fix(utils): reuse capitalize --- packages/utils/src/lib/case-conversions.ts | 6 +----- packages/utils/src/lib/case-conversions.unit.test.ts | 10 +++++++--- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/packages/utils/src/lib/case-conversions.ts b/packages/utils/src/lib/case-conversions.ts index 0a99d44cc..3fb6a4086 100644 --- a/packages/utils/src/lib/case-conversions.ts +++ b/packages/utils/src/lib/case-conversions.ts @@ -10,11 +10,7 @@ export function kebabCaseToCamelCase<T extends string>( ): KebabCaseToCamelCase<T> { return string .split('-') - .map((segment, index) => - index === 0 - ? segment - : segment.charAt(0).toUpperCase() + segment.slice(1), - ) + .map((segment, index) => (index === 0 ? segment : capitalize(segment))) .join('') as KebabCaseToCamelCase<T>; } diff --git a/packages/utils/src/lib/case-conversions.unit.test.ts b/packages/utils/src/lib/case-conversions.unit.test.ts index b60829055..7734c2177 100644 --- a/packages/utils/src/lib/case-conversions.unit.test.ts +++ b/packages/utils/src/lib/case-conversions.unit.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from 'vitest'; +import { beforeEach, describe, expect, it } from 'vitest'; import { camelCaseToKebabCase, capitalize, @@ -9,11 +9,15 @@ import { describe('capitalize', () => { it('should transform the first string letter to upper case', () => { - expect(capitalize('code PushUp')).toBe('Code PushUp'); + 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 PushUp')).toBe('Code PushUp'); + expect(capitalize('Code')).toBe('Code'); }); it('should accept empty string', () => { From 6bf0402c2e87c02f2fea77b1d015407a2d73c2cd Mon Sep 17 00:00:00 2001 From: Michael <michael.hladky@push-based.io> Date: Thu, 30 Jan 2025 18:14:14 +0100 Subject: [PATCH 09/11] fix(utils): refactor tests --- .../src/lib/case-conversion.type.test.ts | 63 +++++++++++++++++++ packages/utils/src/lib/case-conversions.ts | 12 ++-- .../src/lib/case-conversions.unit.test.ts | 26 ++++---- 3 files changed, 80 insertions(+), 21 deletions(-) create mode 100644 packages/utils/src/lib/case-conversion.type.test.ts 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<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 */ diff --git a/packages/utils/src/lib/case-conversions.ts b/packages/utils/src/lib/case-conversions.ts index 3fb6a4086..589b65b34 100644 --- a/packages/utils/src/lib/case-conversions.ts +++ b/packages/utils/src/lib/case-conversions.ts @@ -16,17 +16,15 @@ export function kebabCaseToCamelCase<T extends string>( /** * Converts a camelCase string to kebab-case. - * @param string - The camelCase string to convert. + * @param input - The camelCase string to convert. * @returns The kebab-case string. */ export function camelCaseToKebabCase<T extends string>( - string: T, + input: T, ): CamelCaseToKebabCase<T> { - return string - .replace(/([A-Z])([A-Z][a-z])/g, '$1-$2') // Split between uppercase followed by uppercase+lowercase - .replace(/([a-z])([A-Z])/g, '$1-$2') // Split between lowercase followed by uppercase - .replace(/([A-Z]+)([A-Z][a-z])/g, '$1-$2') // Additional split for consecutive uppercase - .replace(/[\s_]+/g, '-') // Replace spaces and underscores with hyphens + 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>; } diff --git a/packages/utils/src/lib/case-conversions.unit.test.ts b/packages/utils/src/lib/case-conversions.unit.test.ts index 7734c2177..8fdbdbba9 100644 --- a/packages/utils/src/lib/case-conversions.unit.test.ts +++ b/packages/utils/src/lib/case-conversions.unit.test.ts @@ -1,4 +1,4 @@ -import { beforeEach, describe, expect, it } from 'vitest'; +import { describe, expect, it } from 'vitest'; import { camelCaseToKebabCase, capitalize, @@ -50,30 +50,28 @@ describe('kebabCaseToCamelCase', () => { }); describe('camelCaseToKebabCase', () => { - it('should convert simple camelCase to kebab-case', () => { - expect(camelCaseToKebabCase('helloWorld')).toBe('hello-world'); + it('should convert camelCase to kebab-case', () => { + expect(camelCaseToKebabCase('myTestString')).toBe('my-test-string'); }); - it('should handle multiple capital letters', () => { - expect(camelCaseToKebabCase('thisIsALongString')).toBe( - 'this-is-a-long-string', - ); + it('should handle acronyms properly', () => { + expect(camelCaseToKebabCase('APIResponse')).toBe('api-response'); }); - it('should handle consecutive capital letters', () => { + it('should handle consecutive uppercase letters correctly', () => { expect(camelCaseToKebabCase('myXMLParser')).toBe('my-xml-parser'); }); - it('should handle spaces and underscores', () => { - expect(camelCaseToKebabCase('hello_world test')).toBe('hello-world-test'); + it('should handle single-word camelCase', () => { + expect(camelCaseToKebabCase('singleWord')).toBe('single-word'); }); - it('should handle single word', () => { - expect(camelCaseToKebabCase('hello')).toBe('hello'); + it('should not modify already kebab-case strings', () => { + expect(camelCaseToKebabCase('already-kebab')).toBe('already-kebab'); }); - it('should handle empty string', () => { - expect(camelCaseToKebabCase('')).toBe(''); + it('should not modify non-camelCase inputs', () => { + expect(camelCaseToKebabCase('not_camelCase')).toBe('not_camel-case'); }); }); From ffd3b174ce4f1c9d7ee9ae3c156548be666af026 Mon Sep 17 00:00:00 2001 From: Michael <michael.hladky@push-based.io> Date: Thu, 30 Jan 2025 18:16:35 +0100 Subject: [PATCH 10/11] ci: extend consistent file name rule --- eslint.config.js | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) 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?$`, + }, ], }, }, From 503bc82cb5f59e188d0700eced692f4cca1039a3 Mon Sep 17 00:00:00 2001 From: Michael <michael.hladky@push-based.io> Date: Thu, 30 Jan 2025 18:22:50 +0100 Subject: [PATCH 11/11] fix(utils): fix cyclic dep --- packages/utils/src/lib/text-formats/table.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/utils/src/lib/text-formats/table.ts b/packages/utils/src/lib/text-formats/table.ts index 14d57df0c..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 '@code-pushup/utils'; +import { capitalize } from '../case-conversions.js'; export function rowToStringArray({ rows, columns = [] }: Table): string[][] { if (Array.isArray(rows.at(0)) && typeof columns.at(0) === 'object') {