From ef4b1a6f50c120032f258f86b8a5f99df2ef1e0e Mon Sep 17 00:00:00 2001 From: Alexey Date: Sun, 16 Dec 2018 17:21:04 +0300 Subject: [PATCH] :zap: improvement(index): Allow pluralization customization via constructor options (closes #464) (#482) by @Raiondesu MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * improvement(getChoiceIndex): make getChoiceIndex overridable This provides better pluralization customization. :D * update(docs): fit the new functionality * build(dist): generate dist files * revert(dist): unbuild files to correspond with guidelines * docs(pluralization): fix typo * improvement(test/unit): add test case for custom pluralization * docs(pluralization): remove unnecessary code from new pluralization example * update(types): add types for the new pluralization feature * ⚡improvement(types): remove duplicate type definitions Replace Number and Date format options with standard TS `Intl` types. * ⚡improvement(tests): Let types allow to pass getChoiceIndex into options * ⚡improvement(index): Set getChoiceIndex for current instance from opts Fixes #464 * 🐛fix(types): fix type aliases for format options * ⚡improvement(index): allow to pass a pluralization rules map instead Fixes #464 * 📃docs(api): fix md typo * ⚡improvement(types/flow): add `pluralizationRules` to the instance types * 📃docs(pluralization): add the documentation for #464 functionality * 📃docs(pluralization): fix typo * ⭐️new(test): add a test case for #464 * 📃docs(pluralization): improve custom pluralization definitions * ⭐new(test): add a test for backward compatibility with #451 and 8.4.0 * improvement(index): apply the pluralization rule to the instance For better extensibility. * 📃docs(api): improve `pluralizationRules` property definition * docs(pluralization): fix jsdoc comment misplacement * Revert "⚡improvement(types): remove duplicate type definitions" This reverts commit 286bc2e4a492b93958ea788812dec4f4e2002ea6. * ⚡revert(types): Bring back original VueI18n aliases for format options --- decls/i18n.js | 10 +- gitbook/en/api.md | 34 ++++++- gitbook/en/pluralization.md | 65 ++++++------ src/index.js | 32 ++++-- test/unit/issues.test.js | 191 ++++++++++++++++++++++++++---------- types/index.d.ts | 10 ++ 6 files changed, 251 insertions(+), 91 deletions(-) diff --git a/decls/i18n.js b/decls/i18n.js index af44ae745..e45e4ba1e 100644 --- a/decls/i18n.js +++ b/decls/i18n.js @@ -61,7 +61,10 @@ declare type I18nOptions = { root?: I18n, // for internal fallbackRoot?: boolean, sync?: boolean, - silentTranslationWarn?: boolean + silentTranslationWarn?: boolean, + pluralizationRules?: { + [lang: string]: (choice: number, choicesLength: number) => number, + }, }; declare type IntlAvailability = { @@ -100,7 +103,10 @@ declare interface I18n { getNumberFormat (locale: Locale): NumberFormat, setNumberFormat (locale: Locale, format: NumberFormat): void, mergeNumberFormat (locale: Locale, format: NumberFormat): void, - n (value: number, ...args: any): NumberFormatResult + n (value: number, ...args: any): NumberFormatResult, + pluralizationRules: { + [lang: string]: (choice: number, choicesLength: number) => number + } }; declare interface Formatter { diff --git a/gitbook/en/api.md b/gitbook/en/api.md index 4ec610b03..d4da72e20 100644 --- a/gitbook/en/api.md +++ b/gitbook/en/api.md @@ -38,6 +38,8 @@ Localize the locale message of `key` with pluralization. Localize in preferentially component locale messages than global locale messages. If not specified component locale messages, localize with global locale messages. If you specified `locale`, localize the locale messages of `locale`. If you will specify string value to `values`, localize the locale messages of value. If you will specify Array or Object value to `values`, you must specify with `values` of [$t](#t). + If default pluralization does not suit your needs, see [pluralization rules in constructor options](#pluralizationrules) and [custom pluralization](pluralization.md). + #### getChoiceIndex - **Arguments:** @@ -237,7 +239,7 @@ You can specify the below some options of `I18nOptions` constructor options of [ If `false`, regardless of the root level locale, localize for each component locale. -### silentTranslationWarn +#### silentTranslationWarn > 6.1+ @@ -249,6 +251,26 @@ You can specify the below some options of `I18nOptions` constructor options of [ If `true`, supress localization fail warnings. +#### pluralizationRules + +> 8.5+ + +- **Type:** `Object` + +- **Default:** `{}` + + A set of rules for word pluralization in a following format: + ```js + { + // Key - locale for the rule to be applied to. + // Value - mapping function that maps a choice index from `$tc` to the actual choice of the plural word. + + 'ru': function (choice, choiceIndex) => Number/* index of the plural word */; + 'en': function (choice, choiceIndex) => Number/* index of the plural word */; + 'jp': function (choice, choiceIndex) => Number/* index of the plural word */; + } + ``` + ### Properties #### locale @@ -321,6 +343,16 @@ You can specify the below some options of `I18nOptions` constructor options of [ Whether suppress warnings outputted when localization fails. +#### pluralizationRules + +> 8.5+ + +- **Type:** `Object` + +- **Default:** `{}` + + A set of rules for word pluralization. Key is a locale, value is the rule function for that locale. + ### Methods #### getLocaleMessage( locale ) diff --git a/gitbook/en/pluralization.md b/gitbook/en/pluralization.md index 4d7fa2743..0b0f016b6 100644 --- a/gitbook/en/pluralization.md +++ b/gitbook/en/pluralization.md @@ -43,43 +43,46 @@ This will output the following HTML: Such pluralization, however, does not apply to all languages (Slavic languages, for example, have different pluralization rules). -In order to implement these rules you can override the `VueI18n.prototype.getChoiceIndex` function. +In order to implement these rules you can pass an optional `pluralizationRules` object into `VueI18n` constructor options. Very simplified example using rules for Slavic languages (Russian, Ukrainian, etc.): ```js -/** - * @param choice {number} a choice index given by the input to $tc: `$tc('path.to.rule', choiceIndex)` - * @param choicesLength {number} an overall amount of available choices - * @returns a final choice index to select plural word by -**/ -VueI18n.prototype.getChoiceIndex = function (choice, choicesLength) { - // this === VueI18n instance, so the locale property also exists here - if (this.locale !== 'ru') { - // proceed to the default implementation +new VueI18n({ + pluralizationRules: { + /** Key - language to use the rule for, 'ru', in this case */ + /** Value - function + * @param choice {number} a choice index given by the input to $tc: `$tc('path.to.rule', choiceIndex)` + * @param choicesLength {number} an overall amount of available choices + * @returns a final choice index to select plural word by + **/ + 'ru': function (choice, choicesLength) { + // this === VueI18n instance, so the locale property also exists here + + if (choice === 0) { + return 0; + } + + const teen = choice > 10 && choice < 20; + const endsWithOne = choice % 10 === 1; + + if (choicesLength < 4) { + return (!teen && endsWithOne) ? 1 : 2; + } + if (!teen && endsWithOne) { + return 1; + } + if (!teen && choice % 10 >= 2 && choice % 10 <= 4) { + return 2; + } + + return (choicesLength < 4) ? 2 : 3; + } } - - if (choice === 0) { - return 0; - } - - const teen = choice > 10 && choice < 20; - const endsWithOne = choice % 10 === 1; - - if (!teen && endsWithOne) { - return 1; - } - - if (!teen && choice % 10 >= 2 && choice % 10 <= 4) { - return 2; - } - - return (choicesLength < 4) ? 2 : 3; -} +}); ``` This would effectively give this: - ```javascript const messages = { ru: { @@ -119,3 +122,7 @@ Which results in:

11 бананов

31 банан

``` + +### Default pluralization + +If your current locale is not found in a pluralization map, the [default](#pluralization) rule of the english langugage will be used. diff --git a/src/index.js b/src/index.js index 9b80435a1..75a4fe8e9 100644 --- a/src/index.js +++ b/src/index.js @@ -56,6 +56,9 @@ export default class VueI18n { _numberFormatters: Object _path: I18nPath _dataListeners: Array + pluralizationRules: { + [lang: string]: (choice: number, choicesLength: number) => number + } constructor (options: I18nOptions = {}) { // Auto install if it is not done yet and `window` has `Vue`. @@ -88,6 +91,8 @@ export default class VueI18n { this._path = new I18nPath() this._dataListeners = [] + this.pluralizationRules = options.pluralizationRules || {} + this._exist = (message: Object, key: Path): boolean => { if (!message || !key) { return false } return !isNull(this._path.getPathValue(message, key)) @@ -435,17 +440,26 @@ export default class VueI18n { * @returns a final choice index */ getChoiceIndex (choice: number, choicesLength: number): number { - choice = Math.abs(choice) - - if (choicesLength === 2) { - return choice - ? choice > 1 - ? 1 - : 0 - : 1 + // Default (old) getChoiceIndex implementation - english-compatible + const defaultImpl = (_choice: number, _choicesLength: number) => { + _choice = Math.abs(_choice) + + if (_choicesLength === 2) { + return _choice + ? _choice > 1 + ? 1 + : 0 + : 1 + } + + return _choice ? Math.min(_choice, 2) : 0 } - return choice ? Math.min(choice, 2) : 0 + if (this.locale in this.pluralizationRules) { + return this.pluralizationRules[this.locale].apply(this, [choice, choicesLength]) + } else { + return defaultImpl(choice, choicesLength) + } } tc (key: Path, choice?: number, ...values: any): TranslateResult { diff --git a/test/unit/issues.test.js b/test/unit/issues.test.js index fbfdecea9..df9abf4da 100644 --- a/test/unit/issues.test.js +++ b/test/unit/issues.test.js @@ -384,56 +384,6 @@ describe('issues', () => { }) }) - describe('#78', () => { - it('should allow custom pluralization', () => { - const defaultImpl = VueI18n.prototype.getChoiceIndex - VueI18n.prototype.getChoiceIndex = function (choice, choicesLength) { - if (this.locale !== 'ru') { - return defaultImpl.apply(this, arguments) - } - - if (choice === 0) { - return 0 - } - - const teen = choice > 10 && choice < 20 - const endsWithOne = choice % 10 === 1 - - if (choicesLength < 4) { - return (!teen && endsWithOne) ? 1 : 2 - } - - if (!teen && endsWithOne) { - return 1 - } - - if (!teen && choice % 10 >= 2 && choice % 10 <= 4) { - return 2 - } - - return (choicesLength < 4) ? 2 : 3 - } - - - i18n = new VueI18n({ - locale: 'en', - messages: { - ru: { - car: '0 машин | 1 машина | {n} машины | {n} машин' - } - } - }) - vm = new Vue({ i18n }) - - assert(vm.$tc('car', 0), '0 машин') - assert(vm.$tc('car', 1), '1 машина') - assert(vm.$tc('car', 2), '2 машины') - assert(vm.$tc('car', 4), '4 машины') - assert(vm.$tc('car', 12), '12 машин') - assert(vm.$tc('car', 21), '21 машина') - }) - }) - describe('#450', () => { it('shoulbe be translated with v-t', done => { const vm = new Vue({ @@ -539,4 +489,145 @@ describe('issues', () => { }).then(done) }) }) + + describe('#78, #464', () => { + it('should fallback to default pluralization', () => { + // / Test default pluralization rule (english) + const i18n = new VueI18n({ + locale: 'en', + messages: { + 'en': { + test: 'no tests | 1 test | {n} tests' + } + } + }) + + assert.strictEqual(i18n.tc('test', 1), '1 test') + assert.strictEqual(i18n.tc('test', 0), 'no tests') + assert.strictEqual(i18n.tc('test', 10), '10 tests') + }) + + it('should use custom pluralization if available', () => { + // Test custom pluralization rule (slavic languages) + function slavicPluralization (choice, choicesLength) { + if (choice === 0) { + return 0 + } + + const teen = choice > 10 && choice < 20 + const endsWithOne = choice % 10 === 1 + + if (choicesLength < 4) { + return (!teen && endsWithOne) ? 1 : 2 + } + + if (!teen && endsWithOne) { + return 1 + } + + if (!teen && choice % 10 >= 2 && choice % 10 <= 4) { + return 2 + } + + return (choicesLength < 4) ? 2 : 3 + } + + let i18n = new VueI18n({ + locale: 'en', + messages: { + 'en': { + test: 'no tests | 1 test | {n} tests' + }, + 'ru': { + test: 'нет тестов | 1 тест | {n} теста | {n} тестов' + } + }, + pluralizationRules: { + 'ru': slavicPluralization + } + }) + + assert.strictEqual(i18n.tc('test', 1), '1 test') + assert.strictEqual(i18n.tc('test', 0), 'no tests') + assert.strictEqual(i18n.tc('test', 10), '10 tests') + + i18n.locale = 'ru' + + assert.strictEqual(i18n.tc('test', 1), '1 тест') + assert.strictEqual(i18n.tc('test', 3), '3 теста') + assert.strictEqual(i18n.tc('test', 0), 'нет тестов') + assert.strictEqual(i18n.tc('test', 10), '10 тестов') + + i18n = new VueI18n({ + locale: 'ru', + messages: { + ru: { + car: '0 машин | 1 машина | {n} машины | {n} машин' + } + }, + pluralizationRules: { + ru: slavicPluralization + } + }) + vm = new Vue({ i18n }) + + assert(vm.$tc('car', 0), '0 машин') + assert(vm.$tc('car', 1), '1 машина') + assert(vm.$tc('car', 2), '2 машины') + assert(vm.$tc('car', 4), '4 машины') + assert(vm.$tc('car', 12), '12 машин') + assert(vm.$tc('car', 21), '21 машина') + }) + + it('ensures backward-compatibility with #451', () => { + const defaultImpl = VueI18n.prototype.getChoiceIndex + VueI18n.prototype.getChoiceIndex = function (choice, choicesLength) { + if (this.locale !== 'ru') { + return defaultImpl.apply(this, arguments) + } + + if (choice === 0) { + return 0 + } + + const teen = choice > 10 && choice < 20 + const endsWithOne = choice % 10 === 1 + + if (choicesLength < 4) { + return (!teen && endsWithOne) ? 1 : 2 + } + + if (!teen && endsWithOne) { + return 1 + } + + if (!teen && choice % 10 >= 2 && choice % 10 <= 4) { + return 2 + } + + return (choicesLength < 4) ? 2 : 3 + } + + + i18n = new VueI18n({ + locale: 'en', + messages: { + ru: { + car: '0 машин | 1 машина | {n} машины | {n} машин' + } + } + }) + vm = new Vue({ i18n }) + + assert(vm.$tc('car', 0), '0 машин') + assert(vm.$tc('car', 1), '1 машина') + assert(vm.$tc('car', 2), '2 машины') + assert(vm.$tc('car', 4), '4 машины') + assert(vm.$tc('car', 12), '12 машин') + assert(vm.$tc('car', 21), '21 машина') + + // Set the default implementation back + VueI18n.prototype.getChoiceIndex = defaultImpl + }) + }) }) diff --git a/types/index.d.ts b/types/index.d.ts index 047a66df6..0fec49b02 100644 --- a/types/index.d.ts +++ b/types/index.d.ts @@ -44,6 +44,14 @@ declare namespace VueI18n { interface NumberFormat { [key: string]: NumberFormatOptions; } interface NumberFormats { [key: string]: NumberFormat; } type NumberFormatResult = string; + type PluralizationRulesMap = { + /** + * @param choice {number} a choice index given by the input to $tc: `$tc('path.to.rule', choiceIndex)` + * @param choicesLength {number} an overall amount of available choices + * @returns a final choice index + */ + [lang: string]: (choice: number, choicesLength: number) => number; + }; interface Formatter { interpolate(message: string, values?: Values): any[]; @@ -68,6 +76,7 @@ declare namespace VueI18n { fallbackRoot?: boolean; sync?: boolean; silentTranslationWarn?: boolean; + pluralizationRules?: PluralizationRulesMap; } } @@ -103,6 +112,7 @@ export declare interface IVueI18n { missing: VueI18n.MissingHandler; formatter: VueI18n.Formatter; silentTranslationWarn: boolean; + pluralizationRules: VueI18n.PluralizationRulesMap; } declare class VueI18n {