Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 6 additions & 8 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import type {
SplitByCase,
CaseOptions,
TrainCase,
TitleCase,
FlatCase,
} from "./types";

Expand Down Expand Up @@ -92,7 +93,7 @@ export function pascalCase(): "";
export function pascalCase<
T extends string | readonly string[],
UserCaseOptions extends CaseOptions = CaseOptions,
>(str: T, opts?: CaseOptions): PascalCase<T, UserCaseOptions["normalize"]>;
>(str: T, opts?: UserCaseOptions): PascalCase<T, UserCaseOptions["normalize"]>;
export function pascalCase<
T extends string | readonly string[],
UserCaseOptions extends CaseOptions = CaseOptions,
Expand Down Expand Up @@ -176,22 +177,19 @@ export function titleCase(): "";
export function titleCase<
T extends string | readonly string[],
UserCaseOptions extends CaseOptions = CaseOptions,
>(
str: T,
opts?: UserCaseOptions,
): TrainCase<T, UserCaseOptions["normalize"], " ">;
>(str: T, opts?: UserCaseOptions): TitleCase<T, UserCaseOptions["normalize"]>;
export function titleCase<
T extends string | readonly string[],
UserCaseOptions extends CaseOptions = CaseOptions,
>(str?: T, opts?: UserCaseOptions) {
return (Array.isArray(str) ? str : splitByCase(str as string))
.filter(Boolean)
.map((p) =>
titleCaseExceptions.test(p)
.map((p, i) =>
i > 0 && titleCaseExceptions.test(p)
? p.toLowerCase()
: upperFirst(opts?.normalize ? p.toLowerCase() : p),
)
.join(" ") as TrainCase<T, UserCaseOptions["normalize"]>;
.join(" ") as TitleCase<T, UserCaseOptions["normalize"]>;
}

export * from "./types";
87 changes: 85 additions & 2 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,74 @@ type JoinLowercaseWords<
: JoinLowercaseWords<R, Joiner, `${Accumulator}${Joiner}${Lowercase<F>}`>
: Accumulator;

type JoinCapitalizedWords<
T extends readonly string[],
Joiner extends string,
Accumulator extends string = "",
Normalize extends boolean | undefined = false,
> = T extends readonly [infer F extends string, ...infer R extends string[]]
? F extends ""
? JoinCapitalizedWords<R, Joiner, Accumulator, Normalize>
: Accumulator extends ""
? JoinCapitalizedWords<
R,
Joiner,
`${Capitalize<Normalize extends true ? Lowercase<F> : F>}`,
Normalize
>
: JoinCapitalizedWords<
R,
Joiner,
`${Accumulator}${Joiner}${Capitalize<Normalize extends true ? Lowercase<F> : F>}`,
Normalize
>
: Accumulator;

type TitleCaseExceptions =
| "a"
| "an"
| "and"
| "as"
| "at"
| "but"
| "by"
| "for"
| "if"
| "in"
| "is"
| "nor"
| "of"
| "on"
| "or"
| "the"
| "to"
| "with";

type JoinTitleCaseWords<
T extends readonly string[],
Joiner extends string = " ",
Accumulator extends string = "",
Normalize extends boolean | undefined = false,
> = T extends readonly [infer F extends string, ...infer R extends string[]]
? F extends ""
? JoinTitleCaseWords<R, Joiner, Accumulator, Normalize>
: Accumulator extends ""
? JoinTitleCaseWords<
R,
Joiner,
`${Capitalize<Normalize extends true ? Lowercase<F> : F>}`,
Normalize
>
: JoinTitleCaseWords<
R,
Joiner,
`${Accumulator}${Joiner}${Lowercase<F> extends TitleCaseExceptions
? Lowercase<F>
: Capitalize<Normalize extends true ? Lowercase<F> : F>}`,
Normalize
>
: Accumulator;

type LastOfArray<T extends any[]> = T extends [...any, infer R] ? R : never;
type RemoveLastOfArray<T extends any[]> = T extends [...infer F, any]
? F
Expand Down Expand Up @@ -155,10 +223,25 @@ export type TrainCase<
? string
: T extends string
? SplitByCase<T> extends readonly string[]
? CapitalizedWords<SplitByCase<T>, Joiner>
? JoinCapitalizedWords<SplitByCase<T>, Joiner, "", Normalize>
Comment thread
coderabbitai[bot] marked this conversation as resolved.
: never
: T extends readonly string[]
? JoinCapitalizedWords<T, Joiner, "", Normalize>
: never;

export type TitleCase<
T,
Normalize extends boolean | undefined = false,
> = string extends T
? string
: string[] extends T
? string
: T extends string
? SplitByCase<T> extends readonly string[]
? JoinTitleCaseWords<SplitByCase<T>, " ", "", Normalize>
: never
: T extends readonly string[]
? CapitalizedWords<T, Joiner, Normalize>
? JoinTitleCaseWords<T, " ", "", Normalize>
: never;

export type FlatCase<
Expand Down
1 change: 1 addition & 0 deletions test/scule.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,7 @@ describe("titleCase", () => {
["foo", "Foo"],
["foo-bar", "Foo Bar"],
["this-IS-aTitle", "This is a Title"],
["and_foo", "And Foo"],
])("%s => %s", (input, expected) => {
expect(titleCase(input)).toMatchObject(expected);
});
Expand Down
60 changes: 60 additions & 0 deletions test/types.test-d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,10 @@ import type {
PascalCase,
CamelCase,
JoinByCase,
TrainCase,
TitleCase,
} from "../src/types";
import { trainCase, titleCase } from "../src/index";

describe("SplitByCase", () => {
test("types", () => {
Expand Down Expand Up @@ -96,3 +99,60 @@ describe("JoinByCase", () => {
assertType<JoinByCase<["Foo", "Bar"], "-">>("foo-bar");
});
});

describe("TrainCase", () => {
test("types", () => {
expectTypeOf<TrainCase<string>>().toEqualTypeOf<string>();
expectTypeOf<TrainCase<string[]>>().toEqualTypeOf<string>();
});

test("string", () => {
assertType<TrainCase<"">>("");
assertType<TrainCase<"foo">>("Foo");
assertType<TrainCase<"foo_bar">>("Foo-Bar");
assertType<TrainCase<"foo-bAr">>("Foo-B-Ar");
assertType<TrainCase<"foo--bar-Baz">>("Foo-Bar-Baz");
assertType<TrainCase<"FOO_BAR", true>>("Foo-Bar");
assertType<TrainCase<"FOO_BAR", false>>("FOO-BAR");
assertType<TrainCase<"foo_bar_baz", false, " ">>("Foo Bar Baz");
});

test("array", () => {
assertType<TrainCase<["foo", "bar"]>>("Foo-Bar");
assertType<TrainCase<["foo", "", "bar"]>>("Foo-Bar");
assertType<TrainCase<["FOO", "BAR"], true>>("Foo-Bar");
assertType<TrainCase<["foo", "bar"], false, " ">>("Foo Bar");
});

test("inference with options", () => {
const res1 = trainCase("foo_bar", { normalize: true });
expectTypeOf(res1).toEqualTypeOf<"Foo-Bar">();

const res2 = titleCase("foo_bar", { normalize: true });
expectTypeOf(res2).toEqualTypeOf<"Foo Bar">();

const res3 = titleCase("foo_and_bar", { normalize: true });
expectTypeOf(res3).toEqualTypeOf<"Foo and Bar">();
});
});

describe("TitleCase", () => {
test("types", () => {
expectTypeOf<TitleCase<string>>().toEqualTypeOf<string>();
expectTypeOf<TitleCase<string[]>>().toEqualTypeOf<string>();
});

test("string", () => {
assertType<TitleCase<"">>("");
assertType<TitleCase<"foo">>("Foo");
assertType<TitleCase<"foo_bar">>("Foo Bar");
assertType<TitleCase<"foo_and_bar">>("Foo and Bar");
assertType<TitleCase<"FOO_AND_BAR", true>>("Foo and Bar");
assertType<TitleCase<"and_foo", true>>("And Foo");
});

test("array", () => {
assertType<TitleCase<["foo", "and", "bar"]>>("Foo and Bar");
assertType<TitleCase<["FOO", "AND", "BAR"], true>>("Foo and Bar");
});
});