diff --git a/README.md b/README.md index d0afe2d..eda47f2 100644 --- a/README.md +++ b/README.md @@ -4,12 +4,6 @@ Utilities in the I18N package are designed for internationalization of Gravity UI services. -### Breaking changes in 0.6.0 - -- Removed static method setDefaultLang, you have to use i18n.setLang instead -- Removed default Rum Logger, you have to connect your own logger from application side -- Removed static property LANGS - ### Install `npm install --save @gravity-ui/i18n` @@ -112,17 +106,32 @@ i18n('label_template', {inputValue: 'something', folderName: 'somewhere'}); // ### Pluralization -Pluralization can be used for easy localization of keys that depend on numeric values: +Pluralization can be used for easy localization of keys that depend on numeric values. Current library uses [CLDR Plural Rules](https://unicode-org.github.io/cldr-staging/charts/latest/supplemental/language_plural_rules.html) via [Intl.PluralRules API](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/PluralRules). -#### `keysets.json` +You may need to [polyfill](https://github.com/eemeli/intl-pluralrules) the [Intl.Plural Rules API](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/PluralRules) if it is not available in the browser. + +There are 6 plural forms (see [resolvedOptions](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/PluralRules/resolvedOptions)): + +- zero (also will be used when count = 0 even if the form is not supported in the language) +- one (singular) +- two (dual) +- few (paucal) +- many (also used for fractions if they have a separate class) +- other (required form for all languages — general plural form — also used if the language only has a single form) + +#### Example of `keysets.json` with plural key ```json { - "label_seconds": ["{{count}} second is left", "{{count}} seconds are left", "{{count}} seconds are left", "No time left"] + "label_seconds": { + "one": "{{count}} second is left", + "other":"{{count}} seconds are left", + "zero": "No time left" + } } ``` -#### `index.js` +#### Usage in JS ```js i18n('label_seconds', {count: 1}); // => 1 second @@ -132,9 +141,19 @@ i18n('label_seconds', {count: 10}); // => 10 seconds i18n('label_seconds', {count: 0}); // => No time left ``` -A pluralized key contains 4 values, each corresponding to a `PluralForm` enum value. The enum values are: `One`, `Few`, `Many`, and `None`, respectively. Template variable name for pluralization is `count`. +#### [Deprecated] Old plurals format -#### Custom pluralization +Old format will be removed in v2. + +```json +{ + "label_seconds": ["{{count}} second is left", "{{count}} seconds are left", "{{count}} seconds are left", "No time left"] +} +``` + +A pluralized key contains 4 values, each |corresponding to a `PluralForm` enum value. The enum values are: `One`, `Few`, `Many`, and `None`, respectively. Template variable name for pluralization is `count`. + +#### [Deprecated] Custom pluralization Since every language has its own way of pluralization, the library provides a method to configure the rules for any chosen language. @@ -156,10 +175,12 @@ i18n.configurePluralization({ }); ``` -#### Provided pluralization rulesets +#### [Deprecated] Provided pluralization rulesets + The two languages supported out of the box are English and Russian. ##### English + Language key: `en`. * `One` corresponds to 1 and -1. * `Few` is not used. @@ -167,6 +188,7 @@ Language key: `en`. * `None` corresponds to 0. ##### Russian + Language key: `ru`. * `One` corresponds to any number ending in 1, except ±11. * `Few` corresponds to any number ending in 2, 3 or 4, except ±12, ±13 and ±14. @@ -174,6 +196,7 @@ Language key: `ru`. * `None` corresponds to 0. ##### Default + The English ruleset is used by default, for any language without a configured pluralization function. ### Typing @@ -185,8 +208,6 @@ To type the `i18nInstance.i18n` function, follow the steps: Prepare a JSON keyset file so that the typing procedure can fetch data. Where you fetch keysets from, add creation of an additional `data.json` file. To decrease the file size and speed up IDE parsing, you can replace all values by `'str'`. ```ts -// Example from the console - async function createFiles(keysets: Record) { await mkdirp(DEST_PATH); @@ -226,8 +247,6 @@ async function createFiles(keysets: Record) { In your `ui/utils/i18n` directories (where you configure i18n and export it to be used by all interfaces), import the typing function `I18NFn` with your `Keysets`. After your i18n has been configured, return the casted function ```ts -// Example from the console - import {I18NFn} from '@gravity-ui/i18n'; // This must be a typed import! import type Keysets from '../../../dist/public/build/i18n/data.json'; diff --git a/src/index.spec.ts b/src/index.spec.ts index 7c56ea4..410ec90 100644 --- a/src/index.spec.ts +++ b/src/index.spec.ts @@ -307,6 +307,187 @@ describe('i18n', () => { expect(logger.log).toHaveBeenCalledTimes(callsLength + 1); }); + + it('basic checks for plurals with Intl.PluralRules', () => { + i18n.setLang('ru'); + i18n.registerKeyset('ru', 'app', { + users: { + 'zero': 'нет пользователей', + 'one': '{{count}} пользователь', + 'few': '{{count}} пользователя', + 'many': '{{count}} пользователей', + 'other': '', + }, + }); + + expect(i18n.i18n('app', 'users', { + count: 0 + })).toBe('нет пользователей'); + + expect(i18n.i18n('app', 'users', { + count: 1 + })).toBe('1 пользователь'); + + expect(i18n.i18n('app', 'users', { + count: 2 + })).toBe('2 пользователя'); + + expect(i18n.i18n('app', 'users', { + count: 3 + })).toBe('3 пользователя'); + + expect(i18n.i18n('app', 'users', { + count: 5 + })).toBe('5 пользователей'); + + expect(i18n.i18n('app', 'users', { + count: 11 + })).toBe('11 пользователей'); + }); + + it('should throw exception when missing required plural form', () => { + i18n.setLang('ru'); + i18n.registerKeyset('ru', 'app', { + // @ts-ignore + users: {'many': '{{count}} пользователей'} + }); + + expect(() => { + i18n.i18n('app', 'users', { + count: 11, + }) + }).toThrow(new Error(`Missing required plural form 'other' for key 'users'`)); + }); + + it('should use `other` form when no other forms are specified', () => { + i18n.setLang('ru'); + i18n.registerKeyset('ru', 'app', { + users: {'other': '{{count}} пользователей'} + }); + + expect(i18n.i18n('app', 'users', { + count: 21, + })).toBe('21 пользователей'); + + expect(i18n.i18n('app', 'users', { + count: 0, + })).toBe('0 пользователей'); + + expect(i18n.i18n('app', 'users', { + count: 10, + })).toBe('10 пользователей'); + + expect(i18n.i18n('app', 'users', { + count: 2, + })).toBe('2 пользователей'); + + expect(i18n.i18n('app', 'users', { + count: 1, + })).toBe('1 пользователей'); + }); + + it('should use `other` form when no other forms are specified', () => { + i18n.setLang('ru'); + i18n.registerKeyset('ru', 'app', { + users: {'other': '{{count}} пользователей'}, + articles: {'one': '{{count}} статья', 'other': '{{count}} статей'}, + }); + + expect(i18n.i18n('app', 'users', { + count: 21, + })).toBe('21 пользователей'); + + expect(i18n.i18n('app', 'users', { + count: 0, + })).toBe('0 пользователей'); + + expect(i18n.i18n('app', 'users', { + count: 10, + })).toBe('10 пользователей'); + + expect(i18n.i18n('app', 'users', { + count: 2, + })).toBe('2 пользователей'); + + expect(i18n.i18n('app', 'users', { + count: 1, + })).toBe('1 пользователей'); + + expect(i18n.i18n('app', 'articles', { + count: 1, + })).toBe('1 статья'); + + expect(i18n.i18n('app', 'articles', { + count: 21, + })).toBe('21 статья'); + + expect(i18n.i18n('app', 'articles', { + count: 0, + })).toBe('0 статей'); + + expect(i18n.i18n('app', 'articles', { + count: 5, + })).toBe('5 статей'); + + expect(i18n.i18n('app', 'articles', { + count: 3, + })).toBe('3 статей'); + }); + + it('compare results between old and new plural formats', () => { + i18n.setLang('ru'); + i18n.registerKeyset('ru', 'app', { + usersOldPlural: [ + '{{count}} пользователь', + '{{count}} пользователя', + '{{count}} пользователей', + 'нет пользователей', + ], + users: { + 'zero': 'нет пользователей', + 'one': '{{count}} пользователь', + 'few': '{{count}} пользователя', + 'many': '{{count}} пользователей', + 'other': '', + }, + }); + + expect(i18n.i18n('app', 'users', { + count: 0 + })).toBe(i18n.i18n('app', 'usersOldPlural', { + count: 0 + })); + + expect(i18n.i18n('app', 'users', { + count: 1 + })).toBe(i18n.i18n('app', 'usersOldPlural', { + count: 1 + })); + + expect(i18n.i18n('app', 'users', { + count: 2 + })).toBe(i18n.i18n('app', 'usersOldPlural', { + count: 2 + })); + + expect(i18n.i18n('app', 'users', { + count: 3 + })).toBe(i18n.i18n('app', 'usersOldPlural', { + count: 3 + })); + + expect(i18n.i18n('app', 'users', { + count: 5 + })).toBe(i18n.i18n('app', 'usersOldPlural', { + count: 5 + })); + + expect(i18n.i18n('app', 'users', { + count: 11 + })).toBe(i18n.i18n('app', 'usersOldPlural', { + count: 11 + })); + }); }); describe('constructor options', () => { diff --git a/src/index.ts b/src/index.ts index d6b87db..f31774f 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,10 +1,12 @@ import {replaceParams} from './replace-params'; import {ErrorCode, mapErrorCodeToMessage} from './translation-helpers'; import type {ErrorCodeType} from './translation-helpers'; -import {PluralForm} from './types'; +import {isPluralValue} from './types'; import type {KeysData, KeysetData, Logger, Params, Pluralizer} from './types'; + import pluralizerEn from './plural/en'; import pluralizerRu from './plural/ru'; +import {getPluralValue} from './plural/general'; export * from './types'; @@ -111,6 +113,9 @@ export class I18N { this.fallbackLang = fallbackLang; } + /** + * @deprecated Plurals automatically used from Intl.PluralRules. You can safely remove this call. Will be removed in v2. + */ configurePluralization(pluralizers: Record) { this.pluralizers = Object.assign({}, this.pluralizers, pluralizers); } @@ -124,7 +129,7 @@ export class I18N { } else if (isAlreadyRegistered) { this.warn(`Keyset '${keysetName}' is already registered.`); } - + this.data[lang] = Object.assign({}, this.data[lang], {[keysetName]: data}); } @@ -216,14 +221,6 @@ export class I18N { return langCode ? this.data[langCode] : undefined; } - protected getLanguagePluralizer(lang?: string): Pluralizer { - const pluralizer = lang ? this.pluralizers[lang] : undefined; - if (!pluralizer) { - this.warn(`Pluralization is not configured for language '${lang}', falling back to the english ruleset`); - } - return pluralizer || pluralizerEn; - } - private getTranslationData(args: { keysetName: string; key: string; @@ -258,27 +255,21 @@ export class I18N { return {details: {code: ErrorCode.MissingKey, keysetName, key}}; } - if (Array.isArray(keyValue)) { - if (keyValue.length < 3) { - return {details: {code: ErrorCode.MissingKeyPlurals, keysetName, key}}; - } - + if (isPluralValue(keyValue)) { const count = Number(params?.count); if (Number.isNaN(count)) { return {details: {code: ErrorCode.MissingKeyParamsCount, keysetName, key}}; } - const pluralizer = this.getLanguagePluralizer(lang); - result.text = keyValue[pluralizer(count, PluralForm)] || keyValue[PluralForm.Many]; - - if (result.text === undefined) { - return {details: {code: ErrorCode.MissingKeyPlurals, keysetName, key}}; - } - - if (keyValue[PluralForm.None] === undefined) { - result.details = {code: ErrorCode.MissingKeyFor0, keysetName, key}; - } + result.text = getPluralValue({ + key, + value: keyValue, + count, + lang: this.lang || 'en', + pluralizers: this.pluralizers, + log: (message) => this.warn(message, keysetName, key), + }); } else { result.text = keyValue; } diff --git a/src/plural/general.ts b/src/plural/general.ts new file mode 100644 index 0000000..b35603a --- /dev/null +++ b/src/plural/general.ts @@ -0,0 +1,58 @@ +import type { DeprecatedPluralValue, PluralValue, Pluralizer } from "../types"; +import {PluralForm} from '../types'; + +export function getPluralViaIntl(key: string, value: PluralValue, count: number, lang: string) { + if (typeof value.other === 'undefined') { + throw new Error(`Missing required plural form 'other' for key '${key}'`); + } + + if (value.zero && count === 0) { + return value.zero; + } + + if (!Intl.PluralRules) { + throw new Error('Intl.PluralRules is not available. Use polyfill.'); + } + + const pluralRules = new Intl.PluralRules(lang); + return value[pluralRules.select(count)] || value.other; +} + +type FormatPluralArgs = { + key: string; + value: DeprecatedPluralValue | PluralValue; + fallbackValue?: string; + count: number; + lang: string; + pluralizers?: Record; + log: (message: string) => void; +} + +export function getPluralValue({value, count, lang, pluralizers, log, key}: FormatPluralArgs) { + if (!Array.isArray(value)) { + return getPluralViaIntl(key, value, count, lang) || key; + } + + if (!pluralizers) { + log('Can not use deprecated plural format without pluralizers'); + return key; + } + + if (!pluralizers[lang]) { + log(`Pluralization is not configured for language '${lang}', falling back to the english ruleset`); + } + + if (value.length < 3) { + log('Missing required plurals'); + return key; + } + + const pluralizer = pluralizers[lang] || pluralizers['en']; + + if (!pluralizer) { + log('Fallback pluralization is not configured!'); + return key; + } + + return value[pluralizer(count, PluralForm)] || value[PluralForm.Many] || key; +} diff --git a/src/types.ts b/src/types.ts index 78a4717..e80496f 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,4 +1,5 @@ -export type KeysData = Record; +export type KeyData = string | DeprecatedPluralValue | PluralValue; +export type KeysData = Record; export type KeysetData = Record; type NoEnumLikeStringLiteral = string extends T ? T : never; @@ -61,6 +62,24 @@ export enum PluralForm { None } +/** + * @deprecated Old plurals format. Use new format from type PluralValue. Will be removed in v2. + */ +export type DeprecatedPluralValue = string[] + +export type PluralValue = { + zero?: string; + one?: string; + two?: string; + few?: string; + many?: string; + other: string; +} + +export function isPluralValue(value: KeyData): value is DeprecatedPluralValue | PluralValue { + return typeof value !== 'string'; +} + export interface Logger { log(message: string, options?: {level?: string; logger?: string; extra?: Record}): void; }