├── .editorconfig ├── .github └── workflows │ └── main.yml ├── .gitignore ├── .npmrc ├── .prettierignore ├── cli.js ├── funding.yml ├── index.js ├── license ├── package.json ├── readme.md ├── test.js └── tsconfig.json /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | indent_style = space 5 | indent_size = 2 6 | end_of_line = lf 7 | charset = utf-8 8 | trim_trailing_whitespace = true 9 | insert_final_newline = true 10 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: main 2 | on: 3 | - pull_request 4 | - push 5 | jobs: 6 | main: 7 | name: ${{matrix.node}} 8 | runs-on: ubuntu-latest 9 | steps: 10 | - uses: actions/checkout@v3 11 | - uses: actions/setup-node@v3 12 | with: 13 | node-version: ${{matrix.node}} 14 | - run: npm install 15 | - run: npm test 16 | - uses: codecov/codecov-action@v1 17 | strategy: 18 | matrix: 19 | node: 20 | - lts/hydrogen 21 | - node 22 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | *.d.ts 3 | *.log 4 | coverage/ 5 | node_modules/ 6 | yarn.lock 7 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | package-lock=false 2 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | coverage/ 2 | *.md 3 | -------------------------------------------------------------------------------- /cli.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | import {URL} from 'node:url' 3 | import fs from 'node:fs' 4 | import process from 'node:process' 5 | import {soundex} from './index.js' 6 | 7 | /** @type {import('type-fest').PackageJson} */ 8 | const pack = JSON.parse( 9 | String(fs.readFileSync(new URL('package.json', import.meta.url))) 10 | ) 11 | 12 | const argv = process.argv.slice(2) 13 | 14 | if (argv.includes('--help') || argv.includes('-h')) { 15 | console.log(help()) 16 | } else if (argv.includes('--version') || argv.includes('-v')) { 17 | console.log(pack.version) 18 | } else if (argv.length === 0) { 19 | process.stdin.resume() 20 | process.stdin.setEncoding('utf8') 21 | process.stdin.on('data', function (data) { 22 | console.log(phonetics(String(data))) 23 | }) 24 | } else { 25 | console.log(phonetics(argv.join(' '))) 26 | } 27 | 28 | /** @param {string} values */ 29 | function phonetics(values) { 30 | return values 31 | .split(/\s+/g) 32 | .map(function (value) { 33 | return soundex(value) 34 | }) 35 | .join(' ') 36 | } 37 | 38 | function help() { 39 | return [ 40 | '', 41 | ' Usage: ' + pack.name + ' [options] ', 42 | '', 43 | ' ' + pack.description, 44 | '', 45 | ' Options:', 46 | '', 47 | ' -h, --help output usage information', 48 | ' -v, --version output version number', 49 | '', 50 | ' Usage:', 51 | '', 52 | ' # output phonetics', 53 | ' $ ' + pack.name + ' phonetics unicorn', 54 | ' ' + phonetics('phonetics unicorn'), 55 | '', 56 | ' # output phonetics from stdin', 57 | ' $ echo "phonetics banana" | ' + pack.name, 58 | ' ' + phonetics('phonetics banana'), 59 | '' 60 | ].join('\n') 61 | } 62 | -------------------------------------------------------------------------------- /funding.yml: -------------------------------------------------------------------------------- 1 | github: wooorm 2 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | // Minimum length of Soundex keys. 2 | const minLength = 4 3 | 4 | // Soundex values belonging to characters. 5 | // This map also includes vowels (with a value of 0) to easily distinguish 6 | // between an unknown value or a vowel. 7 | /** @type {Record} */ 8 | const map = {} 9 | 10 | /* eslint-disable no-multi-assign */ 11 | map.a = map.e = map.i = map.o = map.u = map.y = 0 12 | map.b = map.f = map.p = map.v = 1 13 | map.c = map.g = map.j = map.k = map.q = map.s = map.x = map.z = 2 14 | map.d = map.t = 3 15 | map.l = 4 16 | map.m = map.n = 5 17 | map.r = 6 18 | /* eslint-enable no-multi-assign */ 19 | 20 | /** 21 | * Get the soundex key from a given value. 22 | * 23 | * @param {string} value 24 | * Value to use. 25 | * @param {number} [maxLength=4] 26 | * Create a code that is at most `maxLength` in size. 27 | * The minimum is always 4 (padded on the right). 28 | * @returns {string} 29 | * Soundex key for `value`. 30 | */ 31 | export function soundex(value, maxLength) { 32 | const lowercase = String(value).toLowerCase() 33 | /** @type {Array.} */ 34 | const results = [] 35 | let index = -1 36 | /** @type {number|undefined} */ 37 | let previous 38 | 39 | while (++index < lowercase.length) { 40 | const character = lowercase.charAt(index) 41 | /** @type {number|undefined} */ 42 | let phonetics = map[character] 43 | 44 | if (index === 0) { 45 | // Initial letter 46 | results.push(character.toUpperCase()) 47 | } else if (phonetics && phonetics !== previous) { 48 | // Phonetics value 49 | results.push(phonetics) 50 | } else if (phonetics === 0) { 51 | // Vowel 52 | phonetics = undefined 53 | } else { 54 | // Unknown character (including H and W) 55 | phonetics = previous 56 | } 57 | 58 | previous = phonetics 59 | } 60 | 61 | return pad(results.join('')).slice(0, maxLength || minLength) 62 | } 63 | 64 | /** 65 | * Pad a given value with zero characters. The function only pads four characters. 66 | * 67 | * @param {string} value 68 | * @returns {string} 69 | */ 70 | function pad(value) { 71 | const length = minLength - value.length 72 | let index = -1 73 | 74 | while (++index < length) { 75 | value += '0' 76 | } 77 | 78 | return value 79 | } 80 | -------------------------------------------------------------------------------- /license: -------------------------------------------------------------------------------- 1 | (The MIT License) 2 | 3 | Copyright (c) 2014 Titus Wormer 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining 6 | a copy of this software and associated documentation files (the 7 | 'Software'), to deal in the Software without restriction, including 8 | without limitation the rights to use, copy, modify, merge, publish, 9 | distribute, sublicense, and/or sell copies of the Software, and to 10 | permit persons to whom the Software is furnished to do so, subject to 11 | the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be 14 | included in all copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, 17 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 18 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. 19 | IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY 20 | CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, 21 | TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE 22 | SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 23 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "soundex-code", 3 | "version": "2.0.1", 4 | "description": "Soundex phonetic algorithm", 5 | "license": "MIT", 6 | "keywords": [ 7 | "natural", 8 | "language", 9 | "phonetics", 10 | "soundex", 11 | "cli", 12 | "bin" 13 | ], 14 | "homepage": "https://words.github.io/soundex-code/", 15 | "repository": "words/soundex-code", 16 | "funding": { 17 | "type": "github", 18 | "url": "https://github.com/sponsors/wooorm" 19 | }, 20 | "bugs": "https://github.com/words/soundex-code/issues", 21 | "author": "Titus Wormer (https://wooorm.com)", 22 | "contributors": [ 23 | "Titus Wormer (https://wooorm.com)" 24 | ], 25 | "sideEffects": false, 26 | "type": "module", 27 | "main": "index.js", 28 | "types": "index.d.ts", 29 | "bin": "cli.js", 30 | "files": [ 31 | "index.d.ts", 32 | "index.js", 33 | "cli.js" 34 | ], 35 | "dependencies": { 36 | "type-fest": "^3.0.0" 37 | }, 38 | "devDependencies": { 39 | "@types/node": "^18.0.0", 40 | "c8": "^7.0.0", 41 | "prettier": "^2.0.0", 42 | "remark-cli": "^11.0.0", 43 | "remark-preset-wooorm": "^9.0.0", 44 | "type-coverage": "^2.0.0", 45 | "typescript": "^4.0.0", 46 | "xo": "^0.52.0" 47 | }, 48 | "scripts": { 49 | "prepack": "npm run build && npm run format", 50 | "build": "tsc --build --clean && tsc --build && type-coverage", 51 | "format": "remark . -qfo && prettier . -w --loglevel warn && xo --fix", 52 | "test-api": "node --conditions development test.js", 53 | "test-coverage": "c8 --check-coverage --100 --reporter lcov npm run test-api", 54 | "test": "npm run build && npm run format && npm run test-coverage" 55 | }, 56 | "prettier": { 57 | "tabWidth": 2, 58 | "useTabs": false, 59 | "singleQuote": true, 60 | "bracketSpacing": false, 61 | "semi": false, 62 | "trailingComma": "none" 63 | }, 64 | "xo": { 65 | "prettier": true 66 | }, 67 | "remarkConfig": { 68 | "plugins": [ 69 | "preset-wooorm" 70 | ] 71 | }, 72 | "typeCoverage": { 73 | "atLeast": 100, 74 | "detail": true, 75 | "strict": true 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 | # soundex-code 2 | 3 | [![Build][build-badge]][build] 4 | [![Coverage][coverage-badge]][coverage] 5 | [![Downloads][downloads-badge]][downloads] 6 | [![Size][size-badge]][size] 7 | 8 | [Soundex][wiki] phonetic algorithm. 9 | 10 | ## Contents 11 | 12 | * [What is this?](#what-is-this) 13 | * [When should I use this?](#when-should-i-use-this) 14 | * [Install](#install) 15 | * [Use](#use) 16 | * [API](#api) 17 | * [`soundex(value[, maxLength])`](#soundexvalue-maxlength) 18 | * [CLI](#cli) 19 | * [Types](#types) 20 | * [Compatibility](#compatibility) 21 | * [Related](#related) 22 | * [Contribute](#contribute) 23 | * [Security](#security) 24 | * [License](#license) 25 | 26 | ## What is this? 27 | 28 | This package exposes a phonetic algorithm. 29 | That means it gets a certain string (typically a human name), and turns it into 30 | a code, which can then be compared to other codes (of other names), to check if 31 | they are (likely) pronounced the same. 32 | 33 | ## When should I use this? 34 | 35 | You’re probably dealing with natural language, and know you need this, if 36 | you’re here! 37 | 38 | Soundex is one of the earlier phonetics algorithms, specifically designed for 39 | surnames, inspiring others such as [`metaphone`][metaphone]. 40 | `metaphone` (and [`double-metaphone`][double-metaphone]) are better. 41 | 42 | Depending on your goals, you likely want to additionally use a stemmer (such as 43 | [`stemmer`][stemmer]). 44 | 45 | ## Install 46 | 47 | This package is [ESM only][esm]. 48 | In Node.js (version 14.14+, 16.0+), install with [npm][]: 49 | 50 | ```sh 51 | npm install soundex-code 52 | ``` 53 | 54 | In Deno with [`esm.sh`][esmsh]: 55 | 56 | ```js 57 | import {soundex} from 'https://esm.sh/soundex-code@2' 58 | ``` 59 | 60 | In browsers with [`esm.sh`][esmsh]: 61 | 62 | ```html 63 | 66 | ``` 67 | 68 | ## Use 69 | 70 | ```js 71 | import {soundex} from 'soundex-code' 72 | 73 | soundex('phonetics') // => 'P532' 74 | soundex('Ashcraft') // => 'A261' 75 | soundex('Lissajous') // => 'L222' 76 | soundex('Smith') === soundex('Schmit') // => true 77 | 78 | soundex('Ashcraftersson', 6) // => 'A26136' 79 | soundex('A', 6) // => 'A000' 80 | ``` 81 | 82 | ## API 83 | 84 | This package exports the identifier `soundex`. 85 | There is no default export. 86 | 87 | ### `soundex(value[, maxLength])` 88 | 89 | Get the soundex key from a given value. 90 | 91 | ###### `value` 92 | 93 | Value to use (`string`, required). 94 | 95 | ###### `maxLength` 96 | 97 | Create a code that is at most `maxLength` in size (`number`, default: `4`). 98 | The minimum is always 4 (padded on the right). 99 | 100 | ##### Returns 101 | 102 | Soundex key for `value` (`string`). 103 | 104 | ## CLI 105 | 106 | ```txt 107 | Usage: soundex-code [options] 108 | 109 | Soundex phonetic algorithm. 110 | 111 | Options: 112 | 113 | -h, --help output usage information 114 | -v, --version output version number 115 | 116 | Usage: 117 | 118 | # output phonetics 119 | $ soundex-code phonetics unicorn 120 | P532 U526 121 | 122 | # output phonetics from stdin 123 | $ echo "phonetics banana" | soundex-code 124 | P532 B550 125 | ``` 126 | 127 | ## Types 128 | 129 | This package is fully typed with [TypeScript][]. 130 | It exports no additional types. 131 | 132 | ## Compatibility 133 | 134 | This package is at least compatible with all maintained versions of Node.js. 135 | As of now, that is Node.js 14.14+ and 16.0+. 136 | It also works in Deno and modern browsers. 137 | 138 | ## Related 139 | 140 | * [`metaphone`][metaphone] 141 | — metaphone implementation 142 | * [`double-metaphone`][double-metaphone] 143 | — double metaphone implementation 144 | * [`stemmer`][stemmer] 145 | — porter stemmer algorithm 146 | * [`dice-coefficient`](https://github.com/words/dice-coefficient) 147 | — sørensen–dice coefficient 148 | * [`levenshtein-edit-distance`](https://github.com/words/levenshtein-edit-distance) 149 | — levenshtein edit distance 150 | * [`syllable`](https://github.com/words/syllable) 151 | — syllable count in an English word 152 | 153 | ## Contribute 154 | 155 | Yes please! 156 | See [How to Contribute to Open Source][contribute]. 157 | 158 | ## Security 159 | 160 | This package is safe. 161 | 162 | ## License 163 | 164 | [MIT][license] © [Titus Wormer][author] 165 | 166 | 167 | 168 | [build-badge]: https://github.com/words/soundex-code/workflows/main/badge.svg 169 | 170 | [build]: https://github.com/words/soundex-code/actions 171 | 172 | [coverage-badge]: https://img.shields.io/codecov/c/github/words/soundex-code.svg 173 | 174 | [coverage]: https://codecov.io/github/words/soundex-code 175 | 176 | [downloads-badge]: https://img.shields.io/npm/dm/soundex-code.svg 177 | 178 | [downloads]: https://www.npmjs.com/package/soundex-code 179 | 180 | [size-badge]: https://img.shields.io/bundlephobia/minzip/soundex-code.svg 181 | 182 | [size]: https://bundlephobia.com/result?p=soundex-code 183 | 184 | [npm]: https://www.npmjs.com 185 | 186 | [esm]: https://gist.github.com/sindresorhus/a39789f98801d908bbc7ff3ecc99d99c 187 | 188 | [esmsh]: https://esm.sh 189 | 190 | [typescript]: https://www.typescriptlang.org 191 | 192 | [contribute]: https://opensource.guide/how-to-contribute/ 193 | 194 | [license]: license 195 | 196 | [author]: https://wooorm.com 197 | 198 | [wiki]: https://en.wikipedia.org/wiki/Soundex 199 | 200 | [metaphone]: https://github.com/words/metaphone 201 | 202 | [double-metaphone]: https://github.com/words/double-metaphone 203 | 204 | [stemmer]: https://github.com/words/stemmer 205 | -------------------------------------------------------------------------------- /test.js: -------------------------------------------------------------------------------- 1 | import assert from 'node:assert' 2 | import util from 'node:util' 3 | import cp from 'node:child_process' 4 | import fs from 'node:fs' 5 | import {URL} from 'node:url' 6 | import {PassThrough} from 'node:stream' 7 | import test from 'node:test' 8 | import {soundex} from './index.js' 9 | 10 | const exec = util.promisify(cp.exec) 11 | 12 | /** @type {import('type-fest').PackageJson} */ 13 | const pack = JSON.parse( 14 | String(fs.readFileSync(new URL('package.json', import.meta.url))) 15 | ) 16 | 17 | const own = {}.hasOwnProperty 18 | 19 | test('api', async function (t) { 20 | assert.equal(soundex('PHONETICS'), soundex('phonetics'), 'case insensitive') 21 | assert.equal( 22 | soundex('PhoNeTicS'), 23 | soundex('phonetics'), 24 | 'case insensitive (2)' 25 | ) 26 | 27 | assert.equal(soundex('p'), 'P000', 'pad') 28 | assert.equal(soundex('pc'), 'P200', 'pad (2)') 29 | assert.equal(soundex('pcd'), 'P230', 'pad (3)') 30 | assert.equal(soundex('pcdl'), 'P234', 'pad (4)') 31 | assert.equal(soundex('pcdlm'), 'P234', 'pad (5)') 32 | assert.equal(soundex('pcdlmr'), 'P234', 'pad (6)') 33 | 34 | assert.equal(soundex('b', 2), 'B0', 'max-length argument (1)') 35 | assert.equal(soundex('bc', 2), 'B2', 'max-length argument (2)') 36 | assert.equal(soundex('bcd', 2), 'B2', 'max-length argument (3)') 37 | assert.equal(soundex('bcdl', 2), 'B2', 'max-length argument (4)') 38 | assert.equal(soundex('bcdlm', 2), 'B2', 'max-length argument (5)') 39 | assert.equal(soundex('bcdlmr', 2), 'B2', 'max-length argument (6)') 40 | 41 | assert.equal(soundex('b', 6), 'B000', 'max-length argument (7)') 42 | assert.equal(soundex('bc', 6), 'B200', 'max-length argument (8)') 43 | assert.equal(soundex('bcd', 6), 'B230', 'max-length argument (9)') 44 | assert.equal(soundex('bcdl', 6), 'B234', 'max-length argument (10)') 45 | assert.equal(soundex('bcdlm', 6), 'B2345', 'max-length argument (11)') 46 | assert.equal(soundex('bcdlmr', 6), 'B23456', 'max-length argument (12)') 47 | assert.equal(soundex('bcdlmrf', 6), 'B23456', 'max-length argument (13)') 48 | 49 | // Natural provides several unit tests. See: 50 | // 51 | await t.test('compatible with (Node) Natural', function () { 52 | run({ 53 | blackberry: 'B421', 54 | calculate: 'C424', 55 | fox: 'F200', 56 | jump: 'J510', 57 | phonetics: 'P532' 58 | }) 59 | }) 60 | 61 | // The PHP implementation, based on Knuths, gives several examples. See: 62 | // 63 | await t.test('compatible with (Node) Natural', function () { 64 | run({ 65 | Euler: 'E460', 66 | Gauss: 'G200', 67 | Hilbert: 'H416', 68 | Knuth: 'K530', 69 | Lloyd: 'L300', 70 | Lukasiewicz: 'L222', 71 | Ellery: 'E460', 72 | Ghosh: 'G200', 73 | Heilbronn: 'H416', 74 | Kant: 'K530', 75 | Ladd: 'L300', 76 | Lissajous: 'L222' 77 | }) 78 | }) 79 | 80 | // The original implementation gives several examples. See: 81 | // 82 | await t.test('compatible with (Node) Natural', function () { 83 | run({ 84 | Washington: 'W252', 85 | Lee: 'L000', 86 | Gutierrez: 'G362', 87 | Pfister: 'P236', 88 | Jackson: 'J250', 89 | Tymczak: 'T522', 90 | VanDeusen: 'V532', 91 | Deusen: 'D250', 92 | Ashcraft: 'A261' 93 | }) 94 | }) 95 | }) 96 | 97 | test('cli', async function () { 98 | assert.deepEqual( 99 | await exec('./cli.js considerations'), 100 | {stdout: 'C523\n', stderr: ''}, 101 | 'one' 102 | ) 103 | 104 | assert.deepEqual( 105 | await exec('./cli.js detestable vileness'), 106 | {stdout: 'D323 V452\n', stderr: ''}, 107 | 'two' 108 | ) 109 | 110 | await new Promise(function (resolve) { 111 | const input = new PassThrough() 112 | const subprocess = cp.exec('./cli.js', function (error, stdout, stderr) { 113 | assert.deepEqual( 114 | [error, stdout, stderr], 115 | [null, 'D323 V452\n', ''], 116 | 'stdin' 117 | ) 118 | }) 119 | assert(subprocess.stdin, 'expected stdin on `subprocess`') 120 | input.pipe(subprocess.stdin) 121 | input.write('detestable') 122 | setImmediate(function () { 123 | input.end(' vileness') 124 | setImmediate(resolve) 125 | }) 126 | }) 127 | 128 | const h = await exec('./cli.js -h') 129 | 130 | assert.ok(/\sUsage: soundex-code/.test(h.stdout), '-h') 131 | 132 | const help = await exec('./cli.js --help') 133 | 134 | assert.ok(/\sUsage: soundex-code/.test(help.stdout), '-h') 135 | 136 | assert.deepEqual( 137 | await exec('./cli.js -v'), 138 | {stdout: pack.version + '\n', stderr: ''}, 139 | '-v' 140 | ) 141 | 142 | assert.deepEqual( 143 | await exec('./cli.js --version'), 144 | {stdout: pack.version + '\n', stderr: ''}, 145 | '--version' 146 | ) 147 | }) 148 | 149 | /** 150 | * @param {Record} tests 151 | */ 152 | function run(tests) { 153 | let index = 0 154 | /** @type {string} */ 155 | let key 156 | 157 | for (key in tests) { 158 | if (own.call(tests, key)) { 159 | assert.equal(soundex(key), tests[key], String(++index)) 160 | } 161 | } 162 | } 163 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "include": ["**/**.js"], 3 | "exclude": ["coverage", "node_modules"], 4 | "compilerOptions": { 5 | "checkJs": true, 6 | "declaration": true, 7 | "emitDeclarationOnly": true, 8 | "exactOptionalPropertyTypes": true, 9 | "forceConsistentCasingInFileNames": true, 10 | "lib": ["es2020"], 11 | "module": "node16", 12 | "newLine": "lf", 13 | "skipLibCheck": true, 14 | "strict": true, 15 | "target": "es2020" 16 | } 17 | } 18 | --------------------------------------------------------------------------------