diff --git a/CHANGELOG.md b/CHANGELOG.md index d6005cb63..a38003f84 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,14 @@ Changelog _Note: Gaps between patch versions are faulty, broken or test releases._ +## v3.101.0 (2024-09-25) + +#### :boom: breaking change +* `core/prelude/i18n/helpers` + * changed `i18n` translations format. + * added `intl` support for pluralization. + * now `i18n` prefer to use `intl` api for pluralization if it's possible, otherwise fallback to old plural form logic. + ## v3.100.1 (2024-09-10) #### :bug: Bug Fix diff --git a/index.d.ts b/index.d.ts index 3c46fb974..759155c70 100644 --- a/index.d.ts +++ b/index.d.ts @@ -173,14 +173,16 @@ declare function i18n( /** * Parameters for the internationalization function */ -type I18nParams = {count?: number | StringPluralizationForms} & { +type I18nParams = { + count?: number | StringPluralizationForms; +} & { [key: string]: string | number; }; /** * String pluralization constants that can be used instead of numbers */ -type StringPluralizationForms = 'one' | 'some' | 'many' | 'none'; +type StringPluralizationForms = 'one' | 'two' | 'few' | 'many' | 'other' | 'zero'; declare function setImmediate(fn: AnyFunction): number; declare function clearImmediate(id: number): void; diff --git a/package.json b/package.json index 20a4790f3..faefde881 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,7 @@ "main": "lib/core/index.js", "typings": "index.d.ts", "license": "MIT", - "version": "3.100.1", + "version": "3.101.0", "author": "kobezzza (https://github.com/kobezzza)", "repository": { "type": "git", diff --git a/src/core/prelude/CHANGELOG.md b/src/core/prelude/CHANGELOG.md index 2d121a9ec..e9edc94ff 100644 --- a/src/core/prelude/CHANGELOG.md +++ b/src/core/prelude/CHANGELOG.md @@ -9,6 +9,14 @@ Changelog > - :house: [Internal] > - :nail_care: [Polish] +## v3.101.0 (2024-09-25) + +#### :boom: breaking change + +* changed `i18n` translations format. +* added `intl` support for pluralization. +* now `i18n` prefer to use `intl` api for pluralization if it's possible, otherwise fallback to old plural form logic. + ## v3.99.0 (2024-04-25) #### :rocket: New Feature diff --git a/src/core/prelude/i18n/const.ts b/src/core/prelude/i18n/const.ts index 25b6ffbc9..93ccab071 100644 --- a/src/core/prelude/i18n/const.ts +++ b/src/core/prelude/i18n/const.ts @@ -37,13 +37,3 @@ export const region: RegionStore = { value: undefined, isDefault: false }; - -/** - * A dictionary to map literal pluralization forms to numbers - */ -export const pluralizeMap = Object.createDict({ - none: 0, - one: 1, - some: 2, - many: 5 -}); diff --git a/src/core/prelude/i18n/helpers.ts b/src/core/prelude/i18n/helpers.ts index 338eb01e2..cbe0aa125 100644 --- a/src/core/prelude/i18n/helpers.ts +++ b/src/core/prelude/i18n/helpers.ts @@ -11,8 +11,8 @@ import extend from 'core/prelude/extend'; import langPacs, { Translation, PluralTranslation } from 'lang'; -import { locale, pluralizeMap } from 'core/prelude/i18n/const'; -import type { PluralizationCount } from 'core/prelude/i18n/interface'; +import { locale } from 'core/prelude/i18n/const'; +import type { I18nOpts, PluralizationCount } from 'core/prelude/i18n/interface'; /** @see [[i18n]] */ extend(globalThis, 'i18n', i18nFactory); @@ -44,6 +44,8 @@ export function i18nFactory( throw new ReferenceError('The locale for internationalization is not defined'); } + const pluralRules: CanUndef = getPluralRules(resolvedLocale); + return function i18n(value: string | TemplateStringsArray, params?: I18nParams) { if (Object.isArray(value) && value.length !== 1) { throw new SyntaxError('Using i18n with template literals is allowed only without variables'); @@ -55,7 +57,7 @@ export function i18nFactory( translateValue = langPacs[resolvedLocale]?.[correctKeyset ?? '']?.[key]; if (translateValue != null && translateValue !== '') { - return resolveTemplate(translateValue, params); + return resolveTemplate(translateValue, params, {pluralRules}); } logger.error( @@ -63,7 +65,7 @@ export function i18nFactory( `Key: ${key}, KeysetNames: ${keysetNames.join(', ')}, LocaleName: ${resolvedLocale}, available locales: ${Object.keys(langPacs).join(', ')}` ); - return resolveTemplate(key, params); + return resolveTemplate(key, params, {pluralRules}); }; } @@ -79,19 +81,19 @@ export function i18nFactory( * * console.log(example); // 'My name is John, I live in Denver' * - * const examplePluralize = resolveTemplate([ - * {count} product, // One - * {count} products, // Some - * {count} products, // Many - * {count} products, // None - * ], {count: 5}); + * const examplePluralize = resolveTemplate({ + * one: {count} product, + * few: {count} products, + * many: {count} products, + * zero: {count} products, + * }, {count: 5}); * * console.log(examplePluralize); // '5 products' * ``` */ -export function resolveTemplate(value: Translation, params?: I18nParams): string { +export function resolveTemplate(value: Translation, params?: I18nParams, opts: I18nOpts = {}): string { const - template = Object.isArray(value) ? pluralizeText(value, params?.count) : value; + template = Object.isPlainObject(value) ? pluralizeText(value, params?.count, opts.pluralRules) : value; return template.replace(/{([^}]+)}/g, (_, key) => { if (params?.[key] == null) { @@ -108,28 +110,36 @@ export function resolveTemplate(value: Translation, params?: I18nParams): string * * @param pluralTranslation - list of translation variants * @param count - the value on the basis of which the form of pluralization will be selected + * @param rules - Intl plural rules for selected locale * * @example * ```typescript - * const result = pluralizeText([ - * {count} product, // One - * {count} products, // Some - * {count} products, // Many - * {count} products, // None - * ], 5); + * const result = pluralizeText({ + * one: {count} product, + * few: {count} products, + * many: {count} products, + * zero: {count} products, + * other: {count} products, + * }, 5, new Intl.PluralRulse('en')); * * console.log(result); // '{count} products' * ``` */ -export function pluralizeText(pluralTranslation: PluralTranslation, count: CanUndef): string { +export function pluralizeText( + pluralTranslation: PluralTranslation, + count: CanUndef, + rules: CanUndef +): string { let normalizedCount; if (Object.isNumber(count)) { normalizedCount = count; } else if (Object.isString(count)) { - if (count in pluralizeMap) { - normalizedCount = pluralizeMap[count]; + const translation = pluralTranslation[count]; + + if (translation != null) { + return translation; } } @@ -138,18 +148,59 @@ export function pluralizeText(pluralTranslation: PluralTranslation, count: CanUn normalizedCount = 1; } - switch (normalizedCount) { + const + pluralFormName = getPluralFormName(normalizedCount, rules), + translation = pluralTranslation[pluralFormName]; + + if (translation == null) { + logger.error(`Plural form ${pluralFormName} doesn't exist.`, `String: ${pluralTranslation[0]}`); + return pluralTranslation.one; + } + + return translation; +} + +/** + * Returns the plural form name for a given number `n` based on the specified pluralization rules. + * Otherwise will be used default set of rules. + * + * If a `rules` object implementing `Intl.PluralRules` is provided, it will use that to determine the plural form. + * Otherwise, it will fall back to a custom rule set: + * - Returns 'zero' for `n === 0`. + * - Returns 'one' for `n === 1`. + * - Returns 'few' for `n > 1 && n < 5`. + * - Returns 'many' for all other values of `n`. + * + * @param n - The number to evaluate for pluralization. + * @param rules - Plural rules object. If undefined, a default rule set is used. + */ +export function getPluralFormName(n: number, rules?: CanUndef): keyof Required { + if (rules != null) { + return rules.select(n); + } + + switch (n) { case 0: - return pluralTranslation[3]; + return 'zero'; case 1: - return pluralTranslation[0]; + return 'one'; default: - if (normalizedCount > 1 && normalizedCount < 5) { - return pluralTranslation[1]; + if (n > 1 && n < 5) { + return 'few'; } - return pluralTranslation[2]; + return 'many'; + } +} + +/** + * Returns an instance of `Intl.PluralRules` for a given locale, if supported. + * @param locale - The locale for which to generate plural rules. + */ +export function getPluralRules(locale: Language): CanUndef { + if ('PluralRules' in globalThis['Intl']) { + return new globalThis['Intl'].PluralRules(locale); } } diff --git a/src/core/prelude/i18n/interface.ts b/src/core/prelude/i18n/interface.ts index 1ed364274..9981d87ae 100644 --- a/src/core/prelude/i18n/interface.ts +++ b/src/core/prelude/i18n/interface.ts @@ -41,4 +41,8 @@ export interface LocaleKVStorage { set?: SyncStorage['set']; } -export type PluralizationCount = StringPluralizationForms | string | number; +export type PluralizationCount = StringPluralizationForms | number; + +export interface I18nOpts { + pluralRules?: Intl.PluralRules; +} diff --git a/src/core/prelude/i18n/spec.js b/src/core/prelude/i18n/spec.js deleted file mode 100644 index 952748751..000000000 --- a/src/core/prelude/i18n/spec.js +++ /dev/null @@ -1,71 +0,0 @@ -/*! - * V4Fire Core - * https://github.com/V4Fire/Core - * - * Released under the MIT license - * https://github.com/V4Fire/Core/blob/master/LICENSE - */ - -import { pluralizeText, resolveTemplate } from 'core/prelude/i18n'; - -describe('core/prelude/i18n', () => { - describe('text pluralization', () => { - it('using pluralization constants to choose the right form', () => { - const input = { - forms: ['first form', 'second form', 'third form', 'fourth form'], - count: ['one', 'some', 'many', 'none'] - }; - - input.forms.forEach((form, index) => { - expect(pluralizeText(input.forms, input.count[index])).toBe(form); - }); - }); - - it('using a number to choose the right form of pluralization', () => { - const input = { - forms: ['first form', 'second form', 'third form', 'fourth form'], - count: [1, 2, 100, 0] - }; - - input.forms.forEach((form, index) => { - expect(pluralizeText(input.forms, input.count[index])).toBe(form); - }); - }); - - it('if the `count` parameter is invalid, then the default pluralization form should be returned', () => { - const input = { - forms: [true, false, false, false], - count: 'Some invalid count' - }; - - expect(pluralizeText(input.forms, input.count)).toBe(true); - }); - }); - - describe('substitution of variables and pluralization forms in a template', () => { - it('template resolving without additional parameters', () => { - expect(resolveTemplate('foo bar baz')).toBe('foo bar baz'); - }); - - it('passing variables for template resolving', () => { - const tpl = 'foo {macros} {macros2}'; - expect(resolveTemplate(tpl, {macros: 'bar', macros2: 'baz'})).toBe('foo bar baz'); - }); - - it('if the variable is not set, then it should be displayed as text', () => { - const tpl = 'foo {macros} {macros2}'; - expect(resolveTemplate(tpl, {macros: 'bar'})).toBe('foo bar macros2'); - }); - - it('passing the `count` parameter for template resolving', () => { - const res = resolveTemplate([ - 'one {count}', - 'two {count}', - 'five {count}', - 'zero {count}' - ], {count: 5}); - - expect(res).toBe('five 5'); - }); - }); -}); diff --git a/src/core/prelude/i18n/spec.ts b/src/core/prelude/i18n/spec.ts new file mode 100644 index 000000000..69cd3b5cb --- /dev/null +++ b/src/core/prelude/i18n/spec.ts @@ -0,0 +1,130 @@ +/*! + * V4Fire Core + * https://github.com/V4Fire/Core + * + * Released under the MIT license + * https://github.com/V4Fire/Core/blob/master/LICENSE + */ + +import { pluralizeText, resolveTemplate } from 'core/prelude/i18n'; +import { getPluralFormName } from 'core/prelude/i18n/helpers'; + +describe('core/prelude/i18n', () => { + const rules = new Intl.PluralRules('en'); + + const forms = { + one: 'first form', + two: 'second form', + few: 'third form', + many: 'fifth form', + zero: 'zeroth form', + other: 'others form' + }; + + const formNames = >Object.keys(forms); + + describe('pluralization forms detection', () => { + it('detecting plural form without Intl rules', () => { + expect(getPluralFormName(0)).toBe('zero'); + expect(getPluralFormName(1)).toBe('one'); + expect(getPluralFormName(2)).toBe('few'); + expect(getPluralFormName(5)).toBe('many'); + }); + + it('detecting plural form using Intl rules', () => { + expect(getPluralFormName(0, rules)).toBe('other'); + expect(getPluralFormName(1, rules)).toBe('one'); + expect(getPluralFormName(2, rules)).toBe('other'); + expect(getPluralFormName(5, rules)).toBe('other'); + }); + }); + + describe('text pluralization', () => { + it('using pluralization constants to choose the right form', () => { + formNames.forEach((form) => { + expect(pluralizeText(forms, form, rules)).toBe(forms[form]); + }); + }); + + it('using a number to choose the right form of pluralization', () => { + const input = { + forms, + count: [1, 2, 100, 0] + }; + + [forms.one, forms.other, forms.other, forms.other].forEach((form, index) => { + expect(pluralizeText(input.forms, input.count[index], rules)).toBe(form); + }); + }); + }); + + describe('substitution of variables and pluralization forms in a template', () => { + it('template resolving without additional parameters', () => { + expect(resolveTemplate('foo bar baz')).toBe('foo bar baz'); + }); + + it('passing variables for template resolving', () => { + const tpl = 'foo {macros} {macros2}'; + expect(resolveTemplate(tpl, {macros: 'bar', macros2: 'baz'})).toBe('foo bar baz'); + }); + + it('if the variable is not set, then it should be displayed as text', () => { + const tpl = 'foo {macros} {macros2}'; + expect(resolveTemplate(tpl, {macros: 'bar'})).toBe('foo bar macros2'); + }); + + it('passing the `count` parameter for template resolving', () => { + const res1 = resolveTemplate({ + one: 'one {count}', + few: 'few {count}', + many: 'many {count}', + other: 'other {count}' + }, {count: 5}, {pluralRules: rules}); + + const res2 = resolveTemplate({ + one: 'one {count}', + few: 'few {count}', + many: 'many {count}', + other: 'other {count}' + }, {count: 1}, {pluralRules: rules}); + + expect(res1).toBe('other 5'); + expect(res2).toBe('one 1'); + }); + }); + + describe('pluralization for cyrillic language', () => { + it('russian language with Intl', () => { + const + cyrillicRules = new Intl.PluralRules('ru'), + forms = { + one: '{count} яблоко', + few: '{count} яблока', + many: '{count} яблок', + zero: '{count} яблок' + }; + + expect(resolveTemplate(forms, {count: 1}, {pluralRules: cyrillicRules})).toBe('1 яблоко'); + expect(resolveTemplate(forms, {count: 2}, {pluralRules: cyrillicRules})).toBe('2 яблока'); + expect(resolveTemplate(forms, {count: 0}, {pluralRules: cyrillicRules})).toBe('0 яблок'); + expect(resolveTemplate(forms, {count: 12}, {pluralRules: cyrillicRules})).toBe('12 яблок'); + expect(resolveTemplate(forms, {count: 22}, {pluralRules: cyrillicRules})).toBe('22 яблока'); + }); + + it('russian language without Intl', () => { + const + forms = { + one: '{count} яблоко', + few: '{count} яблока', + many: '{count} яблок', + zero: '{count} яблок' + }; + + expect(resolveTemplate(forms, {count: 1})).toBe('1 яблоко'); + expect(resolveTemplate(forms, {count: 2})).toBe('2 яблока'); + expect(resolveTemplate(forms, {count: 0})).toBe('0 яблок'); + expect(resolveTemplate(forms, {count: 12})).toBe('12 яблок'); + expect(resolveTemplate(forms, {count: 22})).toBe('22 яблок'); + }); + }); +}); diff --git a/src/lang/interface.ts b/src/lang/interface.ts index 4dc2dbd29..4aba15f2d 100644 --- a/src/lang/interface.ts +++ b/src/lang/interface.ts @@ -12,7 +12,14 @@ export type LangPacs = { export type Translation = string | PluralTranslation; -export type PluralTranslation = [one: string, some: string, many: string, none: string]; +export interface PluralTranslation { + one: string; + two?: string; + few?: string; + many?: string; + zero?: string; + other?: string; +} export type Translations = Dictionary;