Skip to content

Commit

Permalink
add str.case.*
Browse files Browse the repository at this point in the history
  • Loading branch information
ajmnz committed Nov 15, 2024
1 parent 32850c4 commit 3509060
Show file tree
Hide file tree
Showing 5 changed files with 304 additions and 5 deletions.
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -39,8 +39,9 @@
]
},
"scripts": {
"test": "vitest run",
"test": "vitest run && yarn test:types",
"test:watch": "vitest",
"test:types": "tsc --noEmit ./test/test-types.ts",
"build": "rimraf -rf dist && tsc && copyfiles package.json README.md dist",
"typecheck": "tsc --noEmit",
"dev": "tsc -w",
Expand Down
93 changes: 89 additions & 4 deletions src/string.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,28 @@
type FCapitalize<T extends string> = T extends `${infer F}${infer Rest}`
? `${Capitalize<F>}${Rest}`
: T;
import { fromEntries } from "./object";
import type { ConvertCase, FCapitalize, StringCase } from "./string.types";

export default {
const cases = ["title", "camel", "pascal", "snake", "kebab"] as const;

interface CaseTransformer<
C1 extends StringCase,
C2 extends StringCase,
S extends string,
> {
(): string;
(narrow: true): ConvertCase<C1, C2, S>;
}

type CaseFunction<C extends StringCase> = <S extends string>(
str: S
) => {
[K in Exclude<StringCase, C> as `to${FCapitalize<K>}`]: CaseTransformer<C, K, S>;
};

type CaseShape = {
[K in StringCase as `from${FCapitalize<K>}`]: CaseFunction<K>;
};

const exp = {
/**
* Capitalizes first letters of words in a string.
*
Expand Down Expand Up @@ -190,4 +210,69 @@ export default {
?.join(spacer) || str
);
},
/**
* Case transformers
*/
case: fromEntries(
cases.map((c): [`from${FCapitalize<typeof c>}`, CaseFunction<typeof c>] => {
return [
`from${(c.charAt(0).toUpperCase() + c.slice(1)) as FCapitalize<typeof c>}`,
(str) =>
fromEntries(
cases
.filter((c1) => c !== c1)
.map((c1) => [
`to${(c1.charAt(0).toUpperCase() + c1.slice(1)) as FCapitalize<typeof c1>}`,
() => {
let words: string[] = [];
switch (c) {
case "title":
case "pascal":
words = str.match(/[A-Z][a-z]+|[a-z]+/g) || [];
break;
case "camel":
words = str.match(/[A-Z][a-z]*|[a-z]+/g) || [];
break;
case "snake":
words = str.split("_");
break;
case "kebab":
words = str.split("-");
break;
}

if (c !== "title") {
words = words.map((s) => s.replace(/\s*/g, ""));
}

switch (c1) {
case "title":
return words
.map((w) => w.charAt(0).toUpperCase() + w.slice(1).toLowerCase())
.join(" ");
case "pascal":
return words
.map((w) => w.charAt(0).toUpperCase() + w.slice(1).toLowerCase())
.join("");
case "camel":
return words
.map((w, i) =>
i === 0
? w.toLowerCase()
: w.charAt(0).toUpperCase() + w.slice(1).toLowerCase()
)
.join("");
case "snake":
return words.map((w) => w.toLowerCase()).join("_");
case "kebab":
return words.map((w) => w.toLowerCase()).join("-");
}
},
])
),
];
})
) as CaseShape,
};

export default exp;
136 changes: 136 additions & 0 deletions src/string.types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
export type FCapitalize<T extends string> = T extends `${infer F}${infer Rest}`
? `${Capitalize<F>}${Rest}`
: T;

//
// Case conversions
//

export type StringCase = "title" | "camel" | "pascal" | "snake" | "kebab";

type RemoveSpaces<S extends string> = S extends `${infer A} ${infer B}`
? RemoveSpaces<`${A}${B}`>
: S;

/**
* Convert one string case to another
*/
export type ConvertCase<
From extends StringCase,
To extends StringCase,
Input extends string,
> = From extends keyof ConvertMap<Input>
? To extends keyof ConvertMap<Input>[From]
? ConvertMap<Input>[From][To]
: never
: never;

type ConvertMap<S1 extends string, S2 extends RemoveSpaces<S1> = RemoveSpaces<S1>> = {
title: {
camel: TitleToCamel<S1>; // "Example String" -> "exampleString"
pascal: TitleToPascal<S1>; // "Example String" -> "ExampleString"
snake: TitleToSnake<S1>; // "Example String" -> "example_string"
kebab: TitleToKebab<S1>; // "Example String" -> "example-string"
};
camel: {
title: CamelToTitle<S2>; // "exampleString" -> "Example String"
pascal: CamelToPascal<S2>; // "exampleString" -> "ExampleString"
snake: CamelToSnake<S2>; // "exampleString" -> "example_string"
kebab: CamelToKebab<S2>; // "exampleString" -> "example-string"
};
pascal: {
title: PascalToTitle<S2>; // "ExampleString" -> "Example String"
camel: PascalToCamel<S2>; // "ExampleString" -> "exampleString"
snake: PascalToSnake<S2>; // "ExampleString" -> "example_string"
kebab: PascalToKebab<S2>; // "ExampleString" -> "example-string"
};
snake: {
title: SnakeToTitle<S2>; // "example_string" -> "Example String"
camel: SnakeToCamel<S2>; // "example_string" -> "exampleString"
pascal: SnakeToPascal<S2>; // "example_string" -> "ExampleString"
kebab: SnakeToKebab<S2>; // "example_string" -> "example-string"
};
kebab: {
title: KebabToTitle<S2>; // "example-string" -> "Example String"
camel: KebabToCamel<S2>; // "example-string" -> "exampleString"
pascal: KebabToPascal<S2>; // "example-string" -> "ExampleString"
snake: KebabToSnake<S2>; // "example-string" -> "example_string"
};
};

//
// Title helpers
//

type TitleToSnake<S extends string> = S extends `${infer T} ${infer U}`
? `${Lowercase<T>}_${TitleToSnake<U>}`
: Lowercase<S>;
type TitleToKebab<S extends string> = S extends `${infer T} ${infer U}`
? `${Lowercase<T>}-${TitleToKebab<U>}`
: Lowercase<S>;
type TitleToCamel<S extends string> =
Capitalize<TitleToPascal<S>> extends infer R ? Uncapitalize<R & string> : S;
type TitleToPascal<S extends string> = S extends `${infer T} ${infer U}`
? `${Capitalize<Lowercase<T>>}${TitleToPascal<U>}`
: Capitalize<Lowercase<S>>;

//
// Snake helpers
//

type SnakeToTitle<S extends string> = S extends `${infer T}_${infer U}`
? `${Capitalize<Lowercase<T>>}${SnakeToTitle<U> extends "" ? "" : ` ${SnakeToTitle<U>}`}`
: Capitalize<Lowercase<S>>;

type SnakeToCamel<S extends string> = S extends `${infer T}_${infer U}`
? `${Lowercase<T>}${Capitalize<SnakeToCamel<U>>}`
: S;

type SnakeToPascal<S extends string> = Capitalize<SnakeToCamel<S>>;
type SnakeToKebab<S extends string> = S extends `${infer T}_${infer U}`
? `${Lowercase<T>}-${SnakeToKebab<U>}`
: Lowercase<S>;

//
// Kebab helpers
//

type KebabToTitle<S extends string> = S extends `${infer T}-${infer U}`
? `${Capitalize<Lowercase<T>>} ${KebabToTitle<U>}`
: Capitalize<Lowercase<S>>;

type KebabToSnake<S extends string> = S extends `${infer T}-${infer U}`
? `${Lowercase<T>}_${KebabToSnake<U>}`
: Lowercase<S>;

type KebabToCamel<S extends string> = S extends `${infer T}-${infer U}`
? `${Lowercase<T>}${Capitalize<KebabToCamel<U>>}`
: S;

type KebabToPascal<S extends string> = Capitalize<KebabToCamel<S>>;

//
// Camel helpers
//

type CamelToSnake<S extends string> = S extends `${infer T}${infer U}`
? U extends ""
? Lowercase<T>
: `${Lowercase<T>}${U extends Capitalize<U> ? "_" : ""}${CamelToSnake<U>}`
: S;
type CamelToKebab<S extends string> = S extends `${infer T}${infer U}`
? `${Lowercase<T>}${U extends "" ? "" : U extends Capitalize<U> ? "-" : ""}${CamelToKebab<U>}`
: S;
type CamelToPascal<S extends string> = Capitalize<S>;
type CamelToTitle<S extends string> =
CamelToSnake<S> extends infer R ? SnakeToTitle<R & string> : S;

//
// Pascal helpers
//

type PascalToSnake<S extends string> = CamelToSnake<Uncapitalize<S>>;
type PascalToKebab<S extends string> = CamelToKebab<Uncapitalize<S>>;
type PascalToCamel<S extends string> = Uncapitalize<S>;
type PascalToTitle<S extends string> =
PascalToSnake<S> extends infer R ? SnakeToTitle<R & string> : S;
30 changes: 30 additions & 0 deletions test/str.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,4 +59,34 @@ describe("str", () => {
expect(str.divide("abcdefgh", 1, ",")).toBe("a,b,c,d,e,f,g,h");
expect(str.divide("", 3, "-")).toBe("");
});

test("str.case.*", () => {
expect(str.case.fromSnake("my_example_string").toCamel()).toBe("myExampleString");
expect(str.case.fromSnake("my_example_string").toPascal()).toBe("MyExampleString");
expect(str.case.fromSnake("my_example_string").toKebab()).toBe("my-example-string");
expect(str.case.fromSnake("my_example_string").toTitle()).toBe("My Example String");

expect(str.case.fromKebab("my-example-string").toCamel()).toBe("myExampleString");
expect(str.case.fromKebab("my-example-string").toPascal()).toBe("MyExampleString");
expect(str.case.fromKebab("my-example-string").toSnake()).toBe("my_example_string");
expect(str.case.fromKebab("my-example-string").toTitle()).toBe("My Example String");

expect(str.case.fromCamel("myExampleString").toSnake()).toBe("my_example_string");
expect(str.case.fromCamel("myExampleString").toPascal()).toBe("MyExampleString");
expect(str.case.fromCamel("myExampleString").toKebab()).toBe("my-example-string");
expect(str.case.fromCamel("myExampleString").toTitle()).toBe("My Example String");

expect(str.case.fromPascal("MyExampleString").toCamel()).toBe("myExampleString");
expect(str.case.fromPascal("MyExampleString").toSnake()).toBe("my_example_string");
expect(str.case.fromPascal("MyExampleString").toKebab()).toBe("my-example-string");
expect(str.case.fromPascal("MyExampleString").toTitle()).toBe("My Example String");

expect(str.case.fromTitle("My Example String").toCamel()).toBe("myExampleString");
expect(str.case.fromTitle("My Example String").toSnake()).toBe("my_example_string");
expect(str.case.fromTitle("My Example String").toPascal()).toBe("MyExampleString");
expect(str.case.fromTitle("My Example String").toKebab()).toBe("my-example-string");

// @ts-expect-error test
expect(str.case.fromTitle("My Example String").toTitle).toBeUndefined();
});
});
47 changes: 47 additions & 0 deletions test/test-types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
/* eslint-disable @typescript-eslint/no-unused-vars */
import type * as types from "../src/string.types";

