From 632615a6cba52300e4d74b3494e33f2b39aa7109 Mon Sep 17 00:00:00 2001 From: Dunkan <70066170+dcdunkan@users.noreply.github.com> Date: Sun, 18 Sep 2022 14:58:03 +0530 Subject: [PATCH] 1.0: Fluent, Deno support, docs, and more (#20) Co-authored-by: Roj Co-authored-by: EdJoPaTo Co-authored-by: KnorpelSenf Co-authored-by: Roj Co-authored-by: Loskir --- .editorconfig | 12 +- .gitattributes | 1 - .github/dependabot.yml | 20 --- .github/workflows/build.yml | 32 ++++ .github/workflows/nodejs.yml | 29 ---- .gitignore | 9 +- .npmrc | 1 - .vscode/extensions.json | 6 + .vscode/settings.json | 7 + LICENSE | 4 +- README.md | 105 ++++++++----- deno.jsonc | 22 +++ examples/deno.ts | 60 ++++++++ examples/example-grammy-bot.ts | 62 -------- examples/example-telegraf-bot.ts | 70 --------- examples/locales/ckb.ftl | 4 + examples/locales/de.ftl | 12 ++ examples/locales/en-us.json | 3 - examples/locales/en.ftl | 12 ++ examples/locales/en.yaml | 4 - examples/locales/ku.ftl | 4 + examples/locales/ru.yaml | 2 - examples/node.ts | 56 +++++++ package.json | 93 ------------ scripts/dnt.ts | 47 ++++++ scripts/package.json | 40 +++++ source/context.ts | 91 ----------- source/i18n.ts | 165 -------------------- source/index.ts | 4 - source/pluralize.ts | 86 ----------- source/tabelize.ts | 35 ----- source/types.ts | 17 --- src/deps.ts | 17 +++ src/i18n.ts | 228 ++++++++++++++++++++++++++++ src/mod.ts | 2 + src/types.ts | 101 ++++++++++++ src/utils.ts | 23 +++ test/analyse-language-repository.ts | 88 ----------- test/basics.ts | 20 --- test/context.ts | 57 ------- test/pluralize.ts | 25 --- tests/bot.ts | 57 +++++++ tests/bot_test.ts | 168 ++++++++++++++++++++ tests/deps.ts | 12 ++ tests/i18n_test.ts | 131 ++++++++++++++++ tests/session_bot.ts | 52 +++++++ tests/session_bot_test.ts | 47 ++++++ tests/utils.ts | 49 ++++++ tsconfig.json | 12 -- 49 files changed, 1265 insertions(+), 939 deletions(-) delete mode 100644 .gitattributes delete mode 100644 .github/dependabot.yml create mode 100644 .github/workflows/build.yml delete mode 100644 .github/workflows/nodejs.yml delete mode 100644 .npmrc create mode 100644 .vscode/extensions.json create mode 100644 .vscode/settings.json create mode 100644 deno.jsonc create mode 100644 examples/deno.ts delete mode 100644 examples/example-grammy-bot.ts delete mode 100644 examples/example-telegraf-bot.ts create mode 100644 examples/locales/ckb.ftl create mode 100644 examples/locales/de.ftl delete mode 100644 examples/locales/en-us.json create mode 100644 examples/locales/en.ftl delete mode 100644 examples/locales/en.yaml create mode 100644 examples/locales/ku.ftl delete mode 100644 examples/locales/ru.yaml create mode 100644 examples/node.ts delete mode 100644 package.json create mode 100644 scripts/dnt.ts create mode 100644 scripts/package.json delete mode 100644 source/context.ts delete mode 100644 source/i18n.ts delete mode 100644 source/index.ts delete mode 100644 source/pluralize.ts delete mode 100644 source/tabelize.ts delete mode 100644 source/types.ts create mode 100644 src/deps.ts create mode 100644 src/i18n.ts create mode 100644 src/mod.ts create mode 100644 src/types.ts create mode 100644 src/utils.ts delete mode 100644 test/analyse-language-repository.ts delete mode 100644 test/basics.ts delete mode 100644 test/context.ts delete mode 100644 test/pluralize.ts create mode 100644 tests/bot.ts create mode 100644 tests/bot_test.ts create mode 100644 tests/deps.ts create mode 100644 tests/i18n_test.ts create mode 100644 tests/session_bot.ts create mode 100644 tests/session_bot_test.ts create mode 100644 tests/utils.ts delete mode 100644 tsconfig.json diff --git a/.editorconfig b/.editorconfig index 01538363..80976aba 100644 --- a/.editorconfig +++ b/.editorconfig @@ -1,14 +1,8 @@ -# editorconfig.org -root = true - [*] -indent_style = space -indent_size = 2 end_of_line = lf charset = utf-8 -trim_trailing_whitespace = true -insert_final_newline = true - -[*.{yml,yaml}] indent_style = space indent_size = 2 +insert_final_newline = true +trim_trailing_whitespace = true +max_line_length = 80 \ No newline at end of file diff --git a/.gitattributes b/.gitattributes deleted file mode 100644 index 6313b56c..00000000 --- a/.gitattributes +++ /dev/null @@ -1 +0,0 @@ -* text=auto eol=lf diff --git a/.github/dependabot.yml b/.github/dependabot.yml deleted file mode 100644 index c4b2f302..00000000 --- a/.github/dependabot.yml +++ /dev/null @@ -1,20 +0,0 @@ -# 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" - day: "saturday" - time: "02:42" # UTC - commit-message: - prefix: "build(npm):" - ignore: - - dependency-name: "@types/node" - update-types: ["version-update:semver-major"] - # Requires Node.js 14 or later - - dependency-name: "@sindresorhus/tsconfig" - update-types: ["version-update:semver-major"] diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 00000000..dae76163 --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,32 @@ +name: Build + +on: + push: + branches: [main] + pull_request: + branches: [main] + +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + + - uses: denoland/setup-deno@v1 + with: + deno-version: v1.x + + - name: Format + run: deno fmt --check + + - name: Lint + run: deno lint + + - name: Check + run: deno check src/mod.ts + + - name: Test + run: deno task test + + - name: Backport + run: deno task dnt 0.0.0-workflow.0 diff --git a/.github/workflows/nodejs.yml b/.github/workflows/nodejs.yml deleted file mode 100644 index 622b1339..00000000 --- a/.github/workflows/nodejs.yml +++ /dev/null @@ -1,29 +0,0 @@ -name: Node.js - -on: - push: - pull_request: - schedule: - # Check if it works with current dependencies - - cron: '42 2 * * 6' # weekly on Saturday 2:42 UTC - -jobs: - test: - name: Node.js ${{ matrix.node-version }} - runs-on: ubuntu-latest - strategy: - fail-fast: false - matrix: - node-version: - - 18 - - 16 - - 14 - - 12 - steps: - - uses: actions/setup-node@v3 - with: - node-version: ${{ matrix.node-version }} - - uses: actions/checkout@v3 - - run: npm install - - run: npm test - - run: npm pack diff --git a/.gitignore b/.gitignore index 2ab2a0dc..49db23f8 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,3 @@ -.nyc_output -/*-*.*.*.tgz -coverage -dist -node_modules -yarn.lock +out/ +node_modules/ +package-lock.json \ No newline at end of file diff --git a/.npmrc b/.npmrc deleted file mode 100644 index 43c97e71..00000000 --- a/.npmrc +++ /dev/null @@ -1 +0,0 @@ -package-lock=false diff --git a/.vscode/extensions.json b/.vscode/extensions.json new file mode 100644 index 00000000..64546169 --- /dev/null +++ b/.vscode/extensions.json @@ -0,0 +1,6 @@ +{ + "recommendations": [ + "denoland.vscode-deno", + "editorconfig.editorconfig" + ] +} diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 00000000..1ffa96f9 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,7 @@ +{ + "deno.enable": true, + "deno.lint": true, + "[typescript]": { + "editor.defaultFormatter": "denoland.vscode-deno" + } +} diff --git a/LICENSE b/LICENSE index 29635057..47576e62 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ -The MIT License (MIT) +MIT License -Copyright (c) 2016 Vitaly Domnikov +Copyright (c) 2022 Dunkan Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/README.md b/README.md index bfaa60eb..81aa36e5 100644 --- a/README.md +++ b/README.md @@ -1,59 +1,90 @@ -# i18n for grammY and Telegraf +# grammY i18n -Internationalization middleware for [grammY](https://github.com/grammyjs/grammy) and [Telegraf](https://github.com/telegraf/telegraf). +Internationalization plugin for [grammY](https://grammy.dev) based on +[Project Fluent](https://projectfluent.org). Check out +[the official documentation](https://grammy.dev/plugins/i18n.html) for this +plugin. ## Installation -```bash +Node.js + +```sh npm install @grammyjs/i18n ``` +Deno + +```ts +import { I18n, I18nFlavor } from "https://deno.land/x/grammy_i18n/mod.ts"; +``` + ## Example -```plaintext -yaml and json are ok -Example directory structure: -├── locales -│   ├── en.yaml -│   ├── en-US.yaml -│   ├── it.json -│   └── ru.yaml -└── bot.js +Example project structure: + +``` +. +├─ locales/ +│ ├── en.ftl +│ ├── it.ftl +│ └── ru.ftl +└── bot.ts ``` -```js -import {Bot, session} from 'grammy' -import {I18n, pluralize} from '@grammyjs/i18n' +Example bot +[not using sessions](https://grammy.dev/plugins/i18n.html#without-sessions): +```ts +import { Bot, Context } from "https://deno.land/x/grammy/mod.ts"; +import { I18n, I18nFlavor } from "https://deno.land/x/grammy_i18n/mod.ts"; + +// Create a new I18n instance. const i18n = new I18n({ - defaultLanguageOnMissing: true, // implies allowMissing = true - directory: 'locales', - useSession: true, -}) + defaultLocale: "en", + directory: "locales", +}); -// Also you can provide i18n data directly -i18n.loadLocale('en', {greeting: 'Hello!'}) +// For proper typings and auto-completions in IDEs, +// extend the `Context` using `I18nFlavor`. +type MyContext = Context & I18nFlavor; -const bot = new Bot(process.env['BOT_TOKEN']!) -bot.use(session()) -bot.use(i18n.middleware()) +// Create a bot as usual, but use the modified Context type. +const bot = new Bot(""); // <- Put your bot token here -// Start message handler -bot.command('start', async ctx => ctx.reply(ctx.i18n.t('greeting'))) +// Tell the bot to use the middleware from the instance. +// Remember to register this middleware before registering +// other middlewares. +bot.use(i18n.middleware()); + +bot.command("start", async (ctx) => { + // Use the method `t` or `translate` from the context and pass + // in the message id (key) of the message you want to get. + await ctx.reply(ctx.t("greeting")); +}); -bot.start() +// Start your bot +bot.start(); ``` -A full example for both grammY and Telegraf are in the [examples folder](/examples). +See the [documentation](https://grammy.dev/plugins/i18n.html) and +[examples/](examples/) for more detailed examples. -## User context +## Credits -Commonly used Context functions: +Thanks to... -```ts -bot.use(ctx => { - ctx.i18n.locale() // Get current locale - ctx.i18n.locale(code) // Set current locale - ctx.i18n.t(resourceKey, [data]) // Get resource value (data will be used by template engine) -}); -``` +- Slava Fomin II ([@slavafomin](https://github.com/slavafomin)) for the Node.js + implementation of the + [original Fluent plugin](https://github.com/the-moebius/grammy-fluent) and the + [better Fluent integration](https://github.com/the-moebius/fluent). +- Roj ([@roj1512](https://github.com/roj1512)) for the + [Deno port](https://github.com/roj1512/fluent) of the original + [@fluent/bundle](https://github.com/projectfluent/fluent.js/tree/master/fluent-bundle) + and + [@fluent/langneg](https://github.com/projectfluent/fluent.js/tree/master/fluent-langneg) + packages. +- Dunkan ([@dcdunkan](https://github.com/dcdunkan)) for the + [Deno port](https://github.com/dcdunkan/deno_fluent) of the + [@moebius/fluent](https://github.com/the-moebius/fluent). +- And all the previous maintainers and contributors of this i18n plugin. diff --git a/deno.jsonc b/deno.jsonc new file mode 100644 index 00000000..bf3bdc00 --- /dev/null +++ b/deno.jsonc @@ -0,0 +1,22 @@ +{ + "fmt": { + "files": { + "exclude": ["./out/"] + } + }, + "lint": { + "files": { + "exclude": ["./out/"] + } + }, + "tasks": { + "example": "cd examples && deno run --allow-net --allow-read deno.ts", + "test": "deno test --allow-read --allow-write", + "dnt": "deno run --allow-env --allow-net --allow-read --allow-run --allow-write scripts/dnt.ts" + }, + "test": { + "files": { + "exclude": ["./out/"] + } + } +} diff --git a/examples/deno.ts b/examples/deno.ts new file mode 100644 index 00000000..91f92eeb --- /dev/null +++ b/examples/deno.ts @@ -0,0 +1,60 @@ +import { + Bot, + Context, + session, + SessionFlavor, +} from "https://deno.land/x/grammy@v1.11.0/mod.ts"; +import { I18n, I18nFlavor } from "../src/mod.ts"; + +interface SessionData { + apples: number; +} + +type MyContext = + & Context + & I18nFlavor + & SessionFlavor; + +const bot = new Bot(""); // <-- put your bot token here (https://t.me/BotFather) + +bot.use(session({ + initial: () => ({ apples: 0 }), +})); + +const i18n = new I18n({ + defaultLocale: "en", + useSession: true, + directory: "locales", +}); + +bot.use(i18n); + +bot.command("start", async (ctx) => { + await ctx.reply(ctx.t("greeting")); +}); + +bot.command(["en", "de", "ku", "ckb"], async (ctx) => { + const locale = ctx.msg.text.substring(1).split(" ")[0]; + await ctx.i18n.setLocale(locale); + await ctx.reply(ctx.t("language-set")); +}); + +bot.command("add", async (ctx) => { + ctx.session.apples++; + await ctx.reply(ctx.t("cart", { + apples: ctx.session.apples, + })); +}); + +bot.command("cart", async (ctx) => { + await ctx.reply(ctx.t("cart", { + apples: ctx.session.apples, + })); +}); + +bot.command("checkout", async (ctx) => { + ctx.session.apples = 0; + await ctx.reply(ctx.t("checkout")); +}); + +bot.start(); diff --git a/examples/example-grammy-bot.ts b/examples/example-grammy-bot.ts deleted file mode 100644 index 3a801938..00000000 --- a/examples/example-grammy-bot.ts +++ /dev/null @@ -1,62 +0,0 @@ -import * as path from 'path'; -import * as process from 'process'; - -import {Bot, Context as BaseContext, session, SessionFlavor} from 'grammy'; - -import {I18n, pluralize, I18nContextFlavor} from '../source'; - -interface Session { - apples: number; -} - -type MyContext = BaseContext & I18nContextFlavor & SessionFlavor; - -// 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({initial: () => ({apples: 0})})); -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++; - 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}); - 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 deleted file mode 100644 index 120cab19..00000000 --- a/examples/example-telegraf-bot.ts +++ /dev/null @@ -1,70 +0,0 @@ -import * as path from 'path'; -import * as process from 'process'; - -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(async (ctx, next) => { - ctx.session ??= {apples: 0}; - return next(); -}); - -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++; - 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}); - 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/ckb.ftl b/examples/locales/ckb.ftl new file mode 100644 index 00000000..c495dd15 --- /dev/null +++ b/examples/locales/ckb.ftl @@ -0,0 +1,4 @@ +greeting = سڵاو، { $first_name }! +cart = سڵاو، { $first_name }، لە سەبەتەکەتدا{ $apples } سێو هەن. +checkout = سپاس بۆ بازاڕیکردنەکەت! +language-set = کوردی هەڵبژێردرا! diff --git a/examples/locales/de.ftl b/examples/locales/de.ftl new file mode 100644 index 00000000..c99062ec --- /dev/null +++ b/examples/locales/de.ftl @@ -0,0 +1,12 @@ +greeting = Hallo { $first_name }! + +cart = { $first_name }, es { + $apples -> + [0] ist kein Apfel + [one] ist ein Apfel + *[other] sind { $apples } Äpfel + } in deinem Einkaufswagen. + +checkout = Danke für deinen Einkauf! + +language-set = Die Sprache wurde zu Deutsch geändert! diff --git a/examples/locales/en-us.json b/examples/locales/en-us.json deleted file mode 100644 index da23f58c..00000000 --- a/examples/locales/en-us.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "checkout": "Yo!" -} diff --git a/examples/locales/en.ftl b/examples/locales/en.ftl new file mode 100644 index 00000000..fb1350e9 --- /dev/null +++ b/examples/locales/en.ftl @@ -0,0 +1,12 @@ +greeting = Hello { $first_name }! + +cart = { $first_name }, there { + $apples -> + [0] are no apples + [one] is one apple + *[other] are { $apples } apples + } in your cart. + +checkout = Thank you for purchasing! + +language-set = Language has been set to English! diff --git a/examples/locales/en.yaml b/examples/locales/en.yaml deleted file mode 100644 index 7dc0f487..00000000 --- a/examples/locales/en.yaml +++ /dev/null @@ -1,4 +0,0 @@ -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/examples/locales/ku.ftl b/examples/locales/ku.ftl new file mode 100644 index 00000000..06419c44 --- /dev/null +++ b/examples/locales/ku.ftl @@ -0,0 +1,4 @@ +greeting = Silav, { $first_name }! +cart = { $first_name }, di sepeta te de { $apples } sêv hene. +checkout = Spas bo kirîna te! +language-set = Kurdî hate hilbijartin! diff --git a/examples/locales/ru.yaml b/examples/locales/ru.yaml deleted file mode 100644 index 3bac8179..00000000 --- a/examples/locales/ru.yaml +++ /dev/null @@ -1,2 +0,0 @@ -greeting: Привет ${uppercase(from.first_name)}! -cart: В вашей корзине ${pluralize(apples, 'яблоко', 'яблокa', 'яблок')} diff --git a/examples/node.ts b/examples/node.ts new file mode 100644 index 00000000..430ce2c6 --- /dev/null +++ b/examples/node.ts @@ -0,0 +1,56 @@ +import { Bot, Context, session, SessionFlavor } from "grammy"; +import { I18n, I18nFlavor } from "@grammyjs/i18n"; + +interface SessionData { + apples: number; +} + +type MyContext = + & Context + & I18nFlavor + & SessionFlavor; + +const bot = new Bot(""); // <-- put your bot token here (https://t.me/BotFather) + +bot.use(session({ + initial: () => ({ apples: 0 }), +})); + +const i18n = new I18n({ + defaultLocale: "en", + useSession: true, + directory: "locales", +}); + +bot.use(i18n); + +bot.command("start", async (ctx) => { + await ctx.reply(ctx.t("greeting")); +}); + +bot.command(["en", "de", "ku", "ckb"], async (ctx) => { + const locale = ctx.msg.text.substring(1).split(" ")[0]; + await ctx.i18n.setLocale(locale); + await ctx.reply(ctx.t("language-set")); +}); + +// Add apple to cart +bot.command("add", async (ctx) => { + ctx.session.apples++; + await ctx.reply(ctx.t("cart", { + apples: ctx.session.apples, + })); +}); + +bot.command("cart", async (ctx) => { + await ctx.reply(ctx.t("cart", { + apples: ctx.session.apples, + })); +}); + +bot.command("checkout", async (ctx) => { + ctx.session.apples = 0; + await ctx.reply(ctx.t("checkout")); +}); + +bot.start(); diff --git a/package.json b/package.json deleted file mode 100644 index 8baeda82..00000000 --- a/package.json +++ /dev/null @@ -1,93 +0,0 @@ -{ - "name": "@grammyjs/i18n", - "version": "0.5.1", - "description": "Internationalization middleware for grammY and Telegraf", - "keywords": [ - "telegram bot", - "grammy", - "telegraf", - "bot framework", - "i18n", - "internationalization", - "middleware" - ], - "license": "MIT", - "repository": "grammyjs/i18n", - "homepage": "https://grammy.dev/plugins/i18n.html", - "author": { - "name": "EdJoPaTo", - "email": "i18n-npm-package@edjopato.de", - "url": "https://edjopato.de" - }, - "contributors": [ - "Vitaly Domnikov ", - "Slava Fomin II " - ], - "scripts": { - "build": "del-cli dist && tsc", - "prepack": "npm run build", - "start": "ts-node examples/example-grammy-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": "^2.0.0", - "@types/js-yaml": "^4.0.0", - "@types/node": "^12.20.37", - "ava": "^4.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.49.0" - }, - "files": [ - "dist/source", - "!*.test.*" - ], - "main": "dist/source", - "types": "dist/source", - "nyc": { - "all": true, - "reporter": [ - "lcov", - "text" - ] - }, - "publishConfig": { - "access": "public" - }, - "xo": { - "rules": { - "@typescript-eslint/naming-convention": "off", - "@typescript-eslint/prefer-readonly-parameter-types": "warn", - "ava/no-ignored-test-files": "off", - "import/extensions": "off", - "object-shorthand": "off", - "unicorn/prefer-module": "off", - "unicorn/prefer-node-protocol": "off" - }, - "overrides": [ - { - "files": [ - "**/*.test.*", - "examples/**/*.*", - "test/**/*.*" - ], - "rules": { - "@typescript-eslint/prefer-readonly-parameter-types": "off" - } - } - ] - } -} diff --git a/scripts/dnt.ts b/scripts/dnt.ts new file mode 100644 index 00000000..e30e73af --- /dev/null +++ b/scripts/dnt.ts @@ -0,0 +1,47 @@ +import { + dirname, + fromFileUrl, + join, +} from "https://deno.land/std@0.154.0/path/mod.ts"; + +import { build, emptyDir } from "https://deno.land/x/dnt@0.30.0/mod.ts"; + +import package_ from "./package.json" assert { type: "json" }; + +const version = Deno.args[0]; +if (!version) { + throw new Error("Provide the version as an argument"); +} + +const rootDir = join(dirname(fromFileUrl(import.meta.url)), "../"); +const outDir = join(rootDir, "out"); + +await emptyDir(outDir); + +await build({ + outDir, + shims: { + deno: true, + }, + package: { + version, + ...package_, + }, + esModule: false, + entryPoints: ["./src/mod.ts"], + mappings: { + "https://lib.deno.dev/x/grammy@1.x/mod.ts": { + name: "grammy", + version: "^1.10.0", + peerDependency: true, + }, + "https://lib.deno.dev/x/grammy@1.x/types.ts": { + name: "grammy", + version: "^1.10.0", + subPath: "out/types", + peerDependency: true, + }, + }, +}); + +Deno.copyFileSync(join(rootDir, "LICENSE"), join(outDir, "LICENSE")); diff --git a/scripts/package.json b/scripts/package.json new file mode 100644 index 00000000..be265e90 --- /dev/null +++ b/scripts/package.json @@ -0,0 +1,40 @@ +{ + "name": "@grammyjs/i18n", + "description": "Internationalization middleware for grammY based on Fluent.", + "keywords": [ + "bot", + "bot-framework", + "fluent", + "ftl", + "globalization", + "grammy", + "grammy-middleware", + "i18n", + "internationalization", + "l10n", + "languages", + "locales", + "localization", + "mozilla", + "telegram", + "telegram-bot", + "translation" + ], + "license": "MIT", + "homepage": "https://github.com/grammyjs/i18n#readme", + "repository": "github:grammyjs/i18n", + "bugs": { + "url": "https://github.com/grammyjs/i18n/issues" + }, + "author": { + "name": "dcdunkan", + "email": "dcdunkan@gmail.com", + "url": "https://github.com/dcdunkan" + }, + "engines": { + "node": ">=12" + }, + "publishConfig": { + "access": "public" + } +} diff --git a/source/context.ts b/source/context.ts deleted file mode 100644 index c992397b..00000000 --- a/source/context.ts +++ /dev/null @@ -1,91 +0,0 @@ -import {Config, Repository, TemplateData, Template} from './types'; - -export interface I18nContextFlavor { - readonly i18n: I18nContext; -} - -export class I18nContext { - languageCode: string; - shortLanguageCode: string; - private readonly templateData: Readonly; - - constructor( - private readonly repository: Readonly, - private readonly config: Config, - languageCode: string, - templateData: Readonly, - ) { - this.templateData = { - ...config.templateData, - ...templateData, - }; - - const result = parseLanguageCode(repository, 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 deleted file mode 100644 index eaeacab8..00000000 --- a/source/i18n.ts +++ /dev/null @@ -1,165 +0,0 @@ -import * as fs from 'fs'; -import * as path from 'path'; - -import * as yaml from 'js-yaml'; - -import {Config, LanguageCode, Repository, RepositoryEntry, Template, TemplateData} from './types'; -import {I18nContext} from './context'; -import {pluralize} from './pluralize'; -import {tableize} from './tabelize'; - -// eslint-disable-next-line @typescript-eslint/no-var-requires, @typescript-eslint/no-require-imports, @typescript-eslint/no-unsafe-assignment -const compile = require('compile-template'); - -interface MinimalMiddlewareContext { - readonly from?: { - readonly language_code?: string; - } | undefined; - 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'); - if (extension === '.yaml' || extension === '.yml') { - const data = yaml.load(fileContent) as Record; - this.loadLocale(languageCode, tableize(data)); - } else if (extension === '.json') { - const data = JSON.parse(fileContent) as Record; - 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-assignment - const session: Session | undefined = await (this.config.useSession && (ctx as any)[this.config.sessionName]); - const languageCode = session?.__language_code ?? ctx.from?.language_code ?? this.config.defaultLanguage; - - // @ts-expect-error writing to readonly property - ctx.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('${')) { - // eslint-disable-next-line @typescript-eslint/no-unsafe-call - result[key] = compile(value) as Template; - } else { - result[key] = () => value; - } - } - - return result; -} diff --git a/source/index.ts b/source/index.ts deleted file mode 100644 index a8d627b1..00000000 --- a/source/index.ts +++ /dev/null @@ -1,4 +0,0 @@ -export * from './context'; -export * from './i18n'; -export * from './pluralize'; -export * from './types'; diff --git a/source/pluralize.ts b/source/pluralize.ts deleted file mode 100644 index 40df80c4..00000000 --- a/source/pluralize.ts +++ /dev/null @@ -1,86 +0,0 @@ -// 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 deleted file mode 100644 index 1f5846f5..00000000 --- a/source/tabelize.ts +++ /dev/null @@ -1,35 +0,0 @@ -/*! - * 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 deleted file mode 100644 index a3c1163e..00000000 --- a/source/types.ts +++ /dev/null @@ -1,17 +0,0 @@ -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/src/deps.ts b/src/deps.ts new file mode 100644 index 00000000..8fcdb150 --- /dev/null +++ b/src/deps.ts @@ -0,0 +1,17 @@ +export { + Fluent, + type FluentBundleOptions, + type FluentOptions, + type LocaleId, + type TranslationContext, +} from "https://deno.land/x/better_fluent@v1.0.0/mod.ts"; + +export { type FluentVariable } from "https://deno.land/x/fluent@v0.0.0/bundle/mod.ts"; + +export { + type Context, + type HearsContext, + type MiddlewareFn, +} from "https://lib.deno.dev/x/grammy@1.x/mod.ts"; + +export { extname, resolve } from "https://deno.land/std@0.154.0/path/mod.ts"; diff --git a/src/i18n.ts b/src/i18n.ts new file mode 100644 index 00000000..d239d3e5 --- /dev/null +++ b/src/i18n.ts @@ -0,0 +1,228 @@ +import { + type Context, + Fluent, + type FluentBundleOptions, + type HearsContext, + type LocaleId, + type MiddlewareFn, + resolve, + type TranslationContext, +} from "./deps.ts"; + +import { readLocalesDir, readLocalesDirSync } from "./utils.ts"; + +import type { I18nConfig, I18nFlavor, TranslateFunction } from "./types.ts"; + +export class I18n { + private config: I18nConfig; + readonly fluent: Fluent; + readonly locales = new Array(); + + constructor(config: Partial>) { + this.config = { defaultLocale: "en", ...config }; + this.fluent = new Fluent(this.config.fluentOptions); + if (config.directory) { + this.loadLocalesDirSync(config.directory); + } + } + + /** + * Loads locales from the specified directory and registers them in the Fluent instance. + * @param directory Path to the directory to look for the translation files. + */ + async loadLocalesDir(directory: string): Promise { + const localeFiles = await readLocalesDir(directory); + await Promise.all(localeFiles.map(async (file) => { + const path = resolve(directory, file); + const locale = file.substring(0, file.lastIndexOf(".")); + + await this.loadLocale(locale, { + filePath: path, + bundleOptions: this.config.fluentBundleOptions, + }); + })); + } + + /** + * Loads locales from the specified directory and registers them in the Fluent instance. + * @param directory Path to the directory to look for the translation files. + */ + loadLocalesDirSync(directory: string): void { + for (const file of readLocalesDirSync(directory)) { + const path = resolve(directory, file); + const locale = file.substring(0, file.lastIndexOf(".")); + + this.loadLocaleSync(locale, { + filePath: path, + bundleOptions: this.config.fluentBundleOptions, + }); + } + } + + /** + * Registers a locale in the Fluent instance based on the provided options. + * @param locale Locale ID + * @param options Options to specify the source and behavior of the translation + */ + async loadLocale( + locale: LocaleId, + options: { + filePath?: string; + source?: string; + isDefault?: boolean; + bundleOptions?: FluentBundleOptions; + }, + ): Promise { + await this.fluent.addTranslation({ + locales: locale, + isDefault: locale === this.config.defaultLocale, + bundleOptions: this.config.fluentBundleOptions, + ...options, + }); + + this.locales.push(locale); + } + + /** + * Synchronously registers a locale in the Fluent instance based on the provided options. + * @param locale Locale ID + * @param options Options to specify the source and behavior of the translation + */ + loadLocaleSync( + locale: LocaleId, + options: { + filePath?: string; + source?: string; + isDefault?: boolean; + bundleOptions?: FluentBundleOptions; + }, + ): void { + this.fluent.addTranslationSync({ + locales: locale, + isDefault: locale === this.config.defaultLocale, + bundleOptions: this.config.fluentBundleOptions, + ...options, + }); + + this.locales.push(locale); + } + + /** + * Gets a message by its key from the specified locale. + * Alias of `translate` method. + */ + t( + locale: LocaleId, + key: string, + context?: TranslationContext, + ): string { + return this.translate(locale, key, context); + } + + /** Gets a message by its key from the specified locale. */ + translate( + locale: LocaleId, + key: string, + context?: TranslationContext, + ): string { + return this.fluent.translate(locale, key, context); + } + + /** Returns a middleware to .use on the `Bot` instance. */ + middleware(): MiddlewareFn { + return middleware(this.fluent, this.config); + } +} + +function middleware( + fluent: Fluent, + { + defaultLocale, + localeNegotiator, + useSession, + globalTranslationContext, + }: I18nConfig, +): MiddlewareFn { + return async function (ctx, next): Promise { + let translate: TranslateFunction; + + function useLocale(locale: LocaleId): void { + translate = fluent.withLocale(locale); + } + + async function getNegotiatedLocale(): Promise { + return await localeNegotiator?.(ctx) ?? + // deno-lint-ignore no-explicit-any + (await (useSession && (ctx as any).session))?.__language_code ?? + ctx.from?.language_code ?? + defaultLocale; + } + + async function setLocale(locale: LocaleId): Promise { + if (!useSession) { + throw new Error( +"You are calling `ctx.i18n.setLocale()` without setting `useSession` to `true` \ +in the configuration. It doesn't make sense because you cannot set a locale in \ +the session that way. When you call `ctx.i18n.setLocale()`, the bot tries to \ +store the user locale in the session storage. But since you don't have session \ +enabled, it cannot store the locale information in the session storage. You \ +should either enable sessions or use `ctx.i18n.useLocale()` instead.", + ); + } + + // deno-lint-ignore no-explicit-any + (await (ctx as any).session).__language_code = locale; + await negotiateLocale(); + } + + // Determining the locale to use for translations + async function negotiateLocale(): Promise { + const negotiatedLocale = await getNegotiatedLocale(); + useLocale(negotiatedLocale); + } + + // Also exports ctx object properties for accessing them directly from + // the translation source files. + function translateWrapper( + key: string, + translationContext?: TranslationContext, + ): string { + return translate(key, { + ...globalTranslationContext?.(ctx), + ...translationContext, + }); + } + + ctx.i18n = { + fluent, + renegotiateLocale: negotiateLocale, + useLocale, + getLocale: getNegotiatedLocale, + setLocale, + }; + ctx.t = translateWrapper; + ctx.translate = translateWrapper; + + await negotiateLocale(); + await next(); + }; +} + +/** + * A filter middleware for listening to the messages send by the in their language. + * It is useful when you have to listen for custom keyboard texts. + * + * ```ts + * bot.filter(hears("menu-btn"), (ctx) => ...) + * ``` + * + * @param key Key of the message to listen for. + */ +export function hears(key: string) { + return function ( + ctx: C, + ): ctx is HearsContext { + const expected = ctx.t(key); + return ctx.hasText(expected); + }; +} diff --git a/src/mod.ts b/src/mod.ts new file mode 100644 index 00000000..5a53116e --- /dev/null +++ b/src/mod.ts @@ -0,0 +1,2 @@ +export * from "./types.ts"; +export { hears, I18n } from "./i18n.ts"; diff --git a/src/types.ts b/src/types.ts new file mode 100644 index 00000000..7dfdc9d9 --- /dev/null +++ b/src/types.ts @@ -0,0 +1,101 @@ +import { + Context, + Fluent, + FluentBundleOptions, + FluentOptions, + FluentVariable, + LocaleId, + TranslationContext, +} from "./deps.ts"; + +export type LocaleNegotiator = (ctx: C) => + | LocaleId + | undefined + | PromiseLike; + +export type TranslateFunction = ( + key: string, + context?: TranslationContext, +) => string; + +export interface I18nFlavor { + /** I18n context namespace object */ + i18n: { + /** Fluent instance used internally. */ + fluent: Fluent; + /** Returns the current locale. */ + getLocale(): Promise; + /** + * Equivalent for manually setting the locale in session and calling `renegotiateLocale()`. + * If the `useSession` in the i18n configuration is set to true, sets the locale in session. + * Otherwise throws an error. + * You can suppress the error by using `useLocale()` instead. + * @param locale Locale ID to set in the session. + */ + setLocale(locale: LocaleId): Promise; + /** + * Sets the specified locale to be used for future translations. + * Effect lasts only for the duration of current update and is not preserved. + * Could be used to change the translation locale in the middle of update processing + * (e.g. when user changes the language). + * @param locale Locale ID to set. + */ + useLocale(locale: LocaleId): void; + /** + * You can manually trigger additional locale negotiation by calling this method. + * This could be useful if locale negotiation conditions has changed and new locale must be applied + * (e.g. user has changed the language and you need to display an answer in new locale). + */ + renegotiateLocale(): Promise; + }; + /** Translation function bound to the current locale. */ + translate: TranslateFunction; + /** Translation function bound to the current locale. */ + t: TranslateFunction; +} + +export interface I18nConfig { + /** + * A locale ID to use by default. + * This is used when locale negotiator and session (if enabled) returns an empty result. + * The default value is "_en_". + */ + defaultLocale: LocaleId; + /** + * Directory to load translations from. + */ + directory?: string; + /** + * Whether to use session to get and set language code. + * You must be using session with it. + */ + useSession?: boolean; + /** Configuration for the Fluent instance used internally. */ + fluentOptions?: FluentOptions; + /** Bundle options to use when adding a translation to the Fluent instance. */ + fluentBundleOptions?: FluentBundleOptions; + /** + * An optional function that determines which locale to use. + * See [Locale Negotiation](https://grammy.dev/plugins/i18n.html#custom-locale-negotiation) for more details. + */ + localeNegotiator?: LocaleNegotiator; + /** + * Convenience function for defining global variables that are used frequently in the translation context. + * Variables defined inside this can be used directly in the translation source file without having to specifying them when calling the translate function. + * It is possible to overwrite the values by re-defining them in the translation context of translate function. + * + * @example + * ```ts + * function defaultTranslationContext(ctx: Context) { + * return { + * name: ctx.from?.first_name || "", + * fullName: `${ctx.from?.first_name}${ + * ctx.from?.last_name ? ` ${ctx.from.last_name}` : "" + * }`, + * // ... + * }; + * } + * ``` + */ + globalTranslationContext?: (ctx: C) => Record; +} diff --git a/src/utils.ts b/src/utils.ts new file mode 100644 index 00000000..b7898fcd --- /dev/null +++ b/src/utils.ts @@ -0,0 +1,23 @@ +import { extname } from "./deps.ts"; + +export async function readLocalesDir(path: string): Promise { + const files = new Array(); + for await (const entry of Deno.readDir(path)) { + if (!entry.isFile) continue; + const extension = extname(entry.name); + if (extension !== ".ftl") continue; + files.push(entry.name); + } + return files; +} + +export function readLocalesDirSync(path: string): string[] { + const files = new Array(); + for (const entry of Deno.readDirSync(path)) { + if (!entry.isFile) continue; + const extension = extname(entry.name); + if (extension !== ".ftl") continue; + files.push(entry.name); + } + return files; +} diff --git a/test/analyse-language-repository.ts b/test/analyse-language-repository.ts deleted file mode 100644 index 44a083d5..00000000 --- a/test/analyse-language-repository.ts +++ /dev/null @@ -1,88 +0,0 @@ -import test from 'ava'; - -import {I18n} from '../source/i18n'; - -test('resourceKeys flat', t => { - const i18n = new I18n(); - i18n.loadLocale('en', { - greeting: 'Hello!', - }); - - t.deepEqual(i18n.resourceKeys('en'), [ - 'greeting', - ]); -}); - -test('resourceKeys with depth', t => { - const i18n = new I18n(); - i18n.loadLocale('en', { - greeting: 'Hello!', - foo: { - bar: '42', - hell: { - devil: 666, - }, - }, - }); - - t.deepEqual(i18n.resourceKeys('en'), [ - 'greeting', - 'foo.bar', - 'foo.hell.devil', - ]); -}); - -test('resourceKeys of not existing locale are empty', t => { - const i18n = new I18n(); - i18n.loadLocale('en', { - greeting: 'Hello!', - }); - - t.deepEqual(i18n.resourceKeys('de'), []); -}); - -function createMultiLanguageExample() { - const i18n = new I18n(); - i18n.loadLocale('en', { - greeting: 'Hello!', - checkout: 'Thank you!', - }); - i18n.loadLocale('ru', { - greeting: 'Привет!', - }); - return i18n; -} - -test('availableLocales', t => { - const i18n = createMultiLanguageExample(); - t.deepEqual(i18n.availableLocales(), [ - 'en', - 'ru', - ]); -}); - -test('missingKeys ', t => { - const i18n = createMultiLanguageExample(); - t.deepEqual(i18n.missingKeys('en', 'ru'), []); - t.deepEqual(i18n.missingKeys('ru'), [ - 'checkout', - ]); -}); - -test('overspecifiedKeys', t => { - const i18n = createMultiLanguageExample(); - t.deepEqual(i18n.overspecifiedKeys('ru'), []); - t.deepEqual(i18n.overspecifiedKeys('en', 'ru'), [ - 'checkout', - ]); -}); - -test('translationProgress', t => { - const i18n = createMultiLanguageExample(); - - // 'checkout' is missing - t.is(i18n.translationProgress('ru'), 0.5); - - // Overspecified (unneeded 'checkout') but everything required is there - t.is(i18n.translationProgress('en', 'ru'), 1); -}); diff --git a/test/basics.ts b/test/basics.ts deleted file mode 100644 index 619e9706..00000000 --- a/test/basics.ts +++ /dev/null @@ -1,20 +0,0 @@ -import test from 'ava'; - -import {I18n} from '../source/i18n'; - -test('can translate', t => { - const i18n = new I18n(); - i18n.loadLocale('en', { - greeting: 'Hello!', - }); - t.is(i18n.t('en', 'greeting'), 'Hello!'); -}); - -test('allowMissing false throws', t => { - const i18n = new I18n({ - allowMissing: false, - }); - t.throws(() => { - i18n.t('en', 'greeting'); - }, {message: 'telegraf-i18n: \'en.greeting\' not found'}); -}); diff --git a/test/context.ts b/test/context.ts deleted file mode 100644 index 4c9f8004..00000000 --- a/test/context.ts +++ /dev/null @@ -1,57 +0,0 @@ -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.ts b/test/pluralize.ts deleted file mode 100644 index 8818bda6..00000000 --- a/test/pluralize.ts +++ /dev/null @@ -1,25 +0,0 @@ -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/tests/bot.ts b/tests/bot.ts new file mode 100644 index 00000000..63c1b522 --- /dev/null +++ b/tests/bot.ts @@ -0,0 +1,57 @@ +import { Bot, Context, session, SessionFlavor } from "./deps.ts"; +import { hears, I18n, I18nFlavor } from "../src/mod.ts"; +import { makeTempLocalesDir } from "./utils.ts"; + +interface SessionData { + apples: number; +} + +type MyContext = + & Context + & I18nFlavor + & SessionFlavor; + +export const bot = new Bot("TOKEN"); + +bot.use(session({ + initial: () => ({ apples: 0 }), +})); + +export const i18n = new I18n({ + defaultLocale: "en", + directory: makeTempLocalesDir(), + fluentBundleOptions: { + useIsolating: false, + }, + globalTranslationContext: (ctx) => ({ + name: ctx.from?.first_name || "", + }), +}); + +bot.use(i18n); + +bot.chatType("private").command("start", async (ctx) => { + await ctx.reply(ctx.t("greeting", { name: ctx.from.first_name })); +}); + +bot.chatType("private").command("add", async (ctx) => { + ctx.session.apples++; + await ctx.reply(ctx.t("cart", { + apples: ctx.session.apples, + })); +}); + +bot.chatType("private").command("cart", async (ctx) => { + await ctx.reply(ctx.t("cart", { + apples: ctx.session.apples, + })); +}); + +bot.chatType("private").command("checkout", async (ctx) => { + ctx.session.apples = 0; + await ctx.reply(ctx.t("checkout")); +}); + +bot.filter(hears("hello"), async (ctx) => { + await ctx.reply(ctx.t("hello")); +}); diff --git a/tests/bot_test.ts b/tests/bot_test.ts new file mode 100644 index 00000000..9041fabb --- /dev/null +++ b/tests/bot_test.ts @@ -0,0 +1,168 @@ +import { bot, i18n } from "./bot.ts"; +import { assertEquals, assertNotEquals, Chats } from "./deps.ts"; + +const chats = new Chats(bot); + +Deno.test("Load locales and check registered", () => { + assertEquals(i18n.locales.sort(), ["en", "ru"]); +}); + +Deno.test("English user", async (t) => { + const user = chats.newUser({ + id: 5147129198, // E N G L I S H + first_name: "English", + last_name: "User", + language_code: "en", + }); + + await t.step("Hears `Hello!` but not `Здравствуйте!`", async () => { + await user.sendMessage("Hello!"); + assertEquals(user.last.text, "Hello!"); + + await user.sendMessage("Здравствуйте!"); + assertNotEquals(user.last.text, "Здравствуйте!"); + }); + + await t.step("start command", async () => { + await user.command("start"); + assertEquals(user.last.text, "Hello, English!"); + }); + + await t.step("empty cart", async () => { + await user.command("cart"); + assertEquals( + user.last.text, + "Hey English, there are no apples in your cart.", + ); + }); + + await t.step("add one apple in session", async () => { + await user.command("add"); + assertEquals( + user.last.text, + "Hey English, there is one apple in your cart.", + ); + }); + + await t.step("checkout command", async () => { + await user.command("checkout"); + assertEquals(user.last.text, "Thank you for purchasing!"); + }); + + await t.step("check if cart is empty after checkout", async () => { + await user.command("cart"); + assertEquals( + user.last.text, + "Hey English, there are no apples in your cart.", + ); + }); + + await t.step("add 10 apples in session", async () => { + for (let i = 0; i < 10; i++) { + await user.command("add"); + } + assertEquals( + user.last.text, + "Hey English, there are 10 apples in your cart.", + ); + }); + + await t.step("there are 10 apples in session", async () => { + await user.command("cart"); + assertEquals( + user.last.text, + "Hey English, there are 10 apples in your cart.", + ); + }); + + await t.step("add 5 more apples in session", async () => { + for (let i = 0; i < 5; i++) { + await user.command("add"); + } + assertEquals( + user.last.text, + "Hey English, there are 15 apples in your cart.", + ); + }); + + await t.step("checkout again", async () => { + await user.command("checkout"); + assertEquals(user.last.text, "Thank you for purchasing!"); + }); +}); + +Deno.test("Russian user", async (t) => { + const user = chats.newUser({ + id: 182119199114, // R U S S I A N + first_name: "Russian", + last_name: "User", + language_code: "ru", + }); + + await t.step("Hears `Здравствуйте!` but not `Hello!`", async () => { + await user.sendMessage("Здравствуйте!"); + assertEquals(user.last.text, "Здравствуйте!"); + + await user.sendMessage("Hello!"); + assertNotEquals(user.last.text, "Hello!"); + }); + + await t.step("start command", async () => { + await user.command("start"); + assertEquals(user.last.text, "Здравствуйте, Russian!"); + }); + + await t.step("empty cart", async () => { + await user.command("cart"); + assertEquals( + user.last.text, + "Привет Russian, в твоей корзине нет яблок.", + ); + }); + + await t.step("checkout command", async () => { + await user.command("checkout"); + assertEquals(user.last.text, "Спасибо за покупку!"); + }); + + await t.step("check if cart is empty after checkout", async () => { + await user.command("cart"); + assertEquals( + user.last.text, + "Привет Russian, в твоей корзине нет яблок.", + ); + }); + + await t.step("add 10 apples in session", async () => { + for (let i = 0; i < 10; i++) { + await user.command("add"); + } + assertEquals( + user.last.text, + "Привет Russian, в твоей корзине 10 яблок.", + ); + }); + + await t.step("there are 10 apples in session", async () => { + await user.command("cart"); + assertEquals( + user.last.text, + "Привет Russian, в твоей корзине 10 яблок.", + ); + }); + + await t.step("add 5 more apples in session", async () => { + for (let i = 0; i < 5; i++) { + await user.command("add"); + } + assertEquals( + user.last.text, + "Привет Russian, в твоей корзине 15 яблок.", + ); + }); + + await t.step("checkout again", async () => { + await user.command("checkout"); + assertEquals(user.last.text, "Спасибо за покупку!"); + }); +}); diff --git a/tests/deps.ts b/tests/deps.ts new file mode 100644 index 00000000..0389a6a8 --- /dev/null +++ b/tests/deps.ts @@ -0,0 +1,12 @@ +export { Chats } from "https://ghc.deno.dev/dcdunkan/tests@v0.1.6/mod.ts"; +export { + assertEquals, + assertNotEquals, +} from "https://deno.land/std@0.153.0/testing/asserts.ts"; +export { join } from "https://deno.land/std@0.154.0/path/mod.ts"; +export { + Bot, + Context, + session, + type SessionFlavor, +} from "https://lib.deno.dev/x/grammy@1.x/mod.ts"; diff --git a/tests/i18n_test.ts b/tests/i18n_test.ts new file mode 100644 index 00000000..c01a76e7 --- /dev/null +++ b/tests/i18n_test.ts @@ -0,0 +1,131 @@ +import { I18n } from "../src/mod.ts"; +import { assertEquals, join } from "./deps.ts"; +import { makeTempLocalesDir } from "./utils.ts"; + +const localesDir = makeTempLocalesDir(); + +const i18n = new I18n({ + defaultLocale: "en", + directory: localesDir, +}); + +Deno.test("Load locales and check registered", () => { + assertEquals(i18n.locales.sort(), ["en", "ru"]); +}); + +Deno.test("English", async (t) => { + await t.step("hello", () => { + assertEquals(i18n.t("en", "hello"), "Hello!"); + }); + await t.step("pluralize", () => { + assertEquals( + i18n.t("en", "cart", { + name: "Name", + apples: 0, + }), + "Hey \u2068Name\u2069, there \u2068are no apples\u2069 in your cart.", + ); + assertEquals( + i18n.t("en", "cart", { + name: "Name", + apples: 1, + }), + "Hey \u2068Name\u2069, there \u2068is one apple\u2069 in your cart.", + ); + assertEquals( + i18n.t("en", "cart", { + name: "Name", + apples: 5, + }), + "Hey \u2068Name\u2069, there \u2068are \u20685\u2069 apples\u2069 in your cart.", + ); + }); + + await t.step("checkout", () => { + assertEquals( + i18n.t("en", "checkout"), + "Thank you for purchasing!", + ); + }); +}); + +Deno.test("Russian", async (t) => { + await t.step("hello", () => { + assertEquals(i18n.t("ru", "hello"), "Здравствуйте!"); + }); + + await t.step("pluralize", () => { + assertEquals( + i18n.t("ru", "cart", { + name: "Имя", + apples: 0, + }), + "Привет \u2068Имя\u2069, в твоей корзине \u2068нет яблок\u2069.", + ); + assertEquals( + i18n.t("ru", "cart", { + name: "Имя", + apples: 1, + }), + "Привет \u2068Имя\u2069, в твоей корзине \u2068\u20681\u2069 яблоко\u2069.", + ); + assertEquals( + i18n.t("ru", "cart", { + name: "Имя", + apples: 3, + }), + "Привет \u2068Имя\u2069, в твоей корзине \u2068\u20683\u2069 яблока\u2069.", + ); + assertEquals( + i18n.t("ru", "cart", { + name: "Имя", + apples: 7, + }), + "Привет \u2068Имя\u2069, в твоей корзине \u2068\u20687\u2069 яблок\u2069.", + ); + assertEquals( + i18n.t("ru", "cart", { + name: "Имя", + apples: 11, + }), + "Привет \u2068Имя\u2069, в твоей корзине \u2068\u206811\u2069 яблок\u2069.", + ); + assertEquals( + i18n.t("ru", "cart", { + name: "Имя", + apples: 101, + }), + "Привет \u2068Имя\u2069, в твоей корзине \u2068\u2068101\u2069 яблоко\u2069.", + ); + assertEquals( + i18n.t("ru", "cart", { + name: "Имя", + apples: 123, + }), + "Привет \u2068Имя\u2069, в твоей корзине \u2068\u2068123\u2069 яблока\u2069.", + ); + }); + + await t.step("checkout", () => { + assertEquals( + i18n.t("ru", "checkout"), + "Спасибо за покупку!", + ); + }); +}); + +Deno.test("Add locale", async (t) => { + await t.step("From file", () => { + i18n.loadLocaleSync("en2", { + filePath: join(localesDir, "en.ftl"), + }); + assertEquals(i18n.t("en2", "hello"), "Hello!"); + }); + + await t.step("From source text", () => { + i18n.loadLocaleSync("ml", { + source: "hello = നമസ്കാരം", + }); + assertEquals(i18n.t("ml", "hello"), "നമസ്കാരം"); + }); +}); diff --git a/tests/session_bot.ts b/tests/session_bot.ts new file mode 100644 index 00000000..93d54849 --- /dev/null +++ b/tests/session_bot.ts @@ -0,0 +1,52 @@ +import { Bot, Context, session, SessionFlavor } from "./deps.ts"; +import { I18n, I18nFlavor } from "../src/mod.ts"; +import { makeTempLocalesDir } from "./utils.ts"; + +type SessionData = Record; +type MyContext = + & Context + & I18nFlavor + & SessionFlavor; + +export const bot = new Bot("TOKEN"); + +bot.use(session({ + initial: () => ({}), +})); + +const i18n = new I18n({ + defaultLocale: "en", + directory: makeTempLocalesDir(), + useSession: true, + fluentBundleOptions: { + useIsolating: false, + }, + globalTranslationContext: (ctx) => ({ + name: ctx.from?.first_name || "", + }), +}); + +bot.use(i18n); + +bot.chatType("private").command("start", async (ctx) => { + await ctx.reply(ctx.t("greeting")); +}); + +bot.chatType("private").command("language", async (ctx) => { + if (ctx.match === "") { + return await ctx.reply(ctx.t("language.hint")); + } + + // `i18n.locales` contains all the locales that have been registered + if (!i18n.locales.includes(ctx.match)) { + return await ctx.reply(ctx.t("language.invalid-locale")); + } + + // `ctx.i18n.getLocale` returns the locale currently using. + if (await ctx.i18n.getLocale() === ctx.match) { + return await ctx.reply(ctx.t("language.already-set")); + } + + await ctx.i18n.setLocale(ctx.match); + await ctx.reply(ctx.t("language.language-set")); +}); diff --git a/tests/session_bot_test.ts b/tests/session_bot_test.ts new file mode 100644 index 00000000..f01b5443 --- /dev/null +++ b/tests/session_bot_test.ts @@ -0,0 +1,47 @@ +import { bot } from "./session_bot.ts"; +import { assertEquals, Chats } from "./deps.ts"; + +const chats = new Chats(bot); + +const user = chats.newUser({ + first_name: "Test", + id: 1234567890, + language_code: "en", +}); + +Deno.test("/start", async () => { + await user.command("start"); + assertEquals(user.last.text, "Hello, Test!"); +}); + +Deno.test("/language", async (t) => { + await t.step("no match", async () => { + await user.command("language"); + assertEquals(user.last.text, "Enter a language with the command"); + }); + + await t.step("invalid language", async () => { + await user.command("language", "blah"); + assertEquals(user.last.text, "Invalid language"); + }); + + await t.step("already set", async () => { + await user.command("language", "en"); + assertEquals(user.last.text, "Language is already set!"); + }); + + await t.step("set 'ru'", async () => { + await user.command("language", "ru"); + assertEquals(user.last.text, "Язык успешно установлен!"); + }); + + await t.step("'ru': already set", async () => { + await user.command("language", "ru"); + assertEquals(user.last.text, "Этот язык уже установлен!"); + }); + + await t.step("back to 'en'", async () => { + await user.command("language", "en"); + assertEquals(user.last.text, "Language set successfullY!"); + }); +}); diff --git a/tests/utils.ts b/tests/utils.ts new file mode 100644 index 00000000..4b14426f --- /dev/null +++ b/tests/utils.ts @@ -0,0 +1,49 @@ +import { join } from "./deps.ts"; + +export function makeTempLocalesDir() { + const dir = Deno.makeTempDirSync(); + Deno.writeTextFileSync( + join(dir, "en.ftl"), + `hello = Hello! + +greeting = Hello, { $name }! + +cart = Hey { $name }, there { + $apples -> + [0] are no apples + [one] is one apple + *[other] are { $apples } apples +} in your cart. + +checkout = Thank you for purchasing! + +language = + .hint = Enter a language with the command + .invalid-locale = Invalid language + .already-set = Language is already set! + .language-set = Language set successfullY!`, + ); + Deno.writeTextFileSync( + join(dir, "ru.ftl"), + `hello = Здравствуйте! + +greeting = Здравствуйте, { $name }! + +cart = Привет { $name }, в твоей корзине { + $apples -> + [0] нет яблок + [one] {$apples} яблоко + [few] {$apples} яблока + *[other] {$apples} яблок +}. + +checkout = Спасибо за покупку! + +language = + .hint = Отправьте язык после команды + .invalid-locale = Неверный язык + .already-set = Этот язык уже установлен! + .language-set = Язык успешно установлен!`, + ); + return dir; +} diff --git a/tsconfig.json b/tsconfig.json deleted file mode 100644 index f978e1cf..00000000 --- a/tsconfig.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "extends": "@sindresorhus/tsconfig/tsconfig.json", - "include": [ - "examples", - "source", - "test" - ], - "compilerOptions": { - "module": "CommonJS", - "outDir": "dist" - } -}