Skip to content

Commit 0fcd334

Browse files
committed
feat: Add string utils toCamelCase(), toSnakeCase(), toKebabCase(), and toPascalCase()
1 parent 829fe90 commit 0fcd334

File tree

5 files changed

+230
-38
lines changed

5 files changed

+230
-38
lines changed

.changeset/dull-meals-post.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@layerstack/utils': patch
3+
---
4+
5+
feat: Add string utils `toCamelCase()`, `toSnakeCase()`, `toKebabCase()`, and `toPascalCase()`

packages/utils/src/lib/object.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
1-
import { get, camelCase, mergeWith } from 'lodash-es';
1+
import { get, mergeWith } from 'lodash-es';
22
import { entries, fromEntries, keys } from './typeHelpers.js';
3+
import { toCamelCase } from './string.js';
34

45
export function isLiteralObject(obj: any): obj is object {
56
return obj && typeof obj === 'object' && obj.constructor === Object;
@@ -11,7 +12,7 @@ export function isEmptyObject(obj: any) {
1112

1213
export function camelCaseKeys(obj: any) {
1314
return keys(obj).reduce(
14-
(acc, key) => ((acc[camelCase(key ? String(key) : undefined)] = obj[key]), acc),
15+
(acc, key) => ((acc[toCamelCase(key ? String(key) : '')] = obj[key]), acc),
1516
{} as any
1617
);
1718
}
Lines changed: 141 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,149 @@
1-
import { describe, it, expect } from 'vitest';
1+
import { describe, test, expect } from 'vitest';
22

3-
import { toTitleCase } from './string.js';
3+
import {
4+
isUpperCase,
5+
romanize,
6+
toCamelCase,
7+
toKebabCase,
8+
toPascalCase,
9+
toSnakeCase,
10+
toTitleCase,
11+
truncate,
12+
} from './string.js';
13+
14+
describe('isUpperCase()', () => {
15+
test.each([
16+
['A', true],
17+
['a', false],
18+
['THE QUICK BROWN FOX', true],
19+
['the quick brown fox', false],
20+
['The Quick Brown Fox', false],
21+
['The quick brown fox', false],
22+
])('isUpperCase(%s) => %s', (original, expected) => {
23+
expect(isUpperCase(original)).equal(expected);
24+
});
25+
});
426

527
describe('toTitleCase()', () => {
6-
it('basic', () => {
7-
const original = 'this is a test';
8-
const expected = 'This is a Test';
28+
test.each([
29+
['A long time ago', 'A Long Time Ago'], // sentence
30+
['the quick brown fox', 'The Quick Brown Fox'], // lower case
31+
['THE QUICK BROWN FOX', 'The Quick Brown Fox'], // upper case
32+
['the_quick_brown_fox', 'The Quick Brown Fox'], // snake case
33+
['the-quick-brown-fox', 'The Quick Brown Fox'], // kebab case
34+
['theQuickBrownFox', 'The Quick Brown Fox'], // pascal case
35+
['the - quick * brown# fox', 'The Quick Brown Fox'], // punctuation
36+
])('toTitleCase(%s) => %s', (original, expected) => {
937
expect(toTitleCase(original)).equal(expected);
1038
});
39+
});
1140

12-
it('basic', () => {
13-
const original = 'A long time ago';
14-
const expected = 'A Long Time Ago';
15-
expect(toTitleCase(original)).equal(expected);
41+
describe('toCamelCase()', () => {
42+
test.each([
43+
['the quick brown fox', 'theQuickBrownFox'], // lower case
44+
['the_quick_brown_fox', 'theQuickBrownFox'], // snake case
45+
['the-quick-brown-fox', 'theQuickBrownFox'], // kebab case
46+
['THE-QUICK-BROWN-FOX', 'theQuickBrownFox'], // snake case (all caps)
47+
['theQuickBrownFox', 'theQuickBrownFox'], // pascal case
48+
['thequickbrownfox', 'thequickbrownfox'], // lowercase
49+
['the - quick * brown# fox', 'theQuickBrownFox'], // punctuation
50+
['behold theQuickBrownFox', 'beholdTheQuickBrownFox'],
51+
['Behold theQuickBrownFox', 'beholdTheQuickBrownFox'],
52+
['The quick brown FOX', 'theQuickBrownFox'], // all caps words are camel-cased
53+
['theQUickBrownFox', 'theQUickBrownFox'], // all caps substrings >= 4 chars are camel-cased
54+
['theQUIckBrownFox', 'theQUIckBrownFox'],
55+
])('toCamelCase(%s) => %s', (original, expected) => {
56+
expect(toCamelCase(original)).equal(expected);
57+
});
58+
});
59+
60+
describe('toSnakeCase()', () => {
61+
test.each([
62+
['the quick brown fox', 'the_quick_brown_fox'], // lower case
63+
['the-quick-brown-fox', 'the_quick_brown_fox'], // kebab case
64+
['the_quick_brown_fox', 'the_quick_brown_fox'], // snake case
65+
['theQuickBrownFox', 'the_quick_brown_fox'], // pascal case
66+
['theQuickBrown Fox', 'the_quick_brown_fox'], // space separated words
67+
['thequickbrownfox', 'thequickbrownfox'], // no spaces
68+
['the - quick * brown# fox', 'the_quick_brown_fox'], // punctuation
69+
['theQUICKBrownFox', 'the_q_u_i_c_k_brown_fox'], // all caps words are snake-cased
70+
])('toSnakeCase(%s) => %s', (original, expected) => {
71+
expect(toSnakeCase(original)).equal(expected);
72+
});
73+
});
74+
75+
describe('toKebabCase()', () => {
76+
test.each([
77+
['the quick brown fox', 'the-quick-brown-fox'], // lower case
78+
['the-quick-brown-fox', 'the-quick-brown-fox'], // kebab case
79+
['the_quick_brown_fox', 'the-quick-brown-fox'], // snake case
80+
['theQuickBrownFox', 'the-quick-brown-fox'], // pascal case
81+
['theQuickBrown Fox', 'the-quick-brown-fox'], // space separated words
82+
['thequickbrownfox', 'thequickbrownfox'], // no spaces
83+
['the - quick * brown# fox', 'the-quick-brown-fox'], // punctuation
84+
['theQUICKBrownFox', 'the-q-u-i-c-k-brown-fox'], // all caps words are snake-cased
85+
])('toKebabCase(%s) => %s', (original, expected) => {
86+
expect(toKebabCase(original)).equal(expected);
87+
});
88+
});
89+
90+
describe('toPascalCase()', () => {
91+
test.each([
92+
['the quick brown fox', 'TheQuickBrownFox'], // lower case
93+
['the_quick_brown_fox', 'TheQuickBrownFox'], // snake case
94+
['the-quick-brown-fox', 'TheQuickBrownFox'], // kebab case
95+
['theQuickBrownFox', 'TheQuickBrownFox'], // pascal case
96+
['thequickbrownfox', 'Thequickbrownfox'], // lowercase
97+
['the - quick * brown# fox', 'TheQuickBrownFox'], // punctuation
98+
['theQUICKBrownFox', 'TheQUICKBrownFox'], // all caps words are pascal-cased
99+
])('toPascalCase(%s) => %s', (original, expected) => {
100+
expect(toPascalCase(original)).equal(expected);
101+
});
102+
});
103+
104+
describe('romanize()', () => {
105+
test.each([
106+
[1, 'I'],
107+
[2, 'II'],
108+
[3, 'III'],
109+
[4, 'IV'],
110+
[5, 'V'],
111+
[6, 'VI'],
112+
[7, 'VII'],
113+
[8, 'VIII'],
114+
[9, 'IX'],
115+
[10, 'X'],
116+
[11, 'XI'],
117+
[12, 'XII'],
118+
[13, 'XIII'],
119+
[14, 'XIV'],
120+
[15, 'XV'],
121+
[16, 'XVI'],
122+
[17, 'XVII'],
123+
[18, 'XVIII'],
124+
[19, 'XIX'],
125+
[20, 'XX'],
126+
[40, 'XL'],
127+
[49, 'XLIX'],
128+
[50, 'L'],
129+
[90, 'XC'],
130+
[100, 'C'],
131+
[400, 'CD'],
132+
[500, 'D'],
133+
[900, 'CM'],
134+
[1000, 'M'],
135+
])('romanize(%s) => %s', (original, expected) => {
136+
expect(romanize(original)).equal(expected);
137+
});
138+
});
139+
140+
describe('truncate()', () => {
141+
test.each([
142+
['the quick brown fox', 9, undefined, 'the quick…'],
143+
['the quick brown fox', 15, undefined, 'the quick brown…'],
144+
['the quick brown fox', 15, 3, 'the quick br…fox'],
145+
['the quick brown fox', 9, Infinity, '…brown fox'],
146+
])('truncate(%s, %s) => %s', (original, totalChars, endChars, expected) => {
147+
expect(truncate(original, totalChars, endChars)).equal(expected);
16148
});
17149
});

packages/utils/src/lib/string.ts

Lines changed: 79 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -1,53 +1,80 @@
11
import { entries } from './typeHelpers.js';
22

3+
// any combination of spaces and punctuation characters - http://stackoverflow.com/a/25575009
4+
const wordSeparatorsRegEx = /[\s\u2000-\u206F\u2E00-\u2E7F\\'!"#$%&()*+,\-.\/:;<=>?@\[\]^_`{|}~]+/;
5+
6+
const uppercaseChars = '[A-Z\u00C0-\u00DC\u00D9-\u00DD]'; // includes accented characters
7+
const capitalsRegEx = new RegExp(uppercaseChars, 'g');
8+
const allCapitalsRegEx = new RegExp(`^${uppercaseChars}+$`);
9+
10+
const camelCaseRegEx = /^[a-z\u00E0-\u00FCA-Z\u00C0-\u00DC][\d|a-z\u00E0-\u00FCA-Z\u00C0-\u00DC]*$/;
11+
312
/**
413
* Check if str only contians upper case letters
514
*/
615
export function isUpperCase(str: string) {
7-
return /^[A-Z]*$/.test(str);
16+
return /^[A-Z ]*$/.test(str);
817
}
918

1019
/**
1120
* Returns string with the first letter of each word converted to uppercase (and remainder as lowercase)
1221
*/
1322
export function toTitleCase(str: string, ignore = ['a', 'an', 'is', 'the']) {
14-
return str
15-
.toLowerCase()
16-
.split(' ')
23+
const withSpaces = isUpperCase(str) ? str : str.replace(/([A-Z])/g, ' $1').trim();
24+
return withSpaces
25+
.split(wordSeparatorsRegEx)
1726
.map((word, index) => {
18-
if (index > 0 && ignore.includes(word)) {
27+
if (index !== 0 && ignore.includes(word)) {
1928
return word;
2029
} else {
21-
return word.charAt(0).toUpperCase() + word.slice(1);
30+
return word.charAt(0).toUpperCase() + word.slice(1).toLowerCase();
2231
}
2332
})
2433
.join(' ');
2534
}
2635

27-
/**
28-
* Generates a unique Id, with prefix if provided
29-
*/
30-
const idMap = new Map<string, number>();
31-
export function uniqueId(prefix = '') {
32-
let id = (idMap.get(prefix) ?? 0) + 1;
33-
idMap.set(prefix, id);
34-
return prefix + id;
36+
/** Convert string to camel case */
37+
export function toCamelCase(str: string) {
38+
const words = str.split(wordSeparatorsRegEx);
39+
return words
40+
.map((word, i) => {
41+
if (word === '') {
42+
return '';
43+
}
44+
const isCamelCase = camelCaseRegEx.test(word) && !allCapitalsRegEx.test(word);
45+
let firstLetter = word[0];
46+
firstLetter = i > 0 ? firstLetter.toUpperCase() : firstLetter.toLowerCase();
47+
return firstLetter + (!isCamelCase ? word.slice(1).toLowerCase() : word.slice(1));
48+
})
49+
.join('');
3550
}
3651

37-
/**
38-
* Truncate text with option to keep a number of characters on end. Inserts ellipsis between parts
39-
*/
40-
export function truncate(text: string, totalChars: number, endChars: number = 0) {
41-
endChars = Math.min(endChars, totalChars);
52+
/** Convert string to snake case */
53+
export function toSnakeCase(str: string) {
54+
// Replace capitals with space + lower case equivalent for later parsing
55+
return str
56+
.replace(capitalsRegEx, (match) => ' ' + (match.toLowerCase() || match))
57+
.split(wordSeparatorsRegEx)
58+
.join('_');
59+
}
4260

43-
const start = text.slice(0, totalChars - endChars);
44-
const end = endChars > 0 ? text.slice(-endChars) : '';
61+
/** Convert string to kebab case */
62+
export function toKebabCase(str: string) {
63+
return str
64+
.replace(capitalsRegEx, (match) => '-' + (match.toLowerCase() || match))
65+
.split(wordSeparatorsRegEx)
66+
.join('-');
67+
}
4568

46-
if (start.length + end.length < text.length) {
47-
return start + '…' + end;
48-
} else {
49-
return text;
50-
}
69+
/** Convert string to pascal case */
70+
export function toPascalCase(str: string) {
71+
return (
72+
str
73+
.split(wordSeparatorsRegEx)
74+
.map((word) => word[0].toUpperCase() + word.slice(1))
75+
// .map((word) => toTitleCase(word, []))
76+
.join('')
77+
);
5178
}
5279

5380
/** Get the roman numeral for the given value */
@@ -79,3 +106,29 @@ export function romanize(value: number) {
79106

80107
return result;
81108
}
109+
110+
/**
111+
* Truncate text with option to keep a number of characters on end. Inserts ellipsis between parts
112+
*/
113+
export function truncate(text: string, totalChars: number, endChars: number = 0) {
114+
endChars = Math.min(endChars, totalChars);
115+
116+
const start = text.slice(0, totalChars - endChars);
117+
const end = endChars > 0 ? text.slice(-endChars) : '';
118+
119+
if (start.length + end.length < text.length) {
120+
return start + '…' + end;
121+
} else {
122+
return text;
123+
}
124+
}
125+
126+
/**
127+
* Generates a unique Id, with prefix if provided
128+
*/
129+
const idMap = new Map<string, number>();
130+
export function uniqueId(prefix = '') {
131+
let id = (idMap.get(prefix) ?? 0) + 1;
132+
idMap.set(prefix, id);
133+
return prefix + id;
134+
}

packages/utils/src/lib/styles.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { entries } from './typeHelpers.js';
2+
import { toKebabCase } from './string.js';
23

34
/**
45
* Convert object to style string
@@ -8,7 +9,7 @@ export function objectToString(styleObj: { [key: string]: string }) {
89
.map(([key, value]) => {
910
if (value) {
1011
// Convert camelCase into kaboob-case (ex. (transformOrigin => transform-origin))
11-
const propertyName = key.replace(/([A-Z])/g, '-$1').toLowerCase();
12+
const propertyName = toKebabCase(key);
1213
return `${propertyName}: ${value};`;
1314
} else {
1415
return null;

0 commit comments

Comments
 (0)