├── .editorconfig ├── .github └── workflows │ └── ci.yml ├── .gitignore ├── LICENSE ├── README.md ├── SECURITY.md ├── package-lock.json ├── package.json ├── packages ├── change-case │ ├── README.md │ ├── package.json │ ├── src │ │ ├── index.spec.ts │ │ ├── index.ts │ │ ├── keys.spec.ts │ │ └── keys.ts │ └── tsconfig.json ├── sponge-case │ ├── README.md │ ├── package.json │ ├── src │ │ ├── index.spec.ts │ │ └── index.ts │ └── tsconfig.json ├── swap-case │ ├── README.md │ ├── package.json │ ├── src │ │ ├── index.spec.ts │ │ └── index.ts │ └── tsconfig.json └── title-case │ ├── README.md │ ├── package.json │ ├── src │ ├── index.spec.ts │ └── index.ts │ └── tsconfig.json └── tsconfig.base.json /.editorconfig: -------------------------------------------------------------------------------- 1 | # EditorConfig is awesome: http://EditorConfig.org 2 | 3 | root = true 4 | 5 | [*] 6 | indent_size = 2 7 | indent_style = space 8 | end_of_line = lf 9 | charset = utf-8 10 | trim_trailing_whitespace = true 11 | insert_final_newline = true 12 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | on: 3 | - push 4 | - pull_request 5 | jobs: 6 | test: 7 | name: Node.js ${{ matrix.node-version }} 8 | runs-on: ubuntu-latest 9 | strategy: 10 | matrix: 11 | node-version: 12 | - "18" 13 | - "*" 14 | steps: 15 | - uses: actions/checkout@v3 16 | - uses: actions/setup-node@v1 17 | with: 18 | node-version: ${{ matrix.node-version }} 19 | - name: Get npm cache directory 20 | id: npm-cache-dir 21 | shell: bash 22 | run: echo "dir=$(npm config get cache)" >> ${GITHUB_OUTPUT} 23 | - uses: actions/cache@v3 24 | id: npm-cache 25 | with: 26 | path: ${{ steps.npm-cache-dir.outputs.dir }} 27 | key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }} 28 | restore-keys: | 29 | ${{ runner.os }}-node- 30 | - run: npm ci --workspaces --include-workspace-root 31 | - run: npm test --workspaces 32 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | coverage/ 3 | .DS_Store 4 | npm-debug.log 5 | dist/ 6 | *.tsbuildinfo 7 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2014 Blake Embrey (hello@blakeembrey.com) 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Change Case Monorepo 2 | 3 | > Transform a string between `camelCase`, `PascalCase`, `Capital Case`, `snake_case`, `param-case`, `CONSTANT_CASE` and others. 4 | 5 | ## Packages 6 | 7 | - [change-case](https://github.com/blakeembrey/change-case/tree/master/packages/change-case) 8 | - [sponge-case](https://github.com/blakeembrey/change-case/tree/master/packages/sponge-case) 9 | - [swap-case](https://github.com/blakeembrey/change-case/tree/master/packages/swap-case) 10 | - [title-case](https://github.com/blakeembrey/change-case/tree/master/packages/title-case) 11 | 12 | ### TypeScript and ESM 13 | 14 | All packages are [pure ESM packages](https://gist.github.com/sindresorhus/a39789f98801d908bbc7ff3ecc99d99c) and ship with TypeScript definitions. They cannot be `require`'d or used with legacy `node` module resolution in TypeScript. 15 | 16 | ## Related 17 | 18 | - [Meteor](https://github.com/Konecty/change-case) 19 | - [Atom](https://github.com/robhurring/atom-change-case) 20 | - [VSCode](https://github.com/wmaurer/vscode-change-case) 21 | 22 | ## License 23 | 24 | MIT 25 | -------------------------------------------------------------------------------- /SECURITY.md: -------------------------------------------------------------------------------- 1 | # Security Policy 2 | 3 | ## Security contact information 4 | 5 | To report a security vulnerability, please use the [Tidelift security contact](https://tidelift.com/security). Tidelift will coordinate the fix and disclosure. 6 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "root", 3 | "private": true, 4 | "workspaces": [ 5 | "packages/*" 6 | ], 7 | "scripts": { 8 | "prepare": "ts-scripts install" 9 | }, 10 | "devDependencies": { 11 | "@borderless/ts-scripts": "^0.15.0", 12 | "@vitest/coverage-v8": "^3.0.9", 13 | "typescript": "^5.2.2", 14 | "vitest": "^3.0.9" 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /packages/change-case/README.md: -------------------------------------------------------------------------------- 1 | # Change Case 2 | 3 | > Transform a string between `camelCase`, `PascalCase`, `Capital Case`, `snake_case`, `kebab-case`, `CONSTANT_CASE` and others. 4 | 5 | ## Installation 6 | 7 | ``` 8 | npm install change-case --save 9 | ``` 10 | 11 | ## Usage 12 | 13 | ```js 14 | import * as changeCase from "change-case"; 15 | 16 | changeCase.camelCase("TEST_VALUE"); //=> "testValue" 17 | ``` 18 | 19 | ### Built-in methods 20 | 21 | | Method | Result | 22 | | ----------------- | ----------- | 23 | | `camelCase` | `twoWords` | 24 | | `capitalCase` | `Two Words` | 25 | | `constantCase` | `TWO_WORDS` | 26 | | `dotCase` | `two.words` | 27 | | `kebabCase` | `two-words` | 28 | | `noCase` | `two words` | 29 | | `pascalCase` | `TwoWords` | 30 | | `pascalSnakeCase` | `Two_Words` | 31 | | `pathCase` | `two/words` | 32 | | `sentenceCase` | `Two words` | 33 | | `snakeCase` | `two_words` | 34 | | `trainCase` | `Two-Words` | 35 | 36 | **Tip:** Change case assumes you are switching between different programming cases. It does not retain punctuation or other sensitivities. For example, `WOW! That's crazy.` would be `wowThatSCrazy` in camel case. If you're looking for a more generic title/sentence case library, consider trying [title-case](https://github.com/blakeembrey/change-case/blob/main/packages/title-case/README.md). 37 | 38 | All methods accept an `options` object as the second argument: 39 | 40 | - `delimiter?: string` The character to use between words. Default depends on method, e.g. `_` in snake case. 41 | - `locale?: string[] | string | false` Lower/upper according to specified locale, defaults to host environment. Set to `false` to disable. 42 | - `split?: (value: string) => string[]` A function to define how the input is split into words. Defaults to `split`. 43 | - `prefixCharacters?: string` Retain at the beginning of the string. Defaults to `""`. Example: use `"_"` to keep the underscores in `__typename`. 44 | - `suffixCharacters?: string` Retain at the end of the string. Defaults to `""`. Example: use `"_"` to keep the underscore in `type_`. 45 | 46 | By default, `pascalCase` and `snakeCase` separate ambiguous characters with `_`. For example, `V1.2` would become `V1_2` instead of `V12`. If you prefer them merged you can set `mergeAmbiguousCharacters` to `true`. 47 | 48 | ### Split 49 | 50 | **Change case** exports a `split` utility which can be used to build other case functions. It accepts a string and returns each "word" as an array. For example: 51 | 52 | ```js 53 | split("fooBar") 54 | .map((x) => x.toLowerCase()) 55 | .join("_"); //=> "foo_bar" 56 | ``` 57 | 58 | ## Change Case Keys 59 | 60 | ```js 61 | import * as changeKeys from "change-case/keys"; 62 | 63 | changeKeys.camelCase({ TEST_KEY: true }); //=> { testKey: true } 64 | ``` 65 | 66 | **Change case keys** wraps around the core methods to transform object keys to any case. 67 | 68 | ### API 69 | 70 | - **input: any** Any JavaScript value. 71 | - **depth: number** Specify the depth to transfer for case transformation. Defaults to `1`. 72 | - **options: object** Same as base case library. 73 | 74 | ## TypeScript and ESM 75 | 76 | This package is a [pure ESM package](https://gist.github.com/sindresorhus/a39789f98801d908bbc7ff3ecc99d99c) and ships with TypeScript definitions. It cannot be `require`'d or used with CommonJS module resolution in TypeScript. 77 | 78 | ## License 79 | 80 | MIT 81 | -------------------------------------------------------------------------------- /packages/change-case/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "change-case", 3 | "version": "5.4.4", 4 | "description": "Transform a string between `camelCase`, `PascalCase`, `Capital Case`, `snake_case`, `kebab-case`, `CONSTANT_CASE` and others", 5 | "keywords": [ 6 | "change", 7 | "case", 8 | "convert", 9 | "transform", 10 | "camel-case", 11 | "pascal-case", 12 | "param-case", 13 | "kebab-case", 14 | "header-case" 15 | ], 16 | "homepage": "https://github.com/blakeembrey/change-case/tree/master/packages/change-case#readme", 17 | "bugs": { 18 | "url": "https://github.com/blakeembrey/change-case/issues" 19 | }, 20 | "repository": { 21 | "type": "git", 22 | "url": "git://github.com/blakeembrey/change-case.git" 23 | }, 24 | "license": "MIT", 25 | "author": { 26 | "name": "Blake Embrey", 27 | "email": "hello@blakeembrey.com", 28 | "url": "http://blakeembrey.me" 29 | }, 30 | "type": "module", 31 | "exports": { 32 | ".": "./dist/index.js", 33 | "./keys": "./dist/keys.js" 34 | }, 35 | "main": "./dist/index.js", 36 | "types": "./dist/index.d.ts", 37 | "files": [ 38 | "dist/" 39 | ], 40 | "scripts": { 41 | "bench": "vitest bench", 42 | "build": "ts-scripts build", 43 | "format": "ts-scripts format", 44 | "prepublishOnly": "npm run build", 45 | "specs": "ts-scripts specs", 46 | "test": "ts-scripts test" 47 | }, 48 | "publishConfig": { 49 | "access": "public" 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /packages/change-case/src/index.spec.ts: -------------------------------------------------------------------------------- 1 | import { describe, it, expect } from "vitest"; 2 | import { 3 | camelCase, 4 | capitalCase, 5 | constantCase, 6 | dotCase, 7 | kebabCase, 8 | noCase, 9 | pascalCase, 10 | pathCase, 11 | sentenceCase, 12 | snakeCase, 13 | split, 14 | splitSeparateNumbers, 15 | trainCase, 16 | Options, 17 | } from "./index.js"; 18 | 19 | type Result = { 20 | camelCase: string; 21 | capitalCase: string; 22 | constantCase: string; 23 | dotCase: string; 24 | kebabCase: string; 25 | noCase: string; 26 | pascalCase: string; 27 | pascalSnakeCase: string; 28 | pathCase: string; 29 | sentenceCase: string; 30 | snakeCase: string; 31 | trainCase: string; 32 | }; 33 | 34 | const tests: [string, Result, Options?][] = [ 35 | [ 36 | "", 37 | { 38 | camelCase: "", 39 | capitalCase: "", 40 | constantCase: "", 41 | dotCase: "", 42 | kebabCase: "", 43 | noCase: "", 44 | pascalCase: "", 45 | pascalSnakeCase: "", 46 | pathCase: "", 47 | sentenceCase: "", 48 | snakeCase: "", 49 | trainCase: "", 50 | }, 51 | ], 52 | [ 53 | "test", 54 | { 55 | camelCase: "test", 56 | capitalCase: "Test", 57 | constantCase: "TEST", 58 | dotCase: "test", 59 | kebabCase: "test", 60 | noCase: "test", 61 | pascalCase: "Test", 62 | pascalSnakeCase: "Test", 63 | pathCase: "test", 64 | sentenceCase: "Test", 65 | snakeCase: "test", 66 | trainCase: "Test", 67 | }, 68 | ], 69 | [ 70 | "test string", 71 | { 72 | camelCase: "testString", 73 | capitalCase: "Test String", 74 | constantCase: "TEST_STRING", 75 | dotCase: "test.string", 76 | kebabCase: "test-string", 77 | noCase: "test string", 78 | pascalCase: "TestString", 79 | pascalSnakeCase: "Test_String", 80 | pathCase: "test/string", 81 | sentenceCase: "Test string", 82 | snakeCase: "test_string", 83 | trainCase: "Test-String", 84 | }, 85 | ], 86 | [ 87 | "Test String", 88 | { 89 | camelCase: "testString", 90 | capitalCase: "Test String", 91 | constantCase: "TEST_STRING", 92 | dotCase: "test.string", 93 | kebabCase: "test-string", 94 | noCase: "test string", 95 | pascalCase: "TestString", 96 | pascalSnakeCase: "Test_String", 97 | pathCase: "test/string", 98 | sentenceCase: "Test string", 99 | snakeCase: "test_string", 100 | trainCase: "Test-String", 101 | }, 102 | ], 103 | [ 104 | "Test String", 105 | { 106 | camelCase: "test$String", 107 | capitalCase: "Test$String", 108 | constantCase: "TEST$STRING", 109 | dotCase: "test$string", 110 | kebabCase: "test$string", 111 | noCase: "test$string", 112 | pascalCase: "Test$String", 113 | pascalSnakeCase: "Test$String", 114 | pathCase: "test$string", 115 | sentenceCase: "Test$string", 116 | snakeCase: "test$string", 117 | trainCase: "Test$String", 118 | }, 119 | { 120 | delimiter: "$", 121 | }, 122 | ], 123 | [ 124 | "TestV2", 125 | { 126 | camelCase: "testV2", 127 | capitalCase: "Test V2", 128 | constantCase: "TEST_V2", 129 | dotCase: "test.v2", 130 | kebabCase: "test-v2", 131 | noCase: "test v2", 132 | pascalCase: "TestV2", 133 | pascalSnakeCase: "Test_V2", 134 | pathCase: "test/v2", 135 | sentenceCase: "Test v2", 136 | snakeCase: "test_v2", 137 | trainCase: "Test-V2", 138 | }, 139 | ], 140 | [ 141 | "_foo_bar_", 142 | { 143 | camelCase: "fooBar", 144 | capitalCase: "Foo Bar", 145 | constantCase: "FOO_BAR", 146 | dotCase: "foo.bar", 147 | kebabCase: "foo-bar", 148 | noCase: "foo bar", 149 | pascalCase: "FooBar", 150 | pascalSnakeCase: "Foo_Bar", 151 | pathCase: "foo/bar", 152 | sentenceCase: "Foo bar", 153 | snakeCase: "foo_bar", 154 | trainCase: "Foo-Bar", 155 | }, 156 | ], 157 | [ 158 | "version 1.2.10", 159 | { 160 | camelCase: "version_1_2_10", 161 | capitalCase: "Version 1 2 10", 162 | constantCase: "VERSION_1_2_10", 163 | dotCase: "version.1.2.10", 164 | kebabCase: "version-1-2-10", 165 | noCase: "version 1 2 10", 166 | pascalCase: "Version_1_2_10", 167 | pascalSnakeCase: "Version_1_2_10", 168 | pathCase: "version/1/2/10", 169 | sentenceCase: "Version 1 2 10", 170 | snakeCase: "version_1_2_10", 171 | trainCase: "Version-1-2-10", 172 | }, 173 | ], 174 | [ 175 | "version 1.21.0", 176 | { 177 | camelCase: "version_1_21_0", 178 | capitalCase: "Version 1 21 0", 179 | constantCase: "VERSION_1_21_0", 180 | dotCase: "version.1.21.0", 181 | kebabCase: "version-1-21-0", 182 | noCase: "version 1 21 0", 183 | pascalCase: "Version_1_21_0", 184 | pascalSnakeCase: "Version_1_21_0", 185 | pathCase: "version/1/21/0", 186 | sentenceCase: "Version 1 21 0", 187 | snakeCase: "version_1_21_0", 188 | trainCase: "Version-1-21-0", 189 | }, 190 | ], 191 | [ 192 | "TestV2", 193 | { 194 | camelCase: "testV_2", 195 | capitalCase: "Test V 2", 196 | constantCase: "TEST_V_2", 197 | dotCase: "test.v.2", 198 | kebabCase: "test-v-2", 199 | noCase: "test v 2", 200 | pascalCase: "TestV_2", 201 | pascalSnakeCase: "Test_V_2", 202 | pathCase: "test/v/2", 203 | sentenceCase: "Test v 2", 204 | snakeCase: "test_v_2", 205 | trainCase: "Test-V-2", 206 | }, 207 | { 208 | separateNumbers: true, 209 | }, 210 | ], 211 | [ 212 | "𝒳123", 213 | { 214 | camelCase: "𝒳_123", 215 | capitalCase: "𝒳 123", 216 | constantCase: "𝒳_123", 217 | dotCase: "𝒳.123", 218 | kebabCase: "𝒳-123", 219 | noCase: "𝒳 123", 220 | pascalCase: "𝒳_123", 221 | pascalSnakeCase: "𝒳_123", 222 | pathCase: "𝒳/123", 223 | sentenceCase: "𝒳 123", 224 | snakeCase: "𝒳_123", 225 | trainCase: "𝒳-123", 226 | }, 227 | { 228 | separateNumbers: true, 229 | }, 230 | ], 231 | [ 232 | "1test", 233 | { 234 | camelCase: "1Test", 235 | capitalCase: "1 Test", 236 | constantCase: "1_TEST", 237 | dotCase: "1.test", 238 | kebabCase: "1-test", 239 | noCase: "1 test", 240 | pascalCase: "1Test", 241 | pascalSnakeCase: "1_Test", 242 | pathCase: "1/test", 243 | sentenceCase: "1 test", 244 | snakeCase: "1_test", 245 | trainCase: "1-Test", 246 | }, 247 | { separateNumbers: true }, 248 | ], 249 | [ 250 | "Foo12019Bar", 251 | { 252 | camelCase: "foo_12019Bar", 253 | capitalCase: "Foo 12019 Bar", 254 | constantCase: "FOO_12019_BAR", 255 | dotCase: "foo.12019.bar", 256 | kebabCase: "foo-12019-bar", 257 | noCase: "foo 12019 bar", 258 | pascalCase: "Foo_12019Bar", 259 | pascalSnakeCase: "Foo_12019_Bar", 260 | pathCase: "foo/12019/bar", 261 | sentenceCase: "Foo 12019 bar", 262 | snakeCase: "foo_12019_bar", 263 | trainCase: "Foo-12019-Bar", 264 | }, 265 | { separateNumbers: true }, 266 | ], 267 | [ 268 | "aNumber2in", 269 | { 270 | camelCase: "aNumber_2In", 271 | capitalCase: "A Number 2 In", 272 | constantCase: "A_NUMBER_2_IN", 273 | dotCase: "a.number.2.in", 274 | kebabCase: "a-number-2-in", 275 | noCase: "a number 2 in", 276 | pascalCase: "ANumber_2In", 277 | pascalSnakeCase: "ANumber_2_In", 278 | pathCase: "a/number/2/in", 279 | sentenceCase: "A number 2 in", 280 | snakeCase: "a_number_2_in", 281 | trainCase: "A-Number-2-In", 282 | }, 283 | { separateNumbers: true }, 284 | ], 285 | [ 286 | "V1Test", 287 | { 288 | camelCase: "v1Test", 289 | capitalCase: "V1 Test", 290 | constantCase: "V1_TEST", 291 | dotCase: "v1.test", 292 | kebabCase: "v1-test", 293 | noCase: "v1 test", 294 | pascalCase: "V1Test", 295 | pascalSnakeCase: "V1_Test", 296 | pathCase: "v1/test", 297 | sentenceCase: "V1 test", 298 | snakeCase: "v1_test", 299 | trainCase: "V1-Test", 300 | }, 301 | ], 302 | [ 303 | "V1Test with separateNumbers", 304 | { 305 | camelCase: "v_1TestWithSeparateNumbers", 306 | capitalCase: "V 1 Test With Separate Numbers", 307 | constantCase: "V_1_TEST_WITH_SEPARATE_NUMBERS", 308 | dotCase: "v.1.test.with.separate.numbers", 309 | kebabCase: "v-1-test-with-separate-numbers", 310 | noCase: "v 1 test with separate numbers", 311 | pascalCase: "V_1TestWithSeparateNumbers", 312 | pascalSnakeCase: "V_1_Test_With_Separate_Numbers", 313 | pathCase: "v/1/test/with/separate/numbers", 314 | sentenceCase: "V 1 test with separate numbers", 315 | snakeCase: "v_1_test_with_separate_numbers", 316 | trainCase: "V-1-Test-With-Separate-Numbers", 317 | }, 318 | { separateNumbers: true }, 319 | ], 320 | [ 321 | "__typename", 322 | { 323 | camelCase: "__typename", 324 | capitalCase: "__Typename", 325 | constantCase: "__TYPENAME", 326 | dotCase: "__typename", 327 | kebabCase: "__typename", 328 | noCase: "__typename", 329 | pascalCase: "__Typename", 330 | pascalSnakeCase: "__Typename", 331 | pathCase: "__typename", 332 | sentenceCase: "__Typename", 333 | snakeCase: "__typename", 334 | trainCase: "__Typename", 335 | }, 336 | { 337 | prefixCharacters: "_$", 338 | }, 339 | ], 340 | [ 341 | "type__", 342 | { 343 | camelCase: "type__", 344 | capitalCase: "Type__", 345 | constantCase: "TYPE__", 346 | dotCase: "type__", 347 | kebabCase: "type__", 348 | noCase: "type__", 349 | pascalCase: "Type__", 350 | pascalSnakeCase: "Type__", 351 | pathCase: "type__", 352 | sentenceCase: "Type__", 353 | snakeCase: "type__", 354 | trainCase: "Type__", 355 | }, 356 | { 357 | suffixCharacters: "_$", 358 | }, 359 | ], 360 | [ 361 | "__type__", 362 | { 363 | camelCase: "__type__", 364 | capitalCase: "__Type__", 365 | constantCase: "__TYPE__", 366 | dotCase: "__type__", 367 | kebabCase: "__type__", 368 | noCase: "__type__", 369 | pascalCase: "__Type__", 370 | pascalSnakeCase: "__Type__", 371 | pathCase: "__type__", 372 | sentenceCase: "__Type__", 373 | snakeCase: "__type__", 374 | trainCase: "__Type__", 375 | }, 376 | { 377 | prefixCharacters: "_", 378 | suffixCharacters: "_", 379 | }, 380 | ], 381 | ]; 382 | 383 | describe("change case", () => { 384 | for (const [input, result, options] of tests) { 385 | it(input, () => { 386 | expect(camelCase(input, options)).toEqual(result.camelCase); 387 | expect(capitalCase(input, options)).toEqual(result.capitalCase); 388 | expect(constantCase(input, options)).toEqual(result.constantCase); 389 | expect(dotCase(input, options)).toEqual(result.dotCase); 390 | expect(trainCase(input, options)).toEqual(result.trainCase); 391 | expect(kebabCase(input, options)).toEqual(result.kebabCase); 392 | expect(noCase(input, options)).toEqual(result.noCase); 393 | expect(pascalCase(input, options)).toEqual(result.pascalCase); 394 | expect(pathCase(input, options)).toEqual(result.pathCase); 395 | expect(sentenceCase(input, options)).toEqual(result.sentenceCase); 396 | expect(snakeCase(input, options)).toEqual(result.snakeCase); 397 | }); 398 | } 399 | 400 | describe("split", () => { 401 | it("should split an empty string", () => { 402 | expect(split("")).toEqual([]); 403 | }); 404 | }); 405 | 406 | describe("pascal case merge option", () => { 407 | it("should merge numbers", () => { 408 | const input = "version 1.2.10"; 409 | 410 | expect(camelCase(input, { mergeAmbiguousCharacters: true })).toEqual( 411 | "version1210", 412 | ); 413 | expect(pascalCase(input, { mergeAmbiguousCharacters: true })).toEqual( 414 | "Version1210", 415 | ); 416 | }); 417 | }); 418 | }); 419 | -------------------------------------------------------------------------------- /packages/change-case/src/index.ts: -------------------------------------------------------------------------------- 1 | // Regexps involved with splitting words in various case formats. 2 | const SPLIT_LOWER_UPPER_RE = /([\p{Ll}\d])(\p{Lu})/gu; 3 | const SPLIT_UPPER_UPPER_RE = /(\p{Lu})([\p{Lu}][\p{Ll}])/gu; 4 | 5 | // Used to iterate over the initial split result and separate numbers. 6 | const SPLIT_SEPARATE_NUMBER_RE = /(\d)\p{Ll}|(\p{L})\d/u; 7 | 8 | // Regexp involved with stripping non-word characters from the result. 9 | const DEFAULT_STRIP_REGEXP = /[^\p{L}\d]+/giu; 10 | 11 | // The replacement value for splits. 12 | const SPLIT_REPLACE_VALUE = "$1\0$2"; 13 | 14 | // The default characters to keep after transforming case. 15 | const DEFAULT_PREFIX_SUFFIX_CHARACTERS = ""; 16 | 17 | /** 18 | * Supported locale values. Use `false` to ignore locale. 19 | * Defaults to `undefined`, which uses the host environment. 20 | */ 21 | export type Locale = string[] | string | false | undefined; 22 | 23 | /** 24 | * Options used for converting strings to pascal/camel case. 25 | */ 26 | export interface PascalCaseOptions extends Options { 27 | mergeAmbiguousCharacters?: boolean; 28 | } 29 | 30 | /** 31 | * Options used for converting strings to any case. 32 | */ 33 | export interface Options { 34 | locale?: Locale; 35 | split?: (value: string) => string[]; 36 | /** @deprecated Pass `split: splitSeparateNumbers` instead. */ 37 | separateNumbers?: boolean; 38 | delimiter?: string; 39 | prefixCharacters?: string; 40 | suffixCharacters?: string; 41 | } 42 | 43 | /** 44 | * Split any cased input strings into an array of words. 45 | */ 46 | export function split(value: string) { 47 | let result = value.trim(); 48 | 49 | result = result 50 | .replace(SPLIT_LOWER_UPPER_RE, SPLIT_REPLACE_VALUE) 51 | .replace(SPLIT_UPPER_UPPER_RE, SPLIT_REPLACE_VALUE); 52 | 53 | result = result.replace(DEFAULT_STRIP_REGEXP, "\0"); 54 | 55 | let start = 0; 56 | let end = result.length; 57 | 58 | // Trim the delimiter from around the output string. 59 | while (result.charAt(start) === "\0") start++; 60 | if (start === end) return []; 61 | while (result.charAt(end - 1) === "\0") end--; 62 | 63 | return result.slice(start, end).split(/\0/g); 64 | } 65 | 66 | /** 67 | * Split the input string into an array of words, separating numbers. 68 | */ 69 | export function splitSeparateNumbers(value: string) { 70 | const words = split(value); 71 | for (let i = 0; i < words.length; i++) { 72 | const word = words[i]; 73 | const match = SPLIT_SEPARATE_NUMBER_RE.exec(word); 74 | if (match) { 75 | const offset = match.index + (match[1] ?? match[2]).length; 76 | words.splice(i, 1, word.slice(0, offset), word.slice(offset)); 77 | } 78 | } 79 | return words; 80 | } 81 | 82 | /** 83 | * Convert a string to space separated lower case (`foo bar`). 84 | */ 85 | export function noCase(input: string, options?: Options) { 86 | const [prefix, words, suffix] = splitPrefixSuffix(input, options); 87 | return ( 88 | prefix + 89 | words.map(lowerFactory(options?.locale)).join(options?.delimiter ?? " ") + 90 | suffix 91 | ); 92 | } 93 | 94 | /** 95 | * Convert a string to camel case (`fooBar`). 96 | */ 97 | export function camelCase(input: string, options?: PascalCaseOptions) { 98 | const [prefix, words, suffix] = splitPrefixSuffix(input, options); 99 | const lower = lowerFactory(options?.locale); 100 | const upper = upperFactory(options?.locale); 101 | const transform = options?.mergeAmbiguousCharacters 102 | ? capitalCaseTransformFactory(lower, upper) 103 | : pascalCaseTransformFactory(lower, upper); 104 | return ( 105 | prefix + 106 | words 107 | .map((word, index) => { 108 | if (index === 0) return lower(word); 109 | return transform(word, index); 110 | }) 111 | .join(options?.delimiter ?? "") + 112 | suffix 113 | ); 114 | } 115 | 116 | /** 117 | * Convert a string to pascal case (`FooBar`). 118 | */ 119 | export function pascalCase(input: string, options?: PascalCaseOptions) { 120 | const [prefix, words, suffix] = splitPrefixSuffix(input, options); 121 | const lower = lowerFactory(options?.locale); 122 | const upper = upperFactory(options?.locale); 123 | const transform = options?.mergeAmbiguousCharacters 124 | ? capitalCaseTransformFactory(lower, upper) 125 | : pascalCaseTransformFactory(lower, upper); 126 | return prefix + words.map(transform).join(options?.delimiter ?? "") + suffix; 127 | } 128 | 129 | /** 130 | * Convert a string to pascal snake case (`Foo_Bar`). 131 | */ 132 | export function pascalSnakeCase(input: string, options?: Options) { 133 | return capitalCase(input, { delimiter: "_", ...options }); 134 | } 135 | 136 | /** 137 | * Convert a string to capital case (`Foo Bar`). 138 | */ 139 | export function capitalCase(input: string, options?: Options) { 140 | const [prefix, words, suffix] = splitPrefixSuffix(input, options); 141 | const lower = lowerFactory(options?.locale); 142 | const upper = upperFactory(options?.locale); 143 | return ( 144 | prefix + 145 | words 146 | .map(capitalCaseTransformFactory(lower, upper)) 147 | .join(options?.delimiter ?? " ") + 148 | suffix 149 | ); 150 | } 151 | 152 | /** 153 | * Convert a string to constant case (`FOO_BAR`). 154 | */ 155 | export function constantCase(input: string, options?: Options) { 156 | const [prefix, words, suffix] = splitPrefixSuffix(input, options); 157 | return ( 158 | prefix + 159 | words.map(upperFactory(options?.locale)).join(options?.delimiter ?? "_") + 160 | suffix 161 | ); 162 | } 163 | 164 | /** 165 | * Convert a string to dot case (`foo.bar`). 166 | */ 167 | export function dotCase(input: string, options?: Options) { 168 | return noCase(input, { delimiter: ".", ...options }); 169 | } 170 | 171 | /** 172 | * Convert a string to kebab case (`foo-bar`). 173 | */ 174 | export function kebabCase(input: string, options?: Options) { 175 | return noCase(input, { delimiter: "-", ...options }); 176 | } 177 | 178 | /** 179 | * Convert a string to path case (`foo/bar`). 180 | */ 181 | export function pathCase(input: string, options?: Options) { 182 | return noCase(input, { delimiter: "/", ...options }); 183 | } 184 | 185 | /** 186 | * Convert a string to path case (`Foo bar`). 187 | */ 188 | export function sentenceCase(input: string, options?: Options) { 189 | const [prefix, words, suffix] = splitPrefixSuffix(input, options); 190 | const lower = lowerFactory(options?.locale); 191 | const upper = upperFactory(options?.locale); 192 | const transform = capitalCaseTransformFactory(lower, upper); 193 | return ( 194 | prefix + 195 | words 196 | .map((word, index) => { 197 | if (index === 0) return transform(word); 198 | return lower(word); 199 | }) 200 | .join(options?.delimiter ?? " ") + 201 | suffix 202 | ); 203 | } 204 | 205 | /** 206 | * Convert a string to snake case (`foo_bar`). 207 | */ 208 | export function snakeCase(input: string, options?: Options) { 209 | return noCase(input, { delimiter: "_", ...options }); 210 | } 211 | 212 | /** 213 | * Convert a string to header case (`Foo-Bar`). 214 | */ 215 | export function trainCase(input: string, options?: Options) { 216 | return capitalCase(input, { delimiter: "-", ...options }); 217 | } 218 | 219 | function lowerFactory(locale: Locale): (input: string) => string { 220 | return locale === false 221 | ? (input: string) => input.toLowerCase() 222 | : (input: string) => input.toLocaleLowerCase(locale); 223 | } 224 | 225 | function upperFactory(locale: Locale): (input: string) => string { 226 | return locale === false 227 | ? (input: string) => input.toUpperCase() 228 | : (input: string) => input.toLocaleUpperCase(locale); 229 | } 230 | 231 | function capitalCaseTransformFactory( 232 | lower: (input: string) => string, 233 | upper: (input: string) => string, 234 | ) { 235 | return (word: string) => `${upper(word[0])}${lower(word.slice(1))}`; 236 | } 237 | 238 | function pascalCaseTransformFactory( 239 | lower: (input: string) => string, 240 | upper: (input: string) => string, 241 | ) { 242 | return (word: string, index: number) => { 243 | const char0 = word[0]; 244 | const initial = 245 | index > 0 && char0 >= "0" && char0 <= "9" ? "_" + char0 : upper(char0); 246 | return initial + lower(word.slice(1)); 247 | }; 248 | } 249 | 250 | function splitPrefixSuffix( 251 | input: string, 252 | options: Options = {}, 253 | ): [string, string[], string] { 254 | const splitFn = 255 | options.split ?? (options.separateNumbers ? splitSeparateNumbers : split); 256 | const prefixCharacters = 257 | options.prefixCharacters ?? DEFAULT_PREFIX_SUFFIX_CHARACTERS; 258 | const suffixCharacters = 259 | options.suffixCharacters ?? DEFAULT_PREFIX_SUFFIX_CHARACTERS; 260 | let prefixIndex = 0; 261 | let suffixIndex = input.length; 262 | 263 | while (prefixIndex < input.length) { 264 | const char = input.charAt(prefixIndex); 265 | if (!prefixCharacters.includes(char)) break; 266 | prefixIndex++; 267 | } 268 | 269 | while (suffixIndex > prefixIndex) { 270 | const index = suffixIndex - 1; 271 | const char = input.charAt(index); 272 | if (!suffixCharacters.includes(char)) break; 273 | suffixIndex = index; 274 | } 275 | 276 | return [ 277 | input.slice(0, prefixIndex), 278 | splitFn(input.slice(prefixIndex, suffixIndex)), 279 | input.slice(suffixIndex), 280 | ]; 281 | } 282 | -------------------------------------------------------------------------------- /packages/change-case/src/keys.spec.ts: -------------------------------------------------------------------------------- 1 | import { describe, it, expect } from "vitest"; 2 | import { camelCase } from "./keys"; 3 | import * as changeCase from "./index.js"; 4 | 5 | type TestCase = [ 6 | unknown, 7 | number | undefined, 8 | unknown, 9 | (changeCase.Options | changeCase.PascalCaseOptions)?, 10 | ]; 11 | 12 | const TEST_CASES: TestCase[] = [ 13 | [ 14 | { 15 | first_name: "bob", 16 | last_name: "the builder", 17 | credentials: [{ built_things: true }], 18 | }, 19 | Infinity, 20 | { 21 | firstName: "bob", 22 | lastName: "the builder", 23 | credentials: [{ builtThings: true }], 24 | }, 25 | ], 26 | [ 27 | { 28 | first_name: "bob", 29 | price: 8.21, 30 | favoriteAnimals: ["red", "green", 3, null, 7], 31 | }, 32 | Infinity, 33 | { 34 | firstName: "bob", 35 | price: 8.21, 36 | favoriteAnimals: ["red", "green", 3, null, 7], 37 | }, 38 | ], 39 | [ 40 | { 41 | TEST_KEY: { 42 | FOO_BAR: true, 43 | }, 44 | }, 45 | undefined, 46 | { 47 | testKey: { 48 | FOO_BAR: true, 49 | }, 50 | }, 51 | ], 52 | [{ TEST: true }, 0, { TEST: true }], 53 | [null, 1, null], 54 | [ 55 | { 56 | outer_property_1_2: "outer", 57 | an_array: [{ inner_property_3_4: true }], 58 | }, 59 | Infinity, 60 | { 61 | outerProperty12: "outer", 62 | anArray: [{ innerProperty34: true }], 63 | }, 64 | { mergeAmbiguousCharacters: true }, 65 | ], 66 | ]; 67 | 68 | describe("change keys", () => { 69 | for (const [input, depth, result, options] of TEST_CASES) { 70 | it(`${input} -> ${result}`, () => { 71 | expect(camelCase(input, depth, options)).toEqual(result); 72 | }); 73 | } 74 | }); 75 | -------------------------------------------------------------------------------- /packages/change-case/src/keys.ts: -------------------------------------------------------------------------------- 1 | import * as changeCase from "./index.js"; 2 | 3 | const isObject = (object: unknown) => 4 | object !== null && typeof object === "object"; 5 | 6 | function changeKeysFactory< 7 | Options extends changeCase.Options = changeCase.Options, 8 | >( 9 | changeCase: (input: string, options?: changeCase.Options) => string, 10 | ): (object: unknown, depth?: number, options?: Options) => unknown { 11 | return function changeKeys( 12 | object: unknown, 13 | depth = 1, 14 | options?: Options, 15 | ): unknown { 16 | if (depth === 0 || !isObject(object)) return object; 17 | 18 | if (Array.isArray(object)) { 19 | return object.map((item) => changeKeys(item, depth - 1, options)); 20 | } 21 | 22 | const result: Record = Object.create( 23 | Object.getPrototypeOf(object), 24 | ); 25 | 26 | Object.keys(object as object).forEach((key) => { 27 | const value = (object as Record)[key]; 28 | const changedKey = changeCase(key, options); 29 | const changedValue = changeKeys(value, depth - 1, options); 30 | result[changedKey] = changedValue; 31 | }); 32 | 33 | return result; 34 | }; 35 | } 36 | 37 | export const camelCase = changeKeysFactory( 38 | changeCase.camelCase, 39 | ); 40 | export const capitalCase = changeKeysFactory(changeCase.capitalCase); 41 | export const constantCase = changeKeysFactory(changeCase.constantCase); 42 | export const dotCase = changeKeysFactory(changeCase.dotCase); 43 | export const trainCase = changeKeysFactory(changeCase.trainCase); 44 | export const noCase = changeKeysFactory(changeCase.noCase); 45 | export const kebabCase = changeKeysFactory(changeCase.kebabCase); 46 | export const pascalCase = changeKeysFactory( 47 | changeCase.pascalCase, 48 | ); 49 | export const pathCase = changeKeysFactory(changeCase.pathCase); 50 | export const sentenceCase = changeKeysFactory(changeCase.sentenceCase); 51 | export const snakeCase = changeKeysFactory(changeCase.snakeCase); 52 | -------------------------------------------------------------------------------- /packages/change-case/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.base.json", 3 | "compilerOptions": { 4 | "outDir": "dist", 5 | "rootDir": "src" 6 | }, 7 | "include": ["src/**/*"], 8 | "exclude": ["src/**/*.spec.*", "src/**/*.bench.*"] 9 | } 10 | -------------------------------------------------------------------------------- /packages/sponge-case/README.md: -------------------------------------------------------------------------------- 1 | # Sponge Case 2 | 3 | > Transform into a string with random capitalization applied. 4 | 5 | ## Installation 6 | 7 | ``` 8 | npm install sponge-case --save 9 | ``` 10 | 11 | ## Usage 12 | 13 | ```js 14 | import { spongeCase } from "sponge-case"; 15 | 16 | spongeCase("string"); //=> "sTrinG" 17 | spongeCase("dot.case"); //=> "dOt.caSE" 18 | spongeCase("PascalCase"); //=> "pASCaLCasE" 19 | spongeCase("version 1.2.10"); //=> "VErSIoN 1.2.10" 20 | ``` 21 | 22 | ## TypeScript and ESM 23 | 24 | This package is a [pure ESM package](https://gist.github.com/sindresorhus/a39789f98801d908bbc7ff3ecc99d99c) and ships with TypeScript definitions. It cannot be `require`'d or used with CommonJS module resolution in TypeScript. 25 | 26 | ## License 27 | 28 | MIT 29 | -------------------------------------------------------------------------------- /packages/sponge-case/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "sponge-case", 3 | "version": "2.0.3", 4 | "description": "Transform into a string with random capitalization applied", 5 | "keywords": [ 6 | "random", 7 | "randomize", 8 | "spongebob", 9 | "mocking", 10 | "capital", 11 | "case", 12 | "convert", 13 | "transform", 14 | "capitalize" 15 | ], 16 | "homepage": "https://github.com/blakeembrey/change-case/tree/master/packages/sponge-case#readme", 17 | "bugs": { 18 | "url": "https://github.com/blakeembrey/change-case/issues" 19 | }, 20 | "repository": { 21 | "type": "git", 22 | "url": "git://github.com/blakeembrey/change-case.git" 23 | }, 24 | "license": "MIT", 25 | "author": { 26 | "name": "Nate Rabins", 27 | "email": "nrabins@gmail.com", 28 | "url": "http://rabins.dev" 29 | }, 30 | "type": "module", 31 | "exports": { 32 | ".": "./dist/index.js" 33 | }, 34 | "main": "./dist/index.js", 35 | "types": "./dist/index.d.ts", 36 | "files": [ 37 | "dist/" 38 | ], 39 | "scripts": { 40 | "bench": "vitest bench", 41 | "build": "ts-scripts build", 42 | "format": "ts-scripts format", 43 | "prepublishOnly": "npm run build", 44 | "specs": "ts-scripts specs", 45 | "test": "ts-scripts test" 46 | }, 47 | "publishConfig": { 48 | "access": "public" 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /packages/sponge-case/src/index.spec.ts: -------------------------------------------------------------------------------- 1 | import { describe, it, expect } from "vitest"; 2 | import { spongeCase } from "./index.js"; 3 | 4 | /* Since strings are non-deterministic, we test string length to ensure integrity */ 5 | 6 | const TEST_CASES: [string, number][] = [ 7 | ["", 0], 8 | ["test", 4], 9 | ["test string", 11], 10 | ["Test String", 11], 11 | ["TestV2", 6], 12 | ["rAnDoM cAsE", 11], 13 | ]; 14 | 15 | describe("random case", () => { 16 | for (const [input, length] of TEST_CASES) { 17 | it(`${input} -> ${length}`, () => { 18 | expect(spongeCase(input)).toHaveLength(length); 19 | }); 20 | } 21 | }); 22 | -------------------------------------------------------------------------------- /packages/sponge-case/src/index.ts: -------------------------------------------------------------------------------- 1 | export function spongeCase(input: string, locale?: string[] | string): string { 2 | let result = ""; 3 | for (const char of input) { 4 | result += 5 | Math.random() > 0.5 6 | ? char.toLocaleUpperCase(locale) 7 | : char.toLocaleLowerCase(locale); 8 | } 9 | return result; 10 | } 11 | -------------------------------------------------------------------------------- /packages/sponge-case/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.base.json", 3 | "compilerOptions": { 4 | "outDir": "dist", 5 | "rootDir": "src" 6 | }, 7 | "include": ["src/**/*"], 8 | "exclude": ["src/**/*.spec.*", "src/**/*.bench.*"] 9 | } 10 | -------------------------------------------------------------------------------- /packages/swap-case/README.md: -------------------------------------------------------------------------------- 1 | # Swap Case 2 | 3 | > Transform a string by swapping every character from upper to lower case, or lower to upper case. 4 | 5 | ## Installation 6 | 7 | ``` 8 | npm install swap-case --save 9 | ``` 10 | 11 | ## Usage 12 | 13 | ```js 14 | import { swapCase } from "swap-case"; 15 | 16 | swapCase("string"); //=> "STRING" 17 | swapCase("dot.case"); //=> "DOT.CASE" 18 | swapCase("PascalCase"); //=> "pASCALcASE" 19 | ``` 20 | 21 | ## TypeScript and ESM 22 | 23 | This package is a [pure ESM package](https://gist.github.com/sindresorhus/a39789f98801d908bbc7ff3ecc99d99c) and ships with TypeScript definitions. It cannot be `require`'d or used with CommonJS module resolution in TypeScript. 24 | 25 | ## License 26 | 27 | MIT 28 | -------------------------------------------------------------------------------- /packages/swap-case/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "swap-case", 3 | "version": "3.0.3", 4 | "description": "Transform a string by swapping every character from upper to lower case, or lower to upper case", 5 | "keywords": [ 6 | "swap", 7 | "case", 8 | "invert", 9 | "convert", 10 | "transform", 11 | "lower", 12 | "upper" 13 | ], 14 | "homepage": "https://github.com/blakeembrey/change-case/tree/master/packages/swap-case#readme", 15 | "bugs": { 16 | "url": "https://github.com/blakeembrey/change-case/issues" 17 | }, 18 | "repository": { 19 | "type": "git", 20 | "url": "git://github.com/blakeembrey/change-case.git" 21 | }, 22 | "license": "MIT", 23 | "author": { 24 | "name": "Blake Embrey", 25 | "email": "hello@blakeembrey.com", 26 | "url": "http://blakeembrey.me" 27 | }, 28 | "type": "module", 29 | "exports": { 30 | ".": "./dist/index.js" 31 | }, 32 | "main": "./dist/index.js", 33 | "types": "./dist/index.d.ts", 34 | "files": [ 35 | "dist/" 36 | ], 37 | "scripts": { 38 | "bench": "vitest bench", 39 | "build": "ts-scripts build", 40 | "format": "ts-scripts format", 41 | "prepublishOnly": "npm run build", 42 | "specs": "ts-scripts specs", 43 | "test": "ts-scripts test" 44 | }, 45 | "publishConfig": { 46 | "access": "public" 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /packages/swap-case/src/index.spec.ts: -------------------------------------------------------------------------------- 1 | import { describe, it, expect } from "vitest"; 2 | import { swapCase } from "./index.js"; 3 | 4 | const TEST_CASES: [string, string][] = [ 5 | ["", ""], 6 | ["test", "TEST"], 7 | ["test string", "TEST STRING"], 8 | ["Test String", "tEST sTRING"], 9 | ["TestV2", "tESTv2"], 10 | ["sWaP cAsE", "SwAp CaSe"], 11 | ]; 12 | 13 | describe("swap case", () => { 14 | for (const [input, result] of TEST_CASES) { 15 | it(`${input} -> ${result}`, () => { 16 | expect(swapCase(input)).toEqual(result); 17 | }); 18 | } 19 | }); 20 | -------------------------------------------------------------------------------- /packages/swap-case/src/index.ts: -------------------------------------------------------------------------------- 1 | export function swapCase(input: string, locale?: string[] | string) { 2 | let result = ""; 3 | for (const char of input) { 4 | const lower = char.toLocaleLowerCase(locale); 5 | result += char === lower ? char.toLocaleUpperCase(locale) : lower; 6 | } 7 | return result; 8 | } 9 | -------------------------------------------------------------------------------- /packages/swap-case/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.base.json", 3 | "compilerOptions": { 4 | "outDir": "dist", 5 | "rootDir": "src" 6 | }, 7 | "include": ["src/**/*"], 8 | "exclude": ["src/**/*.spec.*", "src/**/*.bench.*"] 9 | } 10 | -------------------------------------------------------------------------------- /packages/title-case/README.md: -------------------------------------------------------------------------------- 1 | # Title Case 2 | 3 | > Transform a string into [title case](https://en.wikipedia.org/wiki/Letter_case#Title_case) following English rules. 4 | 5 | ## Installation 6 | 7 | ``` 8 | npm install title-case --save 9 | ``` 10 | 11 | ## Usage 12 | 13 | ```js 14 | import { titleCase } from "title-case"; 15 | 16 | titleCase("string"); //=> "String" 17 | titleCase("follow step-by-step instructions"); //=> "Follow Step-by-Step Instructions" 18 | ``` 19 | 20 | ### Options 21 | 22 | - `locale?: string | string[]` Locale used for `toLocaleUpperCase` during case transformation (default: `undefined`) 23 | - `sentenceCase?: boolean` Only capitalize the first word of each sentence (default: `false`) 24 | - `sentenceTerminators?: Set` Set of characters to consider a new sentence under sentence case behavior (e.g. `.`, default: `SENTENCE_TERMINATORS`) 25 | - `smallWords?: Set` Set of words to keep lower-case when `sentenceCase === false` (default: `SMALL_WORDS`) 26 | - `titleTerminators?: Set` Set of characters to consider a new sentence under title case behavior (e.g. `:`, default: `TITLE_TERMINATORS`) 27 | - `wordSeparators?: Set` Set of characters to consider a new word for capitalization, such as hyphenation (default: `WORD_SEPARATORS`) 28 | 29 | ## TypeScript and ESM 30 | 31 | This package is a [pure ESM package](https://gist.github.com/sindresorhus/a39789f98801d908bbc7ff3ecc99d99c) and ships with TypeScript definitions. It cannot be `require`'d or used with CommonJS module resolution in TypeScript. 32 | 33 | ## License 34 | 35 | MIT 36 | -------------------------------------------------------------------------------- /packages/title-case/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "title-case", 3 | "version": "4.3.2", 4 | "description": "Transform a string into title case following English rules", 5 | "keywords": [ 6 | "title", 7 | "case", 8 | "english", 9 | "capital", 10 | "sentence", 11 | "convert", 12 | "transform" 13 | ], 14 | "homepage": "https://github.com/blakeembrey/change-case/tree/master/packages/title-case#readme", 15 | "bugs": { 16 | "url": "https://github.com/blakeembrey/change-case/issues" 17 | }, 18 | "repository": { 19 | "type": "git", 20 | "url": "git://github.com/blakeembrey/change-case.git" 21 | }, 22 | "license": "MIT", 23 | "author": { 24 | "name": "Blake Embrey", 25 | "email": "hello@blakeembrey.com", 26 | "url": "http://blakeembrey.me" 27 | }, 28 | "type": "module", 29 | "exports": { 30 | ".": "./dist/index.js" 31 | }, 32 | "main": "./dist/index.js", 33 | "types": "./dist/index.d.ts", 34 | "files": [ 35 | "dist/" 36 | ], 37 | "scripts": { 38 | "bench": "vitest bench", 39 | "build": "ts-scripts build", 40 | "format": "ts-scripts format", 41 | "prepublishOnly": "npm run build", 42 | "specs": "ts-scripts specs", 43 | "test": "ts-scripts test" 44 | }, 45 | "publishConfig": { 46 | "access": "public" 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /packages/title-case/src/index.spec.ts: -------------------------------------------------------------------------------- 1 | import { describe, it, expect } from "vitest"; 2 | import { inspect } from "util"; 3 | import { titleCase, Options } from "./index.js"; 4 | 5 | /** 6 | * Original tests from https://github.com/gouch/to-title-case/blob/master/test/tests.json. 7 | */ 8 | const TEST_CASES: [string, string, Options?][] = [ 9 | ["one two", "One Two"], 10 | ["one two three", "One Two Three"], 11 | [ 12 | "Start a an and as at but by en for if in nor of on or per the to v vs via end", 13 | "Start a an and as at but by en for if in nor of on or per the to v vs via End", 14 | ], 15 | ["a small word starts", "A Small Word Starts"], 16 | ["small word ends on", "Small Word Ends On"], 17 | ["questions?", "Questions?"], 18 | ["Two questions?", "Two Questions?"], 19 | ["one sentence. two sentences.", "One Sentence. Two Sentences."], 20 | ["we keep NASA capitalized", "We Keep NASA Capitalized"], 21 | ["pass camelCase through", "Pass camelCase Through"], 22 | ["this sub-phrase is nice", "This Sub-Phrase Is Nice"], 23 | ["follow step-by-step instructions", "Follow Step-by-Step Instructions"], 24 | ["easy as one-two-three end", "Easy as One-Two-Three End"], 25 | ["start on-demand end", "Start On-Demand End"], 26 | ["start in-or-out end", "Start In-or-Out End"], 27 | ["start e-commerce end", "Start E-Commerce End"], 28 | ["start e-mail end", "Start E-Mail End"], 29 | ["your hair[cut] looks (nice)", "Your Hair[cut] Looks (Nice)"], 30 | ["keep that colo(u)r", "Keep that Colo(u)r"], 31 | ["leave Q&A unscathed", "Leave Q&A Unscathed"], 32 | [ 33 | "piña colada while you listen to ænima", 34 | "Piña Colada While You Listen to Ænima", 35 | ], 36 | ["start title – end title", "Start Title – End Title"], 37 | ["start title–end title", "Start Title–End Title"], 38 | ["start title — end title", "Start Title — End Title"], 39 | ["start title—end title", "Start Title—End Title"], 40 | ["start title - end title", "Start Title - End Title"], 41 | ["don't break", "Don't Break"], 42 | ['"double quotes"', '"Double Quotes"'], 43 | ['double quotes "inner" word', 'Double Quotes "Inner" Word'], 44 | ["fancy double quotes “inner” word", "Fancy Double Quotes “Inner” Word"], 45 | ["'single quotes'", "'Single Quotes'"], 46 | ["single quotes 'inner' word", "Single Quotes 'Inner' Word"], 47 | ["fancy single quotes ‘inner’ word", "Fancy Single Quotes ‘Inner’ Word"], 48 | ["“‘a twice quoted subtitle’”", "“‘A Twice Quoted Subtitle’”"], 49 | ["have you read “The Lottery”?", "Have You Read “The Lottery”?"], 50 | ["one: two", "One: Two"], 51 | ["one two: three four", "One Two: Three Four"], 52 | ['one two: "Three Four"', 'One Two: "Three Four"'], 53 | ["one on: an end", "One On: An End"], 54 | ['one on: "an end"', 'One On: "An End"'], 55 | ["email email@example.com address", "Email email@example.com Address"], 56 | [ 57 | "you have an https://example.com/ title", 58 | "You Have an https://example.com/ Title", 59 | ], 60 | ["_underscores around words_", "_Underscores Around Words_"], 61 | ["*asterisks around words*", "*Asterisks Around Words*"], 62 | ["this vs that", "This vs That"], 63 | ["this *vs* that", "This *vs* That"], 64 | ["this v that", "This v That"], 65 | // Contractions with a period are not supported due to sentence support. 66 | // It's difficult to tell if a period is part of a contraction or not. 67 | ["this vs. that", "This Vs. That"], 68 | ["this v. that", "This V. That"], 69 | ["", ""], 70 | [ 71 | "Scott Moritz and TheStreet.com’s million iPhone la-la land", 72 | "Scott Moritz and TheStreet.com’s Million iPhone La-La Land", 73 | ], 74 | [ 75 | "Notes and observations regarding Apple’s announcements from ‘The Beat Goes On’ special event", 76 | "Notes and Observations Regarding Apple’s Announcements From ‘The Beat Goes On’ Special Event", 77 | ], 78 | ["2018", "2018"], 79 | [ 80 | "the quick brown fox jumps over the lazy dog", 81 | "The Quick Brown Fox Jumps over the Lazy Dog", 82 | ], 83 | ["newcastle upon tyne", "Newcastle upon Tyne"], 84 | ["newcastle *upon* tyne", "Newcastle *upon* Tyne"], 85 | [ 86 | "Is human activity responsible for the climate emergency? New report calls it ‘unequivocal.’", 87 | "Is Human Activity Responsible for the Climate Emergency? New Report Calls It ‘Unequivocal.’", 88 | ], 89 | ["лев николаевич толстой", "Лев Николаевич Толстой"], 90 | ["Read foo-bar.com", "Read foo-bar.com"], 91 | ["cowboy bebop: the movie", "Cowboy Bebop: The Movie"], 92 | ["a thing. the thing. and more.", "A Thing. The Thing. And More."], 93 | ['"a quote." a test.', '"A Quote." A Test.'], 94 | ['"The U.N." a quote.', '"The U.N." A Quote.'], 95 | ['"The U.N.". a quote.', '"The U.N.". A Quote.'], 96 | ['"The U.N.". a quote.', '"The U.N.". A quote.', { sentenceCase: true }], 97 | ['"go without"', '"Go Without"'], 98 | ["the iPhone: a quote", "The iPhone: A Quote"], 99 | ["the iPhone: a quote", "The iPhone: a quote", { sentenceCase: true }], 100 | ["the U.N. and me", "The U.N. and Me"], 101 | ["the *U.N.* and me", "The *U.N.* and Me"], 102 | ["the U.N. and me", "The U.N. and me", { sentenceCase: true }], 103 | ["the U.N. and me", "The U.N. And Me", { smallWords: new Set() }], 104 | ["start-and-end", "Start-and-End"], 105 | ["go-to-iPhone", "Go-to-iPhone"], 106 | ["the go-to", "The Go-To"], 107 | ["the go-to", "The go-to", { sentenceCase: true }], 108 | ["this to-go", "This To-Go"], 109 | ["test(ing)", "Test(ing)"], 110 | ["test(s)", "Test(s)"], 111 | ["Keep #tag", "Keep #tag"], 112 | ['"Hello world", says John.', '"Hello World", Says John.'], 113 | [ 114 | '"Hello world", says John.', 115 | '"Hello world", says John.', 116 | { sentenceCase: true }, 117 | ], 118 | ["foo/bar", "Foo/Bar"], 119 | ["this is the *end.*", "This Is the *End.*"], 120 | ["*something about me?* and you.", "*Something About Me?* And You."], 121 | [ 122 | "*something about me?* and you.", 123 | "*Something about me?* And you.", 124 | { sentenceCase: true }, 125 | ], 126 | ["something about _me-too?_ and you.", "Something About _Me-Too?_ And You."], 127 | ["something about _me_? and you.", "Something About _Me_? And You."], 128 | [ 129 | "something about _me_? and you.", 130 | "Something about _me_? And you.", 131 | { sentenceCase: true }, 132 | ], 133 | [ 134 | "something about _me-too_? and you too.", 135 | "Something About _Me-Too_? And You Too.", 136 | ], 137 | ["an example. i.e. test.", "An Example. I.e. Test."], 138 | ["an example, i.e. test.", "An Example, I.e. Test."], 139 | ['an example. "i.e. test."', 'An Example. "I.e. Test."'], 140 | ["an example. i.e. test.", "An example. I.e. test.", { sentenceCase: true }], 141 | ["an example, i.e. test.", "An example, i.e. test.", { sentenceCase: true }], 142 | [ 143 | 'an example. "i.e. test."', 144 | 'An example. "I.e. test."', 145 | { sentenceCase: true }, 146 | ], 147 | ["friday the 13th", "Friday the 13th"], 148 | ["21st century", "21st Century"], 149 | ["foo\nbar", "Foo\nBar"], 150 | ["foo\nbar\nbaz", "Foo\nBar\nBaz"], 151 | ["friday\nthe 13th", "Friday\nThe 13th"], 152 | ]; 153 | 154 | describe("swap case", () => { 155 | for (const [input, result, options] of TEST_CASES) { 156 | it(`${inspect(input)} (${ 157 | options ? JSON.stringify(options) : "null" 158 | }) -> ${inspect(result)}`, () => { 159 | expect(titleCase(input, options)).toEqual(result); 160 | }); 161 | } 162 | }); 163 | -------------------------------------------------------------------------------- /packages/title-case/src/index.ts: -------------------------------------------------------------------------------- 1 | const TOKENS = /(\S+)|\s/g; 2 | const IS_SPECIAL_CASE = /[\.#][\p{L}\p{N}]/u; // #tag, example.com, etc. 3 | const IS_MANUAL_CASE = /\p{Ll}(?=[\p{Lu}])/u; // iPhone, iOS, etc. 4 | const ALPHANUMERIC_PATTERN = /[\p{L}\p{N}]+/gu; 5 | const IS_ACRONYM = /^([^\p{L}])*(?:\p{L}\.){2,}([^\p{L}])*$/u; 6 | 7 | export const WORD_SEPARATORS = new Set(["—", "–", "-", "―", "/"]); 8 | 9 | export const SENTENCE_TERMINATORS = new Set([".", "!", "?", "\n", "\r"]); 10 | 11 | export const TITLE_TERMINATORS = new Set([ 12 | ...SENTENCE_TERMINATORS, 13 | ":", 14 | '"', 15 | "'", 16 | "”", 17 | ]); 18 | 19 | export const SMALL_WORDS = new Set([ 20 | "a", 21 | "an", 22 | "and", 23 | "as", 24 | "at", 25 | "because", 26 | "but", 27 | "by", 28 | "en", 29 | "for", 30 | "if", 31 | "in", 32 | "neither", 33 | "nor", 34 | "of", 35 | "on", 36 | "only", 37 | "or", 38 | "over", 39 | "per", 40 | "so", 41 | "some", 42 | "than", 43 | "that", 44 | "the", 45 | "to", 46 | "up", 47 | "upon", 48 | "v", 49 | "versus", 50 | "via", 51 | "vs", 52 | "when", 53 | "with", 54 | "without", 55 | "yet", 56 | ]); 57 | 58 | export interface Options { 59 | locale?: string | string[]; 60 | sentenceCase?: boolean; 61 | sentenceTerminators?: Set; 62 | smallWords?: Set; 63 | titleTerminators?: Set; 64 | wordSeparators?: Set; 65 | } 66 | 67 | export function titleCase( 68 | input: string, 69 | options: Options | string[] | string = {}, 70 | ) { 71 | const { 72 | locale = undefined, 73 | sentenceCase = false, 74 | sentenceTerminators = SENTENCE_TERMINATORS, 75 | titleTerminators = TITLE_TERMINATORS, 76 | smallWords = SMALL_WORDS, 77 | wordSeparators = WORD_SEPARATORS, 78 | } = typeof options === "string" || Array.isArray(options) 79 | ? { locale: options } 80 | : options; 81 | 82 | const terminators = sentenceCase ? sentenceTerminators : titleTerminators; 83 | let result = ""; 84 | let isNewSentence = true; 85 | 86 | // tslint:disable-next-line 87 | for (const m of input.matchAll(TOKENS)) { 88 | const { 0: match, 1: token, index = 0 } = m; 89 | 90 | if (!token) { 91 | result += match; 92 | if (terminators.has(match)) isNewSentence = true; 93 | continue; 94 | } 95 | 96 | // Ignore URLs, email addresses, acronyms, etc. 97 | if (IS_SPECIAL_CASE.test(token)) { 98 | const acronym = token.match(IS_ACRONYM); 99 | 100 | // The period at the end of an acronym is not a new sentence, 101 | // but we should uppercase first for i.e., e.g., etc. 102 | if (acronym) { 103 | const [_, prefix = "", suffix = ""] = acronym; 104 | result += 105 | sentenceCase && !isNewSentence 106 | ? token 107 | : upperAt(token, prefix.length, locale); 108 | isNewSentence = terminators.has(suffix.charAt(0)); 109 | continue; 110 | } 111 | 112 | result += token; 113 | isNewSentence = terminators.has(token.charAt(token.length - 1)); 114 | } else { 115 | const matches = Array.from(token.matchAll(ALPHANUMERIC_PATTERN)); 116 | let value = token; 117 | let isSentenceEnd = false; 118 | 119 | for (let i = 0; i < matches.length; i++) { 120 | const { 0: word, index: wordIndex = 0 } = matches[i]; 121 | const nextChar = token.charAt(wordIndex + word.length); 122 | 123 | isSentenceEnd = terminators.has(nextChar); 124 | 125 | // Always the capitalize first word and reset "new sentence". 126 | if (isNewSentence) { 127 | isNewSentence = false; 128 | } 129 | // Skip capitalizing all words if sentence case is enabled. 130 | else if (sentenceCase || IS_MANUAL_CASE.test(word)) { 131 | continue; 132 | } 133 | // Handle simple words. 134 | else if (matches.length === 1) { 135 | // Avoid capitalizing small words, except at the end of a sentence. 136 | if (smallWords.has(word)) { 137 | const isFinalToken = index + token.length === input.length; 138 | 139 | if (!isFinalToken && !isSentenceEnd) { 140 | continue; 141 | } 142 | } 143 | } 144 | // Multi-word tokens need to be parsed differently. 145 | else if (i > 0) { 146 | // Avoid capitalizing words without a valid word separator, 147 | // e.g. "apple's" or "test(ing)". 148 | if (!wordSeparators.has(token.charAt(wordIndex - 1))) { 149 | continue; 150 | } 151 | 152 | // Ignore small words in the middle of hyphenated words. 153 | if (smallWords.has(word) && wordSeparators.has(nextChar)) { 154 | continue; 155 | } 156 | } 157 | 158 | value = upperAt(value, wordIndex, locale); 159 | } 160 | 161 | result += value; 162 | isNewSentence = 163 | isSentenceEnd || terminators.has(token.charAt(token.length - 1)); 164 | } 165 | } 166 | 167 | return result; 168 | } 169 | 170 | function upperAt( 171 | input: string, 172 | index: number, 173 | locale: string | string[] | undefined, 174 | ) { 175 | return ( 176 | input.slice(0, index) + 177 | input.charAt(index).toLocaleUpperCase(locale) + 178 | input.slice(index + 1) 179 | ); 180 | } 181 | -------------------------------------------------------------------------------- /packages/title-case/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.base.json", 3 | "compilerOptions": { 4 | "outDir": "dist", 5 | "rootDir": "src" 6 | }, 7 | "include": ["src/**/*"], 8 | "exclude": ["src/**/*.spec.*", "src/**/*.bench.*"] 9 | } 10 | -------------------------------------------------------------------------------- /tsconfig.base.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@borderless/ts-scripts/configs/tsconfig.json", 3 | "compilerOptions": { 4 | "composite": true 5 | } 6 | } 7 | --------------------------------------------------------------------------------