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
8 changes: 4 additions & 4 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ export function splitByCase<
// Splitter
const isSplitter = (splitters as unknown as string).includes(char);
if (isSplitter === true) {
parts.push(buff);
parts.push(buff.trim());
buff = "";
previousUpper = undefined;
continue;
Expand All @@ -54,15 +54,15 @@ export function splitByCase<
if (previousSplitter === false) {
// Case rising edge
if (previousUpper === false && isUpper === true) {
parts.push(buff);
parts.push(buff.trim());
Copy link
Member

Choose a reason for hiding this comment

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

I'm wondering if we could fix algorithm that does not add trailing space in first place?

Copy link
Member

@43081j 43081j Mar 26, 2025

Choose a reason for hiding this comment

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

is this actually just that " " should be a splitter?

then we will skip over it like other splitters

edit: solved by #98 it seems. so we should land that and remove the trim here (if this PR is needed anymore)

buff = char;
previousUpper = isUpper;
continue;
}
// Case falling edge
if (previousUpper === true && isUpper === false && buff.length > 1) {
const lastChar = buff.at(-1);
parts.push(buff.slice(0, Math.max(0, buff.length - 1)));
parts.push(buff.slice(0, Math.max(0, buff.length - 1)).trim());
buff = lastChar + char;
previousUpper = isUpper;
continue;
Expand All @@ -75,7 +75,7 @@ export function splitByCase<
previousSplitter = isSplitter;
}

parts.push(buff);
parts.push(buff.trim());

return parts as SplitByCase<T, Separator[number]>;
}
Expand Down
69 changes: 57 additions & 12 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,17 +13,27 @@ type SameLetterCase<X extends string, Y extends string> =
: IsLower<X> extends IsLower<Y>
? true
: false;

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

type JoinLowercaseWords<
T extends readonly string[],
Joiner extends string,
Expand All @@ -39,6 +49,41 @@ type RemoveLastOfArray<T extends any[]> = T extends [...infer F, any]
? F
: never;

type Whitespace =
| "\u{9}" // '\t'
| "\u{A}" // '\n'
| "\u{B}" // '\v'
| "\u{C}" // '\f'
| "\u{D}" // '\r'
| "\u{20}" // ' '
| "\u{85}"
| "\u{A0}"
| "\u{1680}"
| "\u{2000}"
| "\u{2001}"
| "\u{2002}"
| "\u{2003}"
| "\u{2004}"
| "\u{2005}"
| "\u{2006}"
| "\u{2007}"
| "\u{2008}"
| "\u{2009}"
| "\u{200A}"
| "\u{2028}"
| "\u{2029}"
| "\u{202F}"
| "\u{205F}"
| "\u{3000}"
| "\u{FEFF}";
type TrimLeft<T extends string> = T extends `${Whitespace}${infer R}`
? TrimLeft<R>
: T;
type TrimRight<T extends string> = T extends `${infer R}${Whitespace}`
? TrimRight<R>
: T;
type Trim<T extends string> = TrimLeft<TrimRight<T>>;

export type CaseOptions = {
normalize?: boolean;
};
Expand All @@ -51,15 +96,15 @@ export type SplitByCase<
? string[]
: T extends `${infer F}${infer R}`
? [LastOfArray<Accumulator>] extends [never]
? SplitByCase<R, Separator, [F]>
? SplitByCase<R, Separator, F extends Separator | Whitespace ? [] : [F]>
: LastOfArray<Accumulator> extends string
? R extends ""
? SplitByCase<
R,
Separator,
[
...RemoveLastOfArray<Accumulator>,
`${LastOfArray<Accumulator>}${F}`,
Trim<`${LastOfArray<Accumulator>}${F}`>,
]
>
: SameLetterCase<F, FirstOfString<R>> extends true
Expand All @@ -78,7 +123,7 @@ export type SplitByCase<
Separator,
[
...RemoveLastOfArray<Accumulator>,
`${LastOfArray<Accumulator>}${F}`,
Trim<`${LastOfArray<Accumulator>}${F}`>,
]
>
: IsLower<F> extends true
Expand All @@ -87,7 +132,7 @@ export type SplitByCase<
Separator,
[
...RemoveLastOfArray<Accumulator>,
`${LastOfArray<Accumulator>}${F}`,
Trim<`${LastOfArray<Accumulator>}${F}`>,
FirstOfString<R>,
]
>
Expand Down
11 changes: 11 additions & 0 deletions test/scule.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@ describe("splitByCase", () => {
["foo123-bar", ["foo123", "bar"]],
["FOOBar", ["FOO", "Bar"]],
["ALink", ["A", "Link"]],
["-FooBar", ["", "Foo", "Bar"]],
[" FooBar", ["", "Foo", "Bar"]],
// with custom splitters
[
"foo\\Bar.fuzz-FIZz",
Expand All @@ -50,6 +52,7 @@ describe("pascalCase", () => {
["foo_bar-baz/qux", "FooBarBazQux"],
["FOO_BAR", "FooBar"],
["foo--bar-Baz", "FooBarBaz"],
["FooBarBazQux", "FooBarBazQux"],
])("%s => %s", (input, expected) => {
expect(pascalCase(input, { normalize: true })).toMatchObject(expected);
});
Expand All @@ -59,6 +62,7 @@ describe("camelCase", () => {
test.each([
["FooBarBaz", "fooBarBaz"],
["FOO_BAR", "fooBar"],
["fooBarBaz", "fooBarBaz"],
])("%s => %s", (input, expected) => {
expect(camelCase(input, { normalize: true })).toMatchObject(expected);
});
Expand All @@ -74,6 +78,7 @@ describe("kebabCase", () => {
["FooBAR", "foo-bar"],
["ALink", "a-link"],
["FOO_BAR", "foo-bar"],
["foo-b-ar", "foo-b-ar"],
])("%s => %s", (input, expected) => {
expect(kebabCase(input)).toMatchObject(expected);
});
Expand All @@ -83,6 +88,7 @@ describe("snakeCase", () => {
test.each([
["FooBarBaz", "foo_bar_baz"],
["FOO_BAR", "foo_bar"],
["foo_bar_baz", "foo_bar_baz"],
])("%s => %s", (input, expected) => {
expect(snakeCase(input)).toMatchObject(expected);
});
Expand All @@ -93,6 +99,7 @@ describe("upperFirst", () => {
["", ""],
["foo", "Foo"],
["Foo", "Foo"],
["FooBarBaz", "FooBarBaz"],
])("%s => %s", (input, expected) => {
expect(upperFirst(input)).toMatchObject(expected);
});
Expand All @@ -103,6 +110,7 @@ describe("lowerFirst", () => {
["", ""],
["foo", "foo"],
["Foo", "foo"],
["fooBarBaz", "fooBarBaz"],
])("%s => %s", (input, expected) => {
expect(lowerFirst(input)).toMatchObject(expected);
});
Expand All @@ -120,6 +128,7 @@ describe("trainCase", () => {
["foo--bar-Baz", "Foo-Bar-Baz"],
["WWW-authenticate", "WWW-Authenticate"],
["WWWAuthenticate", "WWW-Authenticate"],
["Foo-B-Ar", "Foo-B-Ar"],
])("%s => %s", (input, expected) => {
expect(trainCase(input)).toMatchObject(expected);
});
Expand All @@ -140,6 +149,7 @@ describe("titleCase", () => {
["foo", "Foo"],
["foo-bar", "Foo Bar"],
["this-IS-aTitle", "This is a Title"],
["Foo Bar", "Foo Bar"],
Copy link
Member

Choose a reason for hiding this comment

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

Maybe add at least to types test as well? (for types we can do simply similar fix with trim should be fine)

Copy link
Author

Choose a reason for hiding this comment

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

I made some changes to types and added types test cases for TrainCase and TitleCase(TrainCase with space joiner).

There are still two cases didn't pass. That's because titleCase and trainCase use filter(Boolean) to filter empty string. I don't know how to fix the type properly so I add a @ts-expect-error comment above it.

Could you do some help?

])("%s => %s", (input, expected) => {
expect(titleCase(input)).toMatchObject(expected);
});
Expand All @@ -154,6 +164,7 @@ describe("flatCase", () => {
["foo_bar-baz/qux", "foobarbazqux"],
["FOO_BAR", "foobar"],
["foo--bar-Baz", "foobarbaz"],
["foobarbaz", "foobarbaz"],
])("%s => %s", (input, expected) => {
expect(flatCase(input)).toMatchObject(expected);
});
Expand Down
56 changes: 56 additions & 0 deletions test/types.test-d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import type {
PascalCase,
CamelCase,
JoinByCase,
TrainCase,
} from "../src/types";

describe("SplitByCase", () => {
Expand All @@ -22,6 +23,9 @@ describe("SplitByCase", () => {
assertType<SplitByCase<"FOOBar">>(["FOO", "Bar"]);
assertType<SplitByCase<"ALink">>(["A", "Link"]);
assertType<SplitByCase<"FOO_BAR">>(["FOO", "BAR"]);
assertType<SplitByCase<"Foo Bar Baz">>(["Foo", "Bar", "Baz"]);
assertType<SplitByCase<"-Bar Baz">>(["Bar", "Baz"]);
assertType<SplitByCase<" Bar Baz">>(["Bar", "Baz"]);
});

test("custom splitters", () => {
Expand Down Expand Up @@ -50,6 +54,7 @@ describe("PascalCase", () => {
assertType<PascalCase<"foo_bar-baz/qux", true>>("FooBarBazQux");
assertType<PascalCase<"foo--bar-Baz", true>>("FooBarBaz");
assertType<PascalCase<"FOO_BAR", true>>("FooBar");
assertType<PascalCase<"FooBarBaz", true>>("FooBarBaz");
});

test("array", () => {
Expand All @@ -72,6 +77,7 @@ describe("CamelCase", () => {
assertType<CamelCase<"FooBARb", true>>("fooBaRb");
assertType<CamelCase<"foo_bar-baz/qux", true>>("fooBarBazQux");
assertType<CamelCase<"FOO_BAR", true>>("fooBar");
assertType<CamelCase<"fooBarBaz", true>>("fooBarBaz");
});

test("array", () => {
Expand All @@ -90,9 +96,59 @@ describe("JoinByCase", () => {
assertType<JoinByCase<"foo", "-">>("foo");
assertType<JoinByCase<"FooBARb", "-">>("foo-ba-rb");
assertType<JoinByCase<"foo_bar-baz/qux", "-">>("foo-bar-baz-qux");
assertType<JoinByCase<"foo-bar-baz", "-">>("foo-bar-baz");
});

test("array", () => {
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<"f">>("F");
assertType<TrainCase<"foo">>("Foo");
assertType<TrainCase<"foo-bAr">>("Foo-B-Ar");
assertType<TrainCase<"AcceptCH">>("Accept-CH");
assertType<TrainCase<"foo_bar-baz/qux">>("Foo-Bar-Baz-Qux");
assertType<TrainCase<"FOO_BAR">>("FOO-BAR");
// @ts-expect-error - splitByCase result may contain empty string
assertType<TrainCase<"foo--bar-Baz">>("Foo-Bar-Baz");
assertType<TrainCase<"WWW-authenticate">>("WWW-Authenticate");
assertType<TrainCase<"WWWAuthenticate">>("WWW-Authenticate");
assertType<TrainCase<"Foo-B-Ar">>("Foo-B-Ar");
});

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

describe("TitleCase", () => {
test("string", () => {
assertType<TrainCase<"">>("");
assertType<TrainCase<"f", false, " ">>("F");
assertType<TrainCase<"foo", false, " ">>("Foo");
assertType<TrainCase<"foo-bAr", false, " ">>("Foo B Ar");
assertType<TrainCase<"AcceptCH", false, " ">>("Accept CH");
assertType<TrainCase<"foo_bar-baz/qux", false, " ">>("Foo Bar Baz Qux");
assertType<TrainCase<"FOO_BAR", false, " ">>("FOO BAR");
// @ts-expect-error - splitByCase result may contain empty string
assertType<TrainCase<"foo--bar-Baz", false, " ">>("Foo Bar Baz");
assertType<TrainCase<"WWW-authenticate", false, " ">>("WWW Authenticate");
assertType<TrainCase<"WWWAuthenticate", false, " ">>("WWW Authenticate");
assertType<TrainCase<"Foo-B-Ar", false, " ">>("Foo B Ar");
});

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