diff --git a/packages/client/src/index.ts b/packages/client/src/index.ts index 7c9237be..bf786a73 100644 --- a/packages/client/src/index.ts +++ b/packages/client/src/index.ts @@ -4,6 +4,7 @@ export { LocalstorageStorage, tl, defaultDcs, + assertNever, } from '@mtcute/core' export * from './types' diff --git a/packages/i18n/README.md b/packages/i18n/README.md new file mode 100644 index 00000000..51392cc8 --- /dev/null +++ b/packages/i18n/README.md @@ -0,0 +1,7 @@ +# @mtcute/i18n + +> I18n for MTCute + +This package implements utility for i18n functionality in `@mtcute/client` based apps. + +Documentation is TBA. diff --git a/packages/i18n/package.json b/packages/i18n/package.json new file mode 100644 index 00000000..22b3478e --- /dev/null +++ b/packages/i18n/package.json @@ -0,0 +1,18 @@ +{ + "name": "@mtcute/i18n", + "private": true, + "version": "1.0.0", + "description": "I18n for MTCute", + "author": "Alisa Sireneva ", + "license": "LGPL-3.0", + "main": "src/index.ts", + "scripts": { + "test": "mocha -r ts-node/register \"tests/**/*.spec.ts\"", + "coverage": "nyc npm run test", + "build": "tsc", + "docs": "npx typedoc" + }, + "dependencies": { + "@mtcute/client": "workspace:^1.0.0" + } +} diff --git a/packages/i18n/src/index.ts b/packages/i18n/src/index.ts new file mode 100644 index 00000000..bf379210 --- /dev/null +++ b/packages/i18n/src/index.ts @@ -0,0 +1,74 @@ +import { MtArgumentError, ParsedUpdate } from '@mtcute/client' +import { I18nValue, MtcuteI18nFunction, OtherLanguageWrap } from './types' +import { createI18nStringsIndex, extractLanguageFromUpdate } from './utils' + +export interface MtcuteI18nParameters { + /** + * Primary language which will also be used as a fallback + */ + primaryLanguage: { + /** + * Two letter language code. + */ + name: string + + /** + * Strings for the language. + */ + strings: Strings + } + + otherLanguages?: Record> + + /** + * Language that will be used if no language is specified + * + * Defaults to {@link primaryLanguage} + */ + defaultLanguage?: string +} + +export function createMtcuteI18n( + params: MtcuteI18nParameters +): MtcuteI18nFunction { + const { + primaryLanguage, + otherLanguages, + defaultLanguage = primaryLanguage.name, + } = params + + const indexes: Record> = {} + const fallbackIndex = (indexes[primaryLanguage.name] = + createI18nStringsIndex(primaryLanguage.strings)) + if (otherLanguages) { + Object.keys(otherLanguages).forEach((lang) => { + indexes[lang] = createI18nStringsIndex(otherLanguages[lang]) + }) + } + + if (!(defaultLanguage in indexes)) { + throw new MtArgumentError( + 'defaultLanguage is not a registered language' + ) + } + + const tr = (lang: ParsedUpdate['data'] | string | null, key: string, ...params: any[]) => { + if (lang === null) lang = defaultLanguage + + if (typeof lang === 'object') { + lang = extractLanguageFromUpdate(lang) ?? defaultLanguage + } + + const strings = indexes[lang] ?? fallbackIndex + + let val = strings[key] ?? fallbackIndex[key] ?? `[missing: ${key}]` + + if (typeof val === 'function') { + val = val(...params) + } + + return val + } + + return tr as MtcuteI18nFunction +} diff --git a/packages/i18n/src/types.ts b/packages/i18n/src/types.ts new file mode 100644 index 00000000..eb19c394 --- /dev/null +++ b/packages/i18n/src/types.ts @@ -0,0 +1,39 @@ +import { ParsedUpdate } from '@mtcute/client' + +type Values = T[keyof T] +type SafeGet = T extends Record ? T[K] : never + +export type I18nValue = string | ((...args: any[]) => string) + +type NestedKeysDelimited = Values<{ + [key in Extract]: T[key] extends I18nValue + ? key + : `${key}.${T[key] extends infer R ? NestedKeysDelimited : never}` +}> + +type GetValueNested = K extends `${infer P}.${infer Q}` + ? GetValueNested, Q> + : SafeGet + +type ExtractParameter = GetValueNested< + Strings, + K +> extends (...params: infer R) => any + ? R + : never + +export type MtcuteI18nFunction = < + K extends NestedKeysDelimited +>( + lang: ParsedUpdate['data'] | string | null, + key: K, + ...params: ExtractParameter +) => string + +export type OtherLanguageWrap = { + [key in keyof Strings]?: Strings[key] extends I18nValue + ? I18nValue + : Strings[key] extends Record + ? OtherLanguageWrap + : never +} diff --git a/packages/i18n/src/utils.ts b/packages/i18n/src/utils.ts new file mode 100644 index 00000000..49f53216 --- /dev/null +++ b/packages/i18n/src/utils.ts @@ -0,0 +1,68 @@ +import { + ParsedUpdate, + assertNever, + User, + Message, + DeleteMessageUpdate, + ChatMemberUpdate, + InlineQuery, + ChosenInlineResult, + CallbackQuery, + PollUpdate, + PollVoteUpdate, + UserStatusUpdate, + BotStoppedUpdate, + BotChatJoinRequestUpdate, +} from '@mtcute/client' +import { I18nValue } from './types' + +export function createI18nStringsIndex( + strings: Record +): Record { + const ret: Record = {} + + function add(obj: Record, prefix: string) { + for (const key in obj) { + const val = obj[key] + + if (typeof val === 'object') { + add(val, prefix + key + '.') + } else { + ret[prefix + key] = val + } + } + } + + add(strings, '') + + return ret +} + +export function extractLanguageFromUpdate( + update: ParsedUpdate['data'] +): string | null | undefined { + switch (update.constructor) { + case Message: + // if sender is Chat it will just be undefined + return ((update as Message).sender as User).language + case ChatMemberUpdate: + case InlineQuery: + case ChosenInlineResult: + case CallbackQuery: + case PollVoteUpdate: + case BotStoppedUpdate: + case BotChatJoinRequestUpdate: + return ( + update as + | ChatMemberUpdate + | InlineQuery + | ChosenInlineResult + | CallbackQuery + | PollVoteUpdate + | BotStoppedUpdate + | BotChatJoinRequestUpdate + ).user.language + } + + return null +} diff --git a/packages/i18n/tests/i18n.spec.ts b/packages/i18n/tests/i18n.spec.ts new file mode 100644 index 00000000..c328f759 --- /dev/null +++ b/packages/i18n/tests/i18n.spec.ts @@ -0,0 +1,94 @@ +import { describe, it } from 'mocha' +import { expect } from 'chai' +import { createMtcuteI18n } from '../src' +import { OtherLanguageWrap } from '../src/types' +import { Message, PeersIndex } from '@mtcute/client' + +describe('i18n', () => { + const en = { + direct: 'Hello', + fn: () => 'World', + withArgs: (name: string) => `Welcome ${name}`, + withArgsObj: ({ name }: { name: string }) => `Welcome ${name}`, + nested: { + string: 'Hello', + nested: { + fn: () => 'Hello', + }, + }, + } + const ru: OtherLanguageWrap = { + direct: 'Привет', + // fn: () => 'World', + withArgs: (name: string) => `Привет ${name}`, + // withArgsObj: ({ name }: { name: string }) => `Welcome ${name}`, + nested: { + // string: 'Hello', + nested: { + fn: 'Привет', + }, + }, + } + + const tr = createMtcuteI18n({ + primaryLanguage: { + name: 'en', + strings: en, + }, + otherLanguages: { ru }, + }) + + it('should work with direct string', () => { + expect(tr('en', 'direct')).to.equal('Hello') + }) + + it('should work with function without args', () => { + expect(tr('en', 'fn')).to.equal('World') + }) + + it('should work with function with args', () => { + expect(tr('en', 'withArgs', '')).to.equal('Welcome ') + expect(tr('en', 'withArgs', 'John')).to.equal('Welcome John') + expect(tr('en', 'withArgsObj', { name: 'John' })).to.equal( + 'Welcome John' + ) + }) + + it('should work with nested values', () => { + expect(tr('en', 'nested.string')).to.equal('Hello') + expect(tr('en', 'nested.nested.fn')).to.equal('Hello') + }) + + it('should work with other languages', () => { + expect(tr('ru', 'direct')).to.equal('Привет') + expect(tr('ru', 'withArgs', 'Ваня')).to.equal('Привет Ваня') + expect(tr('ru', 'nested.nested.fn')).to.equal('Привет') + }) + + it('should fallback to primary language when string is not translated', () => { + expect(tr('ru', 'fn')).to.equal('World') + expect(tr('ru', 'withArgsObj', { name: 'Ваня' })).to.equal( + 'Welcome Ваня' + ) + expect(tr('ru', 'nested.string')).to.equal('Hello') + }) + + it('should fallback to primary language when language is not available', () => { + expect(tr('kz', 'direct')).to.equal('Hello') + expect(tr(null, 'direct')).to.equal('Hello') + }) + + it('should parse language from a message', () => { + const message = new Message( + null as any, + { _: 'message', peerId: { _: 'peerUser', userId: 1 } } as any, + PeersIndex.from({ + users: [ + { _: 'user', id: 1, firstName: 'Пыня', langCode: 'ru' }, + ], + }) + ) + + expect(tr(message, 'direct')).to.equal('Привет') + }) +}) diff --git a/packages/i18n/tests/types.ts b/packages/i18n/tests/types.ts new file mode 100644 index 00000000..3e1eb4d2 --- /dev/null +++ b/packages/i18n/tests/types.ts @@ -0,0 +1,37 @@ +/* eslint-disable */ +// This is a test for TypeScript typings +// This file is never executed, only compiled + +import { Message } from '@mtcute/client' +import { createMtcuteI18n } from '../src' +import { OtherLanguageWrap } from '../src/types' + +const en = { + basic: { + hello: 'Hello', + world: () => 'World', + welcome: (name: string) => `Welcome ${name}`, + }, +} + +const ru: OtherLanguageWrap = { + basic: { + hello: 'Привет', + // world: () => 'Мир', + welcome: (name: string) => `Привет ${name}`, + }, +} + +const tr = createMtcuteI18n({ + primaryLanguage: { + name: 'en', + strings: en, + }, + otherLanguages: { ru }, +}) + +declare const ref: Message + +const a = tr(ref, 'basic.hello') +const b = tr('ru', 'basic.world') // will fallback to en +const c = tr(null, 'basic.welcome', 'John') diff --git a/packages/i18n/tsconfig.json b/packages/i18n/tsconfig.json new file mode 100644 index 00000000..6a8fe885 --- /dev/null +++ b/packages/i18n/tsconfig.json @@ -0,0 +1,19 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "outDir": "./dist" + }, + "include": [ + "./src" + ], + "typedocOptions": { + "name": "@mtcute/html-parser", + "includeVersion": true, + "out": "../../docs/packages/html-parser", + "listInvalidSymbolLinks": true, + "excludePrivate": true, + "entryPoints": [ + "./src/index.ts" + ] + } +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 749dd8d9..70998586 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1,4 +1,4 @@ -lockfileVersion: 5.4 +lockfileVersion: 5.3 importers: @@ -34,8 +34,8 @@ importers: '@types/node': 14.18.16 '@types/node-forge': 1.0.2 '@types/ws': 7.4.7 - '@typescript-eslint/eslint-plugin': 4.33.0_3ekaj7j3owlolnuhj3ykrb7u7i - '@typescript-eslint/parser': 4.33.0_hxadhbs2xogijvk7vq4t2azzbu + '@typescript-eslint/eslint-plugin': 4.33.0_d91404fd3b7596e5b6874ef0a887f4fa + '@typescript-eslint/parser': 4.33.0_eslint@7.32.0+typescript@4.7.4 chai: 4.3.6 dotenv-flow: 3.2.0 eslint: 7.32.0 @@ -47,7 +47,7 @@ importers: prettier: 2.6.2 rimraf: 3.0.2 semver: 7.3.7 - ts-node: 10.8.1_n4ne3vfoqubxgiypogr36fpdje + ts-node: 10.8.1_6f1a4dd4ae850373230f71a3bf15e349 typedoc: 0.23.2_typescript@4.7.4 typescript: 4.7.4 @@ -153,6 +153,12 @@ importers: dependencies: '@mtcute/core': link:../core + packages/i18n: + specifiers: + '@mtcute/client': workspace:^1.0.0 + devDependencies: + '@mtcute/client': link:../client + packages/markdown-parser: specifiers: '@mtcute/client': workspace:^1.0.0 @@ -691,7 +697,7 @@ packages: '@types/node': 14.18.16 dev: true - /@typescript-eslint/eslint-plugin/4.33.0_3ekaj7j3owlolnuhj3ykrb7u7i: + /@typescript-eslint/eslint-plugin/4.33.0_d91404fd3b7596e5b6874ef0a887f4fa: resolution: {integrity: sha512-aINiAxGVdOl1eJyVjaWn/YcVAq4Gi/Yo35qHGCnqbWVz61g39D0h23veY/MA0rFFGfxK7TySg2uwDeNv+JgVpg==} engines: {node: ^10.12.0 || >=12.0.0} peerDependencies: @@ -702,8 +708,8 @@ packages: typescript: optional: true dependencies: - '@typescript-eslint/experimental-utils': 4.33.0_hxadhbs2xogijvk7vq4t2azzbu - '@typescript-eslint/parser': 4.33.0_hxadhbs2xogijvk7vq4t2azzbu + '@typescript-eslint/experimental-utils': 4.33.0_eslint@7.32.0+typescript@4.7.4 + '@typescript-eslint/parser': 4.33.0_eslint@7.32.0+typescript@4.7.4 '@typescript-eslint/scope-manager': 4.33.0 debug: 4.3.4 eslint: 7.32.0 @@ -717,7 +723,7 @@ packages: - supports-color dev: true - /@typescript-eslint/experimental-utils/4.33.0_hxadhbs2xogijvk7vq4t2azzbu: + /@typescript-eslint/experimental-utils/4.33.0_eslint@7.32.0+typescript@4.7.4: resolution: {integrity: sha512-zeQjOoES5JFjTnAhI5QY7ZviczMzDptls15GFsI6jyUOq0kOf9+WonkhtlIhh0RgHRnqj5gdNxW5j1EvAyYg6Q==} engines: {node: ^10.12.0 || >=12.0.0} peerDependencies: @@ -735,7 +741,7 @@ packages: - typescript dev: true - /@typescript-eslint/parser/4.33.0_hxadhbs2xogijvk7vq4t2azzbu: + /@typescript-eslint/parser/4.33.0_eslint@7.32.0+typescript@4.7.4: resolution: {integrity: sha512-ZohdsbXadjGBSK0/r+d87X0SBmKzOq4/S5nzK6SBgJspFo9/CUDJ7hjayuze+JK7CZQLDMroqytp7pOcFKTxZA==} engines: {node: ^10.12.0 || >=12.0.0} peerDependencies: @@ -1083,8 +1089,6 @@ packages: ssri: 9.0.0 tar: 6.1.11 unique-filename: 1.1.1 - transitivePeerDependencies: - - bluebird dev: false /caching-transform/4.0.0: @@ -2353,7 +2357,6 @@ packages: socks-proxy-agent: 6.2.0 ssri: 9.0.0 transitivePeerDependencies: - - bluebird - supports-color dev: false @@ -2581,7 +2584,6 @@ packages: tar: 6.1.11 which: 2.0.2 transitivePeerDependencies: - - bluebird - supports-color dev: false @@ -2874,11 +2876,6 @@ packages: /promise-inflight/1.0.1: resolution: {integrity: sha1-mEcocL8igTL8vdhoEputEsPAKeM=} - peerDependencies: - bluebird: '*' - peerDependenciesMeta: - bluebird: - optional: true dev: false /promise-retry/2.0.1: @@ -3318,7 +3315,7 @@ packages: resolution: {integrity: sha1-gYT9NH2snNwYWZLzpmIuFLnZq2o=} dev: true - /ts-node/10.8.1_n4ne3vfoqubxgiypogr36fpdje: + /ts-node/10.8.1_6f1a4dd4ae850373230f71a3bf15e349: resolution: {integrity: sha512-Wwsnao4DQoJsN034wePSg5nZiw4YKXf56mPIAeD6wVmiv+RytNSWqc2f3fKvcUoV+Yn2+yocD71VOfQHbmVX4g==} hasBin: true peerDependencies: