├── .editorconfig ├── .gitattributes ├── .github └── workflows │ └── main.yml ├── .gitignore ├── .npmrc ├── compare.js ├── index.d.ts ├── index.js ├── index.test-d.ts ├── license ├── package.json ├── readme.md └── test.js /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | indent_style = tab 5 | end_of_line = lf 6 | charset = utf-8 7 | trim_trailing_whitespace = true 8 | insert_final_newline = true 9 | 10 | [*.yml] 11 | indent_style = space 12 | indent_size = 2 13 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | * text=auto eol=lf 2 | -------------------------------------------------------------------------------- /.github/workflows/main.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 | fail-fast: false 11 | matrix: 12 | node-version: 13 | - 14 14 | - 12 15 | steps: 16 | - uses: actions/checkout@v2 17 | - uses: actions/setup-node@v2 18 | with: 19 | node-version: ${{ matrix.node-version }} 20 | - run: npm install 21 | - run: npm test 22 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | yarn.lock 3 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | package-lock=false 2 | -------------------------------------------------------------------------------- /compare.js: -------------------------------------------------------------------------------- 1 | const collator = new Intl.Collator(); 2 | 3 | // eslint-disable-next-line import/no-mutable-exports 4 | let baseCompare = (left, right) => left === right ? 0 : collator.compare(left, right); 5 | 6 | const brokenLocaleCompare = collator.compare('b', 'å') > -1; 7 | if (brokenLocaleCompare) { 8 | baseCompare = (left, right) => left > right ? 1 : (left < right ? -1 : 0); 9 | } 10 | 11 | function naturalCompare(left, right) { 12 | const naturalSplitRegex = /(\d+)/; // Parentheses are important. 13 | 14 | const leftChunks = left.split(naturalSplitRegex); 15 | const rightChunks = right.split(naturalSplitRegex); 16 | 17 | // If the first chunk doesn't match, the `natural` option is irrelevant. 18 | if (leftChunks[0] !== rightChunks[0]) { 19 | return baseCompare(left, right); 20 | } 21 | 22 | const maxValidIndex = Math.min(leftChunks.length, rightChunks.length) - 1; 23 | 24 | // Note that `maxValidIndex` is guaranteed to be even. 25 | for (let i = 1; i < maxValidIndex; i += 2) { 26 | // For odd indexes, values surely match `/^\d+$/`. 27 | const leftNumber = Number.parseInt(leftChunks[i], 10); 28 | const rightNumber = Number.parseInt(rightChunks[i], 10); 29 | 30 | if (leftNumber !== rightNumber) { 31 | return leftNumber - rightNumber; 32 | } 33 | 34 | // If we're here, the numbers were equal. 35 | 36 | // For even indexes, values surely don't match `/\d/`. 37 | // If they are not identical, the `natural` option becomes irrelevant. 38 | if (leftChunks[i + 1] !== rightChunks[i + 1]) { 39 | return baseCompare( 40 | leftChunks.slice(i + 1).join(''), 41 | rightChunks.slice(i + 1).join('') 42 | ); 43 | } 44 | } 45 | 46 | // If we're here, the comparison is fully tied with the `natural` option. 47 | return baseCompare(left, right); 48 | } 49 | 50 | export { 51 | baseCompare, 52 | naturalCompare 53 | }; 54 | -------------------------------------------------------------------------------- /index.d.ts: -------------------------------------------------------------------------------- 1 | export type StringComparator = (left: string, right: string) => number; 2 | 3 | export type Options = { 4 | /** 5 | Whether or not to sort in descending order. 6 | 7 | @default false 8 | */ 9 | readonly descending?: boolean; 10 | 11 | /** 12 | Whether or not to sort case-insensitively. 13 | 14 | Note: If two elements are considered equal in the case-insensitive comparison, the tie-break will be a standard (case-sensitive) comparison. 15 | 16 | @default false 17 | 18 | @example 19 | ``` 20 | import alphaSort from 'alpha-sort'; 21 | 22 | ['bar', 'baz', 'Baz'].sort(alphaSort({caseInsensitive: true})); 23 | //=> ['bar', 'Baz', 'baz'] 24 | ``` 25 | */ 26 | readonly caseInsensitive?: boolean; 27 | 28 | /** 29 | Whether or not to sort using natural sort order (such as sorting `10` after `2`). 30 | 31 | Note: If two elements are considered equal in the natural sort order comparison, the tie-break will be a standard (non-natural) comparison. 32 | 33 | @default false 34 | 35 | @example 36 | ``` 37 | import alphaSort from 'alpha-sort'; 38 | 39 | ['file10.txt', 'file05.txt', 'file0010.txt'].sort(alphaSort({natural: true})); 40 | //=> ['file05.txt', 'file0010.txt', 'file10.txt'] 41 | ``` 42 | */ 43 | readonly natural?: boolean; 44 | 45 | /** 46 | A custom function that you can provide to manipulate the elements before sorting. This does not modify the values of the array; it only interferes in the sorting order. 47 | 48 | This can be used, for example, if you are sorting book titles in English and want to ignore common articles such as `the`, `a` or `an`. 49 | 50 | Note: If two elements are considered equal when sorting with a custom preprocessor, the tie-break will be a comparison without the custom preprocessor. 51 | 52 | @default undefined 53 | 54 | @example 55 | ``` 56 | import alphaSort from 'alpha-sort'; 57 | 58 | ['The Foo', 'Bar'].sort(alphaSort({ 59 | preprocessor: title => title.replace(/^(?:the|a|an) /i, '') 60 | })); 61 | //=> ['Bar', 'The Foo'] 62 | ``` 63 | */ 64 | readonly preprocessor?: (string: string) => string; 65 | }; 66 | 67 | /** 68 | Get a comparator function to be used as argument for `Array#sort`. 69 | 70 | @param options - Choose ascending/descending, case sensitivity, and number natural ordering. 71 | 72 | @example 73 | ``` 74 | import alphaSort from 'alpha-sort'; 75 | 76 | ['b', 'a', 'c'].sort(alphaSort()); 77 | //=> ['a', 'b', 'c'] 78 | 79 | ['b', 'a', 'c'].sort(alphaSort({descending: true})); 80 | //=> ['c', 'b', 'a'] 81 | 82 | ['B', 'a', 'C'].sort(alphaSort({caseInsensitive: true})); 83 | //=> ['a', 'B', 'C'] 84 | 85 | ['file10.txt', 'file2.txt', 'file03.txt'].sort(alphaSort({natural: true})); 86 | //=> ['file2.txt', 'file03.txt', 'file10.txt'] 87 | ``` 88 | */ 89 | export default function alphaSort(options?: Options): StringComparator; 90 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | import {baseCompare, naturalCompare} from './compare.js'; 2 | 3 | export default function alphaSort(options = {}) { 4 | if (arguments.length === 2) { 5 | throw new Error('Invalid `alphaSort` call. Did you use `.sort(alphaSort)` instead of `.sort(alphaSort())` by mistake?'); 6 | } 7 | 8 | if (options.preprocessor && typeof options.preprocessor !== 'function') { 9 | throw new TypeError(`Preprocessor must be a function, got ${typeof options.preprocessor}`); 10 | } 11 | 12 | const ascendingCompare = options.natural ? naturalCompare : baseCompare; 13 | 14 | const compare = options.descending ? 15 | (left, right) => ascendingCompare(right, left) : 16 | ascendingCompare; 17 | 18 | const compareWith = (left, right, transform) => compare(transform(left), transform(right)); 19 | 20 | if (options.preprocessor && options.caseInsensitive) { 21 | return (left, right) => 22 | compareWith(left, right, value => options.preprocessor(value).toLowerCase()) || 23 | compareWith(left, right, value => options.preprocessor(value)) || 24 | compare(left, right); 25 | } 26 | 27 | if (options.preprocessor) { 28 | return (left, right) => 29 | compareWith(left, right, value => options.preprocessor(value)) || 30 | compare(left, right); 31 | } 32 | 33 | if (options.caseInsensitive) { 34 | return (left, right) => 35 | compareWith(left, right, value => value.toLowerCase()) || 36 | compare(left, right); 37 | } 38 | 39 | return compare; 40 | } 41 | -------------------------------------------------------------------------------- /index.test-d.ts: -------------------------------------------------------------------------------- 1 | import {expectType, expectAssignable} from 'tsd'; 2 | import alphaSort from './index.js'; 3 | 4 | declare const options: { 5 | descending?: boolean; 6 | natural?: boolean; 7 | caseInsensitive?: boolean; 8 | preprocessor?: (string: string) => string; 9 | } | undefined; 10 | 11 | expectAssignable[0]>(options); 12 | 13 | expectType(alphaSort()('a', 'b')); 14 | expectType(alphaSort({})('a', 'b')); 15 | expectType(alphaSort(options)('a', 'b')); 16 | 17 | ['b', 'a', 'c'].sort(alphaSort()); 18 | ['b', 'a', 'c'].sort(alphaSort({})); 19 | ['b', 'a', 'c'].sort(alphaSort(options)); 20 | -------------------------------------------------------------------------------- /license: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) Sindre Sorhus (https://sindresorhus.com) 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 6 | 7 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 8 | 9 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 10 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "alpha-sort", 3 | "version": "5.0.0", 4 | "description": "Alphabetically sort an array of strings", 5 | "license": "MIT", 6 | "repository": "sindresorhus/alpha-sort", 7 | "funding": "https://github.com/sponsors/sindresorhus", 8 | "author": { 9 | "name": "Sindre Sorhus", 10 | "email": "sindresorhus@gmail.com", 11 | "url": "https://sindresorhus.com" 12 | }, 13 | "type": "module", 14 | "exports": "./index.js", 15 | "engines": { 16 | "node": ">=12" 17 | }, 18 | "scripts": { 19 | "test": "xo && ava && tsd" 20 | }, 21 | "files": [ 22 | "index.js", 23 | "index.d.ts", 24 | "compare.js" 25 | ], 26 | "keywords": [ 27 | "alpha", 28 | "alphabet", 29 | "alphabetically", 30 | "lexicographically", 31 | "sort", 32 | "compare", 33 | "comparator", 34 | "order", 35 | "locale", 36 | "unicode", 37 | "string", 38 | "intl", 39 | "collator", 40 | "natural" 41 | ], 42 | "devDependencies": { 43 | "ava": "^3.15.0", 44 | "tsd": "^0.14.0", 45 | "xo": "^0.38.2" 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 | # alpha-sort 2 | 3 | > Alphabetically sort an array of strings 4 | 5 | With correct sorting of unicode characters. Supports [natural sort order](https://en.wikipedia.org/wiki/Natural_sort_order) with an option. 6 | 7 | ## Install 8 | 9 | ``` 10 | $ npm install alpha-sort 11 | ``` 12 | 13 | ## Usage 14 | 15 | ```js 16 | import alphaSort from 'alpha-sort'; 17 | 18 | ['b', 'a', 'c'].sort(alphaSort()); 19 | //=> ['a', 'b', 'c'] 20 | 21 | ['b', 'a', 'c'].sort(alphaSort({descending: true})); 22 | //=> ['c', 'b', 'a'] 23 | 24 | ['B', 'a', 'C'].sort(alphaSort({caseInsensitive: true})); 25 | //=> ['a', 'B', 'C'] 26 | 27 | ['file10.txt', 'file2.txt', 'file03.txt'].sort(alphaSort({natural: true})); 28 | //=> ['file2.txt', 'file03.txt', 'file10.txt'] 29 | ``` 30 | 31 | ## API 32 | 33 | ### alphaSort(options?) 34 | 35 | Get a comparator function to be used as argument for [`Array#sort`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/sort). 36 | 37 | #### options 38 | 39 | Type: `object` 40 | 41 | ##### descending 42 | 43 | Type: `boolean`\ 44 | Default: `false` 45 | 46 | Whether or not to sort in descending order. 47 | 48 | ##### caseInsensitive 49 | 50 | Type: `boolean`\ 51 | Default: `false` 52 | 53 | Whether or not to sort case-insensitively. 54 | 55 | Note: If two elements are considered equal in the case-insensitive comparison, the tie-break will be a standard (case-sensitive) comparison. Example: 56 | 57 | ```js 58 | import alphaSort from 'alpha-sort'; 59 | 60 | ['bar', 'baz', 'Baz'].sort(alphaSort({caseInsensitive: true})); 61 | //=> ['bar', 'Baz', 'baz'] 62 | ``` 63 | 64 | ##### natural 65 | 66 | Type: `boolean`\ 67 | Default: `false` 68 | 69 | Whether or not to sort using [natural sort order](https://en.wikipedia.org/wiki/Natural_sort_order) (such as sorting `10` after `2`). 70 | 71 | Note: If two elements are considered equal in the natural sort order comparison, the tie-break will be a standard (non-natural) comparison. Example: 72 | 73 | ```js 74 | import alphaSort from 'alpha-sort'; 75 | 76 | ['file10.txt', 'file05.txt', 'file0010.txt'].sort(alphaSort({natural: true})); 77 | //=> ['file05.txt', 'file0010.txt', 'file10.txt'] 78 | ``` 79 | 80 | ##### preprocessor 81 | 82 | Type: `function`\ 83 | Default: `undefined` 84 | 85 | A custom function that you can provide to manipulate the elements before sorting. This does not modify the values of the array; it only interferes in the sorting order. 86 | 87 | This can be used, for example, if you are sorting book titles in English and want to ignore common articles such as `the`, `a` or `an`: 88 | 89 | ```js 90 | import alphaSort from 'alpha-sort'; 91 | 92 | ['The Foo', 'Bar'].sort(alphaSort({ 93 | preprocessor: title => title.replace(/^(?:the|a|an) /i, '') 94 | })); 95 | //=> ['Bar', 'The Foo'] 96 | ``` 97 | 98 | Note: If two elements are considered equal when sorting with a custom preprocessor, the tie-break will be a comparison without the custom preprocessor. 99 | 100 | ## Related 101 | 102 | - [num-sort](https://github.com/sindresorhus/num-sort) - Sort an array of numbers 103 | -------------------------------------------------------------------------------- /test.js: -------------------------------------------------------------------------------- 1 | import test from 'ava'; 2 | import alphaSort from './index.js'; 3 | 4 | test('main', t => { 5 | t.is(typeof alphaSort(), 'function'); 6 | 7 | t.deepEqual(['b', 'a', 'c'].sort(alphaSort()), ['a', 'b', 'c']); 8 | t.deepEqual(['b', 'å', 'c'].sort(alphaSort()), ['b', 'c', 'å']); 9 | t.deepEqual(['b', '🦄', 'c'].sort(alphaSort()), ['b', 'c', '🦄']); 10 | 11 | t.deepEqual(['b', 'a', 'c'].sort(alphaSort({descending: true})), ['c', 'b', 'a']); 12 | t.deepEqual(['b', 'å', 'c'].sort(alphaSort({descending: true})), ['å', 'c', 'b']); 13 | t.deepEqual(['b', '🦄', 'c'].sort(alphaSort({descending: true})), ['🦄', 'c', 'b']); 14 | }); 15 | 16 | test('case insensitive', t => { 17 | t.deepEqual(['B', 'a', 'C'].sort(alphaSort({caseInsensitive: true})), ['a', 'B', 'C']); 18 | t.deepEqual(['B', 'a', 'C'].sort(alphaSort({descending: true, caseInsensitive: true})), ['C', 'B', 'a']); 19 | t.deepEqual(['bar', 'baz', 'Baz'].sort(alphaSort({caseInsensitive: true})), ['bar', 'Baz', 'baz']); 20 | 21 | t.deepEqual( 22 | ['C', 'åa', 'd', 'A', 'Åb', 'b', 'B', 'bar', 'Bar'].sort(alphaSort({caseInsensitive: true})), 23 | ['A', 'B', 'b', 'Bar', 'bar', 'C', 'd', 'åa', 'Åb'] 24 | ); 25 | t.deepEqual( 26 | ['C', 'åa', 'd', 'A', 'Åb', 'b', 'B', 'bar', 'Bar'].sort(alphaSort({descending: true, caseInsensitive: true})), 27 | ['Åb', 'åa', 'd', 'C', 'bar', 'Bar', 'b', 'B', 'A'] 28 | ); 29 | }); 30 | 31 | test('`natural` option', t => { 32 | const numbers = length => [...Array.from({length})].map((_, index) => String(index)); 33 | 34 | t.deepEqual( 35 | numbers(200).reverse().sort(alphaSort({natural: true})), 36 | numbers(200) 37 | ); 38 | 39 | t.deepEqual( 40 | ['file10.txt', 'file2.txt', 'file03.txt'].sort(alphaSort({natural: true})), 41 | ['file2.txt', 'file03.txt', 'file10.txt'] 42 | ); 43 | 44 | const alreadyInNaturalOrder = [ 45 | 'a', 'a0', 'a1x', 'a2', 'a03x4', 'a003x10', 'a03x10', 'a003y', 'a10', 'abc' 46 | ]; 47 | t.deepEqual( 48 | alreadyInNaturalOrder.slice().sort(alphaSort({descending: true, natural: true})), 49 | alreadyInNaturalOrder.slice().reverse() 50 | ); 51 | 52 | // Check tie-breaking 53 | t.deepEqual( 54 | ['file10.txt', 'file05.txt', 'file0010.txt'].sort(alphaSort({natural: true})), 55 | ['file05.txt', 'file0010.txt', 'file10.txt'] 56 | ); 57 | }); 58 | 59 | test('`preprocessor` option', t => { 60 | t.deepEqual( 61 | ['The Foo', 'Bar'].sort(alphaSort({ 62 | preprocessor: title => title.replace(/^(the|a|an) /i, '') 63 | })), 64 | ['Bar', 'The Foo'] 65 | ); 66 | 67 | const preprocessor = string => string.slice(1); // Strip first character 68 | 69 | t.deepEqual( 70 | ['a9', 'b5', 'z10a', 'c3', 'z10B'].sort(alphaSort({preprocessor})), 71 | ['z10B', 'z10a', 'c3', 'b5', 'a9'] 72 | ); 73 | 74 | t.deepEqual( 75 | ['a9', 'b5', 'z10a', 'c3', 'z10B'].sort(alphaSort({preprocessor, descending: true})), 76 | ['a9', 'b5', 'c3', 'z10a', 'z10B'] 77 | ); 78 | 79 | t.deepEqual( 80 | ['a9', 'b5', 'z10a', 'c3', 'z10B'].sort(alphaSort({preprocessor, natural: true})), 81 | ['c3', 'b5', 'a9', 'z10B', 'z10a'] 82 | ); 83 | 84 | t.deepEqual( 85 | ['a9', 'b5', 'z10a', 'c3', 'z10B'].sort(alphaSort({preprocessor, natural: true, caseInsensitive: true})), 86 | ['c3', 'b5', 'a9', 'z10a', 'z10B'] 87 | ); 88 | 89 | t.deepEqual( 90 | ['a9', 'b5', 'z10a', 'c3', 'z10B'].sort(alphaSort({ 91 | preprocessor, 92 | descending: true, 93 | natural: true, 94 | caseInsensitive: true 95 | })), 96 | ['z10B', 'z10a', 'a9', 'b5', 'c3'] 97 | ); 98 | }); 99 | 100 | test('helpful error on mistake', t => { 101 | t.throws( 102 | () => ['foo', 'bar'].sort(alphaSort), 103 | { 104 | message: 'Invalid `alphaSort` call. Did you use `.sort(alphaSort)` instead of `.sort(alphaSort())` by mistake?' 105 | } 106 | ); 107 | }); 108 | --------------------------------------------------------------------------------