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

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 in unit-test target:

export default defineConfig({
  // ...
  test: {
    // ...
    typecheck: {
      enabled: true,
      include: ['**/*.type.test.ts'],
    },
  },
});

Copy link
Collaborator

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 😕

image

image

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 */
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's possible to configure alternative assertion function names to expect using assertFunctionNames option. I suggest adding this configuration to the base eslint.config.js:

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 */
101 changes: 101 additions & 0 deletions packages/utils/src/lib/case-conversions.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
import type { CamelCaseToKebabCase, KebabCaseToCamelCase } from './types.js';

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

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

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

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

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

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

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

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

View workflow job for this annotation

GitHub Actions / Code PushUp

<✓> JSDoc coverage | Functions coverage

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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