-
Notifications
You must be signed in to change notification settings - Fork 15
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
feat(utils): add string helper #916
Open
BioPhoton
wants to merge
12
commits into
main
Choose a base branch
from
extend-utils
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
Show all changes
12 commits
Select commit
Hold shift + click to select a range
c5d713a
feat(utils): add string helper
BioPhoton 619be58
fix(utils): update docs
BioPhoton 90b78f9
fix(utils): refine string helper
BioPhoton 15c4677
fix(utils): refactor string helper
BioPhoton 28ade37
fix(utils): added title and sentence case functions
BioPhoton 15057fc
fix(utils): include PR feedback
BioPhoton d726b1f
Merge branch 'main' into extend-utils
BioPhoton fa7485b
fix(utils): reuse capitalize
BioPhoton da37aeb
fix(utils): reuse capitalize
BioPhoton 6bf0402
fix(utils): refactor tests
BioPhoton ffd3b17
ci: extend consistent file name rule
BioPhoton 503bc82
fix(utils): fix cyclic dep
BioPhoton File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 */ | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. It's possible to configure alternative assertion function names to export default tseslint.config(
// ...
{
files: ['**/*.type.test.ts'],
rules: {
'vitest/expect-expect': [
'error',
{ assertFunctionNames: ['expectTypeOf', 'assertType'] },
],
},
},
); |
||
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 */ |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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> { | ||
return `${text.charAt(0).toLocaleUpperCase()}${text.slice(1).toLowerCase()}` as Capitalize<T>; | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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(''); | ||
}); | ||
}); |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I noticed these type tests aren't included anywhere. Extending the
vite.config.unit.ts
configuration like this includes them inunit-test
target:There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Unfortunately, it seems to me like the typecheck always passes. When I deliberately introduce errors, they're correctly highlighted in IDE, but the test command reports passed tests anyway 😕