diff --git a/.changeset/string-case-utils.md b/.changeset/string-case-utils.md new file mode 100644 index 0000000000..586a8d0138 --- /dev/null +++ b/.changeset/string-case-utils.md @@ -0,0 +1,5 @@ +--- +"@better-auth/core": patch +--- + +Add `toCamelCase`, `toSnakeCase`, `toPascalCase`, and `toKebabCase` to `@better-auth/core/utils/string`. diff --git a/packages/better-auth/src/client/proxy.ts b/packages/better-auth/src/client/proxy.ts index f5a678d0af..1bcf1d7812 100644 --- a/packages/better-auth/src/client/proxy.ts +++ b/packages/better-auth/src/client/proxy.ts @@ -3,6 +3,7 @@ import type { ClientAtomListener, ClientFetchOption, } from "@better-auth/core"; +import { toKebabCase } from "@better-auth/core/utils/string"; import type { BetterFetch } from "@better-fetch/fetch"; import type { Atom } from "nanostores"; import { isAtom } from "../utils/is-atom"; @@ -67,13 +68,7 @@ export function createDynamicPathProxy>( return createProxy(fullPath); }, apply: async (_, __, args) => { - const routePath = - "/" + - path - .map((segment) => - segment.replace(/[A-Z]/g, (letter) => `-${letter.toLowerCase()}`), - ) - .join("/"); + const routePath = "/" + path.map(toKebabCase).join("/"); const arg = (args[0] || {}) as ProxyRequest; const fetchOptions = (args[1] || {}) as ClientFetchOption; const { query, fetchOptions: argFetchOptions, ...body } = arg; diff --git a/packages/better-auth/src/plugins/open-api/generator.ts b/packages/better-auth/src/plugins/open-api/generator.ts index ea984f241d..e7c44a2e96 100644 --- a/packages/better-auth/src/plugins/open-api/generator.ts +++ b/packages/better-auth/src/plugins/open-api/generator.ts @@ -4,7 +4,7 @@ import type { DBFieldAttributeConfig, DBFieldType, } from "@better-auth/core/db"; -import { capitalizeFirstLetter } from "@better-auth/core/utils/string"; +import { toPascalCase } from "@better-auth/core/utils/string"; import type { Endpoint, EndpointOptions, @@ -427,7 +427,7 @@ export async function generator(ctx: AuthContext, options: BetterAuthOptions) { ) => { if (!operationId) return undefined; const base = seenOperationIds.has(operationId) - ? `${operationId}${capitalizeFirstLetter(method.toLowerCase())}` + ? `${operationId}${toPascalCase(method)}` : operationId; let result = base; let n = 2; diff --git a/packages/cli/src/generators/drizzle.ts b/packages/cli/src/generators/drizzle.ts index 684dfc35ec..20baa3c5d1 100644 --- a/packages/cli/src/generators/drizzle.ts +++ b/packages/cli/src/generators/drizzle.ts @@ -1,4 +1,5 @@ import { existsSync } from "node:fs"; +import { toSnakeCase } from "@better-auth/core/utils/string"; import { initGetFieldName, initGetModelName } from "better-auth/adapters"; import type { BetterAuthDBSchema, DBFieldAttribute } from "better-auth/db"; import { getAuthTables } from "better-auth/db"; @@ -7,14 +8,7 @@ import prettier from "prettier"; import type { SchemaGenerator } from "./types"; function convertToSnakeCase(str: string, camelCase?: boolean) { - if (camelCase) { - return str; - } - // Handle consecutive capitals (like ID, URL, API) by treating them as a single word - return str - .replace(/([A-Z]+)([A-Z][a-z])/g, "$1_$2") // Handle AABb -> AA_Bb - .replace(/([a-z\d])([A-Z])/g, "$1_$2") // Handle aBb -> a_Bb - .toLowerCase(); + return camelCase ? str : toSnakeCase(str); } export const generateDrizzleSchema: SchemaGenerator = async ({ diff --git a/packages/core/src/utils/string.test.ts b/packages/core/src/utils/string.test.ts new file mode 100644 index 0000000000..0cfe627a1e --- /dev/null +++ b/packages/core/src/utils/string.test.ts @@ -0,0 +1,77 @@ +import { describe, expect, it } from "vitest"; +import { + capitalizeFirstLetter, + toCamelCase, + toKebabCase, + toPascalCase, + toSnakeCase, +} from "./string"; + +describe("capitalizeFirstLetter", () => { + it("uppercases the first character only", () => { + expect(capitalizeFirstLetter("hello")).toBe("Hello"); + expect(capitalizeFirstLetter("HELLO")).toBe("HELLO"); + expect(capitalizeFirstLetter("")).toBe(""); + }); +}); + +describe("toSnakeCase", () => { + it.each([ + ["userId", "user_id"], + ["user_id", "user_id"], + ["UserId", "user_id"], + ["USER_ID", "user_id"], + ["URL", "url"], + ["URLPath", "url_path"], + ["my-kebab-case", "my_kebab_case"], + ["foo123Bar", "foo123_bar"], + ["", ""], + ["it's a test", "its_a_test"], + ["한글Test", "한글_test"], + ["user_한글_id", "user_한글_id"], + ["caféBar", "café_bar"], + ])("%s -> %s", (input, expected) => { + expect(toSnakeCase(input)).toBe(expected); + }); +}); + +describe("toKebabCase", () => { + it.each([ + ["userId", "user-id"], + ["user_id", "user-id"], + ["UserId", "user-id"], + ["URLPath", "url-path"], + ["", ""], + ])("%s -> %s", (input, expected) => { + expect(toKebabCase(input)).toBe(expected); + }); +}); + +describe("toCamelCase", () => { + it.each([ + ["user_id", "userId"], + ["user-id", "userId"], + ["UserId", "userId"], + ["URL_PATH", "urlPATH"], + ["my-kebab-case", "myKebabCase"], + ["", ""], + ])("%s -> %s", (input, expected) => { + expect(toCamelCase(input)).toBe(expected); + }); +}); + +describe("toPascalCase", () => { + it.each([ + ["user_id", "UserId"], + ["user-id", "UserId"], + ["userId", "UserId"], + ["URL_PATH", "UrlPath"], + ["get", "Get"], + ["POST", "Post"], + ["my-kebab-case", "MyKebabCase"], + ["한글test", "한글Test"], + ["", ""], + ])("%s -> %s", (input, expected) => { + expect(toPascalCase(input)).toBe(expected); + }); +}); diff --git a/packages/core/src/utils/string.ts b/packages/core/src/utils/string.ts index d27229f6ae..9210389045 100644 --- a/packages/core/src/utils/string.ts +++ b/packages/core/src/utils/string.ts @@ -1,3 +1,40 @@ export function capitalizeFirstLetter(str: string) { return str.charAt(0).toUpperCase() + str.slice(1); } + +const WORD_PATTERN = + /[\p{Ll}\d]+|\p{Lu}+(?!\p{Ll})|\p{Lu}[\p{Ll}\d]+|\p{Lo}+/gu; +const APOSTROPHE_PATTERN = /['\u2019]/g; + +function splitWords(input: string): string[] { + return input.replace(APOSTROPHE_PATTERN, "").match(WORD_PATTERN) ?? []; +} + +export function toSnakeCase(input: string): string { + return splitWords(input) + .map((word) => word.toLowerCase()) + .join("_"); +} + +export function toKebabCase(input: string): string { + return splitWords(input) + .map((word) => word.toLowerCase()) + .join("-"); +} + +export function toCamelCase(input: string): string { + return splitWords(input).reduce((acc, word, i) => { + return ( + acc + + (i === 0 + ? word.toLowerCase() + : `${word[0]!.toUpperCase()}${word.slice(1)}`) + ); + }, ""); +} + +export function toPascalCase(input: string): string { + return splitWords(input) + .map((word) => `${word[0]!.toUpperCase()}${word.slice(1).toLowerCase()}`) + .join(""); +}