├── .eslintignore ├── .eslintrc.json ├── .github └── workflows │ ├── backup.yml │ └── build.yml ├── .gitignore ├── .node-version ├── .npmrc ├── .prettierrc ├── license ├── logo.png ├── package-lock.json ├── package.json ├── readme.md ├── sh ├── build.ts └── coverage.ts ├── src ├── bug-reports.test.ts ├── cjs │ └── index.cjs ├── formats │ ├── ordinal.test.ts │ ├── ordinal.ts │ ├── percentage.test.ts │ └── percentage.ts ├── helpers │ ├── convert-to-number.test.ts │ ├── convert-to-number.ts │ ├── digits-format.test.ts │ ├── digits-format.ts │ ├── fraction-digits.test.ts │ ├── fraction-digits.ts │ ├── from-parts.test.ts │ ├── from-parts.ts │ ├── integer-digits-on-format.ts │ ├── regex.test.ts │ ├── regex.ts │ ├── resolved-options.test.ts │ ├── resolved-options.ts │ ├── value.test.ts │ └── value.ts ├── index.test.ts ├── index.ts ├── numberfmt.d.ts ├── options │ ├── compact-display.test.ts │ ├── compact-display.ts │ ├── currency-display.test.ts │ ├── currency-display.ts │ ├── currency-sign.test.ts │ ├── currency-sign.ts │ ├── currency.test.ts │ ├── currency.ts │ ├── maximum-fraction-digits.test.ts │ ├── maximum-fraction-digits.ts │ ├── minimum-fraction-digits.test.ts │ ├── minimum-fraction-digits.ts │ ├── minimum-integer-digits.test.ts │ ├── minimum-integer-digits.ts │ ├── notation.test.ts │ ├── notation.ts │ ├── sign-display.test.ts │ ├── sign-display.ts │ ├── style.test.ts │ ├── style.ts │ ├── unit-display.test.ts │ ├── unit-display.ts │ ├── unit.test.ts │ └── unit.ts └── tests │ ├── compact.test.ts │ ├── compound.test.ts │ ├── currency.test.ts │ ├── digital.test.ts │ ├── exponential.test.ts │ ├── length.test.ts │ ├── mass.test.ts │ ├── numeric.test.ts │ ├── ordinal.test.ts │ ├── partial-application.test.ts │ ├── percentage.test.ts │ └── sign.test.ts ├── tsconfig.build.json ├── tsconfig.json └── vite.config.mts /.eslintignore: -------------------------------------------------------------------------------- 1 | __data__ 2 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": [ 3 | "airbnb-base", 4 | "airbnb-typescript/base", 5 | "prettier", 6 | "plugin:@typescript-eslint/recommended", 7 | "plugin:import/typescript" 8 | ], 9 | "parser": "@typescript-eslint/parser", 10 | "plugins": [ 11 | "@typescript-eslint" 12 | ], 13 | "parserOptions": { 14 | "ecmaVersion": 9, 15 | "project": "./tsconfig.json" 16 | }, 17 | "env": { 18 | "node": true 19 | }, 20 | "rules": { 21 | "@typescript-eslint/comma-dangle": "off", 22 | "import/prefer-default-export": "off" 23 | }, 24 | "overrides": [ 25 | { 26 | "files": [ 27 | "**/*.ts" 28 | ], 29 | "rules": { 30 | "no-undef": "off" 31 | } 32 | } 33 | ] 34 | } -------------------------------------------------------------------------------- /.github/workflows/backup.yml: -------------------------------------------------------------------------------- 1 | name: backup 2 | 3 | on: [push, delete] 4 | 5 | jobs: 6 | backup: 7 | runs-on: ubuntu-latest 8 | steps: 9 | - uses: actions/checkout@main 10 | with: 11 | fetch-depth: "0" 12 | - uses: ruicsh/backup-action@main 13 | with: 14 | bitbucket_app_user: ${{ secrets.BACKUP_APP_USER }} 15 | bitbucket_app_password: ${{ secrets.BACKUP_APP_PASSWORD }} 16 | target_repo: tuplo/numberfmt 17 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: build 2 | on: 3 | push: 4 | branches: 5 | - main 6 | 7 | jobs: 8 | test: 9 | runs-on: ubuntu-latest 10 | strategy: 11 | matrix: 12 | node-version: [16.x, 18.x, 20.x] 13 | steps: 14 | - uses: actions/checkout@main 15 | with: 16 | fetch-depth: "0" 17 | - uses: actions/setup-node@main 18 | with: 19 | node-version: 20 20 | - uses: actions/cache@main 21 | with: 22 | path: node_modules 23 | key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }} 24 | - run: | 25 | npm install --frozen-lockfile --legacy-peer-deps --no-audit 26 | npm run lint 27 | npm run test:ci 28 | 29 | test-coverage: 30 | needs: test 31 | name: test-coverage 32 | runs-on: ubuntu-latest 33 | steps: 34 | - uses: actions/checkout@main 35 | with: 36 | fetch-depth: "0" 37 | - uses: actions/setup-node@main 38 | with: 39 | node-version: 20 40 | - uses: actions/cache@main 41 | with: 42 | path: node_modules 43 | key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }} 44 | - run: npm install --frozen-lockfile --legacy-peer-deps --no-audit 45 | - uses: paambaati/codeclimate-action@v2.7.2 46 | env: 47 | CC_TEST_REPORTER_ID: ${{ secrets.CC_TEST_REPORTER_ID }} 48 | with: 49 | coverageCommand: npm run coverage 50 | debug: true 51 | 52 | publish-to-npm: 53 | needs: test 54 | runs-on: ubuntu-latest 55 | steps: 56 | - uses: actions/checkout@main 57 | with: 58 | fetch-depth: "0" 59 | - uses: actions/setup-node@main 60 | with: 61 | node-version: 20 62 | registry-url: https://registry.npmjs.org/ 63 | - run: | 64 | npm install --frozen-lockfile --legacy-peer-deps --no-audit 65 | npm run build 66 | - name: Semantic Release 67 | uses: cycjimmy/semantic-release-action@main 68 | with: 69 | branch: main 70 | env: 71 | GITHUB_TOKEN: ${{ secrets.GH_TOKEN }} 72 | NODE_AUTH_TOKEN: ${{ secrets.NODE_AUTH_TOKEN }} 73 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /.nyc_output 2 | /coverage 3 | /node_modules 4 | /dist 5 | /coverage 6 | -------------------------------------------------------------------------------- /.node-version: -------------------------------------------------------------------------------- 1 | 20.14.0 2 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | save=true 2 | save-exact=true 3 | access=public 4 | fund=false 5 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "trailingComma": "es5", 3 | "printWidth": 80, 4 | "useTabs": true 5 | } 6 | -------------------------------------------------------------------------------- /license: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Tuplo 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 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, 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 THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tuplo/numberfmt/cb2fb303a89ee8013476b0710a6286fb49b767ee/logo.png -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@tuplo/numberfmt", 3 | "description": "Native numeric formatting using a text pattern", 4 | "version": "0.0.0-development", 5 | "repository": "git@github.com:tuplo/numberfmt.git", 6 | "author": "Rui Costa", 7 | "license": "MIT", 8 | "keywords": [ 9 | "numbers", 10 | "formatting", 11 | "Intl.NumberFormat" 12 | ], 13 | "types": "dist/index.d.ts", 14 | "module": "./dist/index.mjs", 15 | "main": "./dist/index.cjs", 16 | "exports": { 17 | ".": [ 18 | { 19 | "import": { 20 | "types": "./dist/index.d.ts", 21 | "default": "./dist/index.mjs" 22 | }, 23 | "require": { 24 | "types": "./dist/index.d.ts", 25 | "default": "./dist/index.cjs" 26 | }, 27 | "default": "./dist/index.mjs" 28 | }, 29 | "./dist/index.mjs" 30 | ] 31 | }, 32 | "files": [ 33 | "dist/index.mjs", 34 | "dist/index.cjs", 35 | "dist/index.d.ts", 36 | "dist/numberfmt.d.ts" 37 | ], 38 | "devDependencies": { 39 | "@tuplo/shell": "1.2.2", 40 | "@types/node": "20.14.2", 41 | "@typescript-eslint/eslint-plugin": "7.13.0", 42 | "@typescript-eslint/parser": "7.13.0", 43 | "@vitest/coverage-v8": "1.6.0", 44 | "esbuild": "0.21.5", 45 | "eslint": "8.57.0", 46 | "eslint-config-airbnb-base": "15.0.0", 47 | "eslint-config-airbnb-typescript": "18.0.0", 48 | "eslint-config-prettier": "9.1.0", 49 | "eslint-plugin-import": "2.29.1", 50 | "npm-check-updates": "16.14.20", 51 | "nyc": "17.0.0", 52 | "prettier": "3.3.2", 53 | "tsx": "4.15.4", 54 | "typescript": "5.4.5", 55 | "vitest": "1.6.0" 56 | }, 57 | "scripts": { 58 | "build": "tsx sh/build.ts", 59 | "coverage": "tsx sh/coverage.ts", 60 | "format": "prettier --write src sh", 61 | "lint:ts": "tsc --noEmit", 62 | "lint": "eslint src", 63 | "test:ci": "LANG=en-GB vitest run", 64 | "test": "LANG=en-GB vitest --watch", 65 | "upgrade": "npm-check-updates -u -x eslint && npm install" 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 |
2 |
3 | Logo 4 | 5 |

numberfmt

6 | 7 |

8 | Number formatting using a text pattern and native Intl.NumberFormat() 9 |

10 |

11 | 12 | 13 | 14 | 15 | 16 |

17 | 18 |
19 | 20 | ## Why 21 | 22 | JS provides powerful number formatting with the standard built-in object `Intl.NumberFormat`, but we find its API a little verbose and hard to grasp its full potential. We took inspiration from older libraries like numbro.js and numeral.js and built a string based pattern for interacting with `Intl.NumberFormat`. Tiny footprint, no dependencies, works on the browser or nodejs. 23 | 24 | ## Usage 25 | 26 | ```typescript 27 | import nf from '@tuplo/numberfmt'; 28 | 29 | // numeric 30 | nf(123_456, '0,0.00'); // → 123,456.00 31 | 32 | // currency 33 | nf(123_456, '0,0GBP'); // → £123,456 34 | 35 | // digital units 36 | nf(123_456, '0b'); // → 120.56kb 37 | 38 | // with locale 39 | nf(123_456, '0,0.00', { locale: 'ar-EG' }); // → ١٢٣٬٤٥٦٫٧٩ 40 | 41 | // functional programming, partial application 42 | const nfp = nf.partial('0,0.00'); 43 | nfp(123_456); // → 123,456.00 44 | ``` 45 | 46 | ## Install 47 | 48 | ```bash 49 | $ npm install @tuplo/numberfmt 50 | 51 | # or with yarn 52 | $ yarn add @tuplo/numberfmt 53 | ``` 54 | 55 | ## Options 56 | 57 | An optional set of options can be provided 58 | 59 | ```typescript 60 | nf(123_456, '0,0.00', { 61 | locale: 'ar-EG', 62 | numberingSystem: 'arab' 63 | }); // → ١٢٣٬٤٥٦٫٧٩ 64 | ``` 65 | 66 | ### locale 67 | 68 | > `string` | optional | defaults to system locale 69 | 70 | The BCP 47 language tag for the locale actually used. 71 | 72 | 73 | ### numberingSystem 74 | 75 | > `string` | optional | derives from locale 76 | 77 | Examples: `arab`, `fullwide`, `hant`, `latn`. 78 | 79 | ## Reference 80 | 81 | | Character | Meaning | | 82 | | --------- | -------------------------------- | ---------------- | 83 | | 0 | digit | 1 | 84 | | . | decimal separator | 1.2 | 85 | | , | thousands group separator | 1,234 | 86 | | [] | optional | | 87 | | - | negative sign | -1 | 88 | | () | negative sign (accounting) | (123) | 89 | | % | percentage | 95% | 90 | | o | ordinals | 1st | 91 | | a | compact notation (short display) | 1K | 92 | | A | compact notation (long display) | 1 thousand | 93 | | e | exponential (scientific) | 1E4 | 94 | | E | exponential (engineering) | 100E3 | 95 | | b | bits | 1.23Mb | 96 | | B | bytes | 1.23TB | 97 | | m | length | 1.23km | 98 | | k | mass | 1.23kg | 99 | | USD | currency symbol | US$1,000 | 100 | | s | currency narrow symbol | $1,000 | 101 | | c | currency code | USD 1,000 | 102 | | n | currency name | 1,000 US dollars | 103 | 104 | ### Numbers 105 | 106 | | Value | Format | Result | | 107 | | ------- | ------- | -------- | --------------------------------- | 108 | | 123456 | 0,0 | 123,456 | thousands separator | 109 | | 1234.5 | 0,0.0 | 1,234.5 | decimal separator | 110 | | 1234.5 | 0,0.00 | 1,234.50 | fixed number of decimal digits | 111 | | 123 | 0[.]0 | 123 | optional decimal separator | 112 | | 123.4 | 0[.]0 | 123.4 | | 113 | | 123.4 | 0[.]00 | 123.40 | | 114 | | 123.45 | 0.00[0] | 123.45 | fixed and optional decimal digits | 115 | | 123.456 | 0.00[0] | 123.456 | | 116 | | -1 | -0 | -1 | negative sign | 117 | | 1 | (0) | 1 | negative sign (accounting) | 118 | | -1 | (0) | (1) | | 119 | 120 | ### Percentage 121 | 122 | | Value | Format | Result | 123 | | ------- | ------ | ------ | 124 | | 1 | 0% | 100% | 125 | | -0.12 | 0% | -12% | 126 | | 0.12345 | 0.00% | 12.34% | 127 | 128 | ### Ordinals 129 | 130 | | Value | Format | Result | 131 | | ----- | ------ | ------- | 132 | | 0 | 0o | 0th | 133 | | 1 | 0o | 1st | 134 | | 2 | 0o | 2nd | 135 | | 3 | 0o | 3rd | 136 | | 4 | 0o | 4th | 137 | | 1234 | 0,0o | 1,234th | 138 | 139 | ### Compact notation 140 | 141 | | Value | Format | Result | | 142 | | ------------- | ------ | ----------- | ----------------------------- | 143 | | 123 | 0a | 123 | short display | 144 | | 1234 | 0a | 1K | | 145 | | 12345 | 0a | 12K | | 146 | | 123456 | 0a | 123K | | 147 | | 1234567 | 0a | 1M | | 148 | | 1234567890 | 0a | 1B | | 149 | | 1234567890123 | 0a | 1T | | 150 | | 1234 | 0A | 1 thousand | long display | 151 | | 1234567 | 0A | 1 million | | 152 | | 1234567890 | 0A | 1 billion | | 153 | | 1234567890123 | 0A | 1 trillion | | 154 | | 1234567 | 0.0a | 1.2M | combined with numeric formats | 155 | | 1234567 | 0.0A | 1.2 million | | 156 | 157 | ### Exponential 158 | 159 | | Value | Format | Result | | 160 | | -------- | ------ | ------ | ----------------------------------------------------- | 161 | | 1 | 0e | 1E0 | scientific (order-of-magnitude) | 162 | | 10 | 0e | 1E1 | | 163 | | 100 | 0e | 1E2 | | 164 | | 1000 | 0e | 1E3 | | 165 | | 10000 | 0e | 1E4 | | 166 | | 1 | 0E | 1E0 | engineering (exponent of ten when divisible by three) | 167 | | 10 | 0E | 10E0 | | 168 | | 100 | 0E | 100E0 | | 169 | | 1000 | 0E | 1E3 | | 170 | | 10000 | 0E | 10E3 | | 171 | | 100000 | 0E | 100E3 | | 172 | | 12345678 | 0.0e | 1.2E7 | combined with numeric formats | 173 | | 12345678 | 0.0E | 12.3E6 | | 174 | 175 | ### Digital 176 | 177 | | Value | Format | Result | | 178 | | ------------- | ------ | ------ | ----------------- | 179 | | 1 | 0b | 1bit | bits (narrow) | 180 | | 1024 | 0b | 1kb | | 181 | | 1048576 | 0b | 1Mb | | 182 | | 1073741824 | 0b | 1Gb | | 183 | | 1099511627776 | 0b | 1Tb | | 184 | | 1 | 0B | 1B | bytes (narrow) | 185 | | 1024 | 0B | 1kB | | 186 | | 1048576 | 0B | 1MB | | 187 | | 1073741824 | 0B | 1GB | | 188 | | 1099511627776 | 0B | 1TB | | 189 | | 1 | 0 b | 1 bit | bits (short) | 190 | | 1024 | 0 b | 1 kb | | 191 | | 1 | 0 B | 1 byte | bytes (short) | 192 | | 1024 | 0 B | 1 kB | | 193 | | 1524 | 0b | 1.49kb | 2 fraction digits | 194 | | 1524 | 0B | 1.49kB | | 195 | 196 | ### Length 197 | 198 | | Value | Format | Result | | 199 | | ----- | ------ | ------ | --------------------------------------------------- | 200 | | 0.001 | 0m | 1mm | value in meters, formatted to closest unit (narrow) | 201 | | 0.01 | 0m | 1cm | | 202 | | 1 | 0m | 1m | | 203 | | 1000 | 0m | 1km | | 204 | | 1200 | 0m | 1.2km | | 205 | | 0.001 | 0 m | 1 mm | (short) | 206 | | 0.01 | 0 m | 1 cm | | 207 | | 1 | 0 m | 1 m | | 208 | | 1000 | 0 m | 1 km | | 209 | | 1200 | 0 m | 1.2 km | | 210 | 211 | ### Mass 212 | 213 | | Value | Format | Result | | 214 | | ----- | ------ | ------- | ------ | 215 | | 1 | 0k | 1kg | narrow | 216 | | 0.001 | 0k | 1g | | 217 | | 1.23 | 0k | 1.23kg | | 218 | | 1 | 0 k | 1 kg | short | 219 | | 0.001 | 0 k | 1 g | | 220 | | 1.23 | 0 k | 1.23 kg | | 221 | 222 | ### Currency 223 | 224 | | Value | Format | Result | | 225 | | ----- | --------- | -------------------- | ----------------- | 226 | | 123 | GBP | £123 | symbol | 227 | | 1234 | 0,0GBP | £1,234 | | 228 | | 1234 | 0,0.00GBP | £1,234.00 | | 229 | | 123 | EUR | €123 | | 230 | | 123 | JPY | JP¥123 | | 231 | | 123 | USD | US$123 | | 232 | | 123 | CAD | CA$123 | | 233 | | 1000 | 0,0GBPs | £1,000 | narrow symbol | 234 | | 1000 | 0,0EURs | €1,000 | | 235 | | 1000 | 0,0USDs | $1,000 | | 236 | | 1000 | 0,0CADs | $1,000 | | 237 | | 1000 | 0,0JPYs | ¥1,000 | | 238 | | 1000 | 0,0GBPc | GBP 1,000 | ISO currency code | 239 | | 1000 | 0,0EURc | EUR 1,000 | | 240 | | 1000 | 0,0USDc | USD 1,000 | | 241 | | 1000 | 0,0JPYc | JPY 1,000 | | 242 | | 1000 | 0,0GBPn | 1,000 British pounds | currency name | 243 | | 1000 | 0,0EURn | 1,000 euros | | 244 | | 1000 | 0,0USDn | 1,000 US dollars | | 245 | | 1000 | 0,0JPYn | 1,000 Japanese yen | | 246 | 247 | ## License 248 | 249 | MIT -------------------------------------------------------------------------------- /sh/build.ts: -------------------------------------------------------------------------------- 1 | import { $ } from "@tuplo/shell"; 2 | 3 | async function main() { 4 | await $`rm -rf dist`; 5 | await $`tsc --build tsconfig.build.json`; 6 | 7 | const flags = ["--bundle", "--platform=node", "--minify"]; 8 | 9 | await $`esbuild src/cjs/index.cjs --outfile=dist/index.cjs ${flags}`; 10 | await $`esbuild src/index.ts --format=esm --outfile=dist/index.mjs ${flags}`; 11 | 12 | await $`rm dist/index.js`; 13 | await $`rm -rf dist/formats dist/helpers dist/options`; 14 | await $`cp src/numberfmt.d.ts dist/`; 15 | } 16 | 17 | main(); 18 | -------------------------------------------------------------------------------- /sh/coverage.ts: -------------------------------------------------------------------------------- 1 | import { $ } from "@tuplo/shell"; 2 | 3 | async function main() { 4 | await $`rm -rf ./node_modules/.cache`; 5 | await $`rm -rf coverage/`; 6 | await $`rm -rf .nyc_output/`; 7 | 8 | const flags = ["--coverage true"].flatMap((f) => f.split(" ")); 9 | await $`NODE_ENV=test LOG_LEVEL=silent nyc yarn run test:ci ${flags}`; 10 | } 11 | 12 | main(); 13 | -------------------------------------------------------------------------------- /src/bug-reports.test.ts: -------------------------------------------------------------------------------- 1 | import nf from "./index"; 2 | 3 | describe("bug reports", () => { 4 | it("shows mismatched values between nodejs and browser", () => { 5 | const value = 47_939; 6 | const actual = nf(value, "0.0a"); 7 | 8 | // const expected = '48K'; // chrome, firefox 9 | const expected = "47.9K"; // nodejs 10 | expect(actual).toBe(expected); 11 | }); 12 | 13 | it("respect number of fraction digits", () => { 14 | const value = 123.4; 15 | const actual = nf(value, "0.00[0]"); 16 | 17 | const expected = "123.40"; 18 | expect(actual).toBe(expected); 19 | }); 20 | 21 | it("respect number of fraction digits using short notation", () => { 22 | const actual = nf(6_500, "0.0a"); 23 | 24 | const expected = "6.5K"; 25 | expect(actual).toBe(expected); 26 | }); 27 | }); 28 | -------------------------------------------------------------------------------- /src/cjs/index.cjs: -------------------------------------------------------------------------------- 1 | import numberfmt from "../index"; 2 | 3 | module.exports = numberfmt; 4 | -------------------------------------------------------------------------------- /src/formats/ordinal.test.ts: -------------------------------------------------------------------------------- 1 | import { formatOrdinal } from "./ordinal"; 2 | 3 | describe("formats ordinals", () => { 4 | it.each([ 5 | ["0o", 1, "1", "en-GB", "1st"], 6 | ["0o", 2, "2", "en-GB", "2nd"], 7 | ])("ordinals: %s", (format, value, formatted, locale, expected) => { 8 | const result = formatOrdinal({ value, formatted, locale, format }); 9 | expect(result).toBe(expected); 10 | }); 11 | }); 12 | -------------------------------------------------------------------------------- /src/formats/ordinal.ts: -------------------------------------------------------------------------------- 1 | import { rgSpaceBetween } from "../helpers/regex"; 2 | 3 | function getSuffixes(locale: string) { 4 | switch (locale) { 5 | case "en-GB": 6 | default: 7 | return new Map([ 8 | ["one", "st"], 9 | ["two", "nd"], 10 | ["few", "rd"], 11 | ["other", "th"], 12 | ]); 13 | } 14 | } 15 | 16 | interface IFormatOrdinalParams { 17 | value: number; 18 | formatted: string; 19 | locale: string; 20 | format: string; 21 | } 22 | 23 | export function formatOrdinal(params: IFormatOrdinalParams) { 24 | const { value, formatted, locale, format } = params; 25 | const pluralRules = new Intl.PluralRules(locale, { 26 | type: "ordinal", 27 | }); 28 | 29 | const rule = pluralRules.select(value); 30 | const suffix = getSuffixes(locale).get(rule); 31 | 32 | return rgSpaceBetween.test(format) 33 | ? `${formatted} ${suffix}` 34 | : `${formatted}${suffix}`; 35 | } 36 | -------------------------------------------------------------------------------- /src/formats/percentage.test.ts: -------------------------------------------------------------------------------- 1 | import { formatPercentage } from "./percentage"; 2 | 3 | describe("percentage", () => { 4 | it.each([ 5 | ["0%", "100%", 1, undefined], 6 | ["0 %", "100 %", 1, undefined], 7 | ["0.0%", "1.0%", 0.01, 1], 8 | ["0.0 %", "1.0 %", 0.01, 1], 9 | ["0.00 %", "1.20 %", 0.012, 2], 10 | ["0.00 %", "1.23 %", 0.0123, 2], 11 | ["0,0.0 %", "2,000 %", 20, undefined], 12 | ["0,0.0 %", "2,000,000 %", 20_000, undefined], 13 | ["0,0.0 %", "2,000,000.00 %", 20_000, 2], 14 | ])( 15 | "percentage: %s = %s", 16 | (format, expected, value, minimumFractionDigits) => { 17 | const nf = new Intl.NumberFormat("en-GB", { 18 | style: "percent", 19 | minimumFractionDigits, 20 | }); 21 | const params = { format, value, numberFormatter: nf }; 22 | const result = formatPercentage(params); 23 | expect(result).toBe(expected); 24 | } 25 | ); 26 | }); 27 | -------------------------------------------------------------------------------- /src/formats/percentage.ts: -------------------------------------------------------------------------------- 1 | import { rgSpaceBetween } from "../helpers/regex"; 2 | import { fromParts } from "../helpers/from-parts"; 3 | 4 | interface IFormatPercentageParams { 5 | format: string; 6 | numberFormatter: Intl.NumberFormat; 7 | value: number; 8 | } 9 | 10 | export function formatPercentage(params: IFormatPercentageParams) { 11 | const { value, numberFormatter, format } = params; 12 | const { minusSign, integer, group, decimal, fraction, percentSign } = 13 | fromParts(numberFormatter.formatToParts(value)); 14 | 15 | return [ 16 | minusSign, 17 | (integer as string[]).join((group as string) || ""), 18 | decimal, 19 | fraction, 20 | rgSpaceBetween.test(format) && " ", 21 | percentSign, 22 | ] 23 | .filter(Boolean) 24 | .join(""); 25 | } 26 | -------------------------------------------------------------------------------- /src/helpers/convert-to-number.test.ts: -------------------------------------------------------------------------------- 1 | import { convertToNumber } from "./convert-to-number"; 2 | 3 | describe("convert to number", () => { 4 | it.each([ 5 | [undefined, null], 6 | [null, null], 7 | [{}, null], 8 | [[], null], 9 | [[1_234], 1_234], 10 | [[1_234, 5_678], null], 11 | ["abc", null], 12 | ["1234", 1_234], 13 | [1_234, 1_234], 14 | ])("converts from user input to number", (userInput, expected) => { 15 | const result = convertToNumber(userInput); 16 | expect(result).toBe(expected); 17 | }); 18 | }); 19 | -------------------------------------------------------------------------------- /src/helpers/convert-to-number.ts: -------------------------------------------------------------------------------- 1 | export function convertToNumber(userInput: unknown) { 2 | if (userInput === null) return null; 3 | if (Array.isArray(userInput) && userInput.length === 0) return null; 4 | const userValue = Number(userInput); 5 | if (Number.isNaN(userValue)) return null; 6 | 7 | return Number(userInput); 8 | } 9 | -------------------------------------------------------------------------------- /src/helpers/digits-format.test.ts: -------------------------------------------------------------------------------- 1 | import { getDigitsFormat } from "./digits-format"; 2 | 3 | describe("digits format", () => { 4 | it.each([ 5 | [undefined, "0,000"], 6 | ["0", "0"], 7 | ["0,0", "0,000"], 8 | ["0,000[.]00", "0,000[.]00"], 9 | ])("digits format: %s = %s", (format, expected) => { 10 | const result = getDigitsFormat(format); 11 | expect(result).toBe(expected); 12 | }); 13 | }); 14 | -------------------------------------------------------------------------------- /src/helpers/digits-format.ts: -------------------------------------------------------------------------------- 1 | import { rgDigitsFormat } from "./regex"; 2 | 3 | export function getDigitsFormat(format?: string) { 4 | const [digitsFormat] = rgDigitsFormat.exec(format || "") || ["0,000"]; 5 | 6 | if (digitsFormat === "0,0") return "0,000"; 7 | 8 | return digitsFormat; 9 | } 10 | -------------------------------------------------------------------------------- /src/helpers/fraction-digits.test.ts: -------------------------------------------------------------------------------- 1 | import { getFractionDigits } from "./fraction-digits"; 2 | 3 | describe("fraction digits", () => { 4 | it.each([ 5 | ["0,0", [0, 0]], 6 | ["0.0", [1, 0]], 7 | ["0.000", [3, 0]], 8 | ["0,000.00", [2, 0]], 9 | ["0[.]0000", [4, 0]], 10 | ["0.00[0]", [2, 1]], 11 | ["0.0[00]", [1, 2]], 12 | ])("gets fraction digits on format: %s", (format, expected) => { 13 | const actual = getFractionDigits(format); 14 | expect(actual).toStrictEqual(expected); 15 | }); 16 | }); 17 | -------------------------------------------------------------------------------- /src/helpers/fraction-digits.ts: -------------------------------------------------------------------------------- 1 | import { rgFractionDigits, rgRequiredDigits, rgOptionalDigits } from "./regex"; 2 | 3 | export function getFractionDigits(format: string) { 4 | const [digits] = rgFractionDigits.exec(format) || [""]; 5 | const [required] = rgRequiredDigits.exec(digits) || [""]; 6 | const [, optional] = rgOptionalDigits.exec(digits) || ["", ""]; 7 | 8 | return [required.length, optional.length]; 9 | } 10 | -------------------------------------------------------------------------------- /src/helpers/from-parts.test.ts: -------------------------------------------------------------------------------- 1 | import { fromParts } from "./from-parts"; 2 | 3 | describe("from parts", () => { 4 | it.each([ 5 | [ 6 | "percentage", 7 | [ 8 | { type: "integer", value: "100" }, 9 | { type: "percentSign", value: "%" }, 10 | ], 11 | { integer: ["100"], percentSign: "%" }, 12 | ], 13 | [ 14 | "grouped integers", 15 | [ 16 | { type: "integer", value: "2" }, 17 | { type: "group", value: "," }, 18 | { type: "integer", value: "000" }, 19 | ], 20 | { integer: ["2", "000"], group: "," }, 21 | ], 22 | ])("converts from parts to object: %s", (_, parts, expected) => { 23 | const result = fromParts(parts as Intl.NumberFormatPart[]); 24 | expect(result).toStrictEqual(expected); 25 | }); 26 | }); 27 | -------------------------------------------------------------------------------- /src/helpers/from-parts.ts: -------------------------------------------------------------------------------- 1 | type INumberParts = Record; 2 | 3 | export function fromParts(parts: Intl.NumberFormatPart[]) { 4 | return parts.reduce((acc, part) => { 5 | const { type, value } = part; 6 | if (type === "integer") { 7 | acc[type] = acc[type] || []; 8 | (acc[type] as string[]).push(value); 9 | } else { 10 | acc[type] = value; 11 | } 12 | return acc; 13 | }, {} as INumberParts); 14 | } 15 | -------------------------------------------------------------------------------- /src/helpers/integer-digits-on-format.ts: -------------------------------------------------------------------------------- 1 | import { rgIntegerDigitsOnFormat } from "./regex"; 2 | 3 | export function getIntegerDigitsOnFormat(format: string) { 4 | const [integersInFormat] = rgIntegerDigitsOnFormat.exec(format) || [""]; 5 | return integersInFormat.length; 6 | } 7 | -------------------------------------------------------------------------------- /src/helpers/regex.test.ts: -------------------------------------------------------------------------------- 1 | import { rgDigitsFormat, rgIsCompact, rgCurrencyFormat } from "./regex"; 2 | 3 | describe("operational regular expressions", () => { 4 | it.each([ 5 | ["0.0", "0.0"], 6 | ["0,000.0", "0,000.0"], 7 | ["0,000[.]0", "0,000[.]0"], 8 | ["0,0[.]0[0]", "0,0[.]0[0]"], 9 | ["0.0a", "0.0"], 10 | ])("extracts digits format: %s", (format, expected) => { 11 | const [, result] = format.match(rgDigitsFormat) || ["", ""]; 12 | expect(result).toBe(expected); 13 | }); 14 | 15 | it.each([ 16 | ["0.0", false], 17 | ["0.000a", true], 18 | ["0.000A", true], 19 | ])("compact display: %s", (format, expected) => { 20 | const result = rgIsCompact.test(format); 21 | expect(result).toBe(expected); 22 | }); 23 | 24 | it.each([ 25 | ["GBP", "GBP"], 26 | ["GBPa", "GBP"], 27 | ["0.0GBPa", "GBP"], 28 | ["CADn", "CADn"], 29 | ["USDs", "USDs"], 30 | ["EURn", "EURn"], 31 | ])("extracts currency format: %s", (format, expected) => { 32 | const [, result] = format.match(rgCurrencyFormat) || ["", ""]; 33 | expect(result).toBe(expected); 34 | }); 35 | }); 36 | -------------------------------------------------------------------------------- /src/helpers/regex.ts: -------------------------------------------------------------------------------- 1 | export const rgCurrency = /([A-Z]{3})[scn]?/; 2 | export const rgCurrencyDisplay = /[A-Z]{3}([scn]?)/; 3 | export const rgCurrencyFormat = /([A-Z]{3}[scn]?)/; 4 | export const rgDigitsFormat = /([0,.[\]]+)/; 5 | export const rgFractionDigits = /\.\]?([0-9[\]]*)/; 6 | export const rgFractionDigitsAreaOptional = /\[\.\]/; 7 | export const rgHasParentheses = /^\([^)]*\)/; 8 | export const rgIntegerDigits = /^([0-9]+)/; 9 | export const rgIntegerDigitsOnFormat = /^([0]+)/; 10 | export const rgIsCompact = /[aA]\)?$/; 11 | export const rgIsEngineering = /E\)?$/; 12 | export const rgIsLongCompactFormat = /A\)?$/; 13 | export const rgIsScientific = /e\)?$/; 14 | export const rgIsShortCompactFormat = /a\)?$/; 15 | export const rgNotationFormat = /[aAeE]\)?$/; 16 | export const rgOptionalDigits = /\[([0]+)\]/; 17 | export const rgOrdinalFormat = /o\)?$/; 18 | export const rgRequiredDigits = /([0]+)/; 19 | export const rgSpaceBetween = /[0\]]\s[o%bBmk]/; 20 | export const rgStartsWithPlus = /^\+/; 21 | export const rgZerosOnTheLeft = /^([0]+)/; 22 | 23 | // units systems 24 | export const rgBitSystem = /b$/; 25 | export const rgByteSystem = /B$/; 26 | export const rgDigitalSystem = /[bB]$/; 27 | export const rgKiloSystem = /k\)?$/; 28 | export const rgMetricSystem = /m\)?$/; 29 | export const rgUnitSystem = /[bBmik]\)?$/; 30 | -------------------------------------------------------------------------------- /src/helpers/resolved-options.test.ts: -------------------------------------------------------------------------------- 1 | import { getResolvedOptions } from "./resolved-options"; 2 | 3 | describe("getResolvedOptions", () => { 4 | it("should return resolved options", () => { 5 | const actual = getResolvedOptions(); 6 | const expected = { 7 | locale: "en-GB", 8 | numberingSystem: "latn", 9 | }; 10 | expect(actual).toStrictEqual(expected); 11 | }); 12 | 13 | it("bases on user's locale", () => { 14 | const userOptions = { locale: "ar-EG" }; 15 | const actual = getResolvedOptions(userOptions); 16 | const expected = { 17 | locale: "ar-EG", 18 | numberingSystem: "arab", 19 | }; 20 | expect(actual).toStrictEqual(expected); 21 | }); 22 | 23 | it("user options overrides system defaults", () => { 24 | const userOptions = { 25 | numberingSystem: "arab", 26 | locale: "id-ID", 27 | }; 28 | const actual = getResolvedOptions(userOptions); 29 | expect(actual).toStrictEqual(userOptions); 30 | }); 31 | }); 32 | -------------------------------------------------------------------------------- /src/helpers/resolved-options.ts: -------------------------------------------------------------------------------- 1 | import type { INumberTimeResolvedOptions } from "src/numberfmt.d"; 2 | 3 | export function getResolvedOptions( 4 | userOptions?: Partial 5 | ) { 6 | const { locale: userLocale } = userOptions || {}; 7 | const { locale, numberingSystem } = { 8 | ...Intl.DateTimeFormat(userLocale).resolvedOptions(), 9 | ...userOptions, 10 | }; 11 | 12 | return { locale, numberingSystem }; 13 | } 14 | -------------------------------------------------------------------------------- /src/helpers/value.test.ts: -------------------------------------------------------------------------------- 1 | import { getCompactPowersOfTwo, getMetricValue, getValue } from "./value"; 2 | 3 | describe("calculates value based on format", () => { 4 | it.each([ 5 | ["0", 1, 1], 6 | ["0.0", 1024, 1024], 7 | ["0b", 1, 1], 8 | ["0b", 1, 1024], 9 | ["0b", 1, 1048576], 10 | ["0b", 1, 1073741824], 11 | ])("value: %s = %s", (format, expected, value) => { 12 | const result = getValue(value, format); 13 | expect(result).toBe(expected); 14 | }); 15 | 16 | it.each([ 17 | ["meter", 1, 1], 18 | ["meter", 10, 10], 19 | ["meter", 999, 999], 20 | ["millimeter", 0.001, 1], 21 | ["centimeter", 0.01, 1], 22 | ["centimeter", 0.09, 9], 23 | ["centimeter", 0.095, 9.5], 24 | ["kilometer", 1000, 1], 25 | ["kilometer", 1200, 1.2], 26 | ])("get metric value: %s - %s", (_, value, expected) => { 27 | const result = getMetricValue(value); 28 | expect(result).toBe(expected); 29 | }); 30 | 31 | it.each([ 32 | [0, 0], 33 | [1, 1], 34 | [1024, 1], 35 | [1500, 1.46484375], 36 | [1048576, 1], 37 | [1278541824, 1.19073486328125], 38 | ])("get compact powers of two: %s", (value, expected) => { 39 | const result = getCompactPowersOfTwo(value); 40 | expect(result).toBe(expected); 41 | }); 42 | }); 43 | -------------------------------------------------------------------------------- /src/helpers/value.ts: -------------------------------------------------------------------------------- 1 | import { rgDigitalSystem, rgMetricSystem, rgKiloSystem } from "./regex"; 2 | 3 | export function getCompactPowersOfTwo(value: number): number { 4 | let n = value; 5 | while (n >= 1024) { 6 | n /= 1024; 7 | } 8 | return n; 9 | } 10 | 11 | export function getMetricValue(value: number): number { 12 | const absValue = Math.abs(value); 13 | 14 | if (absValue > 999.9999) return value / 1000; 15 | if (absValue >= 0.01 && absValue <= 0.0999) return value * 100; 16 | if (absValue >= 0.001 && absValue <= 0.00999) return value * 1000; 17 | 18 | return value; 19 | } 20 | 21 | export function getKiloValue(value: number) { 22 | const absValue = Math.abs(value); 23 | 24 | if (absValue >= 0.001 && absValue <= 0.00999) return value * 1000; 25 | 26 | return value; 27 | } 28 | 29 | export function getValue(value: number, format: string) { 30 | if (rgDigitalSystem.test(format)) return getCompactPowersOfTwo(value); 31 | if (rgMetricSystem.test(format)) return getMetricValue(value); 32 | if (rgKiloSystem.test(format)) return getKiloValue(value); 33 | 34 | return value; 35 | } 36 | -------------------------------------------------------------------------------- /src/index.test.ts: -------------------------------------------------------------------------------- 1 | import nf from "."; 2 | 3 | describe("numberfmt", () => { 4 | it("has default format", () => { 5 | const result = nf(123_456); 6 | const expected = "123,456"; 7 | expect(result).toBe(expected); 8 | }); 9 | 10 | it.each([ 11 | [undefined, ""], 12 | [null, ""], 13 | [{}, ""], 14 | [[], ""], 15 | [[1_234], "1,234"], 16 | [[1_234, 5_678], ""], 17 | ["abc", ""], 18 | ["1234", "1,234"], 19 | [1_234, "1,234"], 20 | ])("handles non numeric values: %s", (value, expected) => { 21 | const result = nf(value, "0,0"); 22 | expect(result).toBe(expected); 23 | }); 24 | 25 | it.each([ 26 | ["en-GB", "123,456.79"], 27 | ["id-ID", "123.456,79"], 28 | ["ar-EG", "١٢٣٬٤٥٦٫٧٩"], 29 | ])("accepts a user locale: %s", (locale, expected) => { 30 | const result = nf(123_456.789, "0,0.00", { locale }); 31 | expect(result).toBe(expected); 32 | }); 33 | 34 | it("accepts a user locale (currencies): %s", () => { 35 | const result = nf(123_456.789, "0,0.00IDRs", { locale: "id-ID" }); 36 | const expected = "Rp 123.456,79"; 37 | expect(result).toBe(expected); 38 | }); 39 | 40 | it("formats number with partial", () => { 41 | const value = 123_456.789; 42 | const fn = nf.partial("0,0.00"); 43 | const actual = fn(value); 44 | 45 | const expected = nf(value, "0,0.00"); 46 | expect(actual).toBe(expected); 47 | }); 48 | 49 | it("setting locale with partial", () => { 50 | const value = 1234.5; 51 | const fn = nf.partial("0.0", { locale: "fr" }); 52 | const actual = fn(value); 53 | 54 | const expected = nf(value, "0.0", { locale: "fr" }); 55 | expect(actual).toBe(expected); 56 | }); 57 | }); 58 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import { convertToNumber } from "./helpers/convert-to-number"; 2 | import { getCompactDisplay } from "./options/compact-display"; 3 | import { getCurrency } from "./options/currency"; 4 | import { getCurrencyDisplay } from "./options/currency-display"; 5 | import { getCurrencySign } from "./options/currency-sign"; 6 | import { getMaximumFractionDigits } from "./options/maximum-fraction-digits"; 7 | import { getMinimumFractionDigits } from "./options/minimum-fraction-digits"; 8 | import { getMinimumIntegerDigits } from "./options/minimum-integer-digits"; 9 | import { getNotation } from "./options/notation"; 10 | import { getSignDisplay } from "./options/sign-display"; 11 | import { getStyle } from "./options/style"; 12 | import { getUnit } from "./options/unit"; 13 | import { getUnitDisplay } from "./options/unit-display"; 14 | 15 | import { formatOrdinal } from "./formats/ordinal"; 16 | import { formatPercentage } from "./formats/percentage"; 17 | 18 | import { getIntegerDigitsOnFormat } from "./helpers/integer-digits-on-format"; 19 | import { getDigitsFormat } from "./helpers/digits-format"; 20 | import { 21 | rgZerosOnTheLeft, 22 | rgOrdinalFormat, 23 | rgHasParentheses, 24 | } from "./helpers/regex"; 25 | import { getValue } from "./helpers/value"; 26 | import { getResolvedOptions } from "./helpers/resolved-options"; 27 | import type { INumberTimeResolvedOptions } from "./numberfmt.d"; 28 | 29 | function numberfmt( 30 | userInput: unknown, 31 | userFormat?: string, 32 | userOptions?: Partial 33 | ) { 34 | const userValue = convertToNumber(userInput); 35 | if (userValue === null) return ""; 36 | 37 | // default format 38 | const format = userFormat || "0,0"; 39 | 40 | const { locale, numberingSystem } = getResolvedOptions(userOptions); 41 | const digitsFormat = getDigitsFormat(format); 42 | 43 | // convert user value to computed values (bits, bytes, metric) 44 | const value = getValue(userValue, format); 45 | 46 | const options = { 47 | compactDisplay: getCompactDisplay(format), 48 | currency: getCurrency(format), 49 | currencyDisplay: getCurrencyDisplay(format), 50 | currencySign: getCurrencySign(format), 51 | maximumFractionDigits: getMaximumFractionDigits(value, format), 52 | minimumFractionDigits: getMinimumFractionDigits(value, format), 53 | minimumIntegerDigits: getMinimumIntegerDigits(value, format), 54 | notation: getNotation(format), 55 | numberingSystem, 56 | signDisplay: getSignDisplay(format), 57 | style: getStyle(format), 58 | useGrouping: digitsFormat.includes(","), 59 | unit: getUnit(userValue, format), 60 | unitDisplay: getUnitDisplay(format), 61 | }; 62 | 63 | const nf = new Intl.NumberFormat(locale, options); 64 | let n = nf.format(value); 65 | 66 | // remove zeros from '0.12' 67 | if (getIntegerDigitsOnFormat(digitsFormat) === 0) { 68 | n = n.replace(rgZerosOnTheLeft, ""); 69 | } 70 | // percentage with spaces before symbol 71 | if (format.includes("%")) { 72 | n = formatPercentage({ value, numberFormatter: nf, format }); 73 | } 74 | // ordinals 75 | if (rgOrdinalFormat.test(format)) { 76 | n = formatOrdinal({ value, formatted: n, format, locale }); 77 | } 78 | // add parentheses when not currency 79 | if (rgHasParentheses.test(format) && value < 0) { 80 | n = `(${n})`; 81 | } 82 | 83 | return n; 84 | } 85 | 86 | // Partial application for functional programming 87 | numberfmt.partial = 88 | (format: string, options?: Partial) => 89 | (userValue: number) => 90 | numberfmt(userValue, format, options); 91 | 92 | export default numberfmt; 93 | -------------------------------------------------------------------------------- /src/numberfmt.d.ts: -------------------------------------------------------------------------------- 1 | export interface INumberTimeResolvedOptions { 2 | locale: string; 3 | numberingSystem: string; 4 | } 5 | -------------------------------------------------------------------------------- /src/options/compact-display.test.ts: -------------------------------------------------------------------------------- 1 | import { getCompactDisplay } from "./compact-display"; 2 | 3 | describe("compact display", () => { 4 | it.each([ 5 | ["0.000", undefined], 6 | ["0.0a", "short"], 7 | ["0.0A", "long"], 8 | ])("compact display: %s", (format, expected) => { 9 | const result = getCompactDisplay(format); 10 | expect(result).toBe(expected); 11 | }); 12 | }); 13 | -------------------------------------------------------------------------------- /src/options/compact-display.ts: -------------------------------------------------------------------------------- 1 | import { 2 | rgIsShortCompactFormat, 3 | rgIsLongCompactFormat, 4 | } from "../helpers/regex"; 5 | 6 | type CompactDisplay = "short" | "long" | undefined; 7 | 8 | export function getCompactDisplay(format: string): CompactDisplay { 9 | if (rgIsShortCompactFormat.test(format)) return "short"; 10 | if (rgIsLongCompactFormat.test(format)) return "long"; 11 | return undefined; 12 | } 13 | -------------------------------------------------------------------------------- /src/options/currency-display.test.ts: -------------------------------------------------------------------------------- 1 | import { getCurrencyDisplay } from "./currency-display"; 2 | 3 | describe("currency display", () => { 4 | it.each([ 5 | ["0GBP", "symbol"], 6 | ["0GBPs", "narrowSymbol"], 7 | ["0GBPc", "code"], 8 | ["0GBPn", "name"], 9 | ])("compact display - %s", (format, expected) => { 10 | const result = getCurrencyDisplay(format); 11 | expect(result).toBe(expected); 12 | }); 13 | }); 14 | -------------------------------------------------------------------------------- /src/options/currency-display.ts: -------------------------------------------------------------------------------- 1 | import { rgCurrencyDisplay } from "../helpers/regex"; 2 | 3 | type CurrencyDisplay = "symbol" | "narrowSymbol" | "code" | "name"; 4 | 5 | export function getCurrencyDisplay(format: string): CurrencyDisplay { 6 | const [, display] = format.match(rgCurrencyDisplay) || [""]; 7 | switch (display) { 8 | case "s": 9 | return "narrowSymbol"; 10 | case "c": 11 | return "code"; 12 | case "n": 13 | return "name"; 14 | default: 15 | return "symbol"; 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /src/options/currency-sign.test.ts: -------------------------------------------------------------------------------- 1 | import { getCurrencySign } from "./currency-sign"; 2 | 3 | describe("currency sign", () => { 4 | it.each([ 5 | ["0.0", "standard"], 6 | ["(0.0)", "accounting"], 7 | ])("currency sign: %s = %s", (format, expected) => { 8 | const result = getCurrencySign(format); 9 | expect(result).toBe(expected); 10 | }); 11 | }); 12 | -------------------------------------------------------------------------------- /src/options/currency-sign.ts: -------------------------------------------------------------------------------- 1 | import { rgHasParentheses } from "../helpers/regex"; 2 | 3 | type CurrencySign = "standard" | "accounting"; 4 | 5 | export function getCurrencySign(format: string): CurrencySign { 6 | return rgHasParentheses.test(format) ? "accounting" : "standard"; 7 | } 8 | -------------------------------------------------------------------------------- /src/options/currency.test.ts: -------------------------------------------------------------------------------- 1 | import { getCurrency } from "./currency"; 2 | 3 | describe("currency", () => { 4 | it.each([ 5 | ["0.0", undefined], 6 | ["0.0gbp", undefined], 7 | ["0.0£", undefined], 8 | ["0.0A", undefined], 9 | ["0.0GBP", "GBP"], 10 | ["0.0[0]GBP", "GBP"], 11 | ["0,000.0[0]GBP", "GBP"], 12 | ["0GBPa", "GBP"], 13 | ["0GBPs", "GBP"], 14 | ["0GBPn", "GBP"], 15 | ["0GBPn", "GBP"], 16 | ["0EUR", "EUR"], 17 | ])("currency: %s = %s", (format, expected) => { 18 | const result = getCurrency(format); 19 | expect(result).toBe(expected); 20 | }); 21 | }); 22 | -------------------------------------------------------------------------------- /src/options/currency.ts: -------------------------------------------------------------------------------- 1 | import { rgCurrency } from "../helpers/regex"; 2 | 3 | export function getCurrency(format: string) { 4 | const [, currency] = format.match(rgCurrency) || [""]; 5 | return currency || undefined; 6 | } 7 | -------------------------------------------------------------------------------- /src/options/maximum-fraction-digits.test.ts: -------------------------------------------------------------------------------- 1 | import { getMaximumFractionDigits } from "./maximum-fraction-digits"; 2 | 3 | describe("maximum fraction digits", () => { 4 | it.each([ 5 | [".0", 1, 1], 6 | ["0.0", 1, 1], 7 | [".00", 1, 2], 8 | ["0.00", 1, 2], 9 | ["0[.]000", 1, 3], 10 | ["0[.]00", 1.001, 2], 11 | ["0[.]00[0]", 1.23, 3], 12 | ["0[.]00[0]", 1.234, 3], 13 | ])("gets maximum fraction digits - %s", (format, value, expected) => { 14 | const result = getMaximumFractionDigits(value, format); 15 | expect(result).toBe(expected); 16 | }); 17 | 18 | it.each([ 19 | ["0b", 1_200, 2], 20 | ["0B", 1_200, 2], 21 | ["0m", 1_200, 2], 22 | ])("fraction digits on unit systems: %s", (format, value, expected) => { 23 | const result = getMaximumFractionDigits(value, format); 24 | expect(result).toBe(expected); 25 | }); 26 | }); 27 | -------------------------------------------------------------------------------- /src/options/maximum-fraction-digits.ts: -------------------------------------------------------------------------------- 1 | import { getDigitsFormat } from "../helpers/digits-format"; 2 | import { getFractionDigits } from "../helpers/fraction-digits"; 3 | import { rgUnitSystem } from "../helpers/regex"; 4 | 5 | export function getMaximumFractionDigits( 6 | value: number, 7 | format: string 8 | ): number { 9 | const digitsFormat = getDigitsFormat(format); 10 | const [required, optional] = getFractionDigits(digitsFormat); 11 | 12 | if (rgUnitSystem.test(format)) { 13 | return Math.max(required + optional, 2); 14 | } 15 | 16 | return required + optional; 17 | } 18 | -------------------------------------------------------------------------------- /src/options/minimum-fraction-digits.test.ts: -------------------------------------------------------------------------------- 1 | import { getMinimumFractionDigits } from "./minimum-fraction-digits"; 2 | 3 | describe("minimum fraction digits", () => { 4 | it.each([ 5 | [".0", 1, 1], 6 | ["0.0", 1, 1], 7 | [".00", 1, 2], 8 | ["0.00", 1, 2], 9 | ["0[.]000", 1, 0], 10 | ["0[.]000", 1.2, 3], 11 | ["0[.]000", 1.23, 3], 12 | ["0[.]00", 1.001, 2], 13 | ["0.00[0]", 1.23, 2], 14 | ["0.00[0]", 1.234, 2], 15 | ["0[.]0[0]", 1, 1], 16 | ["0[.]0[0]", 1.2, 1], 17 | ["0[.]0[0]", 1.23, 1], 18 | ["0[.]00[0]", 1.234, 2], 19 | ])("gets minimum fraction digits: %s", (format, value, expected) => { 20 | const actual = getMinimumFractionDigits(value, format); 21 | expect(actual).toBe(expected); 22 | }); 23 | }); 24 | -------------------------------------------------------------------------------- /src/options/minimum-fraction-digits.ts: -------------------------------------------------------------------------------- 1 | import { getDigitsFormat } from "../helpers/digits-format"; 2 | import { getFractionDigits } from "../helpers/fraction-digits"; 3 | import { 4 | rgFractionDigitsAreaOptional, 5 | rgOptionalDigits, 6 | } from "../helpers/regex"; 7 | 8 | function handleOptionalDigits(value: number, digits: [number, number]) { 9 | const [required] = digits; 10 | 11 | return required; 12 | } 13 | 14 | export function getMinimumFractionDigits(value: number, format: string) { 15 | const digitsFormat = getDigitsFormat(format); 16 | const [required, optional] = getFractionDigits(digitsFormat); 17 | 18 | if (rgFractionDigitsAreaOptional.test(digitsFormat)) { 19 | const strNumber = value.toString(); 20 | if (rgOptionalDigits.test(digitsFormat)) { 21 | return handleOptionalDigits(value, [required, optional]); 22 | } 23 | 24 | return strNumber.indexOf(".") > -1 ? required : 0; 25 | } 26 | 27 | return rgOptionalDigits.test(digitsFormat) ? required : required + optional; 28 | } 29 | -------------------------------------------------------------------------------- /src/options/minimum-integer-digits.test.ts: -------------------------------------------------------------------------------- 1 | import { getMinimumIntegerDigits } from "./minimum-integer-digits"; 2 | 3 | describe("integer digits configuration", () => { 4 | it.each([ 5 | ["0", 1, 1], 6 | ["0.0", 1, 1], 7 | ["00", 2, 1], 8 | ["00000", 5, 1], 9 | ["000", 5, 10_000], 10 | ["0,0", undefined, 1], 11 | [".00", undefined, 0.23], 12 | [".00", 4, 1_000.23], 13 | ["0a", undefined, 1], 14 | ["0A", undefined, 1], 15 | ["0e", undefined, 1_000], 16 | ["0E", undefined, 1_000], 17 | ])("gets minimum integer digits: %s = %s", (format, expected, value) => { 18 | const result = getMinimumIntegerDigits(value, format); 19 | expect(result).toBe(expected); 20 | }); 21 | }); 22 | -------------------------------------------------------------------------------- /src/options/minimum-integer-digits.ts: -------------------------------------------------------------------------------- 1 | import { 2 | rgIntegerDigits, 3 | rgNotationFormat, 4 | rgZerosOnTheLeft, 5 | } from "../helpers/regex"; 6 | import { getIntegerDigitsOnFormat } from "../helpers/integer-digits-on-format"; 7 | import { getDigitsFormat } from "../helpers/digits-format"; 8 | 9 | export function getMinimumIntegerDigits(value: number, format: string) { 10 | const digitsFormat = getDigitsFormat(format); 11 | if (digitsFormat === "0,000") return undefined; 12 | if (rgNotationFormat.test(format || "")) return undefined; 13 | 14 | const integersInFormat = getIntegerDigitsOnFormat(digitsFormat); 15 | const strValue = value.toString(); 16 | const [integersInValue] = rgIntegerDigits.exec(strValue) || [""]; 17 | const [zerosInLeft] = rgZerosOnTheLeft.exec(strValue) || [""]; 18 | 19 | return ( 20 | Math.max(integersInFormat, integersInValue.length - zerosInLeft.length) || 21 | undefined 22 | ); 23 | } 24 | -------------------------------------------------------------------------------- /src/options/notation.test.ts: -------------------------------------------------------------------------------- 1 | import { getNotation } from "./notation"; 2 | 3 | describe("notation", () => { 4 | it.each([ 5 | ["0.0", "standard"], 6 | ["0.0", "standard"], 7 | ["0.0a", "compact"], 8 | ["0.0A", "compact"], 9 | ["(0.0A)", "compact"], 10 | ["0.0e", "scientific"], 11 | ["(0.0e)", "scientific"], 12 | ["0.0E", "engineering"], 13 | ["(0.0E)", "engineering"], 14 | ])("notation: %s = %s", (format, expected) => { 15 | const result = getNotation(format); 16 | expect(result).toBe(expected); 17 | }); 18 | }); 19 | -------------------------------------------------------------------------------- /src/options/notation.ts: -------------------------------------------------------------------------------- 1 | import { rgIsCompact, rgIsScientific, rgIsEngineering } from "../helpers/regex"; 2 | 3 | type INotation = "standard" | "scientific" | "engineering" | "compact"; 4 | 5 | export function getNotation(format: string): INotation { 6 | if (rgIsCompact.test(format)) return "compact"; 7 | if (rgIsScientific.test(format)) return "scientific"; 8 | if (rgIsEngineering.test(format)) return "engineering"; 9 | 10 | return "standard"; 11 | } 12 | -------------------------------------------------------------------------------- /src/options/sign-display.test.ts: -------------------------------------------------------------------------------- 1 | import { getSignDisplay } from "./sign-display"; 2 | 3 | describe("format sign display", () => { 4 | it.each([ 5 | ["0,0", "auto"], 6 | ["+0,0", "exceptZero"], 7 | ["-0,0", "auto"], 8 | ["(0)", "never"], 9 | ["0,0.0", "auto"], 10 | ["+0,0.0", "exceptZero"], 11 | ["-0,0.0", "auto"], 12 | ["(0,0.0)", "never"], 13 | ])("sign display: %s = %s", (format, expected) => { 14 | const result = getSignDisplay(format); 15 | expect(result).toBe(expected); 16 | }); 17 | }); 18 | -------------------------------------------------------------------------------- /src/options/sign-display.ts: -------------------------------------------------------------------------------- 1 | import { rgHasParentheses, rgStartsWithPlus } from "../helpers/regex"; 2 | 3 | type ISignDisplay = "auto" | "never" | "exceptZero"; 4 | 5 | export function getSignDisplay(format: string): ISignDisplay | undefined { 6 | if (rgHasParentheses.test(format)) return "never"; 7 | if (rgStartsWithPlus.test(format)) return "exceptZero"; 8 | return "auto"; 9 | } 10 | -------------------------------------------------------------------------------- /src/options/style.test.ts: -------------------------------------------------------------------------------- 1 | import { getStyle } from "./style"; 2 | 3 | describe("get style", () => { 4 | it.each([ 5 | ["0.0", "decimal"], 6 | ["0.0GBP", "currency"], 7 | ["0.0GBPs", "currency"], 8 | ["0.0GBPc", "currency"], 9 | ["0.0GBPn", "currency"], 10 | ["0.0%", "percent"], 11 | ])("style: $s = $s", (format, expected) => { 12 | const result = getStyle(format); 13 | expect(result).toBe(expected); 14 | }); 15 | }); 16 | -------------------------------------------------------------------------------- /src/options/style.ts: -------------------------------------------------------------------------------- 1 | import { rgCurrencyFormat, rgUnitSystem } from "../helpers/regex"; 2 | 3 | type IStyle = "decimal" | "currency" | "percent" | "unit"; 4 | 5 | export function getStyle(format: string): IStyle { 6 | if (rgCurrencyFormat.test(format)) return "currency"; 7 | if (rgUnitSystem.test(format)) return "unit"; 8 | if (format.includes("%")) return "percent"; 9 | 10 | return "decimal"; 11 | } 12 | -------------------------------------------------------------------------------- /src/options/unit-display.test.ts: -------------------------------------------------------------------------------- 1 | import { getUnitDisplay } from "./unit-display"; 2 | 3 | describe("unit display", () => { 4 | it.each([ 5 | ["0.0", "short"], 6 | ["0b", "narrow"], 7 | ["0 b", "short"], 8 | ["0B", "narrow"], 9 | ["0 B", "short"], 10 | ["0 k", "short"], 11 | ["0k", "narrow"], 12 | ])("unit display: % = %s", (format, expected) => { 13 | const result = getUnitDisplay(format); 14 | expect(result).toBe(expected); 15 | }); 16 | }); 17 | -------------------------------------------------------------------------------- /src/options/unit-display.ts: -------------------------------------------------------------------------------- 1 | import { rgUnitSystem, rgSpaceBetween } from "../helpers/regex"; 2 | 3 | type IUnitDisplay = "short" | "narrow" | "long"; 4 | 5 | export function getUnitDisplay(format: string): IUnitDisplay { 6 | if (rgUnitSystem.test(format) && !rgSpaceBetween.test(format)) { 7 | return "narrow"; 8 | } 9 | 10 | return "short"; 11 | } 12 | -------------------------------------------------------------------------------- /src/options/unit.test.ts: -------------------------------------------------------------------------------- 1 | import { getUnitFromPowerOfTwo, getMetricUnit, getUnit } from "./unit"; 2 | 3 | describe("get unit", () => { 4 | it.each([ 5 | ["bit", 1], 6 | ["kilobit", 1_024], 7 | ["kilobit", 1_024 + 100], 8 | ["kilobit", 1_024 * 1_024 - 100], 9 | ["megabit", 1_024 * 1_024], 10 | ["gigabit", 1_024 * 1_024 * 1_024], 11 | ["terabit", 1_024 * 1_024 * 1_024 * 1_024], 12 | ])("unit for power of twos (bits): %s - %s", (expected, value) => { 13 | const result = getUnitFromPowerOfTwo(value, [ 14 | "bit", 15 | "kilobit", 16 | "megabit", 17 | "gigabit", 18 | "terabit", 19 | ]); 20 | expect(result).toBe(expected); 21 | }); 22 | 23 | it.each([ 24 | ["byte", 1], 25 | ["kilobyte", 1_024], 26 | ["kilobyte", 1_024 + 100], 27 | ["kilobyte", 1_024 * 1_024 - 100], 28 | ["megabyte", 1_024 * 1_024], 29 | ["gigabyte", 1_024 * 1_024 * 1_024], 30 | ["terabyte", 1_024 * 1_024 * 1_024 * 1_024], 31 | ["petabyte", 1_024 * 1_024 * 1_024 * 1_024 * 1_024], 32 | ])("unit for power of twos (bits): %s - %s", (expected, value) => { 33 | const result = getUnitFromPowerOfTwo(value, [ 34 | "byte", 35 | "kilobyte", 36 | "megabyte", 37 | "gigabyte", 38 | "terabyte", 39 | "petabyte", 40 | ]); 41 | expect(result).toBe(expected); 42 | }); 43 | 44 | it.each([ 45 | ["millimeter", 0.001], 46 | ["centimeter", 0.01], 47 | ["meter", 1], 48 | ["kilometer", 1_000], 49 | ])("metric value: %s - %s", (expected, value) => { 50 | const result = getMetricUnit(value); 51 | expect(result).toBe(expected); 52 | }); 53 | 54 | it.each([ 55 | ["0b", "bit", 1], 56 | ["0b", "kilobit", 1_024], 57 | ["0B", "byte", 1], 58 | ["0B", "kilobyte", 1_024], 59 | ])("unit: %s = %s", (format, expected, value) => { 60 | const result = getUnit(value, format); 61 | expect(result).toBe(expected); 62 | }); 63 | }); 64 | -------------------------------------------------------------------------------- /src/options/unit.ts: -------------------------------------------------------------------------------- 1 | import { 2 | rgUnitSystem, 3 | rgBitSystem, 4 | rgByteSystem, 5 | rgMetricSystem, 6 | rgKiloSystem, 7 | } from "../helpers/regex"; 8 | 9 | type IBit = "bit" | "kilobit" | "megabit" | "gigabit" | "terabit"; 10 | 11 | type IByte = 12 | | "byte" 13 | | "kilobyte" 14 | | "megabyte" 15 | | "gigabyte" 16 | | "terabyte" 17 | | "petabyte"; 18 | 19 | type IMetric = "millimeter" | "centimeter" | "meter" | "kilometer"; 20 | 21 | type IKilo = "gram" | "kilogram"; 22 | 23 | type IUnit = IBit | IByte | IMetric | IKilo; 24 | 25 | export function getUnitFromPowerOfTwo( 26 | value: number, 27 | units: (IBit | IByte)[] 28 | ): IBit | IByte { 29 | const idx = Math.floor(Math.log(value) / Math.log(1024)); 30 | return units[idx]; 31 | } 32 | 33 | export function getMetricUnit(value: number): IMetric { 34 | const absValue = Math.abs(value); 35 | 36 | if (absValue > 999.9999) return "kilometer"; 37 | if (absValue >= 0.01 && absValue <= 0.0999) return "centimeter"; 38 | if (absValue >= 0.001 && absValue <= 0.00999) return "millimeter"; 39 | 40 | return "meter"; 41 | } 42 | 43 | export function getKiloUnit(value: number): IKilo { 44 | const absValue = Math.abs(value); 45 | 46 | if (absValue >= 0.001 && absValue <= 0.00999) return "gram"; 47 | 48 | return "kilogram"; 49 | } 50 | 51 | export function getUnit(value: number, format: string): IUnit | undefined { 52 | if (!rgUnitSystem.test(format)) return undefined; 53 | 54 | if (rgMetricSystem.test(format)) return getMetricUnit(value); 55 | if (rgKiloSystem.test(format)) return getKiloUnit(value); 56 | 57 | if (rgBitSystem.test(format)) 58 | return getUnitFromPowerOfTwo(value, [ 59 | "bit", 60 | "kilobit", 61 | "megabit", 62 | "gigabit", 63 | "terabit", 64 | ]); 65 | 66 | if (rgByteSystem.test(format)) 67 | return getUnitFromPowerOfTwo(value, [ 68 | "byte", 69 | "kilobyte", 70 | "megabyte", 71 | "gigabyte", 72 | "terabyte", 73 | "petabyte", 74 | ]); 75 | 76 | return undefined; 77 | } 78 | -------------------------------------------------------------------------------- /src/tests/compact.test.ts: -------------------------------------------------------------------------------- 1 | import nf from ".."; 2 | 3 | describe("compact notation", () => { 4 | it.each([ 5 | ["0a", "123", 123], 6 | ["0a", "1K", 1_234], 7 | ["0a", "12K", 12_345], 8 | ["0a", "123K", 123_456], 9 | ["0a", "1M", 1_234_567], 10 | ["0a", "12M", 12_345_678], 11 | ["0a", "123M", 123_456_789], 12 | ["0a", "1B", 1_234_567_890], 13 | ["0a", "12B", 12_345_678_901], 14 | ["0a", "123B", 123_456_789_012], 15 | ["0a", "1T", 1_234_567_890_123], 16 | ["0a", "12T", 12_345_678_901_234], 17 | ["0a", "123T", 123_456_789_012_345], 18 | ["0.0a", "1.2M", 1_234_567], 19 | ["0.00a", "1.23M", 1_234_567], 20 | ["0.000a", "1.235M", 1_234_567], 21 | ["0.000a", "1.235M", 1_234_567], 22 | ])("short format: %s, %s", (format, expected, value) => { 23 | const result = nf(value, format); 24 | expect(result).toBe(expected); 25 | }); 26 | 27 | it.each([ 28 | ["0A", "1", 1], 29 | ["0A", "123", 123], 30 | ["0A", "1 thousand", 1_234], 31 | ["0A", "12 thousand", 12_345], 32 | ["0A", "123 thousand", 123_456], 33 | ["0A", "1 million", 1_234_567], 34 | ["0A", "12 million", 12_345_678], 35 | ["0A", "123 million", 123_456_789], 36 | ["0A", "1 billion", 1_234_567_890], 37 | ["0A", "12 billion", 12_345_678_901], 38 | ["0A", "123 billion", 123_456_789_012], 39 | ["0A", "1 trillion", 1_234_567_890_123], 40 | ["0A", "12 trillion", 12_345_678_901_234], 41 | ["0A", "123 trillion", 123_456_789_012_345], 42 | ["0.0A", "1.2 million", 1_234_567], 43 | ["0.00A", "1.23 million", 1_234_567], 44 | ])("long format: %s, %s", (format, expected, value) => { 45 | const result = nf(value, format); 46 | expect(result).toBe(expected); 47 | }); 48 | }); 49 | -------------------------------------------------------------------------------- /src/tests/compound.test.ts: -------------------------------------------------------------------------------- 1 | import nf from ".."; 2 | 3 | describe("compound format", () => { 4 | it.each([ 5 | ["(0m)", "(1.23km)", -1_234], 6 | ["(0.000m)", "(1.234km)", -1_234], 7 | ])("compounds: %s = %s", (format, expected, value) => { 8 | const result = nf(value, format); 9 | expect(result).toBe(expected); 10 | }); 11 | }); 12 | -------------------------------------------------------------------------------- /src/tests/currency.test.ts: -------------------------------------------------------------------------------- 1 | import nf from ".."; 2 | 3 | describe("currency", () => { 4 | it.each([ 5 | ["0,0GBP", "£1,000", 1_000], 6 | ["GBP", "£1,000", 1_000], 7 | ["0,0USD", "US$1,000", 1_000], 8 | ["0,0EUR", "€1,000", 1_000], 9 | ["0,0JPY", "JP¥1,000", 1_000], 10 | ["0,0CAD", "CA$1,000", 1_000], 11 | ["0,0GBP", "£123,456", 123_456], 12 | ])("currency (symbol): %s = %s", (format, expected, value) => { 13 | const result = nf(value, format); 14 | expect(result).toBe(expected); 15 | }); 16 | 17 | it.each([ 18 | ["0,0GBPs", "£1,000", 1_000], 19 | ["0,0USDs", "$1,000", 1_000], 20 | ["0,0EURs", "€1,000", 1_000], 21 | ["0,0JPYs", "¥1,000", 1_000], 22 | ["0,0CADs", "$1,000", 1_000], 23 | ])("currency (narrow symbol): %s = %s", (format, expected, value) => { 24 | const result = nf(value, format); 25 | expect(result).toBe(expected); 26 | }); 27 | 28 | it.each([ 29 | ["0,0GBPc", "GBP 1,000", 1_000], 30 | ["0,0USDc", "USD 1,000", 1_000], 31 | ["0,0EURc", "EUR 1,000", 1_000], 32 | ["0,0JPYc", "JPY 1,000", 1_000], 33 | ["0,0CADc", "CAD 1,000", 1_000], 34 | ])("currency (code): %s = %s", (format, expected, value) => { 35 | const result = nf(value, format); 36 | expect(result).toBe(expected); 37 | }); 38 | 39 | it.each([ 40 | ["0,0GBPn", "1 British pound", 1], 41 | ["0,0USDn", "1 US dollar", 1], 42 | ["0,0EURn", "1 euro", 1], 43 | ["0,0JPYn", "1 Japanese yen", 1], 44 | ["0,0CADn", "1 Canadian dollar", 1], 45 | ["0,0GBPn", "1,000 British pounds", 1_000], 46 | ["0,0USDn", "1,000 US dollars", 1_000], 47 | ["0,0EURn", "1,000 euros", 1_000], 48 | ["0,0JPYn", "1,000 Japanese yen", 1_000], 49 | ["0,0CADn", "1,000 Canadian dollars", 1_000], 50 | ])("currency (name): %s = %s", (format, expected, value) => { 51 | const result = nf(value, format); 52 | expect(result).toBe(expected); 53 | }); 54 | 55 | it.each([ 56 | ["0,0GBPa", "£1M", 1_234_567], 57 | ["0,0GBPsa", "£1M", 1_234_567], 58 | ["0,0GBPca", "GBP 1M", 1_234_567], 59 | ["0,0GBPna", "1M British pounds", 1_234_567], 60 | ])("currency: %s = %s", (format, expected, value) => { 61 | const result = nf(value, format); 62 | expect(result).toBe(expected); 63 | }); 64 | }); 65 | -------------------------------------------------------------------------------- /src/tests/digital.test.ts: -------------------------------------------------------------------------------- 1 | import nf from ".."; 2 | 3 | describe("digital", () => { 4 | it.each([ 5 | ["0b", "1bit", 1], 6 | ["0b", "1kb", 1_024], 7 | ["0b", "1.49kb", 1_024 + 500], 8 | ["0b", "1.02kb", 1_024 + 20], 9 | ["0b", "23.39kb", 1_024 * 23 + 400], 10 | ["0b", "1Mb", 1_024 * 1_024], 11 | ["0b", "1Gb", 1_024 * 1_024 * 1_024], 12 | ["0b", "1Tb", 1_024 * 1_024 * 1_024 * 1_024], 13 | ["0 b", "1 bit", 1], 14 | ["0 b", "1 kb", 1_024], 15 | ["0 b", "1.49 kb", 1_024 + 500], 16 | ["0 b", "1.02 kb", 1_024 + 20], 17 | ["0 b", "23.39 kb", 1_024 * 23 + 400], 18 | ["0 b", "1 Mb", 1_024 * 1_024], 19 | ["0 b", "1 Gb", 1_024 * 1_024 * 1_024], 20 | ["0 b", "1 Tb", 1_024 * 1_024 * 1_024 * 1_024], 21 | ["0b", "120.56kb", 123456], 22 | ])("bits: %s = %s", (format, expected, value) => { 23 | const result = nf(value, format); 24 | expect(result).toBe(expected); 25 | }); 26 | 27 | it.each([ 28 | ["0B", "1B", 1], 29 | ["0B", "1kB", 1_024], 30 | ["0B", "1.49kB", 1_024 + 500], 31 | ["0B", "1.02kB", 1_024 + 20], 32 | ["0B", "23.39kB", 1_024 * 23 + 400], 33 | ["0B", "1MB", 1_024 * 1_024], 34 | ["0B", "1GB", 1_024 * 1_024 * 1_024], 35 | ["0B", "1TB", 1_024 * 1_024 * 1_024 * 1_024], 36 | ["0 B", "1 byte", 1], 37 | ["0 B", "1 kB", 1_024], 38 | ["0 B", "1.49 kB", 1_024 + 500], 39 | ["0 B", "1.02 kB", 1_024 + 20], 40 | ["0 B", "23.39 kB", 1_024 * 23 + 400], 41 | ["0 B", "1 MB", 1_024 * 1_024], 42 | ["0 B", "1 GB", 1_024 * 1_024 * 1_024], 43 | ["0 B", "1 TB", 1_024 * 1_024 * 1_024 * 1_024], 44 | ])("bytes: %s = %s", (format, expected, value) => { 45 | const result = nf(value, format); 46 | expect(result).toBe(expected); 47 | }); 48 | }); 49 | -------------------------------------------------------------------------------- /src/tests/exponential.test.ts: -------------------------------------------------------------------------------- 1 | import nf from ".."; 2 | 3 | describe("exponential", () => { 4 | it.each([ 5 | ["0e", "1E0", 1], 6 | ["0e", "1E1", 10], 7 | ["0e", "1E2", 100], 8 | ["0e", "1E3", 1_000], 9 | ["0e", "1E4", 10_000], 10 | ["0e", "1E6", 1_000_000], 11 | ["0.0e", "1.2E7", 12_345_678], 12 | ])("exponential (scientific): %s = %s", (format, expected, value) => { 13 | const result = nf(value, format); 14 | expect(result).toBe(expected); 15 | }); 16 | 17 | it.each([ 18 | ["0E", "1E0", 1], 19 | ["0E", "10E0", 10], 20 | ["0E", "100E0", 100], 21 | ["0E", "1E3", 1_000], 22 | ["0E", "10E3", 10_000], 23 | ["0E", "100E3", 100_000], 24 | ["0E", "1E6", 1_000_000], 25 | ["0.0E", "12.3E6", 12_345_678], 26 | ])("exponential (engineering): %s = %s", (format, expected, value) => { 27 | const result = nf(value, format); 28 | expect(result).toBe(expected); 29 | }); 30 | }); 31 | -------------------------------------------------------------------------------- /src/tests/length.test.ts: -------------------------------------------------------------------------------- 1 | import nf from ".."; 2 | 3 | describe("length", () => { 4 | it.each([ 5 | ["0m", "1mm", 0.001], 6 | ["0m", "1cm", 0.01], 7 | ["0m", "1m", 1], 8 | ["0m", "1km", 1_000], 9 | ["0m", "1.2km", 1_200], 10 | ])("metric (narrow): %s = %s", (format, expected, value) => { 11 | const result = nf(value, format); 12 | expect(result).toBe(expected); 13 | }); 14 | 15 | it.each([ 16 | ["0 m", "1 mm", 0.001], 17 | ["0 m", "1 cm", 0.01], 18 | ["0 m", "1 m", 1], 19 | ["0 m", "1 km", 1_000], 20 | ])("metric (short): %s = %s", (format, expected, value) => { 21 | const result = nf(value, format); 22 | expect(result).toBe(expected); 23 | }); 24 | }); 25 | -------------------------------------------------------------------------------- /src/tests/mass.test.ts: -------------------------------------------------------------------------------- 1 | import nf from ".."; 2 | 3 | describe("mass", () => { 4 | it.each([ 5 | ["0k", "1kg", 1], 6 | ["0k", "1.23kg", 1.23], 7 | ["0k", "1g", 0.001], 8 | ["0 k", "1 kg", 1], 9 | ["0 k", "1 g", 0.001], 10 | ])("mass: %s = %s", (format, expected, value) => { 11 | const result = nf(value, format); 12 | expect(result).toBe(expected); 13 | }); 14 | }); 15 | -------------------------------------------------------------------------------- /src/tests/numeric.test.ts: -------------------------------------------------------------------------------- 1 | import nf from ".."; 2 | 3 | describe("numeric formatting", () => { 4 | it.each([ 5 | ["0.0", 1, "1.0"], 6 | ["0.00", 1.23, "1.23"], 7 | ["0.00", 1.2345, "1.23"], 8 | ["0.00", 1.2, "1.20"], 9 | ["0.00000", 0.23, "0.23000"], 10 | ["0[.]00", 1.23, "1.23"], 11 | ["0[.]000", 1.23, "1.230"], 12 | ["0[.]0000", 1, "1"], 13 | ["0.00", -1.234, "-1.23"], 14 | ["0[.]00", -1, "-1"], 15 | ["0[.]00", -1.234, "-1.23"], 16 | ["0[.]00", 10_000.1, "10000.10"], 17 | ["0[.]00", 10_000.123, "10000.12"], 18 | ["0[.]00", 10_000.456, "10000.46"], 19 | ["0[.]00", 10_000.001, "10000.00"], 20 | ["0[.]00[0]", 10_000.45, "10000.45"], 21 | ["0[.]00[0]", 10_000.451, "10000.451"], 22 | ["0[.]00[0]", 10_000.456, "10000.456"], 23 | ["0[.]00[00]", 10_000.234, "10000.234"], 24 | ["0[.]00[00]", 10_000.2345, "10000.2345"], 25 | ["0,0.00", 123_456, "123,456.00"], 26 | ])("formats fraction digits: %s", (format, value, expected) => { 27 | const result = nf(value, format); 28 | expect(result).toBe(expected); 29 | }); 30 | 31 | it.each([ 32 | ["0", 1_000, "1000"], 33 | ["0.0", 1, "1.0"], 34 | ["0,0", 1_000, "1,000"], 35 | ["000", 1, "001"], 36 | ["000", 10_000, "10000"], 37 | [".00", 0.23, ".23"], 38 | [".00", 1.23, "1.23"], 39 | [".00", 1_000.23, "1000.23"], 40 | [".0", 0, ".0"], 41 | ])("formats integer digits: %s", (format, value, expected) => { 42 | const result = nf(value, format); 43 | expect(result).toBe(expected); 44 | }); 45 | }); 46 | -------------------------------------------------------------------------------- /src/tests/ordinal.test.ts: -------------------------------------------------------------------------------- 1 | import nf from ".."; 2 | 3 | describe("ordinals", () => { 4 | it.each([ 5 | ["0o", "0th", 0], 6 | ["0o", "1st", 1], 7 | ["0o", "2nd", 2], 8 | ["0o", "3rd", 3], 9 | ["0o", "4th", 4], 10 | ["0,0.000o", "1,234.560th", 1_234.56], 11 | ["0 o", "0 th", 0], 12 | ["0 o", "1 st", 1], 13 | ["0 o", "2 nd", 2], 14 | ["0 o", "3 rd", 3], 15 | ["0 o", "4 th", 4], 16 | ["0,0.000 o", "1,234.560 th", 1_234.56], 17 | ])("ordinals: %s = %s", (format, expected, value) => { 18 | const result = nf(value, format); 19 | expect(result).toBe(expected); 20 | }); 21 | }); 22 | -------------------------------------------------------------------------------- /src/tests/partial-application.test.ts: -------------------------------------------------------------------------------- 1 | import nf from "../index"; 2 | 3 | describe("partial applications", () => { 4 | it.each([ 5 | [2, "0.000", "2.000"], 6 | [3_000, "0,000", "3,000"], 7 | [5_000, "0B", "4.88kB"], 8 | ])("partially applies format, then values: %s", (value, format, expected) => { 9 | const fmt = nf.partial(format); 10 | const result = fmt(value); 11 | expect(result).toBe(expected); 12 | }); 13 | }); 14 | -------------------------------------------------------------------------------- /src/tests/percentage.test.ts: -------------------------------------------------------------------------------- 1 | import nf from ".."; 2 | 3 | describe("percentage", () => { 4 | it.each([ 5 | ["0%", "100%", 1], 6 | ["0.000%", "97.488%", 0.974878234], 7 | ["0%", "-43%", -0.43], 8 | ["(0.000%)", "43.000%", 0.43], 9 | ["0 %", "100 %", 1], 10 | ["(0.000 %)", "43.000 %", 0.43], 11 | ])("percentage: %s = %s", (format, expected, value) => { 12 | const result = nf(value, format); 13 | expect(result).toBe(expected); 14 | }); 15 | }); 16 | -------------------------------------------------------------------------------- /src/tests/sign.test.ts: -------------------------------------------------------------------------------- 1 | import nf from ".."; 2 | 3 | describe("signal display", () => { 4 | it.each([ 5 | ["+0", 0, "0"], 6 | ["+0", 1, "+1"], 7 | ["+0", -1, "-1"], 8 | ["-0", 0, "0"], 9 | ["-0", 1, "1"], 10 | ["-0", -1, "-1"], 11 | ["(0)", 0, "0"], 12 | ["(0)", 1, "1"], 13 | ["(0)", -1, "(1)"], 14 | ["+0,0", 1_000, "+1,000"], 15 | ["+0,0", -1_000, "-1,000"], 16 | ["-0,0", 1_000, "1,000"], 17 | ["-0,0", -1_000, "-1,000"], 18 | ["(0,0)", 1_000, "1,000"], 19 | ["(0,0)", -1_000, "(1,000)"], 20 | ["+0.00", 1.23, "+1.23"], 21 | ["+0.00", -1.23, "-1.23"], 22 | ["-0.00", 1.23, "1.23"], 23 | ["-0.00", -1.23, "-1.23"], 24 | ["(0.00)", 1.23, "1.23"], 25 | ["(0.00)", -1.23, "(1.23)"], 26 | ])("sign display: %s", (format, value, expected) => { 27 | const result = nf(value, format); 28 | expect(result).toBe(expected); 29 | }); 30 | }); 31 | -------------------------------------------------------------------------------- /tsconfig.build.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "outDir": "./dist" 5 | }, 6 | "exclude": ["node_modules/**/*", "**/*.test.ts"] 7 | } 8 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "baseUrl": "./", 4 | "declaration": true, 5 | "esModuleInterop": true, 6 | "lib": ["DOM"], 7 | "module": "commonjs", 8 | "moduleResolution": "node", 9 | "outDir": "./dist", 10 | "removeComments": true, 11 | "skipLibCheck": true, 12 | "strict": true, 13 | "target": "es6", 14 | "types": ["vitest/globals"] 15 | }, 16 | "include": ["src/**/*"], 17 | "exclude": ["node_modules/**/*"] 18 | } 19 | -------------------------------------------------------------------------------- /vite.config.mts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "vitest/config"; 2 | import path from "node:path"; 3 | 4 | export default defineConfig({ 5 | test: { 6 | globals: true, 7 | coverage: { 8 | reporter: ["lcov"], 9 | }, 10 | }, 11 | resolve: { 12 | alias: { 13 | src: path.resolve(__dirname, "./src/"), 14 | }, 15 | }, 16 | }); 17 | --------------------------------------------------------------------------------