feat: i18n package

This commit is contained in:
teidesu 2022-07-19 02:47:59 +03:00
parent a3aca20099
commit ebe9786987
10 changed files with 373 additions and 19 deletions

View file

@ -4,6 +4,7 @@ export {
LocalstorageStorage, LocalstorageStorage,
tl, tl,
defaultDcs, defaultDcs,
assertNever,
} from '@mtcute/core' } from '@mtcute/core'
export * from './types' export * from './types'

7
packages/i18n/README.md Normal file
View file

@ -0,0 +1,7 @@
# @mtcute/i18n
> I18n for MTCute
This package implements utility for i18n functionality in `@mtcute/client` based apps.
Documentation is TBA.

View file

@ -0,0 +1,18 @@
{
"name": "@mtcute/i18n",
"private": true,
"version": "1.0.0",
"description": "I18n for MTCute",
"author": "Alisa Sireneva <me@tei.su>",
"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"
}
}

View file

@ -0,0 +1,74 @@
import { MtArgumentError, ParsedUpdate } from '@mtcute/client'
import { I18nValue, MtcuteI18nFunction, OtherLanguageWrap } from './types'
import { createI18nStringsIndex, extractLanguageFromUpdate } from './utils'
export interface MtcuteI18nParameters<Strings> {
/**
* 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<string, OtherLanguageWrap<Strings>>
/**
* Language that will be used if no language is specified
*
* Defaults to {@link primaryLanguage}
*/
defaultLanguage?: string
}
export function createMtcuteI18n<Strings>(
params: MtcuteI18nParameters<Strings>
): MtcuteI18nFunction<Strings> {
const {
primaryLanguage,
otherLanguages,
defaultLanguage = primaryLanguage.name,
} = params
const indexes: Record<string, Record<string, I18nValue>> = {}
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<Strings>
}

View file

@ -0,0 +1,39 @@
import { ParsedUpdate } from '@mtcute/client'
type Values<T> = T[keyof T]
type SafeGet<T, K extends string> = T extends Record<K, any> ? T[K] : never
export type I18nValue = string | ((...args: any[]) => string)
type NestedKeysDelimited<T> = Values<{
[key in Extract<keyof T, string>]: T[key] extends I18nValue
? key
: `${key}.${T[key] extends infer R ? NestedKeysDelimited<R> : never}`
}>
type GetValueNested<T, K extends string> = K extends `${infer P}.${infer Q}`
? GetValueNested<SafeGet<T, P>, Q>
: SafeGet<T, K>
type ExtractParameter<Strings, K extends string> = GetValueNested<
Strings,
K
> extends (...params: infer R) => any
? R
: never
export type MtcuteI18nFunction<Strings> = <
K extends NestedKeysDelimited<Strings>
>(
lang: ParsedUpdate['data'] | string | null,
key: K,
...params: ExtractParameter<Strings, K>
) => string
export type OtherLanguageWrap<Strings> = {
[key in keyof Strings]?: Strings[key] extends I18nValue
? I18nValue
: Strings[key] extends Record<string, any>
? OtherLanguageWrap<Strings[key]>
: never
}

View file

@ -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<string, any>
): Record<string, I18nValue> {
const ret: Record<string, I18nValue> = {}
function add(obj: Record<string, any>, 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
}

View file

@ -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<typeof en> = {
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('Привет')
})
})

View file

@ -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<typeof en> = {
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')

View file

@ -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"
]
}
}

View file

