Skip to content

Commit

Permalink
feat: use Intl.PluralRules for plural keys (#43)
Browse files Browse the repository at this point in the history
* feat: add object format for plural

* docs: add example for new plural format
  • Loading branch information
vladimirfilosof committed Mar 22, 2024
1 parent b83858b commit 1777df6
Show file tree
Hide file tree
Showing 5 changed files with 311 additions and 43 deletions.
53 changes: 36 additions & 17 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down Expand Up @@ -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
Expand All @@ -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.

Expand All @@ -156,24 +175,28 @@ 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.
* `Many` corresponds to any other number, except 0.
* `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.
* `Many` corresponds to any other number, except 0.
* `None` corresponds to 0.

##### Default

The English ruleset is used by default, for any language without a configured pluralization function.

### Typing
Expand All @@ -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<Lang, LangKeysets>) {
await mkdirp(DEST_PATH);

Expand Down Expand Up @@ -226,8 +247,6 @@ async function createFiles(keysets: Record<Lang, LangKeysets>) {
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';
Expand Down
181 changes: 181 additions & 0 deletions src/index.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down
41 changes: 16 additions & 25 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -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';

Expand Down Expand Up @@ -111,6 +113,9 @@ export class I18N {
this.fallbackLang = fallbackLang;
}

/**

Check warning on line 116 in src/index.ts

View workflow job for this annotation

GitHub Actions / test

Missing JSDoc @returns for function

Check warning on line 116 in src/index.ts

View workflow job for this annotation

GitHub Actions / test

Missing JSDoc for parameter 'pluralizers'
* @deprecated Plurals automatically used from Intl.PluralRules. You can safely remove this call. Will be removed in v2.
*/
configurePluralization(pluralizers: Record<string, Pluralizer>) {
this.pluralizers = Object.assign({}, this.pluralizers, pluralizers);
}
Expand All @@ -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});
}

Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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;
}
Expand Down
Loading

0 comments on commit 1777df6

Please sign in to comment.