Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: use Intl.PluralRules for plural keys #43

Merged
merged 2 commits into from
Mar 22, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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 @@
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 @@
} 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 @@
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 @@
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
Loading