@ -1,4 +1,4 @@
lockfileVersion: 5.4 lockfileVersion: 5.3
importers: importers:
@ -34,8 +34,8 @@ importers:
'@types/node': 14.18.16 '@types/node': 14.18.16
'@types/node-forge': 1.0.2 '@types/node-forge': 1.0.2
'@types/ws': 7.4.7 '@types/ws': 7.4.7
'@typescript-eslint/eslint-plugin': 4.33.0_3ekaj7j3owlolnuhj3ykrb7u7i '@typescript-eslint/eslint-plugin': 4.33.0_d91404fd3b7596e5b6874ef0a887f4fa
'@typescript-eslint/parser': 4.33.0_hxadhbs2xogijvk7vq4t2azzbu '@typescript-eslint/parser': 4.33.0_eslint@7.32.0+typescript@4.7.4
chai: 4.3.6 chai: 4.3.6
dotenv-flow: 3.2.0 dotenv-flow: 3.2.0
eslint: 7.32.0 eslint: 7.32.0
@ -47,7 +47,7 @@ importers:
prettier: 2.6.2 prettier: 2.6.2
rimraf: 3.0.2 rimraf: 3.0.2
semver: 7.3.7 semver: 7.3.7
ts-node: 10.8.1_n4ne3vfoqubxgiypogr36fpdje ts-node: 10.8.1_6f1a4dd4ae850373230f71a3bf15e349
typedoc: 0.23.2_typescript@4.7.4 typedoc: 0.23.2_typescript@4.7.4
typescript: 4.7.4 typescript: 4.7.4
@ -153,6 +153,12 @@ importers:
dependencies: dependencies:
'@mtcute/core': link:../core '@mtcute/core': link:../core
packages/i18n:
specifiers:
'@mtcute/client': workspace:^1.0.0
devDependencies:
'@mtcute/client': link:../client
packages/markdown-parser: packages/markdown-parser:
specifiers: specifiers:
'@mtcute/client': workspace:^1.0.0 '@mtcute/client': workspace:^1.0.0
@ -691,7 +697,7 @@ packages:
'@types/node': 14.18.16 '@types/node': 14.18.16
dev: true 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==} resolution: {integrity: sha512-aINiAxGVdOl1eJyVjaWn/YcVAq4Gi/Yo35qHGCnqbWVz61g39D0h23veY/MA0rFFGfxK7TySg2uwDeNv+JgVpg==}
engines: {node: ^10.12.0 || >=12.0.0} engines: {node: ^10.12.0 || >=12.0.0}
peerDependencies: peerDependencies:
@ -702,8 +708,8 @@ packages:
typescript: typescript:
optional: true optional: true
dependencies: dependencies:
'@typescript-eslint/experimental-utils': 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_hxadhbs2xogijvk7vq4t2azzbu '@typescript-eslint/parser': 4.33.0_eslint@7.32.0+typescript@4.7.4
'@typescript-eslint/scope-manager': 4.33.0 '@typescript-eslint/scope-manager': 4.33.0
debug: 4.3.4 debug: 4.3.4
eslint: 7.32.0 eslint: 7.32.0
@ -717,7 +723,7 @@ packages:
- supports-color - supports-color
dev: true 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==} resolution: {integrity: sha512-zeQjOoES5JFjTnAhI5QY7ZviczMzDptls15GFsI6jyUOq0kOf9+WonkhtlIhh0RgHRnqj5gdNxW5j1EvAyYg6Q==}
engines: {node: ^10.12.0 || >=12.0.0} engines: {node: ^10.12.0 || >=12.0.0}
peerDependencies: peerDependencies:
@ -735,7 +741,7 @@ packages:
- typescript - typescript
dev: true 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==} resolution: {integrity: sha512-ZohdsbXadjGBSK0/r+d87X0SBmKzOq4/S5nzK6SBgJspFo9/CUDJ7hjayuze+JK7CZQLDMroqytp7pOcFKTxZA==}
engines: {node: ^10.12.0 || >=12.0.0} engines: {node: ^10.12.0 || >=12.0.0}
peerDependencies: peerDependencies:
@ -1083,8 +1089,6 @@ packages:
ssri: 9.0.0 ssri: 9.0.0
tar: 6.1.11 tar: 6.1.11
unique-filename: 1.1.1 unique-filename: 1.1.1
transitivePeerDependencies:
- bluebird
dev: false dev: false
/caching-transform/4.0.0: /caching-transform/4.0.0:
@ -2353,7 +2357,6 @@ packages:
socks-proxy-agent: 6.2.0 socks-proxy-agent: 6.2.0
ssri: 9.0.0 ssri: 9.0.0
transitivePeerDependencies: transitivePeerDependencies:
- bluebird
- supports-color - supports-color
dev: false dev: false
@ -2581,7 +2584,6 @@ packages:
tar: 6.1.11 tar: 6.1.11
which: 2.0.2 which: 2.0.2
transitivePeerDependencies: transitivePeerDependencies:
- bluebird
- supports-color - supports-color
dev: false dev: false
@ -2874,11 +2876,6 @@ packages:
/promise-inflight/1.0.1: /promise-inflight/1.0.1:
resolution: {integrity: sha1-mEcocL8igTL8vdhoEputEsPAKeM=} resolution: {integrity: sha1-mEcocL8igTL8vdhoEputEsPAKeM=}
peerDependencies:
bluebird: '*'
peerDependenciesMeta:
bluebird:
optional: true
dev: false dev: false
/promise-retry/2.0.1: /promise-retry/2.0.1:
@ -3318,7 +3315,7 @@ packages:
resolution: {integrity: sha1-gYT9NH2snNwYWZLzpmIuFLnZq2o=} resolution: {integrity: sha1-gYT9NH2snNwYWZLzpmIuFLnZq2o=}
dev: true dev: true
/ts-node/10.8.1_n4ne3vfoqubxgiypogr36fpdje: /ts-node/10.8.1_6f1a4dd4ae850373230f71a3bf15e349:
resolution: {integrity: sha512-Wwsnao4DQoJsN034wePSg5nZiw4YKXf56mPIAeD6wVmiv+RytNSWqc2f3fKvcUoV+Yn2+yocD71VOfQHbmVX4g==} resolution: {integrity: sha512-Wwsnao4DQoJsN034wePSg5nZiw4YKXf56mPIAeD6wVmiv+RytNSWqc2f3fKvcUoV+Yn2+yocD71VOfQHbmVX4g==}
hasBin: true hasBin: true
peerDependencies: peerDependencies: