diff --git a/src/index.ts b/src/index.ts index f1983fd..fdf84e5 100644 --- a/src/index.ts +++ b/src/index.ts @@ -6,6 +6,7 @@ import type { SplitByCase, CaseOptions, TrainCase, + TitleCase, FlatCase, } from "./types"; @@ -92,7 +93,7 @@ export function pascalCase(): ""; export function pascalCase< T extends string | readonly string[], UserCaseOptions extends CaseOptions = CaseOptions, ->(str: T, opts?: CaseOptions): PascalCase; +>(str: T, opts?: UserCaseOptions): PascalCase; export function pascalCase< T extends string | readonly string[], UserCaseOptions extends CaseOptions = CaseOptions, @@ -176,22 +177,19 @@ export function titleCase(): ""; export function titleCase< T extends string | readonly string[], UserCaseOptions extends CaseOptions = CaseOptions, ->( - str: T, - opts?: UserCaseOptions, -): TrainCase; +>(str: T, opts?: UserCaseOptions): TitleCase; 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; + .join(" ") as TitleCase; } export * from "./types"; diff --git a/src/types.ts b/src/types.ts index 9e7f1dd..17133fa 100644 --- a/src/types.ts +++ b/src/types.ts @@ -34,6 +34,74 @@ type JoinLowercaseWords< : JoinLowercaseWords}`> : 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 + : Accumulator extends "" + ? JoinCapitalizedWords< + R, + Joiner, + `${Capitalize : F>}`, + Normalize + > + : JoinCapitalizedWords< + R, + Joiner, + `${Accumulator}${Joiner}${Capitalize : 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 + : Accumulator extends "" + ? JoinTitleCaseWords< + R, + Joiner, + `${Capitalize : F>}`, + Normalize + > + : JoinTitleCaseWords< + R, + Joiner, + `${Accumulator}${Joiner}${Lowercase extends TitleCaseExceptions + ? Lowercase + : Capitalize : F>}`, + Normalize + > + : Accumulator; + type LastOfArray = T extends [...any, infer R] ? R : never; type RemoveLastOfArray = T extends [...infer F, any] ? F @@ -155,10 +223,25 @@ export type TrainCase< ? string : T extends string ? SplitByCase extends readonly string[] - ? CapitalizedWords, Joiner> + ? JoinCapitalizedWords, Joiner, "", Normalize> + : never + : T extends readonly string[] + ? JoinCapitalizedWords + : never; + +export type TitleCase< + T, + Normalize extends boolean | undefined = false, +> = string extends T + ? string + : string[] extends T + ? string + : T extends string + ? SplitByCase extends readonly string[] + ? JoinTitleCaseWords, " ", "", Normalize> : never : T extends readonly string[] - ? CapitalizedWords + ? JoinTitleCaseWords : never; export type FlatCase< diff --git a/test/scule.test.ts b/test/scule.test.ts index bd5ec3b..a00cda1 100644 --- a/test/scule.test.ts +++ b/test/scule.test.ts @@ -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); }); diff --git a/test/types.test-d.ts b/test/types.test-d.ts index bd722b1..4960abc 100644 --- a/test/types.test-d.ts +++ b/test/types.test-d.ts @@ -4,7 +4,10 @@ import type { PascalCase, CamelCase, JoinByCase, + TrainCase, + TitleCase, } from "../src/types"; +import { trainCase, titleCase } from "../src/index"; describe("SplitByCase", () => { test("types", () => { @@ -96,3 +99,60 @@ describe("JoinByCase", () => { assertType>("foo-bar"); }); }); + +describe("TrainCase", () => { + test("types", () => { + expectTypeOf>().toEqualTypeOf(); + expectTypeOf>().toEqualTypeOf(); + }); + + test("string", () => { + assertType>(""); + assertType>("Foo"); + assertType>("Foo-Bar"); + assertType>("Foo-B-Ar"); + assertType>("Foo-Bar-Baz"); + assertType>("Foo-Bar"); + assertType>("FOO-BAR"); + assertType>("Foo Bar Baz"); + }); + + test("array", () => { + assertType>("Foo-Bar"); + assertType>("Foo-Bar"); + assertType>("Foo-Bar"); + assertType>("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>().toEqualTypeOf(); + expectTypeOf>().toEqualTypeOf(); + }); + + test("string", () => { + assertType>(""); + assertType>("Foo"); + assertType>("Foo Bar"); + assertType>("Foo and Bar"); + assertType>("Foo and Bar"); + assertType>("And Foo"); + }); + + test("array", () => { + assertType>("Foo and Bar"); + assertType>("Foo and Bar"); + }); +});