From 0a24e2d7cb82ea4a409b48e37cf7225a7b2c4f2e Mon Sep 17 00:00:00 2001 From: ntnyq Date: Wed, 20 Nov 2024 19:50:49 +0800 Subject: [PATCH 1/3] fix(titleCase): remove unexpected space --- src/index.ts | 1 + test/scule.test.ts | 9 +++++++++ 2 files changed, 10 insertions(+) diff --git a/src/index.ts b/src/index.ts index f1983fd..d801a6f 100644 --- a/src/index.ts +++ b/src/index.ts @@ -186,6 +186,7 @@ export function titleCase< >(str?: T, opts?: UserCaseOptions) { return (Array.isArray(str) ? str : splitByCase(str as string)) .filter(Boolean) + .map((p) => p.trim()) .map((p) => titleCaseExceptions.test(p) ? p.toLowerCase() diff --git a/test/scule.test.ts b/test/scule.test.ts index bd5ec3b..6ccf28e 100644 --- a/test/scule.test.ts +++ b/test/scule.test.ts @@ -50,6 +50,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); }); @@ -59,6 +60,7 @@ describe("camelCase", () => { test.each([ ["FooBarBaz", "fooBarBaz"], ["FOO_BAR", "fooBar"], + ["fooBarBaz", "fooBarBaz"], ])("%s => %s", (input, expected) => { expect(camelCase(input, { normalize: true })).toMatchObject(expected); }); @@ -74,6 +76,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); }); @@ -83,6 +86,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); }); @@ -93,6 +97,7 @@ describe("upperFirst", () => { ["", ""], ["foo", "Foo"], ["Foo", "Foo"], + ["FooBarBaz", "FooBarBaz"], ])("%s => %s", (input, expected) => { expect(upperFirst(input)).toMatchObject(expected); }); @@ -103,6 +108,7 @@ describe("lowerFirst", () => { ["", ""], ["foo", "foo"], ["Foo", "foo"], + ["fooBarBaz", "fooBarBaz"], ])("%s => %s", (input, expected) => { expect(lowerFirst(input)).toMatchObject(expected); }); @@ -120,6 +126,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); }); @@ -140,6 +147,7 @@ describe("titleCase", () => { ["foo", "Foo"], ["foo-bar", "Foo Bar"], ["this-IS-aTitle", "This is a Title"], + ["Foo Bar", "Foo Bar"], ])("%s => %s", (input, expected) => { expect(titleCase(input)).toMatchObject(expected); }); @@ -154,6 +162,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); }); From 7d7883d7c5b04167d07cda2d633b772abc98c892 Mon Sep 17 00:00:00 2001 From: ntnyq Date: Wed, 20 Nov 2024 23:07:51 +0800 Subject: [PATCH 2/3] fix: trim space for `splitByCase` result --- src/index.ts | 9 +++--- src/types.ts | 70 +++++++++++++++++++++++++++++++++++++------- test/types.test-d.ts | 54 ++++++++++++++++++++++++++++++++++ 3 files changed, 117 insertions(+), 16 deletions(-) diff --git a/src/index.ts b/src/index.ts index d801a6f..7169cf1 100644 --- a/src/index.ts +++ b/src/index.ts @@ -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; @@ -54,7 +54,7 @@ export function splitByCase< if (previousSplitter === false) { // Case rising edge if (previousUpper === false && isUpper === true) { - parts.push(buff); + parts.push(buff.trim()); buff = char; previousUpper = isUpper; continue; @@ -62,7 +62,7 @@ export function splitByCase< // 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; @@ -75,7 +75,7 @@ export function splitByCase< previousSplitter = isSplitter; } - parts.push(buff); + parts.push(buff.trim()); return parts as SplitByCase; } @@ -186,7 +186,6 @@ export function titleCase< >(str?: T, opts?: UserCaseOptions) { return (Array.isArray(str) ? str : splitByCase(str as string)) .filter(Boolean) - .map((p) => p.trim()) .map((p) => titleCaseExceptions.test(p) ? p.toLowerCase() diff --git a/src/types.ts b/src/types.ts index 9e7f1dd..03b6d43 100644 --- a/src/types.ts +++ b/src/types.ts @@ -13,17 +13,30 @@ type SameLetterCase = : IsLower extends IsLower ? 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 : F>}`, - Normalize - > - : Accumulator; + Accumulator extends string = "", +> = T extends readonly [] + ? Accumulator + : T extends readonly [infer F extends string] + ? CapitalizedWords< + [], + Joiner, + Normalize, + `${Accumulator}${Capitalize : F>}` + > + : T extends readonly [infer F extends string, ...infer R extends string[]] + ? CapitalizedWords< + R, + Joiner, + Normalize, + `${Accumulator}${Capitalize : F>}${Joiner}` + > + : Accumulator; + type JoinLowercaseWords< T extends readonly string[], Joiner extends string, @@ -39,6 +52,41 @@ type RemoveLastOfArray = 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 `${Whitespace}${infer R}` + ? TrimLeft + : T; +type TrimRight = T extends `${infer R}${Whitespace}` + ? TrimRight + : T; +type Trim = TrimLeft>; + export type CaseOptions = { normalize?: boolean; }; @@ -59,7 +107,7 @@ export type SplitByCase< Separator, [ ...RemoveLastOfArray, - `${LastOfArray}${F}`, + Trim<`${LastOfArray}${F}`>, ] > : SameLetterCase> extends true @@ -78,7 +126,7 @@ export type SplitByCase< Separator, [ ...RemoveLastOfArray, - `${LastOfArray}${F}`, + Trim<`${LastOfArray}${F}`>, ] > : IsLower extends true @@ -87,7 +135,7 @@ export type SplitByCase< Separator, [ ...RemoveLastOfArray, - `${LastOfArray}${F}`, + Trim<`${LastOfArray}${F}`>, FirstOfString, ] > diff --git a/test/types.test-d.ts b/test/types.test-d.ts index bd722b1..2628441 100644 --- a/test/types.test-d.ts +++ b/test/types.test-d.ts @@ -4,6 +4,7 @@ import type { PascalCase, CamelCase, JoinByCase, + TrainCase, } from "../src/types"; describe("SplitByCase", () => { @@ -22,6 +23,7 @@ describe("SplitByCase", () => { assertType>(["FOO", "Bar"]); assertType>(["A", "Link"]); assertType>(["FOO", "BAR"]); + assertType>(["Foo", "Bar", "Baz"]); }); test("custom splitters", () => { @@ -50,6 +52,7 @@ describe("PascalCase", () => { assertType>("FooBarBazQux"); assertType>("FooBarBaz"); assertType>("FooBar"); + assertType>("FooBarBaz"); }); test("array", () => { @@ -72,6 +75,7 @@ describe("CamelCase", () => { assertType>("fooBaRb"); assertType>("fooBarBazQux"); assertType>("fooBar"); + assertType>("fooBarBaz"); }); test("array", () => { @@ -90,9 +94,59 @@ describe("JoinByCase", () => { assertType>("foo"); assertType>("foo-ba-rb"); assertType>("foo-bar-baz-qux"); + assertType>("foo-bar-baz"); }); test("array", () => { assertType>("foo-bar"); }); }); + +describe("TrainCase", () => { + test("types", () => { + expectTypeOf>().toEqualTypeOf(); + expectTypeOf>().toEqualTypeOf(); + }); + + test("string", () => { + assertType>(""); + assertType>("F"); + assertType>("Foo"); + assertType>("Foo-B-Ar"); + assertType>("Accept-CH"); + assertType>("Foo-Bar-Baz-Qux"); + assertType>("FOO-BAR"); + // @ts-expect-error - splitByCase result may contain empty string + assertType>("Foo-Bar-Baz"); + assertType>("WWW-Authenticate"); + assertType>("WWW-Authenticate"); + assertType>("Foo-B-Ar"); + }); + + test("array", () => { + assertType>(""); + assertType>("Foo-Bar"); + }); +}); + +describe("TitleCase", () => { + test("string", () => { + assertType>(""); + assertType>("F"); + assertType>("Foo"); + assertType>("Foo B Ar"); + assertType>("Accept CH"); + assertType>("Foo Bar Baz Qux"); + assertType>("FOO BAR"); + // @ts-expect-error - splitByCase result may contain empty string + assertType>("Foo Bar Baz"); + assertType>("WWW Authenticate"); + assertType>("WWW Authenticate"); + assertType>("Foo B Ar"); + }); + + test("array", () => { + assertType>(""); + assertType>("Foo Bar"); + }); +}); From a9cbd9ad0ea4794df607988f0d94e5207f2d949d Mon Sep 17 00:00:00 2001 From: ntnyq Date: Wed, 20 Nov 2024 23:45:15 +0800 Subject: [PATCH 3/3] fix: narrow types for `SplitByCase` --- src/types.ts | 21 +++++++++------------ test/scule.test.ts | 2 ++ test/types.test-d.ts | 2 ++ 3 files changed, 13 insertions(+), 12 deletions(-) diff --git a/src/types.ts b/src/types.ts index 03b6d43..b92f967 100644 --- a/src/types.ts +++ b/src/types.ts @@ -21,21 +21,18 @@ type CapitalizedWords< Accumulator extends string = "", > = T extends readonly [] ? Accumulator - : T extends readonly [infer F extends string] + : T extends readonly [infer F extends string, ...infer R extends string[]] ? CapitalizedWords< - [], + R, Joiner, Normalize, - `${Accumulator}${Capitalize : F>}` + `${Accumulator}${Capitalize : F>}${[ + LastOfArray, + ] extends [never] + ? "" + : Joiner}` > - : T extends readonly [infer F extends string, ...infer R extends string[]] - ? CapitalizedWords< - R, - Joiner, - Normalize, - `${Accumulator}${Capitalize : F>}${Joiner}` - > - : Accumulator; + : Accumulator; type JoinLowercaseWords< T extends readonly string[], @@ -99,7 +96,7 @@ export type SplitByCase< ? string[] : T extends `${infer F}${infer R}` ? [LastOfArray] extends [never] - ? SplitByCase + ? SplitByCase : LastOfArray extends string ? R extends "" ? SplitByCase< diff --git a/test/scule.test.ts b/test/scule.test.ts index 6ccf28e..6a36822 100644 --- a/test/scule.test.ts +++ b/test/scule.test.ts @@ -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", diff --git a/test/types.test-d.ts b/test/types.test-d.ts index 2628441..fcf04ec 100644 --- a/test/types.test-d.ts +++ b/test/types.test-d.ts @@ -24,6 +24,8 @@ describe("SplitByCase", () => { assertType>(["A", "Link"]); assertType>(["FOO", "BAR"]); assertType>(["Foo", "Bar", "Baz"]); + assertType>(["Bar", "Baz"]); + assertType>(["Bar", "Baz"]); }); test("custom splitters", () => {