const TitleToCamel: types.ConvertCase<"title", "camel", "My example string"> =
"myExampleString";
const TitleToPascal: types.ConvertCase<"title", "pascal", "My example string"> =
"MyExampleString";
const TitleToSnake: types.ConvertCase<"title", "snake", "My example string"> =
"my_example_string";
const TitleToKebab: types.ConvertCase<"title", "kebab", "My example string"> =
"my-example-string";

const CamelToTitle: types.ConvertCase<"camel", "title", "myExampleString"> =
"My Example String";
const CamelToPascal: types.ConvertCase<"camel", "pascal", "myExampleString"> =
"MyExampleString";
const CamelToSnake: types.ConvertCase<"camel", "snake", "myExampleString"> =
"my_example_string";
const CamelToKebab: types.ConvertCase<"camel", "kebab", "myExampleString"> =
"my-example-string";

const PascalToTitle: types.ConvertCase<"pascal", "title", "MyExampleString"> =
"My Example String";
const PascalToCamel: types.ConvertCase<"pascal", "camel", "MyExampleString"> =
"myExampleString";
const PascalToSnake: types.ConvertCase<"pascal", "snake", "MyExampleString"> =
"my_example_string";
const PascalToKebab: types.ConvertCase<"pascal", "kebab", "MyExampleString"> =
"my-example-string";

const SnakeToTitle: types.ConvertCase<"snake", "title", "my_example_string"> =
"My Example String";
const SnakeToCamel: types.ConvertCase<"snake", "camel", "my_example_string"> =
"myExampleString";
const SnakeToPascal: types.ConvertCase<"snake", "pascal", "my_example_string"> =
"MyExampleString";
const SnakeToKebab: types.ConvertCase<"snake", "kebab", "my_example_string"> =
"my-example-string";

const KebabToTitle: types.ConvertCase<"kebab", "title", "my-example-string"> =
"My Example String";
const KebabToCamel: types.ConvertCase<"kebab", "camel", "my-example-string"> =
"myExampleString";
const KebabToPascal: types.ConvertCase<"kebab", "pascal", "my-example-string"> =
"MyExampleString";
const KebabToSnake: types.ConvertCase<"kebab", "snake", "my-example-string"> =
"my_example_string";

0 comments on commit 3509060

Please sign in to comment.