diff --git a/packages/i18n/package.json b/packages/i18n/package.json index 5c2e2fe2..a79a4f0a 100644 --- a/packages/i18n/package.json +++ b/packages/i18n/package.json @@ -11,5 +11,8 @@ "coverage": "nyc npm run test", "build": "tsc", "docs": "typedoc" + }, + "devDependencies": { + "@mtcute/client": "workspace:^1.0.0" } } diff --git a/packages/i18n/src/index.ts b/packages/i18n/src/index.ts index 329630f9..61d28552 100644 --- a/packages/i18n/src/index.ts +++ b/packages/i18n/src/index.ts @@ -1,4 +1,3 @@ -import { MtArgumentError, ParsedUpdate } from '@mtcute/client' import { I18nValue, MtcuteI18nAdapter, @@ -21,7 +20,7 @@ export interface MtcuteI18nParameters { name: string /** - * Strings for the language. + * Strings for the language. Can be a function to support backrefs */ strings: Strings } @@ -61,7 +60,7 @@ export function createMtcuteI18n( } if (!(defaultLanguage in indexes)) { - throw new MtArgumentError( + throw new TypeError( 'defaultLanguage is not a registered language' ) } diff --git a/packages/i18n/src/plurals/english.ts b/packages/i18n/src/plurals/english.ts new file mode 100644 index 00000000..897b825a --- /dev/null +++ b/packages/i18n/src/plurals/english.ts @@ -0,0 +1,44 @@ +import { I18nValue, I18nValueDynamic } from "../types"; + +export function ordinalSuffixEnglish(n: number): string { + const v = n % 100 + if (v > 3 && v < 21) return 'th' + switch (v % 10) { + case 1: + return 'st' + case 2: + return 'nd' + case 3: + return 'rd' + default: + return 'th' + } +} + +export function pluralizeEnglish(n: number, one: T, many: T): T { + return n === 1 ? one : many +} + +export function createPluralEnglish( + one: I18nValue<[number, ...Args]>, + many: I18nValue<[number, ...Args]> +): I18nValueDynamic<[number, ...Args]> { + if (typeof one === 'function' && typeof many === 'function') { + return (n, ...args) => (n === 1 ? one(n, ...args) : many(n, ...args)) + } + + if (typeof one === 'string' && typeof many === 'string') { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + return (n, ...args) => (n === 1 ? one : many) + } + + if (typeof one === 'string' && typeof many === 'function') { + return (n, ...args) => (n === 1 ? one : many(n, ...args)) + } + + if (typeof one === 'function' && typeof many === 'string') { + return (n, ...args) => (n === 1 ? one(n, ...args) : many) + } + + throw new TypeError() +} diff --git a/packages/i18n/src/plurals/russian.ts b/packages/i18n/src/plurals/russian.ts new file mode 100644 index 00000000..d560f27d --- /dev/null +++ b/packages/i18n/src/plurals/russian.ts @@ -0,0 +1,33 @@ +import { I18nValue, I18nValueDynamic } from '../types' + +export function pluralizeRussian( + n: number, + one: T, + few: T, + many: T +): T { + // reference: https://unicode-org.github.io/cldr-staging/charts/latest/supplemental/language_plural_rules.html#ru + + // one: 1 книга + // few: 2 книги, 3 книги, 4 книги + // many: 5 книг, 10 книг, 20 книг, 100 книг (also 0 книг, нет книг) + + const mod10 = n % 10 + const mod100 = n % 100 + + if (mod10 === 1 && mod100 !== 11) return one + if (mod10 >= 2 && mod10 <= 4 && (mod100 < 10 || mod100 >= 20)) return few + return many +} + +export function createPluralRussian( + one: I18nValue<[number, ...Args]>, + few: I18nValue<[number, ...Args]>, + many: I18nValue<[number, ...Args]> +): I18nValueDynamic<[number, ...Args]> { + return (n, ...args) => { + const val = pluralizeRussian(n, one, few, many) + if (typeof val === 'function') return val(n, ...args) + return val + } +} diff --git a/packages/i18n/src/types.ts b/packages/i18n/src/types.ts index e287167a..135d38f5 100644 --- a/packages/i18n/src/types.ts +++ b/packages/i18n/src/types.ts @@ -1,12 +1,16 @@ -import { FormattedString, ParsedUpdate } from '@mtcute/client' +import type { FormattedString } from '@mtcute/client' type Values = T[keyof T] type SafeGet = T extends Record ? T[K] : never -export type I18nValue = - | string - | FormattedString - | ((...args: any[]) => string | FormattedString) +export type I18nValueLiteral = string | FormattedString +export type I18nValueDynamic = ( + ...args: Args +) => I18nValueLiteral + +export type I18nValue = + | I18nValueLiteral + | I18nValueDynamic type NestedKeysDelimited = Values<{ [key in Extract]: T[key] extends I18nValue @@ -21,13 +25,13 @@ type GetValueNested = K extends `${infer P}.${infer Q}` type ExtractParameter = GetValueNested< Strings, K -> extends (...params: infer R) => any +> extends (...params: infer R) => I18nValueLiteral ? R - : never + : [] export type MtcuteI18nAdapter = (obj: Input) => string | null | undefined -export type MtcuteI18nFunction = < +export type MtcuteI18nFunction = < K extends NestedKeysDelimited >( lang: Input | string | null, diff --git a/packages/i18n/tests/i18n.spec.ts b/packages/i18n/tests/i18n.spec.ts index 13dcf334..1e65cd93 100644 --- a/packages/i18n/tests/i18n.spec.ts +++ b/packages/i18n/tests/i18n.spec.ts @@ -1,8 +1,9 @@ import { describe, it } from 'mocha' import { expect } from 'chai' -import { createMtcuteI18n } from '../src' -import { OtherLanguageWrap } from '../src/types' +import { createMtcuteI18n, OtherLanguageWrap } from '../src' import { Message, PeersIndex } from '@mtcute/client' +import { createPluralEnglish, pluralizeEnglish } from '../src/plurals/english' +import { createPluralRussian } from '../src/plurals/russian' describe('i18n', () => { const en = { @@ -16,18 +17,27 @@ describe('i18n', () => { fn: () => 'Hello', }, }, + plural: createPluralEnglish('a message', (n) => `${n} messages`), + plural2: createPluralEnglish( + 'a message', + (n: number, s: string) => `${n} messages from ${s}` + ), + plural3: (n: number) => + `${n} ${pluralizeEnglish(n, 'message', 'messages')}`, } const ru: OtherLanguageWrap = { direct: 'Привет', - // fn: () => 'World', withArgs: (name: string) => `Привет ${name}`, - // withArgsObj: ({ name }: { name: string }) => `Welcome ${name}`, nested: { - // string: 'Hello', nested: { fn: 'Привет', }, }, + plural: createPluralRussian( + (n) => `${n} сообщение`, + (n) => `${n} сообщения`, + (n) => `${n === 0 ? 'нет' : n} сообщений` + ), } const tr = createMtcuteI18n({ @@ -99,10 +109,32 @@ describe('i18n', () => { strings: en, }, otherLanguages: { ru }, - adapter: (num: number) => num === 1 ? 'en' : 'ru', + adapter: (num: number) => (num === 1 ? 'en' : 'ru'), }) expect(tr(1, 'direct')).to.equal('Hello') expect(tr(2, 'direct')).to.equal('Привет') }) + + describe('plurals', () => { + it('should pluralize correctly in english', () => { + expect(tr('en', 'plural', 1)).to.equal('a message') + expect(tr('en', 'plural', 2)).to.equal('2 messages') + + expect(tr('en', 'plural2', 1, 'baka')).to.equal('a message') + expect(tr('en', 'plural2', 2, 'baka')).to.equal( + '2 messages from baka' + ) + + expect(tr('en', 'plural3', 1)).to.equal('1 message') + expect(tr('en', 'plural3', 2)).to.equal('2 messages') + }) + + it('should pluralize correctly in russian', () => { + expect(tr('ru', 'plural', 0)).to.equal('нет сообщений') + expect(tr('ru', 'plural', 1)).to.equal('1 сообщение') + expect(tr('ru', 'plural', 2)).to.equal('2 сообщения') + expect(tr('ru', 'plural', 5)).to.equal('5 сообщений') + }) + }) }) diff --git a/packages/i18n/typedoc.js b/packages/i18n/typedoc.js index 56296ca7..dde39d0c 100644 --- a/packages/i18n/typedoc.js +++ b/packages/i18n/typedoc.js @@ -7,5 +7,9 @@ module.exports = { '../../docs/packages/' + require('./package.json').name.replace(/^@.+\//, '') ), - entryPoints: ['./src/index.ts'], + entryPoints: [ + './src/index.ts', + './src/plurals/english.ts', + './src/plurals/russian.ts', + ], }