├── .github └── workflows │ ├── browsers.yaml │ └── nodejs.yaml ├── .gitignore ├── .gitmodules ├── LICENSE ├── README.md ├── babel.config.js ├── examples ├── README.md ├── index.html ├── package.json ├── rollup.config.js ├── src │ ├── factory.js │ ├── polyfill.js │ └── ponyfill.js └── test.sh ├── factory.d.ts ├── package-lock.json ├── package.json ├── plural-rules.d.ts ├── rollup.config.js ├── src ├── factory.mjs ├── plural-rules.mjs └── polyfill.mjs └── test ├── browser-test.html ├── browserstack-driver.js ├── native-numberformat.test.mjs ├── rollup.browser.js ├── rollup.test262.js ├── test-suite.mjs └── test262-prelude.mjs /.github/workflows/browsers.yaml: -------------------------------------------------------------------------------- 1 | name: Browsers 2 | 3 | on: 4 | push: 5 | branches: [main] 6 | pull_request: 7 | workflow_dispatch: 8 | 9 | jobs: 10 | test: 11 | runs-on: ubuntu-latest 12 | 13 | steps: 14 | - name: BrowserStack Env Setup 15 | uses: browserstack/github-actions/setup-env@master 16 | with: 17 | username: ${{ secrets.BROWSERSTACK_USERNAME }} 18 | access-key: ${{ secrets.BROWSERSTACK_ACCESS_KEY }} 19 | project-name: intl-pluralrules 20 | 21 | - name: BrowserStackLocal Start 22 | uses: browserstack/github-actions/setup-local@master 23 | with: 24 | local-testing: start 25 | local-identifier: random 26 | 27 | - uses: actions/checkout@v3 28 | - uses: actions/setup-node@v3 29 | with: { node-version: 16 } 30 | - run: npm ci 31 | - run: python -m http.server 8000 & 32 | 33 | - run: npm run test:browsers 34 | 35 | - name: BrowserStackLocal Stop 36 | uses: browserstack/github-actions/setup-local@master 37 | with: 38 | local-testing: stop 39 | -------------------------------------------------------------------------------- /.github/workflows/nodejs.yaml: -------------------------------------------------------------------------------- 1 | name: Node.js 2 | 3 | on: 4 | push: 5 | branches: [main] 6 | pull_request: 7 | workflow_dispatch: 8 | 9 | jobs: 10 | test: 11 | runs-on: ubuntu-latest 12 | 13 | strategy: 14 | matrix: 15 | node-version: [14, 16, 18, latest] 16 | 17 | steps: 18 | - uses: actions/checkout@v3 19 | - name: Use Node.js ${{ matrix.node-version }} 20 | uses: actions/setup-node@v3 21 | with: 22 | node-version: ${{ matrix.node-version }} 23 | - if: matrix.node-version == 14 24 | run: npm install --global npm@7 25 | - run: npm ci 26 | - run: npm run build 27 | - run: npm test 28 | - working-directory: examples 29 | run: npm run build 30 | - working-directory: examples 31 | run: npm test 32 | 33 | test262: 34 | runs-on: ubuntu-latest 35 | 36 | steps: 37 | - uses: actions/checkout@v3 38 | with: { submodules: true } 39 | - uses: actions/setup-node@v3 40 | with: { node-version: 18 } 41 | - run: npm ci 42 | - run: npm run test262 43 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .* 2 | node_modules 3 | /*.js 4 | /*.mjs 5 | /coverage 6 | dist/ 7 | !/*.config.js 8 | -------------------------------------------------------------------------------- /.gitmodules: -------------------------------------------------------------------------------- 1 | [submodule "test262/test262"] 2 | path = test262 3 | url = https://github.com/tc39/test262.git 4 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright 2015-2018 by Eemeli Aro 2 | 3 | Permission to use, copy, modify, and/or distribute this software for any 4 | purpose with or without fee is hereby granted, provided that the above 5 | copyright notice and this permission notice appear in all copies. 6 | 7 | THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES 8 | WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF 9 | MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR 10 | ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES 11 | WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN 12 | ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF 13 | OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. 14 | 15 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # intl-pluralrules 2 | 3 | A spec-compliant implementation & polyfill for [Intl.PluralRules], 4 | including the `selectRange(start, end)` method introduced in [Intl.NumberFormat v3]. 5 | Also useful if you need proper support for [`minimumFractionDigits`], 6 | which are only supported in Chrome 77 & later. 7 | 8 | For a polyfill without `selectRange()` and with IE 11 support, please use `intl-pluralrules@1`. 9 | 10 | [intl.pluralrules]: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/PluralRules 11 | [intl.numberformat v3]: https://github.com/tc39/proposal-intl-numberformat-v3/ 12 | [`minimumfractiondigits`]: https://bugs.chromium.org/p/v8/issues/detail?id=8866 13 | 14 | ## Installation 15 | 16 | ``` 17 | npm install intl-pluralrules 18 | ``` 19 | 20 | ## Polyfill 21 | 22 | To use as a polyfill, just import it to ensure that `Intl.PluralRules` is 23 | available in your environment: 24 | 25 | ```js 26 | import 'intl-pluralrules' 27 | ``` 28 | 29 | If `Intl.PluralRules` already exists, 30 | includes a `selectRange()` method, 31 | and supports [multiple locales](https://nodejs.org/api/intl.html), 32 | the polyfill will not be loaded. 33 | 34 | ## Ponyfill 35 | 36 | A complete implementation of PluralRules is available as 37 | `intl-pluralrules/plural-rules`, if you'd prefer using it without modifying your 38 | `Intl` object, or if you wish to use it rather than your environment's own: 39 | 40 | ```js 41 | import PluralRules from 'intl-pluralrules/plural-rules' 42 | 43 | new PluralRules('en').select(1) // 'one' 44 | new PluralRules('en', { minimumSignificantDigits: 3 }).select(1) // 'other' 45 | new PluralRules('en').selectRange(0, 1) // 'other' 46 | new PluralRules('fr').selectRange(0, 1) // 'one' 47 | ``` 48 | 49 | ## Factory 50 | 51 | In order to support all available locales, their data needs to be included in 52 | the package. This means that when minified and gzipped, the above-documented 53 | usage adds about 7kB to your application's production size. If this is a 54 | concern, you can use `intl-pluralrules/factory` and [make-plural] to build a 55 | PluralRules class with locale support limited to only what you actually use. 56 | 57 | [make-plural]: https://www.npmjs.com/package/make-plural 58 | 59 | Thanks to tree-shaking, this example that only supports English and French 60 | minifies & gzips to 1472 bytes. Do note that this size difference is only 61 | apparent with minified production builds. 62 | 63 | ```js 64 | import getPluralRules from 'intl-pluralrules/factory' 65 | import { en, fr } from 'make-plural/plurals' 66 | import { en as enCat, fr as frCat } from 'make-plural/pluralCategories' 67 | import { en as enRange, fr as frRange } from 'make-plural/ranges' 68 | 69 | const sel = { en, fr } 70 | const getSelector = lc => sel[lc] 71 | 72 | const cat = { en: enCat, fr: frCat } 73 | const getCategories = (lc, ord) => cat[lc][ord ? 'ordinal' : 'cardinal'] 74 | 75 | const range = { en: enRange, fr: frRange } 76 | const getRangeSelector = lc => range[lc] 77 | 78 | const PluralRules = getPluralRules( 79 | Intl.NumberFormat, // Not available in IE 10 80 | getSelector, 81 | getCategories, 82 | getRangeSelector 83 | ) 84 | export default PluralRules 85 | ``` 86 | 87 | All arguments of 88 | `getPluralRules(NumberFormat, getSelector, getCategories, getRangeSelector)` 89 | are required. 90 | 91 | - `NumberFormat` should be `Intl.NumberFormat`, or an implementation which 92 | supports at least the `"en"` locale and all of the min/max digit count 93 | options. 94 | - `getSelector(lc)` should return a `function(n, ord)` returning the plural 95 | category of `n`, using cardinal plural rules (by default), or ordinal rules if 96 | `ord` is true. `n` may be a number, or the formatted string representation of 97 | a number. This may be called with any user-provided string `lc`, and should 98 | return `undefined` for invalid or unsupported locales. 99 | - `getCategories(lc, ord)` should return the set of available plural categories 100 | for the locale, either for cardinals (by default), or ordinals if `ord` is 101 | true. This function will be called only with values for which `getSelector` 102 | returns a function. 103 | - `getRangeSelector(lc)` should return a `function(start, end)` returning the 104 | plural category of the range. `start` and `end` are the plural categories of 105 | the corresponding values. 106 | -------------------------------------------------------------------------------- /babel.config.js: -------------------------------------------------------------------------------- 1 | const modules = 2 | process.env.NODE_ENV === 'test' ? 'auto' : process.env.TARGET || false 3 | 4 | module.exports = { 5 | presets: [['@babel/preset-env', { modules }]] 6 | } 7 | -------------------------------------------------------------------------------- /examples/README.md: -------------------------------------------------------------------------------- 1 | Examples for `intl-pluralrules`. To build these: 2 | 3 | ```sh 4 | git clone https://github.com/eemeli/intl-pluralrules.git 5 | 6 | cd intl-pluralrules 7 | npm install # The examples use make-plural from the root 8 | npm run build # The examples use ../.. paths 9 | 10 | cd examples 11 | npm run build 12 | 13 | open index.html 14 | 15 | gzip -k dist/*js 16 | stat -f "%z %N" dist/*gz 17 | # 1339 dist/factory.js.gz 18 | # 5221 dist/polyfill.js.gz 19 | # 5091 dist/ponyfill.js.gz 20 | ``` 21 | -------------------------------------------------------------------------------- /examples/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /examples/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "intl-pluralrules-examples", 3 | "description": "Example usage of intl-pluralrules", 4 | "author": "Eemeli Aro ", 5 | "license": "ISC", 6 | "private": true, 7 | "scripts": { 8 | "build": "../node_modules/.bin/rollup -c", 9 | "test": "sh test.sh" 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /examples/rollup.config.js: -------------------------------------------------------------------------------- 1 | import commonjs from '@rollup/plugin-commonjs' 2 | import resolve from '@rollup/plugin-node-resolve' 3 | import { terser } from "rollup-plugin-terser" 4 | 5 | module.exports = [ 6 | { 7 | input: 'src/factory.js', 8 | output: { file: 'dist/factory.js', format: 'iife', plugins: [terser()] }, 9 | plugins: [resolve()] 10 | }, 11 | { 12 | input: 'src/polyfill.js', 13 | output: { file: 'dist/polyfill.js', format: 'iife', plugins: [terser()] }, 14 | plugins: [commonjs()] 15 | }, 16 | { 17 | input: 'src/ponyfill.js', 18 | output: { file: 'dist/ponyfill.js', format: 'iife', plugins: [terser()] }, 19 | plugins: [commonjs()] 20 | } 21 | ] 22 | -------------------------------------------------------------------------------- /examples/src/factory.js: -------------------------------------------------------------------------------- 1 | // This is effectively as skinny as you can make it. Note in particular that the 2 | // imports are named, rather than using the * wildcard; that's required for 3 | // tree-shaking. If you run the Webpack build, you'll see that this compresses 4 | // to a much smaller size than the other examples. 5 | 6 | import { en, fr } from 'make-plural/cardinals' 7 | import { en as enCat, fr as frCat } from 'make-plural/pluralCategories' 8 | import { en as enRange, fr as frRange } from 'make-plural/ranges' 9 | import getPluralRules from '../../factory' 10 | 11 | const sel = { en, fr } 12 | const getSelector = lc => sel[lc] 13 | 14 | const cat = { en: enCat, fr: frCat } 15 | const getCategories = (lc, ord) => cat[lc][ord ? 'ordinal' : 'cardinal'] 16 | 17 | const range = { en: enRange, fr: frRange } 18 | const getRangeSelector = lc => range[lc] 19 | 20 | const PluralRules = getPluralRules( 21 | Intl.NumberFormat, // Not available in IE 10 22 | getSelector, 23 | getCategories, 24 | getRangeSelector 25 | ) 26 | 27 | { 28 | const one = new PluralRules('en').select(1) 29 | const other = new PluralRules('en', { minimumSignificantDigits: 3 }).select(1) 30 | const range = new PluralRules('en').selectRange(0, 1) 31 | console.log('factory', { one, other, range }) 32 | } 33 | -------------------------------------------------------------------------------- /examples/src/polyfill.js: -------------------------------------------------------------------------------- 1 | // The simplest usage pattern. The right thing in most cases. 2 | 3 | import '../../polyfill' 4 | 5 | const one = new Intl.PluralRules('en').select(1) 6 | const other = new Intl.PluralRules('en', { 7 | minimumSignificantDigits: 3 8 | }).select(1) 9 | console.log(Intl.PluralRules.polyfill ? 'polyfill' : 'native', { one, other }) 10 | -------------------------------------------------------------------------------- /examples/src/ponyfill.js: -------------------------------------------------------------------------------- 1 | // About as simple as the polyfill, but without the global namespace pollution. 2 | 3 | import PluralRules from '../../plural-rules' 4 | 5 | const one = new PluralRules('en').select(1) 6 | const other = new PluralRules('en', { minimumSignificantDigits: 3 }).select(1) 7 | console.log('ponyfill', { one, other }) 8 | -------------------------------------------------------------------------------- /examples/test.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | set -e 3 | 4 | node dist/factory.js | grep -q "factory { one: 'one', other: 'other', range: 'other' }" && echo "factory ok" || (echo "factory failed" && false) 5 | node dist/ponyfill.js | grep -q "ponyfill { one: 'one', other: 'other' }" && echo "ponyfill ok" || (echo "ponyfill failed" && false) 6 | node dist/polyfill.js | grep -q "{ one: 'one', other: 'other' }" && echo "polyfill ok" || (echo "polyfill failed" && false) 7 | -------------------------------------------------------------------------------- /factory.d.ts: -------------------------------------------------------------------------------- 1 | export type Category = 'zero' | 'one' | 'two' | 'few' | 'many' | 'other' 2 | export type Selector = (n: number | string, ord?: boolean) => Category 3 | export type RangeSelector = (start: Category, end: Category) => Category 4 | 5 | export default function getPluralRules( 6 | NumberFormat: Intl.NumberFormat, 7 | getSelector: (lc: string) => Selector | undefined, 8 | getCategories: (lc: string, ord?: boolean) => Category[] | undefined, 9 | getRangeSelector: (lc: string) => RangeSelector 10 | ): Intl.PluralRules 11 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "intl-pluralrules", 3 | "version": "2.0.1", 4 | "description": "Intl.PluralRules polyfill", 5 | "keywords": [ 6 | "unicode", 7 | "cldr", 8 | "i18n", 9 | "internationalization", 10 | "pluralization" 11 | ], 12 | "author": "Eemeli Aro ", 13 | "license": "ISC", 14 | "homepage": "https://github.com/eemeli/intl-pluralrules#readme", 15 | "repository": "eemeli/intl-pluralrules", 16 | "files": [ 17 | "factory.*", 18 | "plural-rules.*", 19 | "polyfill.*" 20 | ], 21 | "type": "commonjs", 22 | "main": "./polyfill.js", 23 | "exports": { 24 | ".": "./polyfill.js", 25 | "./factory": [ 26 | { 27 | "import": "./factory.mjs", 28 | "require": "./factory.js" 29 | }, 30 | "./factory.js" 31 | ], 32 | "./plural-rules": "./plural-rules.js", 33 | "./polyfill": "./polyfill.js", 34 | "./package.json": "./package.json" 35 | }, 36 | "browser": { 37 | "./factory.js": "./factory.mjs" 38 | }, 39 | "react-native": { 40 | "./factory.js": "./factory.js" 41 | }, 42 | "browserslist": [ 43 | "> 0.25%, not dead" 44 | ], 45 | "prettier": { 46 | "arrowParens": "avoid", 47 | "semi": false, 48 | "singleQuote": true, 49 | "trailingComma": "none" 50 | }, 51 | "devDependencies": { 52 | "@babel/core": "^7.11.4", 53 | "@babel/preset-env": "^7.11.0", 54 | "@rollup/plugin-babel": "^5.3.0", 55 | "@rollup/plugin-commonjs": "^20.0.0", 56 | "@rollup/plugin-node-resolve": "^9.0.0", 57 | "c8": "^8.0.0", 58 | "chai": "^4.3.6", 59 | "make-plural": "^7.0.0", 60 | "mocha": "^10.0.0", 61 | "mocha-selenium-bridge": "^0.3.0", 62 | "rollup": "^2.26.5", 63 | "rollup-plugin-terser": "^7.0.0", 64 | "test262-harness": "^10.0.0" 65 | }, 66 | "scripts": { 67 | "build": "rollup -c", 68 | "clean": "git clean -fdxe node_modules -e examples/node_modules", 69 | "prepublishOnly": "npm test && npm run build", 70 | "test": "c8 mocha test/*.test.mjs", 71 | "pretest262": "rollup -c test/rollup.test262.js", 72 | "test262": "test262-harness --error-for-failures --features-exclude cross-realm --prelude test/dist/test262-prelude.js 'test262/test/intl402/PluralRules/**/*.js'", 73 | "pretest:browsers": "npx rollup -c test/rollup.browser.js", 74 | "test:browsers": "npm run test:edge && npm run test:firefox && npm run test:safari", 75 | "test:edge": "BROWSER=Edge:110 mocha-selenium-bridge --driver ./test/browserstack-driver.js http://localhost:8000/test/browser-test.html", 76 | "test:firefox": "BROWSER=Firefox:110 mocha-selenium-bridge --driver ./test/browserstack-driver.js http://localhost:8000/test/browser-test.html", 77 | "test:safari": "OS='OS X:Big Sur' BROWSER=Safari:14.1 mocha-selenium-bridge --driver ./test/browserstack-driver.js http://localhost:8000/test/browser-test.html" 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /plural-rules.d.ts: -------------------------------------------------------------------------------- 1 | declare class PluralRules extends Intl.PluralRules {} 2 | export default PluralRules 3 | -------------------------------------------------------------------------------- /rollup.config.js: -------------------------------------------------------------------------------- 1 | import babel from '@rollup/plugin-babel' 2 | import resolve from '@rollup/plugin-node-resolve' 3 | import commonjs from '@rollup/plugin-commonjs' 4 | 5 | /** 6 | * The wrapping added by bundlers when importing ~200 small functions ends up 7 | * being significantly larger when using the `make-plural` ES module endpoint 8 | * compared to CommonJS. However, importing a CommonJS module from a .mjs 9 | * context is difficult to get right in all environment at the same time, so 10 | * we're vendoring that dependency into `plural-rules.js` 11 | */ 12 | export default [ 13 | { 14 | input: 'src/factory.mjs', 15 | output: { file: 'factory.mjs', format: 'es' }, 16 | plugins: [babel({ babelHelpers: 'bundled' })] 17 | }, 18 | { 19 | input: 'src/factory.mjs', 20 | output: { file: 'factory.js', format: 'cjs', exports: 'default' }, 21 | plugins: [babel({ babelHelpers: 'bundled' })] 22 | }, 23 | { 24 | input: 'src/plural-rules.mjs', 25 | external: ['./factory.mjs'], 26 | output: { 27 | file: 'plural-rules.js', 28 | format: 'cjs', 29 | exports: 'default', 30 | paths: id => id.replace(/^.*\/([^/]+)\.mjs$/, './$1.js') 31 | }, 32 | plugins: [ 33 | resolve({ extensions: ['.js'] }), 34 | commonjs(), 35 | babel({ babelHelpers: 'bundled' }) 36 | ] 37 | }, 38 | { 39 | input: 'src/polyfill.mjs', 40 | context: 'this', 41 | external: ['./plural-rules.mjs'], 42 | output: { 43 | file: 'polyfill.js', 44 | format: 'cjs', 45 | paths: id => id.replace(/^.*\/([^/]+)\.mjs$/, './$1.js') 46 | }, 47 | plugins: [babel({ babelHelpers: 'bundled' })] 48 | } 49 | ] 50 | -------------------------------------------------------------------------------- /src/factory.mjs: -------------------------------------------------------------------------------- 1 | const canonicalizeLocaleList = locales => { 2 | if (!locales) return [] 3 | if (!Array.isArray(locales)) locales = [locales] 4 | const res = {} 5 | for (let i = 0; i < locales.length; ++i) { 6 | let tag = locales[i] 7 | if (tag && typeof tag === 'object') tag = String(tag) 8 | if (typeof tag !== 'string') { 9 | // Requiring tag to be a String or Object means that the Number value 10 | // NaN will not be interpreted as the language tag "nan", which stands 11 | // for Min Nan Chinese. 12 | const msg = `Locales should be strings, ${JSON.stringify(tag)} isn't.` 13 | throw new TypeError(msg) 14 | } 15 | 16 | const parts = tag.split('-') 17 | 18 | // does not check for duplicate subtags 19 | if (!parts.every(subtag => /[a-z0-9]+/i.test(subtag))) { 20 | const strTag = JSON.stringify(tag) 21 | const msg = `The locale ${strTag} is not a structurally valid BCP 47 language tag.` 22 | throw new RangeError(msg) 23 | } 24 | 25 | // always use lower case for primary language subtag 26 | let lc = parts[0].toLowerCase() 27 | // replace deprecated codes for Indonesian, Hebrew & Yiddish 28 | parts[0] = { in: 'id', iw: 'he', ji: 'yi' }[lc] ?? lc 29 | 30 | res[parts.join('-')] = true 31 | } 32 | return Object.keys(res) 33 | } 34 | 35 | function getType(opt) { 36 | const type = Object.prototype.hasOwnProperty.call(opt, 'type') && opt.type 37 | if (!type) return 'cardinal' 38 | if (type === 'cardinal' || type === 'ordinal') return type 39 | throw new RangeError('Not a valid plural type: ' + JSON.stringify(type)) 40 | } 41 | 42 | function toNumber(value) { 43 | switch (typeof value) { 44 | case 'number': 45 | return value 46 | case 'bigint': 47 | throw new TypeError('Cannot convert a BigInt value to a number') 48 | default: 49 | return Number(value) 50 | } 51 | } 52 | 53 | export default function getPluralRules( 54 | NumberFormat, 55 | getSelector, 56 | getCategories, 57 | getRangeSelector 58 | ) { 59 | const findLocale = locale => { 60 | do { 61 | if (getSelector(locale)) return locale 62 | locale = locale.replace(/-?[^-]*$/, '') 63 | } while (locale) 64 | return null 65 | } 66 | 67 | const resolveLocale = locales => { 68 | const canonicalLocales = canonicalizeLocaleList(locales) 69 | for (let i = 0; i < canonicalLocales.length; ++i) { 70 | const lc = findLocale(canonicalLocales[i]) 71 | if (lc) return lc 72 | } 73 | const lc = new NumberFormat().resolvedOptions().locale 74 | return findLocale(lc) 75 | } 76 | 77 | class PluralRules { 78 | static supportedLocalesOf(locales) { 79 | return canonicalizeLocaleList(locales).filter(findLocale) 80 | } 81 | 82 | #locale 83 | #range 84 | #select 85 | #type 86 | #nf 87 | 88 | constructor(locales = [], opt = {}) { 89 | this.#locale = resolveLocale(locales) 90 | this.#select = getSelector(this.#locale) 91 | this.#range = getRangeSelector(this.#locale) 92 | this.#type = getType(opt) 93 | this.#nf = new NumberFormat('en', opt) // make-plural expects latin digits with . decimal separator 94 | } 95 | 96 | resolvedOptions() { 97 | const { 98 | minimumIntegerDigits, 99 | minimumFractionDigits, 100 | maximumFractionDigits, 101 | minimumSignificantDigits, 102 | maximumSignificantDigits, 103 | roundingPriority 104 | } = this.#nf.resolvedOptions() 105 | const opt = { 106 | locale: this.#locale, 107 | type: this.#type, 108 | minimumIntegerDigits, 109 | minimumFractionDigits, 110 | maximumFractionDigits 111 | } 112 | if (typeof minimumSignificantDigits === 'number') { 113 | opt.minimumSignificantDigits = minimumSignificantDigits 114 | opt.maximumSignificantDigits = maximumSignificantDigits 115 | } 116 | opt.pluralCategories = getCategories( 117 | this.#locale, 118 | this.#type === 'ordinal' 119 | ).slice(0) 120 | opt.roundingPriority = roundingPriority || 'auto' 121 | return opt 122 | } 123 | 124 | select(number) { 125 | if (!(this instanceof PluralRules)) 126 | throw new TypeError(`select() called on incompatible ${this}`) 127 | if (typeof number !== 'number') number = Number(number) 128 | if (!isFinite(number)) return 'other' 129 | const fmt = this.#nf.format(Math.abs(number)) 130 | return this.#select(fmt, this.#type === 'ordinal') 131 | } 132 | 133 | selectRange(start, end) { 134 | if (!(this instanceof PluralRules)) 135 | throw new TypeError(`selectRange() called on incompatible ${this}`) 136 | if (start === undefined) throw new TypeError('start is undefined') 137 | if (end === undefined) throw new TypeError('end is undefined') 138 | const start_ = toNumber(start) 139 | const end_ = toNumber(end) 140 | if (!isFinite(start_)) throw new RangeError('start must be finite') 141 | if (!isFinite(end_)) throw new RangeError('end must be finite') 142 | return this.#range(this.select(start_), this.select(end_)) 143 | } 144 | } 145 | 146 | if (typeof Symbol !== 'undefined' && Symbol.toStringTag) { 147 | Object.defineProperty(PluralRules.prototype, Symbol.toStringTag, { 148 | value: 'Intl.PluralRules', 149 | writable: false, 150 | configurable: true 151 | }) 152 | } 153 | Object.defineProperty(PluralRules, 'prototype', { writable: false }) 154 | 155 | return PluralRules 156 | } 157 | -------------------------------------------------------------------------------- /src/plural-rules.mjs: -------------------------------------------------------------------------------- 1 | import * as P from 'make-plural/plurals' 2 | import * as C from 'make-plural/pluralCategories' 3 | import * as R from 'make-plural/ranges' 4 | 5 | import getPluralRules from './factory.mjs' 6 | 7 | // In a .mjs context, CommonJS imports only expose the default endpoint. We're 8 | // using them here because with this many small functions, bundlers produce less 9 | // cruft than for ES module exports. 10 | const Plurals = P.default || P 11 | const Categories = C.default || C 12 | const RangePlurals = R.default || R 13 | 14 | // make-plural exports are cast with safe-identifier to be valid JS identifiers 15 | const id = lc => (lc === 'pt-PT' ? 'pt_PT' : lc) 16 | 17 | const getSelector = lc => Plurals[id(lc)] 18 | const getCategories = (lc, ord) => 19 | Categories[id(lc)][ord ? 'ordinal' : 'cardinal'] 20 | const getRangeSelector = lc => RangePlurals[id(lc)] 21 | 22 | const PluralRules = getPluralRules( 23 | Intl.NumberFormat, 24 | getSelector, 25 | getCategories, 26 | getRangeSelector 27 | ) 28 | export default PluralRules 29 | -------------------------------------------------------------------------------- /src/polyfill.mjs: -------------------------------------------------------------------------------- 1 | import PluralRules from './plural-rules.mjs' 2 | 3 | if (typeof Intl === 'undefined') { 4 | if (typeof global !== 'undefined') { 5 | global.Intl = { PluralRules } 6 | } else if (typeof window !== 'undefined') { 7 | window.Intl = { PluralRules } 8 | } else { 9 | this.Intl = { PluralRules } 10 | } 11 | PluralRules.polyfill = true 12 | } else if (!Intl.PluralRules || !Intl.PluralRules.prototype.selectRange) { 13 | Intl.PluralRules = PluralRules 14 | PluralRules.polyfill = true 15 | } else { 16 | const test = ['en', 'es', 'ru', 'zh'] 17 | const supported = Intl.PluralRules.supportedLocalesOf(test) 18 | if (supported.length < test.length) { 19 | Intl.PluralRules = PluralRules 20 | PluralRules.polyfill = true 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /test/browser-test.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | intl-pluralrules browser tests 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 |
17 | 23 | 24 | 25 | -------------------------------------------------------------------------------- /test/browserstack-driver.js: -------------------------------------------------------------------------------- 1 | const { Builder } = require('selenium-webdriver') 2 | 3 | const username = process.env.BROWSERSTACK_USERNAME 4 | const accessKey = process.env.BROWSERSTACK_ACCESS_KEY 5 | const localIdentifier = process.env.BROWSERSTACK_LOCAL_IDENTIFIER 6 | 7 | const server = `http://${username}:${accessKey}@hub.browserstack.com/wd/hub` 8 | 9 | const [os, osVersion] = (process.env.OS ?? 'Windows:10').split(':') 10 | const [browserName, browserVersion] = process.env.BROWSER.split(':') 11 | 12 | const capabilities = { 13 | 'bstack:options': { local: 'true', os, osVersion }, 14 | browserName, 15 | browserVersion 16 | } 17 | 18 | if (localIdentifier) 19 | capabilities['bstack:options'].localIdentifier = localIdentifier 20 | 21 | module.exports = new Builder() 22 | .usingServer(server) 23 | .withCapabilities(capabilities) 24 | .build() 25 | -------------------------------------------------------------------------------- /test/native-numberformat.test.mjs: -------------------------------------------------------------------------------- 1 | import PluralRules from '../src/plural-rules.mjs' 2 | 3 | import { suite } from './test-suite.mjs' 4 | 5 | describe('With native Intl.NumberFormat', () => suite(PluralRules)) 6 | -------------------------------------------------------------------------------- /test/rollup.browser.js: -------------------------------------------------------------------------------- 1 | import babel from '@rollup/plugin-babel' 2 | import resolve from '@rollup/plugin-node-resolve' 3 | import commonjs from '@rollup/plugin-commonjs' 4 | 5 | /** 6 | * For our own browser tests, we need an IIFE bundle with no exports, 7 | * but compiled with the same config as our published code. 8 | */ 9 | export default [ 10 | { 11 | input: 'src/plural-rules.mjs', 12 | output: { 13 | file: 'test/dist/browser-plural-rules.js', 14 | format: 'iife', 15 | exports: 'default', 16 | name: 'PluralRules' 17 | }, 18 | plugins: [ 19 | resolve({ extensions: ['.js'] }), 20 | commonjs(), 21 | babel({ babelHelpers: 'bundled' }) 22 | ] 23 | }, 24 | { 25 | input: 'test/test-suite.mjs', 26 | output: { 27 | file: 'test/dist/browser-test-suite.js', 28 | format: 'iife', 29 | globals: { chai: 'chai' }, 30 | name: 'prTests' 31 | }, 32 | external: ['chai'], 33 | plugins: [babel({ babelHelpers: 'bundled' })] 34 | } 35 | ] 36 | -------------------------------------------------------------------------------- /test/rollup.test262.js: -------------------------------------------------------------------------------- 1 | import babel from '@rollup/plugin-babel' 2 | import resolve from '@rollup/plugin-node-resolve' 3 | import commonjs from '@rollup/plugin-commonjs' 4 | 5 | /** 6 | * For backwards compatibility and utility as a polyfill, 7 | * intl-pluralrules is transpiled to work in older browsers. 8 | * 9 | * For test262 we should not check the validity of that transpilation, 10 | * so the current environment is used as a target instead. 11 | */ 12 | 13 | export default { 14 | input: 'test/test262-prelude.mjs', 15 | context: 'this', 16 | output: { file: 'test/dist/test262-prelude.js', format: 'iife' }, 17 | plugins: [ 18 | resolve({ extensions: ['.js'] }), 19 | commonjs(), 20 | babel({ babelHelpers: 'bundled', targets: 'current node' }) 21 | ] 22 | } 23 | -------------------------------------------------------------------------------- /test/test-suite.mjs: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai' 2 | 3 | export function suite(PluralRules) { 4 | it('should exist', () => { 5 | expect(PluralRules).to.be.instanceOf(Function) 6 | }) 7 | 8 | describe('.supportedLocalesOf()', () => { 9 | it('should be executable', () => { 10 | expect(() => PluralRules.supportedLocalesOf).not.to.throw() 11 | }) 12 | it('should return an empty array when called with no args', () => { 13 | const res = PluralRules.supportedLocalesOf() 14 | expect(res).to.eql([]) 15 | }) 16 | it('should return a valid array', () => { 17 | const locales = ['en', 'fi-FI'] 18 | const res = PluralRules.supportedLocalesOf(locales) 19 | expect(res).to.eql(locales) 20 | }) 21 | it('should accept String objects', () => { 22 | const res = PluralRules.supportedLocalesOf(new String('en')) 23 | expect(res).to.eql(['en']) 24 | }) 25 | it('should complain about non-strings', () => { 26 | expect(() => PluralRules.supportedLocalesOf(['en', 3])).to.throw( 27 | TypeError 28 | ) 29 | expect(() => PluralRules.supportedLocalesOf([null])).to.throw(TypeError) 30 | }) 31 | it('should complain about bad tags', () => { 32 | expect(() => PluralRules.supportedLocalesOf('en-')).to.throw(RangeError) 33 | expect(() => PluralRules.supportedLocalesOf('-en')).to.throw(RangeError) 34 | expect(() => PluralRules.supportedLocalesOf('*-en')).to.throw(RangeError) 35 | }) 36 | }) 37 | 38 | describe('constructor', () => { 39 | it('should require `new`', () => { 40 | expect(() => PluralRules()).to.throw(TypeError) 41 | expect(() => new PluralRules()).not.to.throw() 42 | }) 43 | it('should select a default type & locale', () => { 44 | const p = new PluralRules() 45 | expect(p).to.be.instanceOf(Object) 46 | expect(p.select).to.be.instanceOf(Function) 47 | const opt = p.resolvedOptions() 48 | expect(opt.type).to.equal('cardinal') 49 | expect(typeof opt.locale).to.equal('string') 50 | expect(opt.locale).to.have.lengthOf.above(1) 51 | }) 52 | it('should handle valid simple arguments correctly', () => { 53 | const p = new PluralRules('PT-PT', { type: 'ordinal' }) 54 | expect(p).to.be.instanceOf(Object) 55 | expect(p.select).to.be.instanceOf(Function) 56 | const opt = p.resolvedOptions() 57 | expect(opt.type).to.equal('ordinal') 58 | expect(opt.locale).to.match(/^pt\b/) 59 | }) 60 | it('should choose a locale correctly from multiple choices', () => { 61 | const p = new PluralRules(['tlh', 'IN', 'en']) 62 | expect(p).to.be.instanceOf(Object) 63 | expect(p.select).to.be.instanceOf(Function) 64 | const opt = p.resolvedOptions() 65 | expect(opt.type).to.equal('cardinal') 66 | expect(opt.locale).to.equal('id') 67 | }) 68 | it('should complain about invalid types', () => { 69 | const fn = () => new PluralRules('en', { type: 'invalid' }) 70 | expect(fn).to.throw(RangeError) 71 | }) 72 | }) 73 | 74 | describe('#resolvedOptions()', () => { 75 | it('should exist', () => { 76 | const p = new PluralRules() 77 | expect(p.resolvedOptions).to.be.instanceOf(Function) 78 | }) 79 | 80 | // https://crbug.com/v8/10832 81 | const maybe = 82 | typeof process === 'undefined' || process.version > 'v16' ? it : it.skip 83 | maybe('should return expected values', () => { 84 | const res = new PluralRules('fi-FI', { 85 | minimumIntegerDigits: 2, 86 | minimumSignificantDigits: 3 87 | }).resolvedOptions() 88 | expect(res).to.deep.include({ 89 | minimumIntegerDigits: 2, 90 | minimumSignificantDigits: 3, 91 | maximumSignificantDigits: 21, 92 | pluralCategories: ['one', 'other'], 93 | roundingPriority: 'auto', 94 | type: 'cardinal' 95 | }) 96 | expect(res.locale).to.match(/^fi\b/) 97 | }) 98 | }) 99 | 100 | describe('#select()', () => { 101 | it('should return a string', () => { 102 | const res = new PluralRules().select() 103 | expect(res).to.equal('other') 104 | }) 105 | it('should complain if bound', () => { 106 | const p = new PluralRules() 107 | expect(p.select.bind(null)).to.throw(TypeError) 108 | }) 109 | it('should work for English cardinals', () => { 110 | const p = new PluralRules('en', { type: 'cardinal' }) 111 | expect(p.select(1)).to.equal('one') 112 | expect(p.select('1.0')).to.equal('one') 113 | expect(p.select(-1)).to.equal('one') 114 | expect(p.select(2)).to.equal('other') 115 | expect(p.select('-2.0')).to.equal('other') 116 | }) 117 | it('should work for English ordinals', () => { 118 | const p = new PluralRules('en', { type: 'ordinal' }) 119 | expect(p.select(1)).to.equal('one') 120 | expect(p.select('22')).to.equal('two') 121 | expect(p.select('3.0')).to.equal('few') 122 | expect(p.select(11)).to.equal('other') 123 | }) 124 | it('should work for Arabic', () => { 125 | const p = new PluralRules('ar-SA') 126 | expect(p.select(0)).to.equal('zero') 127 | expect(p.select(1)).to.equal('one') 128 | }) 129 | it('should work with minimumFractionDigits: 1', () => { 130 | const p = new PluralRules('en', { minimumFractionDigits: 1 }) 131 | expect(p.select(1)).to.equal('other') 132 | expect(p.select('1.0')).to.equal('other') 133 | expect(p.select(2)).to.equal('other') 134 | expect(p.select('-2.0')).to.equal('other') 135 | }) 136 | it('should work with maximumFractionDigits: 0', () => { 137 | const p = new PluralRules('en', { maximumFractionDigits: 0 }) 138 | expect(p.select(1)).to.equal('one') 139 | expect(p.select('1.1')).to.equal('one') 140 | expect(p.select(2)).to.equal('other') 141 | expect(p.select('-2.0')).to.equal('other') 142 | }) 143 | it('should work with minimumSignificantDigits: 2', () => { 144 | const p = new PluralRules('en', { minimumSignificantDigits: 2 }) 145 | expect(p.select(1)).to.equal('other') 146 | expect(p.select('1.0')).to.equal('other') 147 | expect(p.select(2)).to.equal('other') 148 | expect(p.select('-2.0')).to.equal('other') 149 | }) 150 | it('should work with maximumSignificantDigits: 1', () => { 151 | const p = new PluralRules('en', { maximumSignificantDigits: 1 }) 152 | expect(p.select(1)).to.equal('one') 153 | expect(p.select('1.1')).to.equal('one') 154 | expect(p.select(2)).to.equal('other') 155 | expect(p.select('-2.0')).to.equal('other') 156 | }) 157 | it('should work with "," as decimal separator', () => { 158 | const p0 = new PluralRules('cs', { minimumFractionDigits: 0 }) 159 | const p1 = new PluralRules('cs', { minimumFractionDigits: 1 }) 160 | expect(p0.select(1)).to.equal('one') 161 | expect(p1.select(1)).to.equal('many') 162 | expect(p0.select(10)).to.equal('other') 163 | expect(p1.select(10)).to.equal('many') 164 | }) 165 | }) 166 | 167 | describe('#selectRange()', () => { 168 | it('should return a string', () => { 169 | const res = new PluralRules().selectRange(0, 1) 170 | expect(res).to.equal('other') 171 | }) 172 | it('should complain if bound', () => { 173 | const p = new PluralRules() 174 | expect(p.selectRange.bind(null)).to.throw(TypeError) 175 | }) 176 | it('should work for English', () => { 177 | const p = new PluralRules('en') 178 | expect(p.selectRange(0, 1)).to.equal('other') 179 | expect(p.selectRange('0.0', '1.0')).to.equal('other') 180 | expect(p.selectRange(1, 2)).to.equal('other') 181 | }) 182 | it('should work for French', () => { 183 | const p = new PluralRules('fr') 184 | expect(p.selectRange(0, 1)).to.equal('one') 185 | expect(p.selectRange('0.0', '1.0')).to.equal('one') 186 | expect(p.selectRange(1, 2)).to.equal('other') 187 | }) 188 | it('should work with minimumFractionDigits: 1', () => { 189 | const p = new PluralRules('fr', { minimumFractionDigits: 1 }) 190 | expect(p.selectRange(0, 1)).to.equal('one') 191 | expect(p.selectRange('0.0', '1.0')).to.equal('one') 192 | expect(p.selectRange(1, 2)).to.equal('other') 193 | }) 194 | it('should work with maximumFractionDigits: 0', () => { 195 | const p = new PluralRules('fr', { maximumFractionDigits: 0 }) 196 | expect(p.selectRange(0, 1)).to.equal('one') 197 | expect(p.selectRange('1.0', '1.1')).to.equal('one') 198 | expect(p.selectRange(1, 2)).to.equal('other') 199 | }) 200 | it('should complain about undefined values', () => { 201 | const p = new PluralRules('en') 202 | expect(() => p.selectRange(undefined, 2)).to.throw(TypeError) 203 | expect(() => p.selectRange(2, undefined)).to.throw(TypeError) 204 | }) 205 | it('should complain about non-numeric values', () => { 206 | const p = new PluralRules('en') 207 | expect(() => p.selectRange('x', 2)).to.throw(RangeError) 208 | expect(() => p.selectRange(2, 'x')).to.throw(RangeError) 209 | }) 210 | 211 | const maybe = typeof BigInt === 'undefined' ? it.skip : it 212 | maybe('should complain about BigInt values', () => { 213 | const p = new PluralRules('en') 214 | expect(() => p.selectRange(2, BigInt(1))).to.throw(TypeError) 215 | expect(() => p.selectRange(BigInt(2), 1)).to.throw(TypeError) 216 | }) 217 | }) 218 | } 219 | -------------------------------------------------------------------------------- /test/test262-prelude.mjs: -------------------------------------------------------------------------------- 1 | /** This is used as a prelude script for test262-harness. */ 2 | 3 | import PluralRules from '../src/plural-rules.mjs' 4 | 5 | Intl.PluralRules = PluralRules 6 | --------------------------------------------------------------------------------