diff --git a/.editorconfig b/.editorconfig index e341389..0153836 100644 --- a/.editorconfig +++ b/.editorconfig @@ -9,8 +9,6 @@ charset = utf-8 trim_trailing_whitespace = true insert_final_newline = true -[*.md] -trim_trailing_whitespace = false - -[*.txt] -trim_trailing_whitespace = false +[*.{yml,yaml}] +indent_style = space +indent_size = 2 diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..6313b56 --- /dev/null +++ b/.gitattributes @@ -0,0 +1 @@ +* text=auto eol=lf diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..5657559 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,20 @@ +# Please see the documentation for all configuration options: +# https://help.github.com/github/administering-a-repository/configuration-options-for-dependency-updates + +version: 2 +updates: + - package-ecosystem: "npm" + directory: "/" + open-pull-requests-limit: 30 + schedule: + interval: "weekly" + time: "03:37" # UTC + commit-message: + prefix: "build(npm):" + + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "weekly" + commit-message: + prefix: "ci(actions):" diff --git a/.github/workflows/nodejs.yml b/.github/workflows/nodejs.yml new file mode 100644 index 0000000..907d0ef --- /dev/null +++ b/.github/workflows/nodejs.yml @@ -0,0 +1,27 @@ +name: Node.js + +on: + push: + pull_request: + schedule: + # Check if it works with current dependencies (weekly on Wednesday 2:32 UTC) + - cron: '32 2 * * 3' + +jobs: + test: + name: Node.js ${{ matrix.node-version }} + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + node-version: + - 16 + - 14 + - 12 + steps: + - uses: actions/checkout@v2 + - uses: actions/setup-node@v2 + with: + node-version: ${{ matrix.node-version }} + - run: npm install + - run: npm test diff --git a/.gitignore b/.gitignore index 77ea052..2ab2a0d 100644 --- a/.gitignore +++ b/.gitignore @@ -1,34 +1,6 @@ -# Logs -logs -*.log -npm-debug.log* - -# Runtime data -pids -*.pid -*.seed - -# Directory for instrumented libs generated by jscoverage/JSCover -lib-cov - -# Coverage directory used by tools like istanbul +.nyc_output +/*-*.*.*.tgz coverage - -# Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) -.grunt - -# node-waf configuration -.lock-wscript - -# Compiled binary addons (http://nodejs.org/api/addons.html) -build/Release - -# Dependency directory +dist node_modules - -# Optional npm cache directory -.npm - -# Optional REPL history -.node_repl_history -package-lock.json +yarn.lock diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index e1aeba3..0000000 --- a/.travis.yml +++ /dev/null @@ -1,7 +0,0 @@ -language: node_js -node_js: - - 8 - - 9 - - 10 - - 11 - - 12 diff --git a/README.md b/README.md new file mode 100644 index 0000000..b963547 --- /dev/null +++ b/README.md @@ -0,0 +1,3 @@ +# i18n for grammY and Telegraf + +This fork is now available as [@grammyjs/i18n](https://github.com/grammyjs/i18n) and works with both grammY and Telegraf. diff --git a/examples/example-bot.js b/examples/example-bot.js deleted file mode 100644 index e2c0a6a..0000000 --- a/examples/example-bot.js +++ /dev/null @@ -1,56 +0,0 @@ -const Telegraf = require('telegraf') -const path = require('path') -const I18n = require('../lib/i18n') -const { Extra } = Telegraf - -// i18n options -const i18n = new I18n({ - directory: path.resolve(__dirname, 'locales'), - defaultLanguage: 'en', - sessionName: 'session', - useSession: true, - templateData: { - pluralize: I18n.pluralize, - uppercase: (value) => value.toUpperCase() - } -}) - -const bot = new Telegraf(process.env.BOT_TOKEN) -bot.use(Telegraf.session()) -bot.use(i18n.middleware()) - -// Start message handler -bot.start(({ i18n, replyWithHTML }) => replyWithHTML(i18n.t('greeting'))) - -// Using i18n helpers -bot.command('help', I18n.reply('greeting', Extra.HTML())) - -// Set locale to `en` -bot.command('en', ({ i18n, replyWithHTML }) => { - i18n.locale('en-US') - return replyWithHTML(i18n.t('greeting')) -}) - -// Set locale to `ru` -bot.command('ru', ({ i18n, replyWithHTML }) => { - i18n.locale('ru') - return replyWithHTML(i18n.t('greeting')) -}) - -// Add apple to cart -bot.command('add', ({ session, i18n, reply }) => { - session.apples = session.apples || 0 - session.apples++ - const message = i18n.t('cart', { apples: session.apples }) - return reply(message) -}) - -// Add apple to cart -bot.command('cart', (ctx) => { - const message = ctx.i18n.t('cart', { apples: ctx.session.apples || 0 }) - return ctx.reply(message) -}) - -// Checkout -bot.command('checkout', ({ reply, i18n }) => reply(i18n.t('checkout'))) -bot.startPolling() diff --git a/examples/example-grammy-bot.ts b/examples/example-grammy-bot.ts new file mode 100644 index 0000000..bd2ae69 --- /dev/null +++ b/examples/example-grammy-bot.ts @@ -0,0 +1,65 @@ +import * as path from 'path' + +import {Bot, Context as BaseContext, session} from 'grammy' + +import {I18n, pluralize, I18nContext} from '../source' + +interface Session { + apples?: number; +} + +interface MyContext extends BaseContext { + readonly i18n: I18nContext; + session: Session; +} + +// I18n options +const i18n = new I18n({ + directory: path.resolve(__dirname, 'locales'), + defaultLanguage: 'en', + sessionName: 'session', + useSession: true, + templateData: { + pluralize, + uppercase: (value: string) => value.toUpperCase(), + }, +}) + +const bot = new Bot(process.env['BOT_TOKEN']!) +bot.use(session()) +bot.use(i18n.middleware()) + +// Start message handler +bot.command('start', async ctx => ctx.reply(ctx.i18n.t('greeting'), {parse_mode: 'HTML'})) + +// Set locale to `en` +bot.command('en', async ctx => { + ctx.i18n.locale('en-US') + return ctx.reply(ctx.i18n.t('greeting'), {parse_mode: 'HTML'}) +}) + +// Set locale to `ru` +bot.command('ru', async ctx => { + ctx.i18n.locale('ru') + return ctx.reply(ctx.i18n.t('greeting'), {parse_mode: 'HTML'}) +}) + +// Add apple to cart +bot.command('add', async ctx => { + ctx.session.apples = ctx.session.apples ?? 0 + ctx.session.apples++ + const message = ctx.i18n.t('cart', {apples: ctx.session.apples}) + return ctx.reply(message) +}) + +// Add apple to cart +bot.command('cart', async ctx => { + const message = ctx.i18n.t('cart', {apples: ctx.session.apples ?? 0}) + return ctx.reply(message) +}) + +// Checkout +bot.command('checkout', async ctx => ctx.reply(ctx.i18n.t('checkout'))) + +// eslint-disable-next-line @typescript-eslint/no-floating-promises +bot.start() diff --git a/examples/example-telegraf-bot.ts b/examples/example-telegraf-bot.ts new file mode 100644 index 0000000..31fd960 --- /dev/null +++ b/examples/example-telegraf-bot.ts @@ -0,0 +1,65 @@ +import * as path from 'path' + +import {Telegraf, Context as BaseContext, session} from 'telegraf' + +import {I18n, pluralize, I18nContext} from '../source' + +interface Session { + apples?: number; +} + +interface MyContext extends BaseContext { + readonly i18n: I18nContext; + session: Session; +} + +// I18n options +const i18n = new I18n({ + directory: path.resolve(__dirname, 'locales'), + defaultLanguage: 'en', + sessionName: 'session', + useSession: true, + templateData: { + pluralize, + uppercase: (value: string) => value.toUpperCase(), + }, +}) + +const bot = new Telegraf(process.env['BOT_TOKEN']!) +bot.use(session()) +bot.use(i18n.middleware()) + +// Start message handler +bot.command('start', async ctx => ctx.reply(ctx.i18n.t('greeting'), {parse_mode: 'HTML'})) + +// Set locale to `en` +bot.command('en', async ctx => { + ctx.i18n.locale('en-US') + return ctx.reply(ctx.i18n.t('greeting'), {parse_mode: 'HTML'}) +}) + +// Set locale to `ru` +bot.command('ru', async ctx => { + ctx.i18n.locale('ru') + return ctx.reply(ctx.i18n.t('greeting'), {parse_mode: 'HTML'}) +}) + +// Add apple to cart +bot.command('add', async ctx => { + ctx.session.apples = ctx.session.apples ?? 0 + ctx.session.apples++ + const message = ctx.i18n.t('cart', {apples: ctx.session.apples}) + return ctx.reply(message) +}) + +// Add apple to cart +bot.command('cart', async ctx => { + const message = ctx.i18n.t('cart', {apples: ctx.session.apples ?? 0}) + return ctx.reply(message) +}) + +// Checkout +bot.command('checkout', async ctx => ctx.reply(ctx.i18n.t('checkout'))) + +// eslint-disable-next-line @typescript-eslint/no-floating-promises +bot.launch() diff --git a/examples/locales/en.yaml b/examples/locales/en.yaml index 43be2bf..7dc0f48 100644 --- a/examples/locales/en.yaml +++ b/examples/locales/en.yaml @@ -1,3 +1,4 @@ greeting: Hello ${uppercase(from.first_name)}! cart: ${from.first_name}, in your cart ${pluralize(apples, 'apple', 'apples')} checkout: Thank you +help: help diff --git a/lib/context.js b/lib/context.js deleted file mode 100644 index 6ed13fd..0000000 --- a/lib/context.js +++ /dev/null @@ -1,61 +0,0 @@ -class I18nContext { - constructor (repository, config, languageCode, templateData) { - this.repository = repository - this.config = config - this.locale(languageCode || config.defaultLanguage) - this.templateData = { - ...config.templateData, - ...templateData - } - } - - locale (languageCode) { - if (!languageCode) { - return this.languageCode - } - - const code = languageCode.toLowerCase() - const shortCode = code.split('-')[0] - - if (!this.repository[code] && !this.repository[shortCode]) { - this.languageCode = this.config.defaultLanguage - this.shortLanguageCode = this.languageCode.split('-')[0] - return - } - - this.languageCode = code - this.shortLanguageCode = shortCode - } - - getTemplate (languageCode, resourceKey = '') { - return resourceKey - .split('.') - .reduce((acc, key) => acc && acc[key], this.repository[languageCode]) - } - - t (resourceKey, templateData) { - let template = this.getTemplate(this.languageCode, resourceKey) || this.getTemplate(this.shortLanguageCode, resourceKey) - - if (!template && this.config.defaultLanguageOnMissing) { - template = this.getTemplate(this.config.defaultLanguage, resourceKey) - } - - if (!template && this.config.allowMissing) { - template = () => resourceKey - } - - if (!template) { - throw new Error(`telegraf-i18n: '${this.languageCode}.${resourceKey}' not found`) - } - const context = { - ...this.templateData, - ...templateData - } - Object.keys(context) - .filter((key) => typeof context[key] === 'function') - .forEach((key) => (context[key] = context[key].bind(this))) - return template(context) - } -} - -module.exports = I18nContext diff --git a/lib/i18n.js b/lib/i18n.js deleted file mode 100644 index f3b6303..0000000 --- a/lib/i18n.js +++ /dev/null @@ -1,164 +0,0 @@ -const fs = require('fs') -const compile = require('compile-template') -const yaml = require('js-yaml') -const path = require('path') -const I18nContext = require('./context.js') -const pluralize = require('./pluralize.js') - -class I18n { - constructor (config = {}) { - this.repository = {} - this.config = { - defaultLanguage: 'en', - sessionName: 'session', - allowMissing: true, - templateData: { - pluralize - }, - ...config - } - if (this.config.directory) { - this.loadLocales(this.config.directory) - } - } - - loadLocales (directory) { - if (!fs.existsSync(directory)) { - throw new Error(`Locales directory '${directory}' not found`) - } - const files = fs.readdirSync(directory) - files.forEach((fileName) => { - const extension = path.extname(fileName) - const languageCode = path.basename(fileName, extension).toLowerCase() - if (extension === '.yaml' || extension === '.yml') { - const data = fs.readFileSync(path.resolve(directory, fileName), 'utf8') - this.loadLocale(languageCode, yaml.safeLoad(data)) - } else if (extension === '.json') { - this.loadLocale(languageCode, require(path.resolve(directory, fileName))) - } - }) - } - - loadLocale (languageCode, i18Data) { - const language = languageCode.toLowerCase() - this.repository[language] = { - ...this.repository[language], - ...compileTemplates(i18Data) - } - } - - resetLocale (languageCode) { - if (languageCode) { - delete this.repository[languageCode.toLowerCase()] - } else { - this.repository = {} - } - } - - availableLocales () { - return Object.keys(this.repository) - } - - resourceKeys (languageCode) { - const language = languageCode.toLowerCase() - return getTemplateKeysRecursive(this.repository[language] || {}) - } - - missingKeys (languageOfInterest, referenceLanguage = this.config.defaultLanguage) { - const interest = this.resourceKeys(languageOfInterest) - const reference = this.resourceKeys(referenceLanguage) - - return reference.filter((ref) => !interest.includes(ref)) - } - - overspecifiedKeys (languageOfInterest, referenceLanguage = this.config.defaultLanguage) { - return this.missingKeys(referenceLanguage, languageOfInterest) - } - - translationProgress (languageOfInterest, referenceLanguage = this.config.defaultLanguage) { - const reference = this.resourceKeys(referenceLanguage).length - const missing = this.missingKeys(languageOfInterest, referenceLanguage).length - - return (reference - missing) / reference - } - - createContext (languageCode, templateData) { - return new I18nContext(this.repository, this.config, languageCode, templateData) - } - - middleware () { - return (ctx, next) => { - const session = this.config.useSession && ctx[this.config.sessionName] - const languageCode = (session && session.__language_code) || (ctx.from && ctx.from.language_code) - - ctx.i18n = new I18nContext( - this.repository, - this.config, - languageCode, - { - from: ctx.from, - chat: ctx.chat - } - ) - - return next().then(() => { - if (session) { - session.__language_code = ctx.i18n.locale() - } - }) - } - } - - t (languageCode, resourceKey, templateData) { - const context = new I18nContext(this.repository, this.config, languageCode, templateData) - return context.t(resourceKey) - } -} - -function compileTemplates (root) { - Object.keys(root).forEach((key) => { - if (!root[key]) { - return - } - if (Array.isArray(root[key])) { - root[key] = root[key].map(compileTemplates) - } - if (typeof root[key] === 'object') { - root[key] = compileTemplates(root[key]) - } - if (typeof root[key] === 'string') { - if (root[key].includes('${')) { - root[key] = compile(root[key]) - } else { - const value = root[key] - root[key] = () => value - } - } - }) - return root -} - -function getTemplateKeysRecursive (root, prefix = '') { - let keys = [] - for (const key of Object.keys(root)) { - const subKey = prefix ? prefix + '.' + key : key - if (typeof root[key] === 'object') { - keys = keys.concat(getTemplateKeysRecursive(root[key], subKey)) - } else { - keys.push(subKey) - } - } - - return keys -} - -I18n.match = function (resourceKey, templateData) { - return (text, ctx) => (text && ctx && ctx.i18n && text === ctx.i18n.t(resourceKey, templateData)) ? [text] : null -} - -I18n.reply = function (resourceKey, extra) { - return ({ reply, i18n }) => reply(i18n.t(resourceKey), extra) -} - -I18n.pluralize = pluralize -module.exports = I18n diff --git a/lib/index.d.ts b/lib/index.d.ts deleted file mode 100644 index 6db9bf8..0000000 --- a/lib/index.d.ts +++ /dev/null @@ -1,32 +0,0 @@ -declare module 'telegraf-i18n' { - interface Config { - directory?: string; - useSession?: boolean; - sessionName?: string; - allowMissing?: boolean; - defaultLanguageOnMissing?: boolean; - defaultLanguage?: string; - } - - type ContextUpdate = (ctx: any, next?: (() => any) | undefined) => any; - - export class I18n { - constructor (input: Config); - loadLocales (directory: string): void; - loadLocale (languageCode: string, i18Data: object): void; - resetLocale (languageCode: string): void; - availableLocales (): string[]; - resourceKeys (languageCode: string): string[]; - missingKeys (languageOfInterest: string, referenceLanguage?: string): string[]; - overspecifiedKeys (languageOfInterest: string, referenceLanguage?: string): string[]; - translationProgress (languageOfInterest: string, referenceLanguage?: string): number; - middleware(): ContextUpdate; - createContext (languageCode: string, templateData: object): void; - t (languageCode?: string, resourceKey?: string, templateData?: object): string; - t (resourceKey?: string, templateData?: object): string; - locale (): string; - locale (languageCode?: string): void; - } - - export default I18n; -} diff --git a/lib/pluralize.js b/lib/pluralize.js deleted file mode 100644 index 4956247..0000000 --- a/lib/pluralize.js +++ /dev/null @@ -1,60 +0,0 @@ -// https://developer.mozilla.org/en-US/docs/Mozilla/Localization/Localization_and_Plurals - -const pluralRules = { - english: (n) => n !== 1 ? 1 : 0, - french: (n) => n > 1 ? 1 : 0, - russian: (n) => { - if (n % 10 === 1 && n % 100 !== 11) { - return 0 - } - return n % 10 >= 2 && n % 10 <= 4 && (n % 100 < 10 || n % 100 >= 20) ? 1 : 2 - }, - czech: (n) => { - if (n === 1) { - return 0 - } - return (n >= 2 && n <= 4) ? 1 : 2 - }, - polish: (n) => { - if (n === 1) { - return 0 - } - return n % 10 >= 2 && n % 10 <= 4 && (n % 100 < 10 || n % 100 >= 20) ? 1 : 2 - }, - icelandic: (n) => (n % 10 !== 1 || n % 100 === 11) ? 1 : 0, - chinese: () => 0, - arabic: (n) => { - if (n >= 0 && n < 3) { - return n - } - if (n % 100 <= 10) { - return 3 - } - if (n >= 11 && n % 100 <= 99) { - return 4 - } - return 5 - } -} - -const mapping = { - english: ['da', 'de', 'en', 'es', 'fi', 'el', 'he', 'hu', 'it', 'nl', 'no', 'pt', 'sv', 'br'], - chinese: ['fa', 'id', 'ja', 'ko', 'lo', 'ms', 'th', 'tr', 'zh', 'jp'], - french: ['fr', 'tl', 'pt-br'], - russian: ['hr', 'ru', 'uk', 'uz'], - czech: ['cs', 'sk'], - icelandic: ['is'], - polish: ['pl'], - arabic: ['ar'] -} - -module.exports = function pluralize (number, ...forms) { - const code = this.shortLanguageCode - let key = Object.keys(mapping).find((key) => mapping[key].includes(code)) - if (!key) { - console.warn(`i18n::Pluralize: Unsupported language ${code}`) - key = 'english' - } - const form = forms[pluralRules[key](number)] - return typeof form === 'function' ? form(number) : `${number} ${form}` -} diff --git a/package.json b/package.json index c129572..034f2c4 100644 --- a/package.json +++ b/package.json @@ -1,53 +1,89 @@ { - "name": "telegraf-i18n", - "version": "6.6.0", - "description": "Telegraf i18n engine", - "main": "lib/i18n.js", - "types": "lib/index.d.ts", - "repository": { - "type": "git", - "url": "git+ssh://git@github.com/telegraf/telegraf-i18n.git" - }, - "keywords": [ - "telegram bot", - "telegraf", - "bot framework", - "i18n", - "internationalization", - "middleware" - ], - "author": "Vitaly Domnikov ", - "license": "MIT", - "bugs": { - "url": "https://github.com/telegraf/telegraf-i18n/issues" - }, - "homepage": "https://github.com/telegraf/telegraf-i18n#readme", - "engines": { - "node": ">=8" - }, - "files": [ - "lib" - ], - "scripts": { - "test": "eslint . && ava" - }, - "dependencies": { - "compile-template": "^0.3.1", - "debug": "^4.0.1", - "js-yaml": "^3.6.1" - }, - "peerDependencies": { - "telegraf": "^3.7.1" - }, - "devDependencies": { - "ava": "^2.2.0", - "eslint": "^6.1.0", - "eslint-config-standard": "^13.0.1", - "eslint-plugin-ava": "^7.1.0", - "eslint-plugin-import": "^2.2.0", - "eslint-plugin-node": "^9.1.0", - "eslint-plugin-promise": "^4.0.0", - "eslint-plugin-standard": "^4.0.0", - "telegraf": "^3.7.1" - } + "name": "telegraf-i18n", + "version": "6.6.0", + "description": "Telegraf i18n engine", + "keywords": [ + "telegram bot", + "grammy", + "telegraf", + "bot framework", + "i18n", + "internationalization", + "middleware" + ], + "license": "MIT", + "bugs": { + "url": "https://github.com/telegraf/telegraf-i18n/issues" + }, + "homepage": "https://github.com/telegraf/telegraf-i18n#readme", + "repository": { + "type": "git", + "url": "git+ssh://git@github.com/telegraf/telegraf-i18n.git" + }, + "author": "Vitaly Domnikov ", + "scripts": { + "build": "del-cli dist && tsc", + "prepack": "npm run build", + "start": "ts-node examples/example-bot.ts", + "test": "tsc --sourceMap && xo && nyc ava" + }, + "type": "commonjs", + "engines": { + "node": ">=12" + }, + "dependencies": { + "compile-template": "^0.3.1", + "debug": "^4.0.1", + "js-yaml": "^4.0.0" + }, + "devDependencies": { + "@sindresorhus/tsconfig": "^1.0.1", + "@types/js-yaml": "^4.0.0", + "@types/node": "^16.0.0", + "ava": "^3.0.0", + "del-cli": "^4.0.0", + "grammy": "^1.2.0", + "nyc": "^15.0.0", + "telegraf": "^4.0.0", + "ts-node": "^10.0.0", + "typescript": "^4.2.3", + "xo": "^0.42.0" + }, + "files": [ + "dist/source", + "!*.test.*" + ], + "main": "dist/source", + "types": "dist/source", + "nyc": { + "all": true, + "reporter": [ + "lcov", + "text" + ] + }, + "xo": { + "semicolon": false, + "space": true, + "rules": { + "@typescript-eslint/prefer-readonly-parameter-types": "warn", + "@typescript-eslint/no-unsafe-assignment": "off", + "@typescript-eslint/no-unsafe-call": "off", + "unicorn/prefer-module": "off", + "unicorn/prefer-node-protocol": "off", + "ava/no-ignored-test-files": "off" + }, + "overrides": [ + { + "files": [ + "**/*.test.*", + "examples/**/*.*", + "test/**/*.*" + ], + "rules": { + "@typescript-eslint/prefer-readonly-parameter-types": "off" + } + } + ] + } } diff --git a/readme.md b/readme.md deleted file mode 100644 index 500eadd..0000000 --- a/readme.md +++ /dev/null @@ -1,87 +0,0 @@ -[![Build Status](https://img.shields.io/travis/telegraf/telegraf-i18n.svg?branch=master&style=flat-square)](https://travis-ci.org/telegraf/telegraf-i18n) -[![NPM Version](https://img.shields.io/npm/v/telegraf-i18n.svg?style=flat-square)](https://www.npmjs.com/package/telegraf-i18n) -[![js-standard-style](https://img.shields.io/badge/code%20style-standard-brightgreen.svg?style=flat-square)](http://standardjs.com/) - -# i18n for Telegraf - -Internationalization middleware for [Telegraf](https://github.com/telegraf/telegraf). - -## Installation - -```js -$ npm install telegraf-i18n -``` - -## Example - -```js -const Telegraf = require('telegraf') -const TelegrafI18n = require('telegraf-i18n') - -/* -yaml and json are ok -Example directory structure: -├── locales -│   ├── en.yaml -│   ├── en-US.yaml -│   ├── it.json -│   └── ru.yaml -└── bot.js -*/ - -const i18n = new TelegrafI18n({ - defaultLanguage: 'en', - allowMissing: false, // Default true - directory: path.resolve(__dirname, 'locales') -}) - -// Also you can provide i18n data directly -i18n.loadLocale('en', {greeting: 'Hello!'}) - -const app = new Telegraf(process.env.BOT_TOKEN) - -// telegraf-i18n can save current locale setting into session. -const i18n = new TelegrafI18n({ - useSession: true, - defaultLanguageOnMissing: true, // implies allowMissing = true - directory: path.resolve(__dirname, 'locales') -}) - -app.use(Telegraf.memorySession()) -app.use(i18n.middleware()) - -app.hears('/start', (ctx) => { - const message = ctx.i18n.t('greeting', { - username: ctx.from.username - }) - return ctx.reply(message) -}) - -app.startPolling() -``` - -See full [example](/examples). - -## User context - -Telegraf user context props and functions: - -```js -app.use((ctx) => { - ctx.i18n.locale() // Get current locale - ctx.i18n.locale(code) // Set current locale - ctx.i18n.t(resourceKey, [context]) // Get resource value (context will be used by template engine) -}); -``` - -## Helpers - -```js -const { match, reply } = require('telegraf-i18n') - -// In case you use custom keyboard with localized labels. -bot.hears(match('keyboard.foo'), (ctx) => ...) - -//Reply helper -bot.command('help', reply('help')) -``` diff --git a/source/context.ts b/source/context.ts new file mode 100644 index 0000000..bdf8bb8 --- /dev/null +++ b/source/context.ts @@ -0,0 +1,86 @@ +import {Config, Repository, TemplateData, Template} from './types' + +export class I18nContext { + readonly config: Config + readonly repository: Repository + readonly templateData: Readonly + languageCode: string + shortLanguageCode: string + + constructor(repository: Readonly, config: Config, languageCode: string, templateData: Readonly) { + this.repository = repository + this.config = config + this.templateData = { + ...config.templateData, + ...templateData, + } + + const result = parseLanguageCode(this.repository, this.config.defaultLanguage, languageCode) + this.languageCode = result.languageCode + this.shortLanguageCode = result.shortLanguageCode + } + + locale(): string; + locale(languageCode: string): void; + locale(languageCode?: string): void | string { + if (!languageCode) { + return this.languageCode + } + + const result = parseLanguageCode(this.repository, this.config.defaultLanguage, languageCode) + this.languageCode = result.languageCode + this.shortLanguageCode = result.shortLanguageCode + } + + getTemplate(languageCode: string, resourceKey: string): Template | undefined { + const repositoryEntry = this.repository[languageCode] + return repositoryEntry?.[resourceKey] + } + + t(resourceKey: string, templateData: Readonly = {}) { + let template = this.getTemplate(this.languageCode, resourceKey) ?? this.getTemplate(this.shortLanguageCode, resourceKey) + + if (!template && this.config.defaultLanguageOnMissing) { + template = this.getTemplate(this.config.defaultLanguage, resourceKey) + } + + if (!template && this.config.allowMissing) { + template = () => resourceKey + } + + if (!template) { + throw new Error(`telegraf-i18n: '${this.languageCode}.${resourceKey}' not found`) + } + + const data: TemplateData = { + ...this.templateData, + ...templateData, + } + + for (const [key, value] of Object.entries(data)) { + if (typeof value === 'function') { + data[key] = value.bind(this) + } + } + + return template(data) + } +} + +function parseLanguageCode(repository: Readonly, defaultLanguage: string, languageCode: string): {languageCode: string; shortLanguageCode: string} { + let code = languageCode.toLowerCase() + const shortCode = shortLanguageCodeFromLong(code) + + if (!repository[code] && !repository[shortCode]) { + code = defaultLanguage + } + + return { + languageCode: code, + shortLanguageCode: shortLanguageCodeFromLong(code), + } +} + +function shortLanguageCodeFromLong(languageCode: string): string { + return languageCode.split('-')[0]! +} diff --git a/source/i18n.ts b/source/i18n.ts new file mode 100644 index 0000000..d11e55a --- /dev/null +++ b/source/i18n.ts @@ -0,0 +1,165 @@ +import * as fs from 'fs' +import * as path from 'path' + +import * as yaml from 'js-yaml' + +import {Config, LanguageCode, Repository, RepositoryEntry, TemplateData} from './types' +import {I18nContext} from './context' +import {pluralize} from './pluralize' +import {tableize} from './tabelize.js' + +// eslint-disable-next-line @typescript-eslint/no-var-requires, @typescript-eslint/no-require-imports +const compile = require('compile-template') + +interface MinimalMiddlewareContext { + readonly from?: { + readonly language_code?: string; + }; + readonly chat: unknown; + + readonly i18n: I18nContext; +} + +interface Session { + __language_code?: string; +} + +export class I18n { + repository: Repository = {} + readonly config: Config + + constructor(config: Partial = {}) { + this.config = { + defaultLanguage: 'en', + sessionName: 'session', + allowMissing: true, + templateData: { + pluralize, + }, + ...config, + } + if (this.config.directory) { + this.loadLocales(this.config.directory) + } + } + + loadLocales(directory: string) { + if (!fs.existsSync(directory)) { + throw new Error(`Locales directory '${directory}' not found`) + } + + const files = fs.readdirSync(directory) + for (const fileName of files) { + const extension = path.extname(fileName) + const languageCode = path.basename(fileName, extension).toLowerCase() + const fileContent = fs.readFileSync(path.resolve(directory, fileName), 'utf8') + let data + if (extension === '.yaml' || extension === '.yml') { + data = yaml.load(fileContent) + } else if (extension === '.json') { + data = JSON.parse(fileContent) + } + + this.loadLocale(languageCode, tableize(data)) + } + } + + loadLocale(languageCode: LanguageCode, i18nData: Readonly>): void { + const tableized = tableize(i18nData) + + const ensureStringData: Record = {} + for (const [key, value] of Object.entries(tableized)) { + ensureStringData[key] = String(value) + } + + const language = languageCode.toLowerCase() + this.repository[language] = { + ...this.repository[language], + ...compileTemplates(ensureStringData), + } + } + + resetLocale(languageCode?: LanguageCode): void { + if (languageCode) { + // eslint-disable-next-line @typescript-eslint/no-dynamic-delete + delete this.repository[languageCode.toLowerCase()] + } else { + this.repository = {} + } + } + + availableLocales(): LanguageCode[] { + return Object.keys(this.repository) + } + + resourceKeys(languageCode: LanguageCode): string[] { + const language = languageCode.toLowerCase() + return Object.keys(this.repository[language] ?? {}) + } + + missingKeys(languageOfInterest: LanguageCode, referenceLanguage = this.config.defaultLanguage): string[] { + const interest = this.resourceKeys(languageOfInterest) + const reference = this.resourceKeys(referenceLanguage) + + return reference.filter(ref => !interest.includes(ref)) + } + + overspecifiedKeys(languageOfInterest: LanguageCode, referenceLanguage = this.config.defaultLanguage): string[] { + return this.missingKeys(referenceLanguage, languageOfInterest) + } + + translationProgress(languageOfInterest: LanguageCode, referenceLanguage = this.config.defaultLanguage): number { + const reference = this.resourceKeys(referenceLanguage).length + const missing = this.missingKeys(languageOfInterest, referenceLanguage).length + + return (reference - missing) / reference + } + + createContext(languageCode: LanguageCode, templateData: Readonly): I18nContext { + return new I18nContext(this.repository, this.config, languageCode, templateData) + } + + middleware(): (ctx: MinimalMiddlewareContext, next: () => Promise) => Promise { + // eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types + return async (ctx, next) => { + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + const session: Session | undefined = this.config.useSession && (ctx as any)[this.config.sessionName] + const languageCode = session?.__language_code ?? ctx.from?.language_code ?? this.config.defaultLanguage; + + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + (ctx as any).i18n = new I18nContext( + this.repository, + this.config, + languageCode, + { + from: ctx.from, + chat: ctx.chat, + }, + ) + + await next() + + if (session) { + session.__language_code = ctx.i18n.locale() + } + } + } + + t(languageCode: LanguageCode, resourceKey: string, templateData: Readonly = {}): string { + return this.createContext(languageCode, templateData).t(resourceKey) + } +} + +function compileTemplates(root: Readonly>): RepositoryEntry { + const result: RepositoryEntry = {} + + for (const [key, value] of Object.entries(root)) { + if (value.includes('${')) { + result[key] = compile(value) + } else { + result[key] = () => value + } + } + + return result +} diff --git a/source/index.ts b/source/index.ts new file mode 100644 index 0000000..ec95dea --- /dev/null +++ b/source/index.ts @@ -0,0 +1,4 @@ +export * from './context' +export * from './i18n' +export * from './pluralize' +export * from './types' diff --git a/source/pluralize.ts b/source/pluralize.ts new file mode 100644 index 0000000..1015832 --- /dev/null +++ b/source/pluralize.ts @@ -0,0 +1,86 @@ +// https://developer.mozilla.org/en-US/docs/Mozilla/Localization/Localization_and_Plurals + +import {I18nContext} from './context' + +type AvailableRuleLanguages = 'english' | 'french' | 'russian' | 'czech' | 'polish' | 'icelandic' | 'chinese' | 'arabic' +type LanguageCode = string +type Form = string | ((n: number) => string) + +const pluralRules: Readonly number>> = { + english: (n: number) => n === 1 ? 0 : 1, + french: (n: number) => n > 1 ? 1 : 0, + russian: (n: number) => { + if (n % 10 === 1 && n % 100 !== 11) { + return 0 + } + + return n % 10 >= 2 && n % 10 <= 4 && (n % 100 < 10 || n % 100 >= 20) ? 1 : 2 + }, + czech: (n: number) => { + if (n === 1) { + return 0 + } + + return (n >= 2 && n <= 4) ? 1 : 2 + }, + polish: (n: number) => { + if (n === 1) { + return 0 + } + + return n % 10 >= 2 && n % 10 <= 4 && (n % 100 < 10 || n % 100 >= 20) ? 1 : 2 + }, + icelandic: (n: number) => (n % 10 !== 1 || n % 100 === 11) ? 1 : 0, + chinese: () => 0, + arabic: (n: number) => { + if (n >= 0 && n < 3) { + return n + } + + if (n % 100 <= 10) { + return 3 + } + + if (n >= 11 && n % 100 <= 99) { + return 4 + } + + return 5 + }, +} + +const AVAILABLE_RULE_LANGUAGES = Object.keys(pluralRules) as readonly AvailableRuleLanguages[] + +const mapping: Readonly> = { + english: ['da', 'de', 'en', 'es', 'fi', 'el', 'he', 'hu', 'it', 'nl', 'no', 'pt', 'sv', 'br'], + chinese: ['fa', 'id', 'ja', 'ko', 'lo', 'ms', 'th', 'tr', 'zh', 'jp'], + french: ['fr', 'tl', 'pt-br'], + russian: ['hr', 'ru', 'uk', 'uz'], + czech: ['cs', 'sk'], + icelandic: ['is'], + polish: ['pl'], + arabic: ['ar'], +} + +function findRuleLanguage(languageCode: string): AvailableRuleLanguages { + const result = AVAILABLE_RULE_LANGUAGES.find(key => mapping[key].includes(languageCode)) + if (!result) { + console.warn(`i18n::Pluralize: Unsupported language ${languageCode}`) + return 'english' + } + + return result +} + +function pluralizeInternal(languageCode: string, number: number, ...forms: readonly Form[]): string { + const key = findRuleLanguage(languageCode) + const rule = pluralRules[key] + const form = forms[rule(number)] + return typeof form === 'function' ? form(number) : `${number} ${String(form)}` +} + +// eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types +export function pluralize(this: I18nContext, number: number, ...forms: readonly Form[]): string { + const code = this.shortLanguageCode + return pluralizeInternal(code, number, ...forms) +} diff --git a/source/tabelize.ts b/source/tabelize.ts new file mode 100644 index 0000000..a5d42b0 --- /dev/null +++ b/source/tabelize.ts @@ -0,0 +1,35 @@ +/*! + * This is adapted from: + * + * tableize-object (https://github.com/jonschlinkert/tableize-object) + * + * Copyright (c) 2016, Jon Schlinkert. + * Licensed under the MIT License. + */ + +/** + * Tableize `obj` by flattening its keys into dot-notation. + * Example: {a: {b: value}} -> {'a.b': value} + */ +export function tableize(object: Record): Record { + const target = {} + flatten(target, object, '') + return target +} + +/** + * Recursively flatten object keys to use dot-notation. + */ +function flatten(target: Record, object: Record, parent: string) { + for (const [key, value] of Object.entries(object)) { + const globalKey = parent + key + + if (typeof value === 'object' && value !== null) { + flatten(target, value as any, globalKey + '.') + } else if (typeof value === 'string' || typeof value === 'number' || typeof value === 'bigint' || typeof value === 'boolean') { + target[globalKey] = value + } else { + throw new TypeError(`Could not parse value of key ${globalKey}. It is a ${typeof value}.`) + } + } +} diff --git a/source/types.ts b/source/types.ts new file mode 100644 index 0000000..2e06857 --- /dev/null +++ b/source/types.ts @@ -0,0 +1,17 @@ +export type LanguageCode = string + +export type TemplateData = Record +export type Template = (data: Readonly) => string + +export type RepositoryEntry = Record +export type Repository = Record> + +export interface Config { + readonly allowMissing?: boolean; + readonly defaultLanguage: LanguageCode; + readonly defaultLanguageOnMissing?: boolean; + readonly directory?: string; + readonly sessionName: string; + readonly templateData: Readonly; + readonly useSession?: boolean; +} diff --git a/test/analyse-language-repository.js b/test/analyse-language-repository.ts similarity index 78% rename from test/analyse-language-repository.js rename to test/analyse-language-repository.ts index e0b489f..89b69fb 100644 --- a/test/analyse-language-repository.js +++ b/test/analyse-language-repository.ts @@ -1,15 +1,15 @@ -const test = require('ava') +import test from 'ava' -const I18n = require('../lib/i18n.js') +import {I18n} from '../source/i18n' test('resourceKeys flat', t => { const i18n = new I18n() i18n.loadLocale('en', { - greeting: 'Hello!' + greeting: 'Hello!', }) t.deepEqual(i18n.resourceKeys('en'), [ - 'greeting' + 'greeting', ]) }) @@ -20,35 +20,35 @@ test('resourceKeys with depth', t => { foo: { bar: '42', hell: { - devil: 666 - } - } + devil: 666, + }, + }, }) t.deepEqual(i18n.resourceKeys('en'), [ 'greeting', 'foo.bar', - 'foo.hell.devil' + 'foo.hell.devil', ]) }) test('resourceKeys of not existing locale are empty', t => { const i18n = new I18n() i18n.loadLocale('en', { - greeting: 'Hello!' + greeting: 'Hello!', }) t.deepEqual(i18n.resourceKeys('de'), []) }) -function createMultiLanguageExample () { +function createMultiLanguageExample() { const i18n = new I18n() i18n.loadLocale('en', { greeting: 'Hello!', - checkout: 'Thank you!' + checkout: 'Thank you!', }) i18n.loadLocale('ru', { - greeting: 'Привет!' + greeting: 'Привет!', }) return i18n } @@ -57,7 +57,7 @@ test('availableLocales', t => { const i18n = createMultiLanguageExample() t.deepEqual(i18n.availableLocales(), [ 'en', - 'ru' + 'ru', ]) }) @@ -65,7 +65,7 @@ test('missingKeys ', t => { const i18n = createMultiLanguageExample() t.deepEqual(i18n.missingKeys('en', 'ru'), []) t.deepEqual(i18n.missingKeys('ru'), [ - 'checkout' + 'checkout', ]) }) @@ -73,7 +73,7 @@ test('overspecifiedKeys', t => { const i18n = createMultiLanguageExample() t.deepEqual(i18n.overspecifiedKeys('ru'), []) t.deepEqual(i18n.overspecifiedKeys('en', 'ru'), [ - 'checkout' + 'checkout', ]) }) @@ -84,5 +84,5 @@ test('translationProgress', t => { t.is(i18n.translationProgress('ru'), 0.5) // Overspecified (unneeded 'checkout') but everything required is there - t.deepEqual(i18n.translationProgress('en', 'ru'), 1) + t.is(i18n.translationProgress('en', 'ru'), 1) }) diff --git a/test/basics.js b/test/basics.ts similarity index 60% rename from test/basics.js rename to test/basics.ts index 814ae4a..7d34150 100644 --- a/test/basics.js +++ b/test/basics.ts @@ -1,20 +1,20 @@ -const test = require('ava') +import test from 'ava' -const I18n = require('../lib/i18n.js') +import {I18n} from '../source/i18n' test('can translate', t => { const i18n = new I18n() i18n.loadLocale('en', { - greeting: 'Hello!' + greeting: 'Hello!', }) t.is(i18n.t('en', 'greeting'), 'Hello!') }) test('allowMissing false throws', t => { const i18n = new I18n({ - allowMissing: false + allowMissing: false, }) t.throws(() => { i18n.t('en', 'greeting') - }, 'telegraf-i18n: \'en.greeting\' not found') + }, {message: 'telegraf-i18n: \'en.greeting\' not found'}) }) diff --git a/test/context.ts b/test/context.ts new file mode 100644 index 0000000..aaf94a6 --- /dev/null +++ b/test/context.ts @@ -0,0 +1,57 @@ +import test from 'ava' + +import {Config, Repository} from '../source' +import {I18nContext} from '../source/context' + +const EXAMPLE_REPO: Readonly = { + en: { + desk: () => 'desk', + foo: () => 'bar', + }, + de: { + desk: () => 'Tisch', + }, +} + +const MINIMAL_CONFIG: Config = { + defaultLanguage: 'en', + sessionName: 'session', + templateData: {}, +} + +test('can get language', t => { + const i18n = new I18nContext(EXAMPLE_REPO, MINIMAL_CONFIG, 'de', {}) + t.is(i18n.locale(), 'de') +}) + +test('can change language', t => { + const i18n = new I18nContext(EXAMPLE_REPO, MINIMAL_CONFIG, 'de', {}) + t.is(i18n.locale(), 'de') + i18n.locale('en') + t.is(i18n.locale(), 'en') +}) + +test('can translate something', t => { + const i18n = new I18nContext(EXAMPLE_REPO, MINIMAL_CONFIG, 'de', {}) + t.is(i18n.t('desk'), 'Tisch') +}) + +test('allowMissing', t => { + const config: Config = { + ...MINIMAL_CONFIG, + allowMissing: true, + } + + const i18n = new I18nContext(EXAMPLE_REPO, config, 'de', {}) + t.is(i18n.t('unknown'), 'unknown') +}) + +test('defaultLanguageOnMissing', t => { + const config: Config = { + ...MINIMAL_CONFIG, + defaultLanguageOnMissing: true, + } + + const i18n = new I18nContext(EXAMPLE_REPO, config, 'de', {}) + t.is(i18n.t('foo'), 'bar') +}) diff --git a/test/pluralize.js b/test/pluralize.js deleted file mode 100644 index 99c28a5..0000000 --- a/test/pluralize.js +++ /dev/null @@ -1,25 +0,0 @@ -const test = require('ava') - -const I18n = require('../lib/i18n.js') - -test('can pluralize', t => { - const i18n = new I18n() - i18n.loadLocale('en', { - // eslint-disable-next-line no-template-curly-in-string - pluralize: "${pluralize(n, 'There was an apple', 'There were apples')}" - }) - t.is(i18n.t('en', 'pluralize', { n: 0 }), '0 There were apples') - t.is(i18n.t('en', 'pluralize', { n: 1 }), '1 There was an apple') - t.is(i18n.t('en', 'pluralize', { n: 5 }), '5 There were apples') -}) - -test('can pluralize using functional forms', t => { - const i18n = new I18n() - i18n.loadLocale('en', { - // eslint-disable-next-line no-template-curly-in-string - pluralize: "${pluralize(n, n => 'There was an apple', n => 'There were ' + n + ' apples')}" - }) - t.is(i18n.t('en', 'pluralize', { n: 0 }), 'There were 0 apples') - t.is(i18n.t('en', 'pluralize', { n: 1 }), 'There was an apple') - t.is(i18n.t('en', 'pluralize', { n: 5 }), 'There were 5 apples') -}) diff --git a/test/pluralize.ts b/test/pluralize.ts new file mode 100644 index 0000000..6979dac --- /dev/null +++ b/test/pluralize.ts @@ -0,0 +1,25 @@ +import test from 'ava' + +import {I18n} from '../source/i18n' + +test('can pluralize', t => { + const i18n = new I18n() + i18n.loadLocale('en', { + // eslint-disable-next-line no-template-curly-in-string, @typescript-eslint/quotes + pluralize: "${pluralize(n, 'There was an apple', 'There were apples')}", + }) + t.is(i18n.t('en', 'pluralize', {n: 0}), '0 There were apples') + t.is(i18n.t('en', 'pluralize', {n: 1}), '1 There was an apple') + t.is(i18n.t('en', 'pluralize', {n: 5}), '5 There were apples') +}) + +test('can pluralize using functional forms', t => { + const i18n = new I18n() + i18n.loadLocale('en', { + // eslint-disable-next-line no-template-curly-in-string, @typescript-eslint/quotes + pluralize: "${pluralize(n, n => 'There was an apple', n => 'There were ' + n + ' apples')}", + }) + t.is(i18n.t('en', 'pluralize', {n: 0}), 'There were 0 apples') + t.is(i18n.t('en', 'pluralize', {n: 1}), 'There was an apple') + t.is(i18n.t('en', 'pluralize', {n: 5}), 'There were 5 apples') +}) diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..9601991 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,12 @@ +{ + "extends": "@sindresorhus/tsconfig", + "include": [ + "examples", + "source", + "test" + ], + "compilerOptions": { + "module": "CommonJS", + "outDir": "dist" + } +}