├── .eslintrc.json ├── .github └── workflows │ └── test.yml ├── .gitignore ├── .husky ├── pre-commit └── pre-push ├── .lintstagedrc.json ├── .npmignore ├── .npmrc ├── .prettierrc.json ├── LICENSE ├── README.md ├── jest.config.json ├── package-lock.json ├── package.json ├── rollup.config.ts ├── src ├── __tests__ │ ├── convert.ts │ ├── index.ts │ └── revert.ts ├── convert.ts ├── helpers.ts ├── index.ts ├── revert.ts └── transformers.ts └── tsconfig.json /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "browser": true, 4 | "es2021": true, 5 | "node": true 6 | }, 7 | 8 | "extends": ["standard-with-typescript", "plugin:prettier/recommended"], 9 | 10 | "ignorePatterns": ["dist/"], 11 | 12 | "overrides": [ 13 | { 14 | "files": ["**/*.ts"], 15 | "rules": { 16 | "@typescript-eslint/strict-boolean-expressions": "off" 17 | } 18 | }, 19 | { 20 | "extends": ["plugin:jest/recommended"], 21 | "files": ["**/__tests__/**"], 22 | "plugins": ["jest"] 23 | } 24 | ], 25 | 26 | "parserOptions": { 27 | "ecmaVersion": "latest", 28 | "project": ["tsconfig.json"], 29 | "sourceType": "module" 30 | }, 31 | 32 | "plugins": ["prettier"], 33 | 34 | "rules": { 35 | "prettier/prettier": "error" 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Test 2 | 3 | on: 4 | push: 5 | branches: [master] 6 | pull_request: 7 | branches: [master] 8 | 9 | jobs: 10 | build: 11 | runs-on: ubuntu-latest 12 | 13 | strategy: 14 | matrix: 15 | node-version: [lts/*] 16 | 17 | steps: 18 | - uses: actions/checkout@v2 19 | - name: Use Node.js ${{ matrix.node-version }} 20 | uses: actions/setup-node@v2 21 | with: 22 | node-version: ${{ matrix.node-version }} 23 | cache: 'npm' 24 | - run: npm ci 25 | - run: npm run lint 26 | - run: npm test 27 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /dist 2 | .DS_Store 3 | .eslintcache 4 | node_modules 5 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | . "$(dirname -- "$0")/_/husky.sh" 3 | 4 | npx lint-staged 5 | -------------------------------------------------------------------------------- /.husky/pre-push: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | . "$(dirname -- "$0")/_/husky.sh" 3 | 4 | npm run lint:tsc 5 | -------------------------------------------------------------------------------- /.lintstagedrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "**/*": "npm run format", 3 | "**/*.{cjs,cts,js,jsx,ts,tsx}": "npm run lint:eslint" 4 | } 5 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | **/* 2 | !dist/**/* 3 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | engine-strict=true 2 | -------------------------------------------------------------------------------- /.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "semi": false, 3 | "singleQuote": true 4 | } 5 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License 2 | 3 | Copyright (c) stldo 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of 6 | this software and associated documentation files (the "Software"), to deal in 7 | the Software without restriction, including without limitation the rights to 8 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of 9 | the Software, and to permit persons to whom the Software is furnished to do so, 10 | subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | 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, FITNESS 17 | FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR 18 | COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER 19 | IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN 20 | CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # url-slug [![Build status][1]][2] [![npm][3]][5] [![npm][4]][5] [![minzipped size][6]][7] 2 | 3 | - **Less than 1kB** minified and gzipped; 4 | - Uses default JavaScript APIs, **no dependencies**; 5 | - **SEO** friendly; 6 | - **RFC 3986** compliant, compatible with URL hosts, paths, queries and 7 | fragments; 8 | - Supports **custom dictionaries** to replace characters; 9 | - Easily **revert slugs**. 10 | 11 | ## Installation 12 | 13 | ```bash 14 | npm install url-slug 15 | ``` 16 | 17 | ## Usage 18 | 19 | ```javascript 20 | import urlSlug from 'url-slug' 21 | 22 | urlSlug('Sir James Paul McCartney MBE is an English singer-songwriter') 23 | // sir-james-paul-mc-cartney-mbe-is-an-english-singer-songwriter 24 | ``` 25 | 26 | ### Usage with Node.js 27 | 28 | > ⚠️ Only named exports are available in Node.js. 29 | 30 | ```javascript 31 | import { convert } from 'url-slug' 32 | 33 | urlSlug('Sir James Paul McCartney MBE is an English singer-songwriter') 34 | // sir-james-paul-mc-cartney-mbe-is-an-english-singer-songwriter 35 | ``` 36 | 37 | ```javascript 38 | const { convert } = require('url-slug') 39 | 40 | urlSlug('Sir James Paul McCartney MBE is an English singer-songwriter') 41 | // sir-james-paul-mc-cartney-mbe-is-an-english-singer-songwriter 42 | ``` 43 | 44 | ### urlSlug(value[, options]), convert(value[, options]) 45 | 46 | Returns `value` value converted to a slug. 47 | 48 | #### value 49 | 50 | A string to be slugified. 51 | 52 | #### options 53 | 54 | | Name | Description | Default | 55 | | ----------- | --------------------------------------------------------------------------------- | ----------------------- | 56 | | camelCase | Split on camel case occurrences | `true` | 57 | | dictionary | [Chars to be replaced][8] | `{}` | 58 | | separator | [Character or string][9] used to separate the slug fragments | `'-'` | 59 | | transformer | A built-in transformer or a custom function (`null` to keep the string unchanged) | `LOWERCASE_TRANSFORMER` | 60 | 61 | #### Examples 62 | 63 | ```javascript 64 | import * as urlSlug from 'url-slug' 65 | 66 | urlSlug.convert('Comfortably Numb', { 67 | transformer: urlSlug.UPPERCASE_TRANSFORMER, 68 | }) 69 | // COMFORTABLY-NUMB 70 | 71 | urlSlug.convert('á é í ó ú Á É Í Ó Ú ç Ç ª º ¹ ² ½ ¼', { 72 | separator: '_', 73 | transformer: false, 74 | }) 75 | // a_e_i_o_u_A_E_I_O_U_c_C_a_o_1_2_1_2_1_4 76 | 77 | urlSlug.convert('Red, red wine, stay close to me…', { 78 | separator: '', 79 | transformer: urlSlug.TITLECASE_TRANSFORMER, 80 | }) 81 | // RedRedWineStayCloseToMe 82 | 83 | urlSlug.convert('Schwarzweiß', { 84 | dictionary: { ß: 'ss', z: 'z ' }, 85 | }) 86 | // schwarz-weiss 87 | ``` 88 | 89 | ### revert(value[, options]) 90 | 91 | Returns the `value` value converted to a regular sentence. 92 | 93 | #### value 94 | 95 | A slug to be reverted to a sentence. 96 | 97 | #### options 98 | 99 | | Name | Description | Default | 100 | | ----------- | --------------------------------------------------------------------------------- | ------- | 101 | | camelCase | Split on camel case occurrences | `false` | 102 | | separator | [Character or string][9] to split the slug (`null` for automatic splitting) | `null` | 103 | | transformer | A built-in transformer or a custom function (`null` to keep the string unchanged) | `false` | 104 | 105 | #### Examples 106 | 107 | ```javascript 108 | import { revert, TITLECASE_TRANSFORMER } from 'url-slug' 109 | 110 | revert('Replace-every_separator.allowed~andSplitCamelCaseToo', { 111 | camelCase: true, 112 | }) 113 | // Replace every separator allowed and Split Camel Case Too 114 | 115 | revert('this-slug-needs-a-title_case', { 116 | separator: '-', 117 | transformer: TITLECASE_TRANSFORMER, 118 | }) 119 | // This Slug Needs A Title_case 120 | ``` 121 | 122 | ### Custom transformers 123 | 124 | Custom transformers are expressed by a function that receives two arguments: 125 | `fragments`, an array containing the words of a sentence or a slug, and 126 | `separator`, which is the separator string set in `convert()` options. When 127 | `revert()` calls a transformer, the `separator` argument will always be a space 128 | character (`' '`) — the `separator` option will be used to split the slug. 129 | Transformers should always return a string. 130 | 131 | #### Examples 132 | 133 | ```javascript 134 | import { convert, revert } from 'url-slug' 135 | 136 | convert('O’Neill is an American surfboard, surfwear and equipment brand', { 137 | transformer: (fragments) => fragments.join('x').toUpperCase(), 138 | }) 139 | // OxNEILLxISxANxAMERICANxSURFBOARDxSURFWEARxANDxEQUIPMENTxBRAND 140 | 141 | revert('WEIrd_SNAke_CAse', { 142 | separator: '_', 143 | transformer: (fragments, separator) => 144 | fragments 145 | .map( 146 | (fragment) => 147 | fragment.slice(0, -2).toLowerCase() + fragment.slice(-2).toUpperCase() 148 | ) 149 | .join(separator), 150 | }) 151 | // weiRD snaKE caSE 152 | ``` 153 | 154 | ### Built-in transformers 155 | 156 | #### LOWERCASE_TRANSFORMER 157 | 158 | Converts the result to lowercase. E.g.: `// SOME WORDS >> some words` 159 | 160 | #### SENTENCECASE_TRANSFORMER 161 | 162 | Converts the result to sentence case. E.g.: `// sOME WORDS >> Some words` 163 | 164 | #### UPPERCASE_TRANSFORMER 165 | 166 | Converts the result to uppercase. E.g.: `// some words >> SOME WORDS` 167 | 168 | #### TITLECASE_TRANSFORMER 169 | 170 | Converts the result to title case. E.g.: `// sOME wORDS >> Some Words` 171 | 172 | ### Separator characters 173 | 174 | Any character or an empty string can be used in the `separator` property. When 175 | the `separator` is an empty string, the `revert()` method will split the slug 176 | only on camel case occurrences if `camelCase` option is set to `true`, 177 | or else it returns an untouched string. The following characters are valid 178 | according to RFC 3986 — defined as _unreserved_ or _sub-delims_ —, and will be 179 | used in `revert()` function if automatic splitting is enabled — `separator` is 180 | set to `null`: 181 | 182 | `-`, `.`, `_`, `~`, `^`, `-`, `.`, `_`, `~`, `!`, `$`, `&`, `'`, `(`, `)`, `*`, 183 | `+`, `,`, `;` or `=` 184 | 185 | ### `dictionary` option 186 | 187 | It must be an object, with keys set as single characters and values as strings 188 | of any length: 189 | 190 | ```js 191 | import { convert } from 'url-slug' 192 | 193 | convert('♥øß', { 194 | dictionary: { 195 | '♥': 'love', 196 | ø: 'o', 197 | ß: 'ss', 198 | //... 199 | }, 200 | }) 201 | // loveoss 202 | ``` 203 | 204 | To add separators before or after a specific character, add a space before or 205 | after the dictionary definition: 206 | 207 | ```js 208 | import { convert } from 'url-slug' 209 | 210 | convert('♥øß', { 211 | dictionary: { 212 | '♥': 'love', 213 | ø: ' o', // A space was added before 214 | ß: 'ss', 215 | //... 216 | }, 217 | }) 218 | // love-oss 219 | 220 | convert('♥øß', { 221 | dictionary: { 222 | '♥': 'love', 223 | ø: ' o ', // A space was added before and after 224 | ß: 'ss', 225 | //... 226 | }, 227 | }) 228 | // love-o-ss 229 | 230 | convert('♥øß', { 231 | dictionary: { 232 | '♥': 'love', 233 | ø: 'o ', // A space was added after 234 | ß: 'ss', 235 | //... 236 | }, 237 | }) 238 | // loveo-ss 239 | ``` 240 | 241 | ### Compatibility 242 | 243 | Compatible with any environment with ES6 support. 244 | 245 | ## License 246 | 247 | [The MIT License][license] 248 | 249 | [1]: https://img.shields.io/github/actions/workflow/status/stldo/url-slug/test.yml?branch=master 250 | [2]: https://github.com/stldo/url-slug/actions/workflows/test.js.yml 251 | [3]: https://img.shields.io/npm/dm/url-slug 252 | [4]: https://img.shields.io/npm/v/url-slug 253 | [5]: https://www.npmjs.com/package/url-slug 254 | [6]: https://img.shields.io/bundlephobia/minzip/url-slug 255 | [7]: https://bundlephobia.com/package/url-slug 256 | [8]: #dictionary-option 257 | [9]: #separator-characters 258 | [license]: ./LICENSE 259 | -------------------------------------------------------------------------------- /jest.config.json: -------------------------------------------------------------------------------- 1 | { 2 | "preset": "ts-jest", 3 | "testEnvironment": "node", 4 | "transform": { 5 | "^.+\\.ts?$": "ts-jest" 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "url-slug", 3 | "version": "4.0.1", 4 | "description": "Slug generator with less than 1 KB and no dependencies, RFC 3986 compliant", 5 | "main": "dist/index.js", 6 | "module": "dist/index.mjs", 7 | "types": "dist/index.d.ts", 8 | "engines": { 9 | "node": ">=18.0.0" 10 | }, 11 | "scripts": { 12 | "build": "rollup --config rollup.config.ts --configPlugin @rollup/plugin-typescript", 13 | "format": "prettier --write --ignore-unknown --ignore-path .gitignore .", 14 | "lint": "npm run lint:tsc && npm run lint:eslint -- --ext cjs,cts,js,jsx,ts,tsx .", 15 | "lint:eslint": "eslint --cache --ignore-path .gitignore", 16 | "lint:tsc": "tsc --noEmit", 17 | "prepare": "husky install && npm run build", 18 | "preversion": "npm run lint && npm test", 19 | "postversion": "git push && git push --tags", 20 | "test": "jest" 21 | }, 22 | "devDependencies": { 23 | "@rollup/plugin-terser": "^0.4.3", 24 | "@rollup/plugin-typescript": "^11.1.1", 25 | "@types/jest": "^29.5.2", 26 | "@typescript-eslint/eslint-plugin": "^5.59.11", 27 | "eslint": "^8.43.0", 28 | "eslint-config-prettier": "^8.8.0", 29 | "eslint-config-standard-with-typescript": "^35.0.0", 30 | "eslint-plugin-import": "^2.27.5", 31 | "eslint-plugin-jest": "^27.2.1", 32 | "eslint-plugin-n": "^15.7.0", 33 | "eslint-plugin-prettier": "^4.2.1", 34 | "eslint-plugin-promise": "^6.1.1", 35 | "husky": "^8.0.3", 36 | "jest": "^29.5.0", 37 | "lint-staged": "^13.2.2", 38 | "prettier": "^2.8.8", 39 | "rollup": "^3.25.1", 40 | "rollup-plugin-dts": "^5.3.0", 41 | "ts-jest": "^29.1.0", 42 | "typescript": "^5.1.3" 43 | }, 44 | "repository": "https://github.com/stldo/url-slug", 45 | "keywords": [ 46 | "slug", 47 | "slugify", 48 | "url", 49 | "urlify", 50 | "url-safe", 51 | "rfc 3986", 52 | "string", 53 | "seo" 54 | ], 55 | "author": "stldo (https://github.com/stldo)", 56 | "license": "MIT" 57 | } 58 | -------------------------------------------------------------------------------- /rollup.config.ts: -------------------------------------------------------------------------------- 1 | import terser from '@rollup/plugin-terser' 2 | import typescript from '@rollup/plugin-typescript' 3 | import { type RollupOptions } from 'rollup' 4 | import dts from 'rollup-plugin-dts' 5 | 6 | import packageJson from './package.json' assert { type: 'json' } 7 | 8 | const BASE_UMD: RollupOptions['output'] = { 9 | exports: 'named', 10 | format: 'umd', 11 | name: 'urlSlug', 12 | } 13 | 14 | const config: RollupOptions[] = [ 15 | { 16 | input: 'src/index.ts', 17 | output: [{ file: packageJson.module, format: 'esm', sourcemap: true }], 18 | plugins: [typescript()], 19 | }, 20 | 21 | { 22 | input: 'src/index.ts', 23 | output: [ 24 | { file: packageJson.module.replace(/\.mjs$/, '.d.ts'), format: 'es' }, 25 | ], 26 | plugins: [dts()], 27 | }, 28 | 29 | { 30 | input: 'src/index.ts', 31 | output: [{ ...BASE_UMD, file: packageJson.main, sourcemap: true }], 32 | plugins: [typescript()], 33 | }, 34 | 35 | { 36 | input: 'src/index.ts', 37 | output: [ 38 | { ...BASE_UMD, file: packageJson.main.replace(/\.js$/, '.min.js') }, 39 | ], 40 | plugins: [terser(), typescript()], 41 | }, 42 | ] 43 | 44 | export default config 45 | -------------------------------------------------------------------------------- /src/__tests__/convert.ts: -------------------------------------------------------------------------------- 1 | import convert, { type ConvertOptions } from '../convert' 2 | import { UPPERCASE_TRANSFORMER, TITLECASE_TRANSFORMER } from '../transformers' 3 | 4 | test('casts input to string', () => { 5 | expect(convert(123 as any)).toBe('123') 6 | }) 7 | 8 | test('uses lowercase transformer and hyphen separator as default', () => { 9 | expect(convert('Url Slug')).toBe('url-slug') 10 | }) 11 | 12 | test('removes accents', () => { 13 | expect(convert('á é í ó ú ç áéíóúç')).toBe('a-e-i-o-u-c-aeiouc') 14 | }) 15 | 16 | test('uses uppercase transformer and the default separator', () => { 17 | const options = { transformer: UPPERCASE_TRANSFORMER } 18 | 19 | expect(convert('a bronx tale', options)).toBe('A-BRONX-TALE') 20 | }) 21 | 22 | test('uses uppercase transformer and underscore as separator', () => { 23 | const options = { 24 | separator: '_', 25 | transformer: TITLECASE_TRANSFORMER, 26 | } 27 | 28 | expect(convert('tom jobim', options)).toBe('Tom_Jobim') 29 | }) 30 | 31 | test('uses multiple characters as separator and maintains the case', () => { 32 | const options = { separator: '-._~-._~', transformer: null } 33 | 34 | expect(convert('Charly García', options)).toBe('Charly-._~-._~Garcia') 35 | }) 36 | 37 | test('returns a camel case slug', () => { 38 | const options = { 39 | separator: '', 40 | transformer: TITLECASE_TRANSFORMER, 41 | } 42 | 43 | expect(convert('java script', options)).toBe('JavaScript') 44 | }) 45 | 46 | test('splits a camel case string', () => { 47 | const options = { transformer: null } 48 | 49 | expect(convert('javaScript')).toBe('java-script') 50 | expect(convert('javaSCRIPT', options)).toBe('java-SCRIPT') 51 | expect(convert('JAVAScript', options)).toBe('JAVA-Script') 52 | expect(convert('jaVAScriPT', options)).toBe('ja-VA-Scri-PT') 53 | expect(convert('JaVaScriPt', options)).toBe('Ja-Va-Scri-Pt') 54 | expect(convert('JaVaScrIpT', options)).toBe('Ja-Va-Scr-Ip-T') 55 | }) 56 | 57 | test('does not split a camel case string', () => { 58 | expect(convert('javaScript', { camelCase: false })).toBe('javascript') 59 | }) 60 | 61 | test('returns only consonants', () => { 62 | const options: ConvertOptions = { 63 | separator: '', 64 | transformer: (fragments, separator) => 65 | fragments.join(separator).replace(/[aeiou]/gi, ''), 66 | } 67 | 68 | expect(convert('React', options)).toBe('Rct') 69 | }) 70 | 71 | test('handles empty strings', () => { 72 | expect(convert('')).toBe('') 73 | }) 74 | 75 | test('handles strings with no alphanumeric characters', () => { 76 | expect(convert('- ( ) [ ]')).toBe('') 77 | }) 78 | 79 | test('replaces characters set in dictionary', () => { 80 | const options: ConvertOptions = { 81 | dictionary: { 82 | '¼': 0.25 as any, 83 | '½': ' 1/2 ', 84 | ß: 'ss', 85 | Œ: 'OE', 86 | }, 87 | } 88 | 89 | expect(convert('aßcŒ', options)).toBe('assc-oe') 90 | expect(convert('¼½1', options)).toBe('0-25-1-2-1') 91 | }) 92 | -------------------------------------------------------------------------------- /src/__tests__/index.ts: -------------------------------------------------------------------------------- 1 | import * as urlSlug from '../index' 2 | 3 | test('sets the convert function as the default export', () => { 4 | expect(urlSlug.convert).toBe(urlSlug.default) 5 | }) 6 | 7 | test('has builtin transformers', () => { 8 | expect(typeof urlSlug.LOWERCASE_TRANSFORMER).toBe('function') 9 | expect(typeof urlSlug.SENTENCECASE_TRANSFORMER).toBe('function') 10 | expect(typeof urlSlug.TITLECASE_TRANSFORMER).toBe('function') 11 | expect(typeof urlSlug.UPPERCASE_TRANSFORMER).toBe('function') 12 | }) 13 | 14 | test('has a working lowercase transformer', () => { 15 | const fragments = ['AA', 'BB'] 16 | const separator = '-' 17 | const transformer = urlSlug.LOWERCASE_TRANSFORMER 18 | 19 | expect(transformer(fragments, separator)).toBe('aa-bb') 20 | }) 21 | 22 | test('has a working sentence case transformer', () => { 23 | const fragments = ['aA', 'BB'] 24 | const separator = '-' 25 | const transformer = urlSlug.SENTENCECASE_TRANSFORMER 26 | 27 | expect(transformer(fragments, separator)).toBe('Aa-bb') 28 | }) 29 | 30 | test('has a working tittle case transformer', () => { 31 | const fragments = ['aA', 'bB'] 32 | const separator = '-' 33 | const transformer = urlSlug.TITLECASE_TRANSFORMER 34 | 35 | expect(transformer(fragments, separator)).toBe('Aa-Bb') 36 | }) 37 | 38 | test('has a working uppercase transformer', () => { 39 | const fragments = ['aa', 'bb'] 40 | const separator = '-' 41 | const transformer = urlSlug.UPPERCASE_TRANSFORMER 42 | 43 | expect(transformer(fragments, separator)).toBe('AA-BB') 44 | }) 45 | -------------------------------------------------------------------------------- /src/__tests__/revert.ts: -------------------------------------------------------------------------------- 1 | import revert from '../revert' 2 | import { UPPERCASE_TRANSFORMER, TITLECASE_TRANSFORMER } from '../transformers' 3 | 4 | it('casts input to string', () => { 5 | expect(revert(123 as any)).toBe('123') 6 | }) 7 | 8 | it('uses unknown reversion and does not change input case', () => { 9 | expect(revert('UrlSlug-_url.~slug')).toBe('UrlSlug url slug') 10 | }) 11 | 12 | it('splits a camel case slug', () => { 13 | const options = { camelCase: true } 14 | 15 | expect(revert('javaScript', options)).toBe('java Script') 16 | expect(revert('javaSCRIPT', options)).toBe('java SCRIPT') 17 | expect(revert('JAVAScript', options)).toBe('JAVA Script') 18 | expect(revert('jaVAScriPT', options)).toBe('ja VA Scri PT') 19 | expect(revert('JaVaScriPt', options)).toBe('Ja Va Scri Pt') 20 | expect(revert('JaVaScrIpT', options)).toBe('Ja Va Scr Ip T') 21 | }) 22 | 23 | it('does not split a camel case slug', () => { 24 | expect(revert('javaScript-language')).toBe('javaScript language') 25 | }) 26 | 27 | it('splits on camel case and convert input to upper case', () => { 28 | const options = { 29 | camelCase: true, 30 | separator: '', 31 | transformer: UPPERCASE_TRANSFORMER, 32 | } 33 | 34 | expect(revert('ClaudioBaglioni_is-NOT-German', options)).toBe( 35 | 'CLAUDIO BAGLIONI_IS-NOT-GERMAN' 36 | ) 37 | }) 38 | 39 | it('returns the title of a Pink Floyd track', () => { 40 | const options = { 41 | separator: "-._~!$&'()*+,;=", 42 | transformer: TITLECASE_TRANSFORMER, 43 | } 44 | 45 | expect(revert("comfortably-._~!$&'()*+,;=numb", options)).toBe( 46 | 'Comfortably Numb' 47 | ) 48 | }) 49 | 50 | it('reverts an empty string to another empty string', () => { 51 | expect(revert('')).toBe('') 52 | }) 53 | -------------------------------------------------------------------------------- /src/convert.ts: -------------------------------------------------------------------------------- 1 | import { CAMELCASE_REGEXP_PATTERN, type Dictionary, replace } from './helpers' 2 | import { LOWERCASE_TRANSFORMER, type Transformer } from './transformers' 3 | 4 | // eslint-disable-next-line no-misleading-character-class 5 | const COMBINING_CHARS = /[\u0300-\u036F\u1AB0-\u1AFF\u1DC0-\u1DFF]+/g 6 | 7 | const CONVERT = /[A-Za-z\d]+/g 8 | 9 | const CONVERT_CAMELCASE = new RegExp( 10 | '[A-Za-z\\d]*?' + CAMELCASE_REGEXP_PATTERN + '|[A-Za-z\\d]+', 11 | 'g' 12 | ) 13 | 14 | export interface ConvertOptions { 15 | camelCase?: boolean 16 | dictionary?: Dictionary 17 | separator?: string 18 | transformer?: Transformer | null 19 | } 20 | 21 | export default function convert( 22 | value: string, 23 | { 24 | camelCase = true, 25 | dictionary, 26 | separator = '-', 27 | transformer = LOWERCASE_TRANSFORMER, 28 | }: ConvertOptions = {} 29 | ): string { 30 | const fragments = ( 31 | dictionary ? replace(String(value), dictionary) : String(value) 32 | ) 33 | .normalize('NFKD') 34 | .replace(COMBINING_CHARS, '') 35 | .match(camelCase ? CONVERT_CAMELCASE : CONVERT) 36 | 37 | if (!fragments) { 38 | return '' 39 | } 40 | 41 | return transformer 42 | ? transformer(fragments, String(separator)) 43 | : fragments.join(String(separator)) 44 | } 45 | -------------------------------------------------------------------------------- /src/helpers.ts: -------------------------------------------------------------------------------- 1 | export type Dictionary = Record 2 | 3 | export const CAMELCASE_REGEXP_PATTERN = '(?:[a-z](?=[A-Z])|[A-Z](?=[A-Z][a-z]))' 4 | 5 | export function replace(value: string, dictionary: Dictionary): string { 6 | for (let index = 0, length = value.length; index < length; index++) { 7 | const char = value[index] 8 | const replacement = dictionary[char] && String(dictionary[char]) 9 | 10 | if (replacement !== undefined) { 11 | value = value.slice(0, index) + replacement + value.slice(index + 1) 12 | 13 | const addedCharsCount = replacement.length - 1 14 | 15 | index += addedCharsCount 16 | length += addedCharsCount 17 | } 18 | } 19 | 20 | return value 21 | } 22 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export { default, default as convert } from './convert' 2 | export { default as revert } from './revert' 3 | export * from './transformers' 4 | -------------------------------------------------------------------------------- /src/revert.ts: -------------------------------------------------------------------------------- 1 | import { CAMELCASE_REGEXP_PATTERN } from './helpers' 2 | import { type Transformer } from './transformers' 3 | 4 | const REVERT = /[^-._~!$&'()*+,;=]+/g 5 | 6 | const REVERT_CAMELCASE = new RegExp( 7 | "[^-._~!$&'()*+,;=]*?" + CAMELCASE_REGEXP_PATTERN + "|[^-._~!$&'()*+,;=]+", 8 | 'g' 9 | ) 10 | 11 | const REVERT_CAMELCASE_ONLY = new RegExp( 12 | '.*?' + CAMELCASE_REGEXP_PATTERN + '|.+', 13 | 'g' 14 | ) 15 | 16 | export interface RevertOptions { 17 | camelCase?: boolean 18 | separator?: string | null 19 | transformer?: Transformer | null 20 | } 21 | 22 | export default function revert( 23 | value: string, 24 | { 25 | camelCase = false, 26 | separator = null, 27 | transformer = null, 28 | }: RevertOptions = {} 29 | ): string { 30 | let fragments 31 | 32 | value = String(value) 33 | 34 | /* Determine which method will be used to split the slug */ 35 | 36 | if (separator === '') { 37 | fragments = camelCase ? value.match(REVERT_CAMELCASE_ONLY) : [value] 38 | } else if (typeof separator === 'string') { 39 | fragments = value.split(separator) 40 | } else { 41 | fragments = value.match(camelCase ? REVERT_CAMELCASE : REVERT) 42 | } 43 | 44 | if (!fragments) { 45 | return '' 46 | } 47 | 48 | return transformer ? transformer(fragments, ' ') : fragments.join(' ') 49 | } 50 | -------------------------------------------------------------------------------- /src/transformers.ts: -------------------------------------------------------------------------------- 1 | export type Transformer = (fragments: string[], separator: string) => string 2 | 3 | export const LOWERCASE_TRANSFORMER: Transformer = (fragments, separator) => { 4 | return fragments.join(separator).toLowerCase() 5 | } 6 | 7 | export const SENTENCECASE_TRANSFORMER: Transformer = (fragments, separator) => { 8 | const sentence = fragments.join(separator) 9 | 10 | return sentence.charAt(0).toUpperCase() + sentence.slice(1).toLowerCase() 11 | } 12 | 13 | export const TITLECASE_TRANSFORMER: Transformer = (fragments, separator) => { 14 | const buffer = [] 15 | 16 | for (let index = 0; index < fragments.length; index++) { 17 | buffer.push( 18 | fragments[index].charAt(0).toUpperCase() + 19 | fragments[index].slice(1).toLowerCase() 20 | ) 21 | } 22 | 23 | return buffer.join(separator) 24 | } 25 | 26 | export const UPPERCASE_TRANSFORMER: Transformer = (fragments, separator) => { 27 | return fragments.join(separator).toUpperCase() 28 | } 29 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES6", 4 | "module": "ESNext", 5 | "moduleResolution": "node", 6 | "resolveJsonModule": true, 7 | "esModuleInterop": true, 8 | "forceConsistentCasingInFileNames": true, 9 | "strict": true, 10 | "skipLibCheck": true 11 | } 12 | } 13 | --------------------------------------------------------------------------------