Skip to content

Commit 6c0097f

Browse files
authored
feat(utils): add string helper (#916)
This PR includes helper for the TypeScript plugin #902
1 parent 990bab0 commit 6c0097f

9 files changed

+378
-24
lines changed

eslint.config.js

+3-1
Original file line numberDiff line numberDiff line change
@@ -87,7 +87,9 @@ export default tseslint.config(
8787
rules: {
8888
'vitest/consistent-test-filename': [
8989
'warn',
90-
{ pattern: String.raw`.*\.(unit|integration|e2e)\.test\.[tj]sx?$` },
90+
{
91+
pattern: String.raw`.*\.(bench|type|unit|integration|e2e)\.test\.[tj]sx?$`,
92+
},
9193
],
9294
},
9395
},

packages/utils/src/index.ts

+8-1
Original file line numberDiff line numberDiff line change
@@ -95,9 +95,15 @@ export {
9595
formatReportScore,
9696
} from './lib/reports/utils.js';
9797
export { isSemver, normalizeSemver, sortSemvers } from './lib/semver.js';
98-
export * from './lib/text-formats/index.js';
9998
export {
99+
camelCaseToKebabCase,
100+
kebabCaseToCamelCase,
100101
capitalize,
102+
toSentenceCase,
103+
toTitleCase,
104+
} from './lib/case-conversions.js';
105+
export * from './lib/text-formats/index.js';
106+
export {
101107
countOccurrences,
102108
distinct,
103109
factorOf,
@@ -121,6 +127,7 @@ export type {
121127
ItemOrArray,
122128
Prettify,
123129
WithRequired,
130+
CamelCaseToKebabCase,
124131
} from './lib/types.js';
125132
export { verboseUtils } from './lib/verbose-utils.js';
126133
export { parseSchema, SchemaValidationError } from './lib/zod-validation.js';
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
import { assertType, describe, expectTypeOf, it } from 'vitest';
2+
import type { CamelCaseToKebabCase, KebabCaseToCamelCase } from './types.js';
3+
4+
/* eslint-disable vitest/expect-expect */
5+
describe('CamelCaseToKebabCase', () => {
6+
// ✅ CamelCase → kebab-case Type Tests
7+
8+
it('CamelCaseToKebabCase works correctly', () => {
9+
expectTypeOf<
10+
CamelCaseToKebabCase<'myTestString'>
11+
>().toEqualTypeOf<'my-test-string'>();
12+
expectTypeOf<
13+
CamelCaseToKebabCase<'APIResponse'>
14+
>().toEqualTypeOf<'a-p-i-response'>();
15+
expectTypeOf<
16+
CamelCaseToKebabCase<'myXMLParser'>
17+
>().toEqualTypeOf<'my-x-m-l-parser'>();
18+
expectTypeOf<
19+
CamelCaseToKebabCase<'singleWord'>
20+
>().toEqualTypeOf<'single-word'>();
21+
22+
// @ts-expect-error Ensures that non-camelCase strings do not pass
23+
assertType<CamelCaseToKebabCase<'hello_world'>>();
24+
25+
// @ts-expect-error Numbers should not be transformed
26+
assertType<CamelCaseToKebabCase<'version2Release'>>();
27+
});
28+
29+
// ✅ kebab-case → CamelCase Type Tests
30+
it('KebabCaseToCamelCase works correctly', () => {
31+
expectTypeOf<
32+
KebabCaseToCamelCase<'my-test-string'>
33+
>().toEqualTypeOf<'myTestString'>();
34+
expectTypeOf<
35+
KebabCaseToCamelCase<'a-p-i-response'>
36+
>().toEqualTypeOf<'aPIResponse'>();
37+
expectTypeOf<
38+
KebabCaseToCamelCase<'my-x-m-l-parser'>
39+
>().toEqualTypeOf<'myXMLParser'>();
40+
expectTypeOf<
41+
KebabCaseToCamelCase<'single-word'>
42+
>().toEqualTypeOf<'singleWord'>();
43+
44+
// @ts-expect-error Ensures that non-kebab-case inputs are not accepted
45+
assertType<KebabCaseToCamelCase<'my Test String'>>();
46+
47+
// @ts-expect-error Numbers should not be transformed
48+
assertType<KebabCaseToCamelCase<'version-2-release'>>();
49+
});
50+
51+
// ✅ Edge Cases
52+
it('Edge cases for case conversions', () => {
53+
expectTypeOf<CamelCaseToKebabCase<''>>().toEqualTypeOf<''>();
54+
expectTypeOf<KebabCaseToCamelCase<''>>().toEqualTypeOf<''>();
55+
56+
// @ts-expect-error Ensures no spaces allowed in input
57+
assertType<CamelCaseToKebabCase<'this is not camelCase'>>();
58+
59+
// @ts-expect-error Ensures no mixed case with dashes
60+
assertType<KebabCaseToCamelCase<'this-Is-Wrong'>>();
61+
});
62+
});
63+
/* eslint-enable vitest/expect-expect */
+101
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
import type { CamelCaseToKebabCase, KebabCaseToCamelCase } from './types.js';
2+
3+
/**
4+
* Converts a kebab-case string to camelCase.
5+
* @param string - The kebab-case string to convert.
6+
* @returns The camelCase string.
7+
*/
8+
export function kebabCaseToCamelCase<T extends string>(
9+
string: T,
10+
): KebabCaseToCamelCase<T> {
11+
return string
12+
.split('-')
13+
.map((segment, index) => (index === 0 ? segment : capitalize(segment)))
14+
.join('') as KebabCaseToCamelCase<T>;
15+
}
16+
17+
/**
18+
* Converts a camelCase string to kebab-case.
19+
* @param input - The camelCase string to convert.
20+
* @returns The kebab-case string.
21+
*/
22+
export function camelCaseToKebabCase<T extends string>(
23+
input: T,
24+
): CamelCaseToKebabCase<T> {
25+
return input
26+
.replace(/([a-z])([A-Z])/g, '$1-$2') // Insert dash before uppercase letters
27+
.replace(/([A-Z])([A-Z][a-z])/g, '$1-$2') // Handle consecutive uppercase letters
28+
.toLowerCase() as CamelCaseToKebabCase<T>;
29+
}
30+
31+
/**
32+
* Converts a string to Title Case.
33+
* - Capitalizes the first letter of each major word.
34+
* - Keeps articles, conjunctions, and short prepositions in lowercase unless they are the first word.
35+
*
36+
* @param input - The string to convert.
37+
* @returns The formatted title case string.
38+
*/
39+
export function toTitleCase(input: string): string {
40+
const minorWords = new Set([
41+
'a',
42+
'an',
43+
'the',
44+
'and',
45+
'or',
46+
'but',
47+
'for',
48+
'nor',
49+
'on',
50+
'in',
51+
'at',
52+
'to',
53+
'by',
54+
'of',
55+
]);
56+
57+
return input
58+
.replace(/([a-z])([A-Z])/g, '$1 $2') // Split PascalCase & camelCase
59+
.replace(/[_-]/g, ' ') // Replace kebab-case and snake_case with spaces
60+
.replace(/(\d+)/g, ' $1 ') // Add spaces around numbers
61+
.replace(/\s+/g, ' ') // Remove extra spaces
62+
.trim()
63+
.split(' ')
64+
.map((word, index) => {
65+
// Preserve uppercase acronyms (e.g., API, HTTP)
66+
if (/^[A-Z]{2,}$/.test(word)) {
67+
return word;
68+
}
69+
70+
// Capitalize first word or non-minor words
71+
if (index === 0 || !minorWords.has(word.toLowerCase())) {
72+
return capitalize(word);
73+
}
74+
return word.toLowerCase();
75+
})
76+
.join(' ');
77+
}
78+
79+
/**
80+
* Converts a string to Sentence Case.
81+
* - Capitalizes only the first letter of the sentence.
82+
* - Retains case of proper nouns.
83+
*
84+
* @param input - The string to convert.
85+
* @returns The formatted sentence case string.
86+
*/
87+
export function toSentenceCase(input: string): string {
88+
return input
89+
.replace(/([a-z])([A-Z])/g, '$1 $2') // Split PascalCase & camelCase
90+
.replace(/[_-]/g, ' ') // Replace kebab-case and snake_case with spaces
91+
.replace(/(\d+)/g, ' $1 ') // Add spaces around numbers
92+
.replace(/\s+/g, ' ') // Remove extra spaces
93+
.trim()
94+
.toLowerCase()
95+
.replace(/^(\w)/, match => match.toUpperCase()) // Capitalize first letter
96+
.replace(/\b([A-Z]{2,})\b/g, match => match); // Preserve uppercase acronyms
97+
}
98+
99+
export function capitalize<T extends string>(text: T): Capitalize<T> {
100+
return `${text.charAt(0).toLocaleUpperCase()}${text.slice(1).toLowerCase()}` as Capitalize<T>;
101+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,190 @@
1+
import { describe, expect, it } from 'vitest';
2+
import {
3+
camelCaseToKebabCase,
4+
capitalize,
5+
kebabCaseToCamelCase,
6+
toSentenceCase,
7+
toTitleCase,
8+
} from './case-conversions.js';
9+
10+
describe('capitalize', () => {
11+
it('should transform the first string letter to upper case', () => {
12+
expect(capitalize('code')).toBe('Code');
13+
});
14+
15+
it('should lowercase all but the the first string letter', () => {
16+
expect(capitalize('PushUp')).toBe('Pushup');
17+
});
18+
19+
it('should leave the first string letter in upper case', () => {
20+
expect(capitalize('Code')).toBe('Code');
21+
});
22+
23+
it('should accept empty string', () => {
24+
expect(capitalize('')).toBe('');
25+
});
26+
});
27+
28+
describe('kebabCaseToCamelCase', () => {
29+
it('should convert simple kebab-case to camelCase', () => {
30+
expect(kebabCaseToCamelCase('hello-world')).toBe('helloWorld');
31+
});
32+
33+
it('should handle multiple hyphens', () => {
34+
expect(kebabCaseToCamelCase('this-is-a-long-string')).toBe(
35+
'thisIsALongString',
36+
);
37+
});
38+
39+
it('should preserve numbers', () => {
40+
expect(kebabCaseToCamelCase('user-123-test')).toBe('user123Test');
41+
});
42+
43+
it('should handle single word', () => {
44+
expect(kebabCaseToCamelCase('hello')).toBe('hello');
45+
});
46+
47+
it('should handle empty string', () => {
48+
expect(kebabCaseToCamelCase('')).toBe('');
49+
});
50+
});
51+
52+
describe('camelCaseToKebabCase', () => {
53+
it('should convert camelCase to kebab-case', () => {
54+
expect(camelCaseToKebabCase('myTestString')).toBe('my-test-string');
55+
});
56+
57+
it('should handle acronyms properly', () => {
58+
expect(camelCaseToKebabCase('APIResponse')).toBe('api-response');
59+
});
60+
61+
it('should handle consecutive uppercase letters correctly', () => {
62+
expect(camelCaseToKebabCase('myXMLParser')).toBe('my-xml-parser');
63+
});
64+
65+
it('should handle single-word camelCase', () => {
66+
expect(camelCaseToKebabCase('singleWord')).toBe('single-word');
67+
});
68+
69+
it('should not modify already kebab-case strings', () => {
70+
expect(camelCaseToKebabCase('already-kebab')).toBe('already-kebab');
71+
});
72+
73+
it('should not modify non-camelCase inputs', () => {
74+
expect(camelCaseToKebabCase('not_camelCase')).toBe('not_camel-case');
75+
});
76+
});
77+
78+
describe('toTitleCase', () => {
79+
it('should capitalize each word in a simple sentence', () => {
80+
expect(toTitleCase('hello world')).toBe('Hello World');
81+
});
82+
83+
it('should capitalize each word in a longer sentence', () => {
84+
expect(toTitleCase('this is a title')).toBe('This Is a Title');
85+
});
86+
87+
it('should convert PascalCase to title case', () => {
88+
expect(toTitleCase('FormatToTitleCase')).toBe('Format to Title Case');
89+
});
90+
91+
it('should convert camelCase to title case', () => {
92+
expect(toTitleCase('thisIsTest')).toBe('This Is Test');
93+
});
94+
95+
it('should convert kebab-case to title case', () => {
96+
expect(toTitleCase('hello-world-example')).toBe('Hello World Example');
97+
});
98+
99+
it('should convert snake_case to title case', () => {
100+
expect(toTitleCase('snake_case_example')).toBe('Snake Case Example');
101+
});
102+
103+
it('should capitalize a single word', () => {
104+
expect(toTitleCase('hello')).toBe('Hello');
105+
});
106+
107+
it('should handle numbers in words correctly', () => {
108+
expect(toTitleCase('chapter1Introduction')).toBe('Chapter 1 Introduction');
109+
});
110+
111+
it('should handle numbers in slugs correctly', () => {
112+
expect(toTitleCase('version2Release')).toBe('Version 2 Release');
113+
});
114+
115+
it('should handle acronyms properly', () => {
116+
expect(toTitleCase('apiResponse')).toBe('Api Response');
117+
});
118+
119+
it('should handle mixed-case inputs correctly', () => {
120+
expect(toTitleCase('thisIs-mixed_CASE')).toBe('This Is Mixed CASE');
121+
});
122+
123+
it('should not modify already formatted title case text', () => {
124+
expect(toTitleCase('Hello World')).toBe('Hello World');
125+
});
126+
127+
it('should return an empty string when given an empty input', () => {
128+
expect(toTitleCase('')).toBe('');
129+
});
130+
});
131+
132+
describe('toSentenceCase', () => {
133+
it('should convert a simple sentence to sentence case', () => {
134+
expect(toSentenceCase('hello world')).toBe('Hello world');
135+
});
136+
137+
it('should maintain a correctly formatted sentence', () => {
138+
expect(toSentenceCase('This is a test')).toBe('This is a test');
139+
});
140+
141+
it('should convert PascalCase to sentence case', () => {
142+
expect(toSentenceCase('FormatToSentenceCase')).toBe(
143+
'Format to sentence case',
144+
);
145+
});
146+
147+
it('should convert camelCase to sentence case', () => {
148+
expect(toSentenceCase('thisIsTest')).toBe('This is test');
149+
});
150+
151+
it('should convert kebab-case to sentence case', () => {
152+
expect(toSentenceCase('hello-world-example')).toBe('Hello world example');
153+
});
154+
155+
it('should convert snake_case to sentence case', () => {
156+
expect(toSentenceCase('snake_case_example')).toBe('Snake case example');
157+
});
158+
159+
it('should capitalize a single word', () => {
160+
expect(toSentenceCase('hello')).toBe('Hello');
161+
});
162+
163+
it('should handle numbers in words correctly', () => {
164+
expect(toSentenceCase('chapter1Introduction')).toBe(
165+
'Chapter 1 introduction',
166+
);
167+
});
168+
169+
it('should handle numbers in slugs correctly', () => {
170+
expect(toSentenceCase('version2Release')).toBe('Version 2 release');
171+
});
172+
173+
it('should handle acronyms properly', () => {
174+
expect(toSentenceCase('apiResponse')).toBe('Api response');
175+
});
176+
177+
it('should handle mixed-case inputs correctly', () => {
178+
expect(toSentenceCase('thisIs-mixed_CASE')).toBe('This is mixed case');
179+
});
180+
181+
it('should not modify already formatted sentence case text', () => {
182+
expect(toSentenceCase('This is a normal sentence.')).toBe(
183+
'This is a normal sentence.',
184+
);
185+
});
186+
187+
it('should return an empty string when given an empty input', () => {
188+
expect(toSentenceCase('')).toBe('');
189+
});
190+
});

0 commit comments

Comments
 (0)