├── .editorconfig ├── .eslintrc ├── .github └── workflows │ ├── ci.yml │ └── release.yml ├── .gitignore ├── .npmrc ├── .nvmrc ├── .prettierignore ├── .prettierrc.js ├── AUTHORS ├── CHANGELOG.md ├── CODEOWNERS ├── CONTRIBUTING ├── LICENSE ├── README-ru.md ├── README.md ├── commitlint.config.js ├── example ├── index.js └── keysets │ ├── en.json │ └── ru.json ├── jest.config.js ├── package-lock.json ├── package.json ├── src ├── consts.ts ├── index.spec.ts ├── index.ts ├── plural │ ├── en.ts │ ├── general.ts │ └── ru.ts ├── replace-params.spec.ts ├── replace-params.ts ├── translation-helpers.ts ├── translations-nesting.spec.ts └── types.ts ├── test-utils └── setup-tests.ts ├── tsconfig.cjs.json └── tsconfig.json /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | indent_style = space 5 | indent_size = 4 6 | end_of_line = lf 7 | charset = utf-8 8 | trim_trailing_whitespace = true 9 | insert_final_newline = true 10 | 11 | [{*.json,*.yml}] 12 | indent_size = 2 13 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["@gravity-ui/eslint-config", "@gravity-ui/eslint-config/server"], 3 | "root": true 4 | } 5 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | on: 2 | push: 3 | branches: [main, next] 4 | pull_request: 5 | branches: [ '**' ] 6 | 7 | jobs: 8 | commitlint: 9 | if: github.actor != 'yc-ui-bot' && github.event_name == 'pull_request' 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: actions/checkout@v3 13 | with: 14 | fetch-depth: 0 15 | - uses: actions/setup-node@v3 16 | with: 17 | node-version: 18 18 | - run: npm ci 19 | - run: npx commitlint --from ${{ github.event.pull_request.base.sha }} --to ${{ github.event.pull_request.head.sha }} --verbose 20 | 21 | test: 22 | runs-on: ubuntu-latest 23 | steps: 24 | - uses: actions/checkout@v3 25 | - uses: actions/setup-node@v3 26 | with: 27 | node-version: 18 28 | - run: npm ci 29 | - run: npm run lint 30 | - run: npm run typecheck 31 | - run: npm test 32 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | push: 5 | branches: [main] 6 | 7 | jobs: 8 | release: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - uses: gravity-ui/release-action@v1 12 | with: 13 | node-version: 18 14 | github-token: ${{ secrets.GRAVITY_UI_BOT_GITHUB_TOKEN }} 15 | npm-token: ${{ secrets.GRAVITY_UI_BOT_NPM_TOKEN }} 16 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | lib-cov 2 | *.seed 3 | *.log 4 | *.csv 5 | *.dat 6 | *.out 7 | *.pid 8 | *.gz 9 | *~ 10 | *.orig 11 | 12 | pids 13 | logs 14 | results 15 | 16 | npm-debug.log 17 | 18 | .idea/ 19 | .DS_Store 20 | .env 21 | .vscode 22 | 23 | node_modules 24 | build 25 | 26 | .cache/* 27 | 28 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | registry=https://registry.npmjs.org 2 | -------------------------------------------------------------------------------- /.nvmrc: -------------------------------------------------------------------------------- 1 | 18 2 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | storybook-static 2 | build 3 | CHANGELOG.md 4 | CONTRIBUTING.md 5 | -------------------------------------------------------------------------------- /.prettierrc.js: -------------------------------------------------------------------------------- 1 | module.exports = require('@gravity-ui/prettier-config'); 2 | -------------------------------------------------------------------------------- /AUTHORS: -------------------------------------------------------------------------------- 1 | The following authors have created the source code of "Yandex Cloud i18n" published and distributed 2 | by YANDEX LLC as the owner: 3 | 4 | Timur Sufiev 5 | Roman Fedorenkov 6 | Andrey Morozov 7 | Evgeny Alaev 8 | Dmitrii Kharin 9 | Maksim Ruseev 10 | Kirill Smorodin 11 | Kirill Sladkov 12 | Vladislav Kharitonov 13 | Roman Barlos 14 | Artem Panchuk 15 | Philipp Skvortsov 16 | Evgeniy Shangin 17 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## [1.8.0](https://github.com/gravity-ui/i18n/compare/v1.7.0...v1.8.0) (2025-04-08) 4 | 5 | 6 | ### Features 7 | 8 | * do not throw an error in case of duplicated keys ([#62](https://github.com/gravity-ui/i18n/issues/62)) ([03d47d0](https://github.com/gravity-ui/i18n/commit/03d47d08022ce27baf0f078e793482d6d83f5e83)) 9 | 10 | ## [1.7.0](https://github.com/gravity-ui/i18n/compare/v1.6.0...v1.7.0) (2024-11-18) 11 | 12 | 13 | ### Features 14 | 15 | * I18n strict params ([#57](https://github.com/gravity-ui/i18n/issues/57)) ([60608b3](https://github.com/gravity-ui/i18n/commit/60608b3dbc6cf4bb071a88d0fd78e81e136f0751)) 16 | 17 | ## [1.6.0](https://github.com/gravity-ui/i18n/compare/v1.5.1...v1.6.0) (2024-07-12) 18 | 19 | 20 | ### Features 21 | 22 | * add translations nesting ([#52](https://github.com/gravity-ui/i18n/issues/52)) ([2ecbaa4](https://github.com/gravity-ui/i18n/commit/2ecbaa4e3783efff36e84e2e3de28ee08eddb3ac)) 23 | 24 | ## [1.5.1](https://github.com/gravity-ui/i18n/compare/v1.5.0...v1.5.1) (2024-05-20) 25 | 26 | 27 | ### Bug Fixes 28 | 29 | * optional other plural form ([e6ee304](https://github.com/gravity-ui/i18n/commit/e6ee30469c25f6d64b53ad4df32d14265f2f3a62)) 30 | 31 | ## [1.5.0](https://github.com/gravity-ui/i18n/compare/v1.4.0...v1.5.0) (2024-05-04) 32 | 33 | 34 | ### Features 35 | 36 | * make other form optional ([#49](https://github.com/gravity-ui/i18n/issues/49)) ([cec6a95](https://github.com/gravity-ui/i18n/commit/cec6a9552359783afa61c836738cee38d552ac03)) 37 | 38 | ## [1.4.0](https://github.com/gravity-ui/i18n/compare/v1.3.0...v1.4.0) (2024-03-22) 39 | 40 | 41 | ### Features 42 | 43 | * use Intl.PluralRules for plural keys ([#43](https://github.com/gravity-ui/i18n/issues/43)) ([1777df6](https://github.com/gravity-ui/i18n/commit/1777df667eef9c6d10ed1170375b6b547ac68da8)) 44 | 45 | ## [1.3.0](https://github.com/gravity-ui/i18n/compare/v1.2.0...v1.3.0) (2024-02-02) 46 | 47 | 48 | ### Features 49 | 50 | * do not throw an error on duplicated keysets out of production ([#45](https://github.com/gravity-ui/i18n/issues/45)) ([aa46095](https://github.com/gravity-ui/i18n/commit/aa4609583a7a5d7215347b6f0d84b31e9f0bb1a7)) 51 | 52 | ## [1.2.0](https://github.com/gravity-ui/i18n/compare/v1.1.0...v1.2.0) (2024-01-26) 53 | 54 | 55 | ### Features 56 | 57 | * add codeowners ([#41](https://github.com/gravity-ui/i18n/issues/41)) ([9907b8c](https://github.com/gravity-ui/i18n/commit/9907b8c82769447cabce152473f9f2534c162ed6)) 58 | * add data, fallbackLang & lang options as constructor arguments ([#40](https://github.com/gravity-ui/i18n/issues/40)) ([a26ee50](https://github.com/gravity-ui/i18n/commit/a26ee507db1d87162ae4486ef99f54db9749be5c)) 59 | 60 | ## [1.1.0](https://github.com/gravity-ui/i18n/compare/v1.0.0...v1.1.0) (2023-05-03) 61 | 62 | 63 | ### Features 64 | 65 | * **keyset:** allow to narrow the type of keyset keys ([#33](https://github.com/gravity-ui/i18n/issues/33)) ([e2c0076](https://github.com/gravity-ui/i18n/commit/e2c00765acc400b25e06ea05ed3a0f895adb92b7)) 66 | 67 | ## [1.0.0](https://github.com/gravity-ui/i18n/compare/v0.6.0...v1.0.0) (2022-09-08) 68 | 69 | 70 | ### Features 71 | 72 | * transfer package to gravity-ui ([#32](https://github.com/gravity-ui/i18n/issues/32)) ([c61a0bf](https://github.com/gravity-ui/i18n/commit/c61a0bfe17139431b4948cc7cbd67fd667830e33)) 73 | 74 | 75 | ### Bug Fixes 76 | 77 | * **pluralize:** add pluralization configuration & fix english ([#31](https://github.com/gravity-ui/i18n/issues/31)) ([1d01348](https://github.com/gravity-ui/i18n/commit/1d013481a81f0e57ca23929b3c369843fecca73b)) 78 | * **replaceParams:** correctly substitute params with special content ([#29](https://github.com/gravity-ui/i18n/issues/29)) ([4f791c5](https://github.com/gravity-ui/i18n/commit/4f791c56822dc9d1645cbe852738056412d5d0e6)) 79 | 80 | ## [0.6.0](https://github.com/yandex-cloud/i18n/compare/v0.5.0...v0.6.0) (2022-05-24) 81 | 82 | 83 | ### Features 84 | 85 | * remove static methods for prior instance method setLang, remove default rumLogger ([#25](https://github.com/yandex-cloud/i18n/issues/25)) ([9a07c54](https://github.com/yandex-cloud/i18n/commit/9a07c5465fc8ddd6a0cfa833a07582e90776d75b)) 86 | 87 | ## [0.5.0](https://www.github.com/yandex-cloud/i18n/compare/v0.4.0...v0.5.0) (2022-04-14) 88 | 89 | 90 | ### Features 91 | 92 | * add cjs build ([#22](https://www.github.com/yandex-cloud/i18n/issues/22)) ([59c09a2](https://www.github.com/yandex-cloud/i18n/commit/59c09a272e9537a2bd1a269a19a3b8a8d71c8031)) 93 | 94 | ## [0.4.0](https://www.github.com/yandex-cloud/i18n/compare/v0.3.0...v0.4.0) (2022-04-04) 95 | 96 | 97 | ### Features 98 | 99 | * add `logger` option to constructor ([#20](https://www.github.com/yandex-cloud/i18n/issues/20)) ([b135807](https://www.github.com/yandex-cloud/i18n/commit/b1358071a215b85c9e34d611a84b184d85511bfc)) 100 | 101 | ## [0.3.0](https://www.github.com/yandex-cloud/i18n/compare/v0.2.2...v0.3.0) (2022-03-23) 102 | 103 | 104 | ### Features 105 | 106 | * add fallback for missing `count` 0 key ([#18](https://www.github.com/yandex-cloud/i18n/issues/18)) ([fff521f](https://www.github.com/yandex-cloud/i18n/commit/fff521f7228693af1f1a94b6c279b2234b63138e)) 107 | 108 | ### [0.2.2](https://www.github.com/yandex-cloud/i18n/compare/v0.2.1...v0.2.2) (2022-03-22) 109 | 110 | 111 | ### Bug Fixes 112 | 113 | * correctly export types from package ([1d29acd](https://www.github.com/yandex-cloud/i18n/commit/1d29acd69f339808c1246074bcddadb9c63d4215)) 114 | 115 | ### [0.2.1](https://www.github.com/yandex-cloud/i18n/compare/v0.2.0...v0.2.1) (2022-03-21) 116 | 117 | 118 | ### chore 119 | 120 | * freeze direct dependencies ([e741995](https://www.github.com/yandex-cloud/i18n/commit/e7419950840a85b7c23d53464903a6589829b052)) 121 | 122 | ## [0.2.0](https://www.github.com/yandex-cloud/i18n/compare/v0.1.1...v0.2.0) (2022-02-07) 123 | 124 | 125 | ### build 126 | 127 | * compile with tsc instead of babel ([22cf7ed](https://www.github.com/yandex-cloud/i18n/commit/22cf7ededf4be276259f5177b45333a23602b6f6)) 128 | 129 | ### [0.1.1](https://www.github.com/yandex-cloud/i18n/compare/v0.1.0...v0.1.1) (2022-01-04) 130 | 131 | 132 | ### Bug Fixes 133 | 134 | * set correct npm package scope ([14f34c6](https://www.github.com/yandex-cloud/i18n/commit/14f34c6a1d0fb59d32271463f9f5d54b9b0ab78b)) 135 | 136 | ## 0.1.0 (2022-01-04) 137 | 138 | 139 | ### Features 140 | 141 | * initial import of i18n library ([3551a26](https://www.github.com/yandex-cloud/i18n/commit/3551a26f402cfe5dc9ca270a3f950307ca5f57fb)) 142 | -------------------------------------------------------------------------------- /CODEOWNERS: -------------------------------------------------------------------------------- 1 | * @dgaponov 2 | -------------------------------------------------------------------------------- /CONTRIBUTING: -------------------------------------------------------------------------------- 1 | # Notice to external contributors 2 | 3 | ## General info 4 | 5 | Hello! In order for us (YANDEX LLC) to accept patches and other contributions from you, you will have to adopt our Yandex Contributor License Agreement (the “**CLA**”). The current version of the CLA can be found here: 6 | 7 | 1. https://yandex.ru/legal/cla/?lang=en (in English) and 8 | 2. https://yandex.ru/legal/cla/?lang=ru (in Russian). 9 | 10 | By adopting the CLA, you state the following: 11 | 12 | - You obviously wish and are willingly licensing your contributions to us for our open source projects under the terms of the CLA, 13 | - You have read the terms and conditions of the CLA and agree with them in full, 14 | - You are legally able to provide and license your contributions as stated, 15 | - We may use your contributions for our open source projects and for any other our project too, 16 | - We rely on your assurances concerning the rights of third parties in relation to your contributions. 17 | 18 | If you agree with these principles, please read and adopt our CLA. By providing us your contributions, you hereby declare that you have already read and adopt our CLA, and we may freely merge your contributions with our corresponding open source project and use it in further in accordance with terms and conditions of the CLA. 19 | 20 | ## Provide contributions 21 | 22 | If you have already adopted terms and conditions of the CLA, you are able to provide your contributions. When you submit your pull request, please add the following information into it: 23 | 24 | ``` 25 | I hereby agree to the terms of the CLA available at: [link]. 26 | ``` 27 | 28 | Replace the bracketed text as follows: 29 | 30 | - [link] is the link to the current version of the CLA: https://yandex.ru/legal/cla/?lang=en (in English) or https://yandex.ru/legal/cla/?lang=ru (in Russian). 31 | 32 | It is enough to provide us such notification once. 33 | 34 | ## Other questions 35 | 36 | If you have any questions, please mail us at opensource@yandex-team.ru. 37 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2021 YANDEX LLC 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /README-ru.md: -------------------------------------------------------------------------------- 1 | # @gravity-ui/i18n · [![npm package](https://img.shields.io/npm/v/@gravity-ui/i18n)](https://www.npmjs.com/package/@gravity-ui/i18n) [![CI](https://img.shields.io/github/actions/workflow/status/gravity-ui/i18n/.github/workflows/ci.yml?branch=main&label=CI&logo=github)](https://github.com/gravity-ui/i18n/actions/workflows/ci.yml?query=branch:main) 2 | 3 | ## Утилиты I18N 4 | 5 | Утилиты пакета `I18N` разработаны для интернационализации компонентов Gravity UI. 6 | 7 | ### Установка 8 | 9 | `npm install --save @gravity-ui/i18n` 10 | 11 | ### API 12 | 13 | #### Конструктор (параметры) 14 | 15 | Принимает объект `options`, включающий необязательный параметр `logger` для логирования предупреждений библиотеки. 16 | 17 | ##### Логгер 18 | 19 | Логгер должен содержать явно определенный метод `log` со следующей сигнатурой: 20 | 21 | * `message` — строка сообщения, которое будет записано в лог; 22 | * `options` — объект параметров логирования: 23 | * `severity` — уровень логирования сообщения, всегда принимает значение `level`. 24 | * `logger` — определяет место для записи сообщений библиотеки. 25 | * `extra` — дополнительные параметры с единственным строковым полем `type`, которое всегда принимает значение `i18n`. 26 | 27 | ### Примеры использования 28 | 29 | #### `keysets/en.json` 30 | 31 | ```json 32 | { 33 | "wizard": { 34 | "label_error-widget-no-access": "No access to the chart" 35 | } 36 | } 37 | ``` 38 | 39 | #### `keysets/ru.json` 40 | 41 | ```json 42 | { 43 | "wizard": { 44 | "label_error-widget-no-access": "Нет доступа к чарту" 45 | } 46 | } 47 | ``` 48 | 49 | #### `index.js` 50 | 51 | ```js 52 | const ru = require('./keysets/ru.json'); 53 | const en = require('./keysets/en.json'); 54 | 55 | const {I18N} = require('@gravity-ui/i18n'); 56 | 57 | const i18n = new I18N(); 58 | i18n.registerKeysets('ru', ru); 59 | i18n.registerKeysets('en', en); 60 | 61 | i18n.setLang('ru'); 62 | console.log( 63 | i18n.i18n('wizard', 'label_error-widget-no-access') 64 | ); // -> "Нет доступа к чарту" 65 | 66 | i18n.setLang('en'); 67 | console.log( 68 | i18n.i18n('wizard', 'label_error-widget-no-access') 69 | ); // -> "No access to the chart 70 | 71 | // Keyset allows for a simpler translations retrieval 72 | const keyset = i18n.keyset('wizard'); 73 | console.log( 74 | keyset('label_error-widget-no-access') 75 | ); // -> "No access to the chart" 76 | 77 | 78 | i18n.setLang('ru'); 79 | console.log( 80 | keyset('label_error-widget-no-access') 81 | ); // -> "Нет доступа к чарту" 82 | 83 | // Checking if keyset has a key 84 | if (i18n.has('wizard', 'label_error-widget-no-access')) { 85 | i18n.i18n('wizard', 'label_error-widget-no-access') 86 | } 87 | ``` 88 | 89 | ### Шаблонизация 90 | 91 | Библиотека поддерживает шаблонизацию. Шаблонизируемые переменные заключаются в двойные фигурные скобки, а значения передаются в функцию i18n в форме словаря с парами «ключ-значение»: 92 | 93 | #### `keysets.json` 94 | 95 | ```json 96 | { 97 | "label_template": "No matches found for '{{inputValue}}' in '{{folderName}}'" 98 | } 99 | ``` 100 | 101 | #### `index.js` 102 | 103 | ```js 104 | i18n('label_template', {inputValue: 'something', folderName: 'somewhere'}); // => No matches found for "something" in "somewhere" 105 | ``` 106 | 107 | ### Плюрализация 108 | 109 | Для удобной локализации ключей, зависящих от числового значения, можно использовать плюрализацию. Текущая библиотека использует [правила плюрализации CLDR](https://unicode-org.github.io/cldr-staging/charts/latest/supplemental/language_plural_rules.html) через [API `Intl.PluralRules`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/PluralRules). 110 | 111 | Может потребоваться добавление [полифила](https://github.com/eemeli/intl-pluralrules) для [API `Intl.Plural Rules`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/PluralRules), если он недоступен в браузере. 112 | 113 | Существует 6 форм множественного числа (см. [`resolvedOptions`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/PluralRules/resolvedOptions)): 114 | 115 | * `zero` (также используется, когда `count = 0`, даже если форма не поддерживается в языке); 116 | * `one` (единственное число); 117 | * `two` (двойственное число); 118 | * `few` (паукальное число для обозначения нескольких предметов); 119 | * `many` (множественное; также используется для дробей, если у них есть отдельный класс); 120 | * `other` (общая форма множественного числа, обязательная для всех языков; также используется, если язык поддерживает только одну форму). 121 | 122 | #### Пример `keysets.json` с ключом для плюрализации 123 | 124 | ```json 125 | { 126 | "label_seconds": { 127 | "one": "{{count}} second is left", 128 | "other":"{{count}} seconds are left", 129 | "zero": "No time left" 130 | } 131 | } 132 | ``` 133 | 134 | #### Использование в JavaScript 135 | 136 | ```js 137 | i18n('label_seconds', {count: 1}); // => 1 second 138 | i18n('label_seconds', {count: 3}); // => 3 seconds 139 | i18n('label_seconds', {count: 7}); // => 7 seconds 140 | i18n('label_seconds', {count: 10}); // => 10 seconds 141 | i18n('label_seconds', {count: 0}); // => No time left 142 | ``` 143 | 144 | #### Старый формат плюрализации (устаревший формат) 145 | 146 | Старый формат будет удален в версии 2. 147 | 148 | ```json 149 | { 150 | "label_seconds": ["{{count}} second is left", "{{count}} seconds are left", "{{count}} seconds are left", "No time left"] 151 | } 152 | ``` 153 | 154 | Ключ плюрализации содержит 4 значения, каждое из которых соответствует значению перечисления `PluralForm`.| Значения перечисления: `One`, `Few`, `Many` и `None` соответственно. Имя переменной шаблона плюрализации — `count`. 155 | 156 | #### Пользовательская плюрализация (устаревшее свойство) 157 | 158 | Так как у каждого языка свои правила плюрализации, библиотека предоставляет метод для настройки этих правил для любого выбранного языка. 159 | 160 | Функция конфигурации принимает объект с языками в качестве ключей и функциями плюрализации в качестве значений. 161 | 162 | Функция плюрализации принимает число и перечисление `PluralForm` и должна возвращать одно из значений перечисления в зависимости от переданного числа. 163 | 164 | ```js 165 | const {I18N} = require('@gravity-ui/i18n'); 166 | 167 | const i18n = new I18N(); 168 | 169 | i18n.configurePluralization({ 170 | en: (count, pluralForms) => { 171 | if (!count) return pluralForms.None; 172 | if (count === 1) return pluralForms.One; 173 | return pluralForms.Many; 174 | }, 175 | }); 176 | ``` 177 | 178 | #### Предустановленные наборы правил плюрализации (устаревшие правила) 179 | 180 | Библиотека изначально поддерживает два языка: английский и русский. 181 | 182 | ##### Английский 183 | 184 | Ключ языка — `en`. 185 | 186 | * `One` соответствует 1 и -1. 187 | * `Few` не используется. 188 | * `Many` соответствует любому числу, кроме 0. 189 | * `None` соответствует 0. 190 | 191 | ##### Русский 192 | 193 | Ключ языка — `ru`. 194 | 195 | * `One` соответствует любому числу, оканчивающемуся на 1, кроме ±11. 196 | * `Few` соответствует любому числу, оканчивающемуся на 2, 3 или 4, кроме ±12, ±13 и ±14. 197 | * `Many` соответствует любому прочему числу, кроме 0. 198 | * `None` соответствует 0. 199 | 200 | ##### Значение по умолчанию 201 | 202 | Если для языка не настроена функция плюрализации, используется набор правил для английского языка. 203 | 204 | ### Вложенность 205 | 206 | 207 | 208 | 209 | 210 | 213 | 214 | Глубина вложенности ключей ограничена одним уровнем (для глоссария). 215 | 216 | 217 | Вложенность позволяет ссылаться на другие ключи в переводе, что удобно для формирования глоссариев. 218 | 219 | #### Базовый уровень 220 | 221 | Ключи 222 | 223 | ```json 224 | { 225 | "nesting1": "1 $t{nesting2}", 226 | "nesting2": "2", 227 | } 228 | ``` 229 | 230 | Пример 231 | 232 | ```ts 233 | i18n('nesting1'); // -> "1 2" 234 | ``` 235 | 236 | На ключи из других наборов можно ссылаться, добавляя в качестве префикса необходимо значение `keysetName`. 237 | 238 | ```json 239 | // global/en.json 240 | { 241 | "app": "App" 242 | } 243 | 244 | // service/en.json 245 | { 246 | "app-service": "$t{global::app} service" 247 | } 248 | ``` 249 | 250 | ### Типизация 251 | 252 | Для типизации функции `i18nInstance.i18n` нужно выполнить несколько шагов. 253 | 254 | #### Подготовка 255 | 256 | Создайте JSON-файл с набором ключей, чтобы процедура типизации могла получать данные. Добавьте создание дополнительного файла `data.json` в месте получения наборов ключей. Для уменьшения размера файла и ускорения парсинга в IDE замените все значения на `'str'`. 257 | 258 | ```ts 259 | async function createFiles(keysets: Record) { 260 | await mkdirp(DEST_PATH); 261 | 262 | const createFilePromises = Object.keys(keysets).map((lang) => { 263 | const keysetsJSON = JSON.stringify(keysets[lang as Lang], null, 4); 264 | const content = umdTemplate(keysetsJSON); 265 | const hash = getContentHash(content); 266 | const filePath = path.resolve(DEST_PATH, `${lang}.${hash.slice(0, 8)}.js`); 267 | 268 | // 269 | let typesPromise; 270 | 271 | if (lang === 'ru') { 272 | const keyset = keysets[lang as Lang]; 273 | Object.keys(keyset).forEach((keysetName) => { 274 | const keyPhrases = keyset[keysetName]; 275 | Object.keys(keyPhrases).forEach((keyName) => { 276 | // mutate object! 277 | keyPhrases[keyName] = 'str'; 278 | }); 279 | }); 280 | 281 | const JSONForTypes = JSON.stringify(keyset, null, 4); 282 | typesPromise = writeFile(path.resolve(DEST_PATH, `data.json`), JSONForTypes, 'utf-8'); 283 | } 284 | // 285 | 286 | return Promise.all([typesPromise, writeFile(filePath, content, 'utf-8')]); 287 | }); 288 | 289 | await Promise.all(createFilePromises); 290 | } 291 | ``` 292 | 293 | #### Подключение 294 | 295 | В директории `ui/utils/i18n` (место настройки и экспорта `i18n` для дальнейшего использования всеми интерфейсами) импортируйте функцию типизации `I18NFn` с вашим `Keysets`. После настройки `i18n` верните функцию с заданным типом. 296 | 297 | ```ts 298 | import {I18NFn} from '@gravity-ui/i18n'; 299 | // This must be a typed import! 300 | import type Keysets from '../../../dist/public/build/i18n/data.json'; 301 | 302 | const i18nInstance = new I18N(); 303 | type TypedI18n = I18NFn; 304 | // ... 305 | export const ci18n = (i18nInstance.i18n as TypedI18n).bind(i18nInstance, 'common'); 306 | export const cui18n = (i18nInstance.i18n as TypedI18n).bind(i18nInstance, 'common.units'); 307 | export const i18n = i18nInstance.i18n.bind(i18nInstance) as TypedI18n; 308 | ``` 309 | 310 | #### Дополнительные аспекты 311 | 312 | **Логика работы типизации** 313 | 314 | Примеры использования: 315 | 316 | * Вызов функции с передачей ключей литералами строк: 317 | 318 | ```ts 319 | i18n('common', 'label_subnet'); // ok 320 | i18n('dcommon', 'label_dsubnet'); // error: Argument of type '"dcommon"' is not assignable to parameter of type ... 321 | i18n('common', 'label_dsubnet'); // error: Argument of type '"label_dsubnet"' is not assignable to parameter of type ... 322 | ``` 323 | 324 | * Вызов функции с передачей строк, которые нельзя вычислить в литералы (если `ts` не может распознать тип строки, он не выдает ошибку): 325 | 326 | ```ts 327 | const someUncomputebleString = `label_random-index-${Math.floor(Math.random() * 4)}`; 328 | i18n('some_service', someUncomputebleString); // ok 329 | 330 | for (let i = 0; i < 4; i++) { 331 | i18n('some_service', `label_random-index-${i}`); // ok 332 | } 333 | ``` 334 | 335 | * Вызов функции с передачей строк, которые можно вычислить в литералы: 336 | 337 | ```ts 338 | const labelColors = ['red', 'green', 'yelllow', 'white'] as const; 339 | for (let i = 0; i < 4; i++) { 340 | i18n('some_service', `label_color-${labelColors[i]}`); // ok 341 | } 342 | 343 | const labelWrongColors = ['red', 'not-existing', 'yelllow', 'white'] as const; 344 | for (let i = 0; i < 4; i++) { 345 | i18n('some_service', `label_color-${labelWrongColors[i]}`); // error: Argument of type '"not-existing"' is not assignable to parameter of type ... 346 | } 347 | ``` 348 | 349 | **Почему нет типизации через класс** 350 | 351 | Данная функция может поломать или усложнить некоторые сценарии использования i18n, поэтому была добавлена в качестве дополнительной функциональности. Если она хорошо себя проявит, то в будущем можно будет добавить ее в класс, чтобы не вызывать экспортируемые функции. 352 | 353 | **Почему могут не работать встроенные методы** 354 | 355 | Типизация встроенных методов функций достаточно сложна для реализации обхода вложенных структур и условных типов. Именно поэтому типизация работает только в случае использования непосредственного вызова функции и вызова `bind`до третьего аргумента. 356 | 357 | **Почему нельзя генерировать сразу файл `.ts`, чтобы типизация выполнялась и для значений ключей** 358 | 359 | Это можно сделать, передав результирующий тип в I18NFn. Однако при больших объемах файла `ts` начинает есть столько ресурсов, что это сильно тормозит IDE, чего не происходит с JSON-файлом. 360 | 361 | **Почему не типизированы остальные методы класса I18N** 362 | 363 | В принципе, их можно типизировать, и мы будем рады, если вы нам поможете это осуществить. Дело в том, что эти методы используются в 1% случаев. 364 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # @gravity-ui/i18n · [![npm package](https://img.shields.io/npm/v/@gravity-ui/i18n)](https://www.npmjs.com/package/@gravity-ui/i18n) [![CI](https://img.shields.io/github/actions/workflow/status/gravity-ui/i18n/.github/workflows/ci.yml?branch=main&label=CI&logo=github)](https://github.com/gravity-ui/i18n/actions/workflows/ci.yml?query=branch:main) 2 | 3 | ## I18N utilities 4 | 5 | Utilities in the I18N package are designed for internationalization of Gravity UI services. 6 | 7 | ### Install 8 | 9 | `npm install --save @gravity-ui/i18n` 10 | 11 | ### API 12 | 13 | #### constructor(options) 14 | 15 | Accepts `options` object with optional `logger` that would be used for logging library warnings. 16 | 17 | ##### logger 18 | 19 | Logger should have explicit `log` method with following signature: 20 | 21 | * `message` - string of message that would be logged 22 | * `options` - object of logging options: 23 | * `level` - level for logging message, always `'info'` 24 | * `logger` - where to log library messages 25 | * `extra` - additional options object, with a single `type` string, that is always `i18n` 26 | 27 | ### Use examples 28 | 29 | #### `keysets/en.json` 30 | 31 | ```json 32 | { 33 | "wizard": { 34 | "label_error-widget-no-access": "No access to the chart" 35 | } 36 | } 37 | ``` 38 | 39 | #### `keysets/ru.json` 40 | 41 | ```json 42 | { 43 | "wizard": { 44 | "label_error-widget-no-access": "Нет доступа к чарту" 45 | } 46 | } 47 | ``` 48 | 49 | #### `index.js` 50 | 51 | ```js 52 | const ru = require('./keysets/ru.json'); 53 | const en = require('./keysets/en.json'); 54 | 55 | const {I18N} = require('@gravity-ui/i18n'); 56 | 57 | const i18n = new I18N(); 58 | i18n.registerKeysets('ru', ru); 59 | i18n.registerKeysets('en', en); 60 | 61 | i18n.setLang('ru'); 62 | console.log( 63 | i18n.i18n('wizard', 'label_error-widget-no-access') 64 | ); // -> "Нет доступа к чарту" 65 | 66 | i18n.setLang('en'); 67 | console.log( 68 | i18n.i18n('wizard', 'label_error-widget-no-access') 69 | ); // -> "No access to the chart 70 | 71 | // Keyset allows for a simpler translations retrieval 72 | const keyset = i18n.keyset('wizard'); 73 | console.log( 74 | keyset('label_error-widget-no-access') 75 | ); // -> "No access to the chart" 76 | 77 | 78 | i18n.setLang('ru'); 79 | console.log( 80 | keyset('label_error-widget-no-access') 81 | ); // -> "Нет доступа к чарту" 82 | 83 | // Checking if keyset has a key 84 | if (i18n.has('wizard', 'label_error-widget-no-access')) { 85 | i18n.i18n('wizard', 'label_error-widget-no-access') 86 | } 87 | ``` 88 | 89 | ### Templating 90 | 91 | The library supports templating. Templated variables are enclosed in double curly brackets, and the values are passed to the i18n function as a key-value dictionary: 92 | 93 | #### `keysets.json` 94 | 95 | ```json 96 | { 97 | "label_template": "No matches found for '{{inputValue}}' in '{{folderName}}'" 98 | } 99 | ``` 100 | 101 | #### `index.js` 102 | 103 | ```js 104 | i18n('label_template', {inputValue: 'something', folderName: 'somewhere'}); // => No matches found for "something" in "somewhere" 105 | ``` 106 | 107 | ### Pluralization 108 | 109 | Pluralization can be used for easy localization of keys that depend on numeric values. Current library uses [CLDR Plural Rules](https://unicode-org.github.io/cldr-staging/charts/latest/supplemental/language_plural_rules.html) via [Intl.PluralRules API](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/PluralRules). 110 | 111 | You may need to [polyfill](https://github.com/eemeli/intl-pluralrules) the [Intl.Plural Rules API](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/PluralRules) if it is not available in the browser. 112 | 113 | There are 6 plural forms (see [resolvedOptions](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/PluralRules/resolvedOptions)): 114 | 115 | - zero (also will be used when count = 0 even if the form is not supported in the language) 116 | - one (singular) 117 | - two (dual) 118 | - few (paucal) 119 | - many (also used for fractions if they have a separate class) 120 | - other (required form for all languages — general plural form — also used if the language only has a single form) 121 | 122 | #### Example of `keysets.json` with plural key 123 | 124 | ```json 125 | { 126 | "label_seconds": { 127 | "one": "{{count}} second is left", 128 | "other":"{{count}} seconds are left", 129 | "zero": "No time left" 130 | } 131 | } 132 | ``` 133 | 134 | #### Usage in JS 135 | 136 | ```js 137 | i18n('label_seconds', {count: 1}); // => 1 second 138 | i18n('label_seconds', {count: 3}); // => 3 seconds 139 | i18n('label_seconds', {count: 7}); // => 7 seconds 140 | i18n('label_seconds', {count: 10}); // => 10 seconds 141 | i18n('label_seconds', {count: 0}); // => No time left 142 | ``` 143 | 144 | #### [Deprecated] Old plurals format 145 | 146 | Old format will be removed in v2. 147 | 148 | ```json 149 | { 150 | "label_seconds": ["{{count}} second is left", "{{count}} seconds are left", "{{count}} seconds are left", "No time left"] 151 | } 152 | ``` 153 | 154 | A pluralized key contains 4 values, each |corresponding to a `PluralForm` enum value. The enum values are: `One`, `Few`, `Many`, and `None`, respectively. Template variable name for pluralization is `count`. 155 | 156 | #### [Deprecated] Custom pluralization 157 | 158 | Since every language has its own way of pluralization, the library provides a method to configure the rules for any chosen language. 159 | 160 | The configuration function accepts an object with languages as keys, and pluralization functions as values. 161 | 162 | A pluralization function accepts a number and the `PluralForm` enum, and is expected to return one of the enum values depending on the provided number. 163 | 164 | ```js 165 | const {I18N} = require('@gravity-ui/i18n'); 166 | 167 | const i18n = new I18N(); 168 | 169 | i18n.configurePluralization({ 170 | en: (count, pluralForms) => { 171 | if (!count) return pluralForms.None; 172 | if (count === 1) return pluralForms.One; 173 | return pluralForms.Many; 174 | }, 175 | }); 176 | ``` 177 | 178 | #### [Deprecated] Provided pluralization rulesets 179 | 180 | The two languages supported out of the box are English and Russian. 181 | 182 | ##### English 183 | 184 | Language key: `en`. 185 | * `One` corresponds to 1 and -1. 186 | * `Few` is not used. 187 | * `Many` corresponds to any other number, except 0. 188 | * `None` corresponds to 0. 189 | 190 | ##### Russian 191 | 192 | Language key: `ru`. 193 | * `One` corresponds to any number ending in 1, except ±11. 194 | * `Few` corresponds to any number ending in 2, 3 or 4, except ±12, ±13 and ±14. 195 | * `Many` corresponds to any other number, except 0. 196 | * `None` corresponds to 0. 197 | 198 | ##### Default 199 | 200 | The English ruleset is used by default, for any language without a configured pluralization function. 201 | 202 | ### Nesting 203 | 204 | 205 | 206 | 207 | 208 | 211 | 212 | Max nesting depth limited - only 1 level (for glossary) 213 | 214 | 215 | Nesting allows you to reference other keys in a translation. Could be useful to build glossary terms. 216 | 217 | #### Basic 218 | 219 | keys 220 | 221 | ```json 222 | { 223 | "nesting1": "1 $t{nesting2}", 224 | "nesting2": "2", 225 | } 226 | ``` 227 | 228 | sample 229 | 230 | ```ts 231 | i18n('nesting1'); // -> "1 2" 232 | ``` 233 | 234 | You can reference keys from other keyset by prepending the keysetName: 235 | ```json 236 | // global/en.json 237 | { 238 | "app": "App" 239 | } 240 | 241 | // service/en.json 242 | { 243 | "app-service": "$t{global::app} service" 244 | } 245 | ``` 246 | 247 | ### Typing 248 | 249 | To type the `i18nInstance.i18n` function, follow the steps: 250 | 251 | #### Preparation 252 | 253 | Prepare a JSON keyset file so that the typing procedure can fetch data. Where you fetch keysets from, add creation of an additional `data.json` file. To decrease the file size and speed up IDE parsing, you can replace all values by `'str'`. 254 | 255 | ```ts 256 | async function createFiles(keysets: Record) { 257 | await mkdirp(DEST_PATH); 258 | 259 | const createFilePromises = Object.keys(keysets).map((lang) => { 260 | const keysetsJSON = JSON.stringify(keysets[lang as Lang], null, 4); 261 | const content = umdTemplate(keysetsJSON); 262 | const hash = getContentHash(content); 263 | const filePath = path.resolve(DEST_PATH, `${lang}.${hash.slice(0, 8)}.js`); 264 | 265 | // 266 | let typesPromise; 267 | 268 | if (lang === 'ru') { 269 | const keyset = keysets[lang as Lang]; 270 | Object.keys(keyset).forEach((keysetName) => { 271 | const keyPhrases = keyset[keysetName]; 272 | Object.keys(keyPhrases).forEach((keyName) => { 273 | // mutate object! 274 | keyPhrases[keyName] = 'str'; 275 | }); 276 | }); 277 | 278 | const JSONForTypes = JSON.stringify(keyset, null, 4); 279 | typesPromise = writeFile(path.resolve(DEST_PATH, `data.json`), JSONForTypes, 'utf-8'); 280 | } 281 | // 282 | 283 | return Promise.all([typesPromise, writeFile(filePath, content, 'utf-8')]); 284 | }); 285 | 286 | await Promise.all(createFilePromises); 287 | } 288 | ``` 289 | 290 | #### Connection 291 | 292 | In your `ui/utils/i18n` directories (where you configure i18n and export it to be used by all interfaces), import the typing function `I18NFn` with your `Keysets`. After your i18n has been configured, return the casted function 293 | 294 | ```ts 295 | import {I18NFn} from '@gravity-ui/i18n'; 296 | // This must be a typed import! 297 | import type Keysets from '../../../dist/public/build/i18n/data.json'; 298 | 299 | const i18nInstance = new I18N(); 300 | type TypedI18n = I18NFn; 301 | // ... 302 | export const ci18n = (i18nInstance.i18n as TypedI18n).bind(i18nInstance, 'common'); 303 | export const cui18n = (i18nInstance.i18n as TypedI18n).bind(i18nInstance, 'common.units'); 304 | export const i18n = i18nInstance.i18n.bind(i18nInstance) as TypedI18n; 305 | ``` 306 | 307 | #### Additional issues 308 | 309 | **Typing logic** 310 | 311 | There are several typing use cases: 312 | 313 | - Calling a function with keys passed as string literals 314 | 315 | ```ts 316 | i18n('common', 'label_subnet'); // ok 317 | i18n('dcommon', 'label_dsubnet'); // error: Argument of type '"dcommon"' is not assignable to parameter of type ... 318 | i18n('common', 'label_dsubnet'); // error: Argument of type '"label_dsubnet"' is not assignable to parameter of type ... 319 | ``` 320 | 321 | - Calling a function, passing to it strings that can't be converted into literals (if ts can't derive the string type, it doesn't throw an error) 322 | 323 | ```ts 324 | const someUncomputebleString = `label_random-index-${Math.floor(Math.random() * 4)}`; 325 | i18n('some_service', someUncomputebleString); // ok 326 | 327 | for (let i = 0; i < 4; i++) { 328 | i18n('some_service', `label_random-index-${i}`); // ok 329 | } 330 | ``` 331 | 332 | - Calling a function, passing to it strings that can be converted into literals 333 | 334 | ```ts 335 | const labelColors = ['red', 'green', 'yelllow', 'white'] as const; 336 | for (let i = 0; i < 4; i++) { 337 | i18n('some_service', `label_color-${labelColors[i]}`); // ok 338 | } 339 | 340 | const labelWrongColors = ['red', 'not-existing', 'yelllow', 'white'] as const; 341 | for (let i = 0; i < 4; i++) { 342 | i18n('some_service', `label_color-${labelWrongColors[i]}`); // error: Argument of type '"not-existing"' is not assignable to parameter of type ... 343 | } 344 | ``` 345 | 346 | **Why typing via a class isn't supported** 347 | 348 | This function can break or complicate some i18n scenarios, so it was added as a functional extension. If it proves effective, we would probably add it to a class to avoid casting exported functions. 349 | 350 | **Why built-in methods might fail** 351 | 352 | Implementing of traversal of nested structures and conditional types using typed built-in function methods is a complex enough task. That's why typing works only when using a direct function call and a `bind` call up to the third argument. 353 | 354 | **Why can't I generate a ts file straightforwardly to typecast key values as well?** 355 | 356 | You can do that by passing the result type to I18NFn. However, with large file sizes, ts starts consuming huge amounts of resources, slowing down the IDE dramatically, but with JSON file this is not the case. 357 | 358 | **Why other methods of the I18N class haven't been typed?** 359 | 360 | They can be typed, we'll appreciate if you help implementing it. The case is that other methods are used in 1% of cases. 361 | 362 | -------------------------------------------------------------------------------- /commitlint.config.js: -------------------------------------------------------------------------------- 1 | // eslint-disable-next-line 2 | module.exports = {extends: ['@commitlint/config-conventional']}; 3 | -------------------------------------------------------------------------------- /example/index.js: -------------------------------------------------------------------------------- 1 | const ru = require('./keysets/ru.json'); 2 | const en = require('./keysets/en.json'); 3 | 4 | const {I18N} = require('../src'); 5 | 6 | const i18n = new I18N(); 7 | i18n.registerKeysets('ru', ru); 8 | i18n.registerKeysets('en', en); 9 | 10 | I18N.setLang('ru'); 11 | console.log( 12 | i18n.i18n('wizard', 'label_error-widget-no-access') 13 | ); // -> "Нет доступа к чарту" 14 | 15 | I18N.setLang('en'); 16 | console.log( 17 | i18n.i18n('wizard', 'label_error-widget-no-access') 18 | ); // -> "No access to the chart 19 | 20 | // Keyset allows for a simpler translations retrieval 21 | const keyset = i18n.keyset('wizard'); 22 | console.log( 23 | keyset('label_error-widget-no-access') 24 | ); // -> "No access to the chart" 25 | 26 | 27 | I18N.setLang('ru'); 28 | console.log( 29 | keyset('label_error-widget-no-access') 30 | ); // -> "Нет доступа к чарту" 31 | -------------------------------------------------------------------------------- /example/keysets/en.json: -------------------------------------------------------------------------------- 1 | { 2 | "wizard": { 3 | "label_error-widget-no-access": "No access to the chart", 4 | "label_error-widget-not-found": "Чарт не найден", 5 | "label_error-widget-unknown-error": "Произошла ошибка", 6 | "button_copy-link": "Копировать ссылку", 7 | "label_message-link-copied": "Ссылка скопирована", 8 | "button_toggle-fullscreen": "На весь экран", 9 | "button_save": "Сохранить", 10 | "field_search": "Поиск", 11 | "section_dimensions": "Измерения", 12 | "section_measures": "Показатели", 13 | "label_dataset-blank": "Для начала работы выберите датасет", 14 | "button_access-rights": "Запросить права", 15 | "button_retry": "Повторить", 16 | "button_choose-dataset": "Выберите датасет", 17 | "button_to-dataset": "Перейти к датасету", 18 | "section_filters": "Фильтры", 19 | "section_colors": "Цвета", 20 | "section_sort": "Сортировка", 21 | "label_choose-visualization-type": "Тип чарта", 22 | "button_choose-visualization": "Выберите тип чарта", 23 | "label_chartkit-menu-open-in-new-tab": "Открыть в новой вкладке", 24 | "label_field-not-exist": "Поле отсутствует в датасете", 25 | "label_field-has-wrong-type": "Поле имеет недопустимый тип", 26 | "label_error-dataset-no-access-rights": "У вас нет доступа к датасету", 27 | "label_error-dataset-not-found": "Датасет не найден", 28 | "label_error-dataset-server-error": "Ошибка: не удалось загрузить датасет", 29 | "label_error-dataset-unknown-error": "Ошибка: не удалось загрузить датасет", 30 | "label_visualization-types-all": "Все", 31 | "label_visualization-types-line": "Графики", 32 | "label_visualization-types-column": "Столбчатые", 33 | "label_visualization-types-pie": "Круговые", 34 | "label_visualization-types-table": "Таблицы", 35 | "label_visualization-line": "Линейная диаграмма", 36 | "label_visualization-scatter": "Точечная диаграмма", 37 | "label_visualization-area": "Диаграмма с областями", 38 | "label_visualization-column": "Столбчатая диаграмма", 39 | "label_visualization-area-100p": "100% диаграмма с областями", 40 | "label_visualization-column-100p": "100% cтолбчатая диаграмма", 41 | "label_visualization-pie": "Круговая диаграмма", 42 | "label_visualization-treemap": "Древовидная диаграмма", 43 | "label_visualization-flat-table": "Таблица", 44 | "label_visualization-pivot-table": "Сводная таблица", 45 | "section_x": "X", 46 | "section_y": "Y", 47 | "section_columns": "Столбцы", 48 | "section_rows": "Строки", 49 | "section_points": "Точки", 50 | "section_size": "Размер", 51 | "label_operation-in": "Принадлежит множеству", 52 | "label_operation-nin": "Не принадлежит множеству", 53 | "label_operation-equals": "Равно", 54 | "label_operation-gt": "Больше", 55 | "label_operation-lt": "Меньше", 56 | "label_operation-gte": "Больше или равно", 57 | "label_operation-lte": "Меньше или равно", 58 | "label_operation-nequals": "Не равно", 59 | "label_value": "Значение", 60 | "label_add-value": "Добавить значение", 61 | "label_time-interval": "Временной интервал", 62 | "label_error-loading-filter-values": "Ошибка: не удалось загрузить значения для фильтра", 63 | "button_remove": "Удалить", 64 | "button_apply": "Применить", 65 | "button_cancel": "Отменить", 66 | "label_choose-from-list": "Выбрать значение из списка", 67 | "label_enter-manualy": "Ввести значение вручную", 68 | "label_items-available": "Доступны", 69 | "label_items-selected": "Выбраны", 70 | "button_select-all": "Выбрать все", 71 | "button_select": "Выбрать", 72 | "button_clear": "Очистить", 73 | "label_save-widget": "Сохранить чарт", 74 | "label_new-widget": "Новый чарт", 75 | "label_invalid-name": "Чарт с таким именем уже сущестует!" 76 | }, 77 | "datalens.main-page.view": { 78 | "button_create-dashboards": "Создать дашборд", 79 | "button_connect-data-source": "Создать подключение", 80 | "button_create-dataset": "Создать датасет", 81 | "button_create-widget": "Создать чарт", 82 | "section_examples": "Примеры", 83 | "label_connections": "Подключения", 84 | "label_connect-yours-data-sources": "Подключайте свои источник данных", 85 | "label_datasets": "Датасеты", 86 | "label_manage-datasets": "Формируйте наборы данных с вычисляемыми полями и агрегациями", 87 | "label_widgets": "Чарты", 88 | "label_view-data-in-charts-etc": "Визуализируйте данные в виде диаграмм и таблиц", 89 | "label_dashboard": "Дашборд", 90 | "label_dashboards": "Дашборды", 91 | "label_create-page-with-widgets": "Создавайте страницы с наборами диаграмм, таблиц и фильтров", 92 | "label_example-1": "Дашборд по данным Метрики", 93 | "label_example-2": "Дашборд по данным из ClickHouse", 94 | "label_example-3": "Диаграмма с областями", 95 | "label_example-4": "Круговая диаграмма", 96 | "label_example-5": "Точечная диаграмма", 97 | "label_example-6": "Древовидная диаграмма", 98 | "label_example-7": "Столбчатая диаграмма" 99 | }, 100 | "component.cloud-folder-select.status": { 101 | "label_loading": "Загрузка…", 102 | "label_error": "Ошибка", 103 | "label_not-found": "Ничего не нашлось", 104 | "label_not-active-folders": "У вас нет ни одного каталога с активным DataLens", 105 | "label_placeholder-none": "Каталог" 106 | }, 107 | "component.access-rights.view": { 108 | "section_main-title": "Права доступа", 109 | "section_given-access-title": "Выданные права", 110 | "section_requesting-access-title": "Запрашиваемые права", 111 | "label_self": "себя", 112 | "label_for": "Для", 113 | "label_requested-for": "запрашивает для", 114 | "label_error-not-found-entry": "Данной сущности нет в системе DLS. Обратитесь к администратору.", 115 | "label_error-general": "Что-то пошло не так. Пожалуйста, повторите запрос позже.", 116 | "label_error-get-grant-details": "Не удалось загрузить историю изменения прав. Повторите запрос позже.", 117 | "label_error-get-prev-requests": "Не удалось загрузить предыдущие запросы.", 118 | "label_grant-details-empty": "Отсутствует история изменения прав доступа.", 119 | "section_deny-request-title": "Отклонить запрос", 120 | "button_deny-request": "Отклонить запрос", 121 | "button_accept-request": "Подтвердить запрос", 122 | "section_accept-grant-title": "Выдать права", 123 | "section_change-grant-title": "Сменить права", 124 | "label_placeholder-deny-request": "Причина отзыва прав (не обязательно)", 125 | "label_placeholder-reason-change-grant": "Причина изменения прав (не обязательно)", 126 | "label_placeholder-comment": "Комментарий (не обязательно)", 127 | "button_recursive": "Применить рекурсивно", 128 | "button_add": "Добавить", 129 | "button_repeat": "Повторить", 130 | "section_deny-all-title": "Отказать всем", 131 | "button_deny-all": "Отказать всем", 132 | "button_deny-all-requests": "Отклонить все запросы", 133 | "label_deny-all-requests": "Будут отклонены все запросы на получение прав доступа", 134 | "section_accept-all-title": "Разрешить всем", 135 | "button_accept-all": "Разрешить всем", 136 | "button_accept-all-requests": "Подтвердить все запросы", 137 | "label_accept-all-requests": "Будут выданы права на все запросы", 138 | "button_repeal": "Отмена", 139 | "button_cancel": "Отменить", 140 | "button_save": "Сохранить", 141 | "section_revoke-rights-title": "Отозвать права", 142 | "button_revoke-rights": "Отозвать права", 143 | "label_revoke-rights": "Права будут отозваны у", 144 | "label_subject-revoke": "отзывает права {{permission}}", 145 | "label_subject-requesting": "запрашивает права {{permission}}", 146 | "label_subject-accept": "выдает права {{permission}}", 147 | "label_subject-change": "сменил права {{from}} на {{to}}", 148 | "section_add-participant-title": "Добавить участника", 149 | "label_add-participant-placeholder": "Добавить участника", 150 | "section_request-access-rights-title": "Запрос прав доступа", 151 | "button_to-request": "Запросить", 152 | "label-nope-previous-requests": "Отсутствуют", 153 | "button_add-comment": "Добавить комментарий", 154 | "label_user-not-found": "Пользователь не найден", 155 | "section_previous-requests": "Предыдущие запросы", 156 | "section_requests": "Запросы", 157 | "section_participants": "Участники" 158 | }, 159 | "component.action-bar.view": { 160 | "label_fullscreen-full": "На весь экран", 161 | "label_fullscreen-back": "Вернуться", 162 | "button_save": "Сохранить", 163 | "button_more": "Ещё" 164 | }, 165 | "component.action-panel.view": { 166 | "button_add-favorite": "Добавить в избранное", 167 | "button_remove-favorite": "Удалить из избранного", 168 | "button_open-navigation": "Открыть навигацию" 169 | }, 170 | "component.dialog-create-dashboard.view": { 171 | "section_title": "Создать дашборд", 172 | "label_error": "Не удалось создать дашборд", 173 | "label_tab-name-on-create": "Вкладка 1", 174 | "label_input-placeholder": "Название", 175 | "button_create": "Создать", 176 | "button_cancel": "Отменить" 177 | }, 178 | "component.dialog-save-widget.view": { 179 | "section_title": "Сохранить чарт", 180 | "label_error": "Не удалось сохранить чарт", 181 | "label_widget-name-default": "Новый чарт", 182 | "button_save": "Сохранить", 183 | "button_cancel": "Отменить" 184 | }, 185 | "component.error-content.view": { 186 | "button_console": "Перейти в консоль", 187 | "button_copy": "Копировать", 188 | "button_access-rights": "Запросить права", 189 | "toast_copied": "Скопировано" 190 | }, 191 | "component.field-editor.view": { 192 | "section_title": "Настройка поля", 193 | "button_save": "Сохранить", 194 | "button_cancel": "Отменить", 195 | "button_create": "Создать", 196 | "value_formula": "Формула", 197 | "value_source-from-field": "Поле из источника", 198 | "value_boolean": "Логический", 199 | "value_date": "Дата", 200 | "value_datetime": "Дата и время", 201 | "value_float": "Дробное число", 202 | "value_integer": "Целое число", 203 | "value_string": "Строка", 204 | "value_auto": "Авто", 205 | "value_none": "Нет", 206 | "value_count": "Количество", 207 | "value_countunique": "Количество уникальных", 208 | "value_max": "Максимум", 209 | "value_min": "Минимум", 210 | "value_avg": "Среднее", 211 | "value_sum": "Сумма", 212 | "field_source-field": "Поле источника", 213 | "field_field-type": "Тип поля", 214 | "field_source-data": "Источник данных", 215 | "field_field-name": "Имя", 216 | "button_add-description": "Добавить описание", 217 | "button_manual": "Справочник", 218 | "field_not-display-in-wizard": "Не отображать в визарде", 219 | "field_data-type": "Тип данных", 220 | "field_aggregation": "Агрегация", 221 | "field_description": "Описание", 222 | "label_information-about-syntax-on-wiki": "Справка по синтаксису функций", 223 | "label_placeholder-field": "Поле", 224 | "value_function": "Функция", 225 | "label_field-already-exist": "Поле с таким именем уже существует", 226 | "label_title-is-empty": "Имя поля не должно быть пустым", 227 | "label_source-is-empty": "Для поля должен быть выбран источник", 228 | "label_confirm-question-on-error": "Вы уверены что хотите выйти?", 229 | "label_data-will-be-lost": "Данные будут утеряны", 230 | "button_yes": "Да", 231 | "button_no": "Нет" 232 | }, 233 | "component.entry-context-menu.view": { 234 | "value_rename": "Переименовать", 235 | "value_delete": "Удалить", 236 | "value_move": "Переместить", 237 | "value_copy": "Копировать", 238 | "value_access": "Права доступа", 239 | "value_copy-link": "Копировать ссылку" 240 | }, 241 | "component.support-dialog.view": { 242 | "button_done": "Готово" 243 | }, 244 | "component.error-dialog.view": { 245 | "label_request-id": "Request-ID:", 246 | "button_copy": "Скопировать", 247 | "button_close": "Закрыть" 248 | }, 249 | "component.navigation.view": { 250 | "switch_personal-folder": "Личная папка", 251 | "value_create-folder": "Папку", 252 | "value_create-dataset": "Датасет", 253 | "value_create-widget": "Чарт", 254 | "value_create-dashboard": "Дашборд", 255 | "switch_root": "Все объекты", 256 | "switch_favorites": "Избранное", 257 | "switch_connections": "Подключения", 258 | "switch_latest": "Последние", 259 | "switch_dashboards": "Дашборды", 260 | "switch_datasets": "Датасеты", 261 | "switch_widgets": "Чарты" 262 | }, 263 | "datalens.header-menu.view": { 264 | "switch_documents": "Документация", 265 | "switch_support": "Поддержка", 266 | "switch_console": "Консоль", 267 | "switch_files": "Все объекты", 268 | "switch_favorites": "Избранное", 269 | "switch_connections": "Подключения", 270 | "switch_datasets": "Датасеты", 271 | "switch_dashboards": "Дашборды", 272 | "switch_widgets": "Чарты", 273 | "section_sections": "Разделы" 274 | }, 275 | "datalens.landing.error": { 276 | "label_title-license-not-accepted": "У вас нет доступа к Яндекс.Облаку", 277 | "label_title-auth-failed": "Что-то пошло не так", 278 | "label_description-auth-failed": "Пожалуйста, обратитесь в тех. поддержку", 279 | "label_title-fail": "Что-то пошло не так", 280 | "label_description-fail": "Пожалуйста, обратитесь в тех. поддержку", 281 | "label_title-auth-denied": "У вас нет доступа к Яндекс.Облаку", 282 | "label_description-auth-denied": "Пожалуйста, обратитесь в тех. поддержку", 283 | "label_title-inaccessible-entry-folder": "У вас нет доступа к DataLens в этом каталоге", 284 | "label_title-missing-entry": "Не найдено", 285 | "label_title-missing-current-cloud-folder": "Выберите каталог для работы с DataLens", 286 | "label_title-forbidden-entry": "У вас нет прав доступа к этому объекту", 287 | "label_title-cloud-folder-access-denied": "У вас нет доступа к объектам DataLens в этом каталоге" 288 | }, 289 | "connection.connections-list.modify": { 290 | "field_title": "Имя подключения", 291 | "button_cancel": "Отменить", 292 | "button_repeat": "Повторить", 293 | "button_create-dataset": "Создать датасет", 294 | "button_create": "Создать", 295 | "button_remove": "Удалить", 296 | "button_create-connection": "Создать подключение", 297 | "switch_create-dataset": "Создать датасет", 298 | "switch_edit-connection": "Редактировать", 299 | "switch_delete-connection": "Удалить", 300 | "switch_access-settings": "Настроить доступ", 301 | "value_no": "Нет", 302 | "column_type": "Тип", 303 | "column_created-at": "Дата создания", 304 | "column_owner": "Владелец", 305 | "column_status": "Статус", 306 | "column_title": "Имя", 307 | "section_connections": "Подключения", 308 | "label_accept-or-decline-removing": "Удалить подключение {{connectionName}}?", 309 | "label_connections-empty-msg": "У вас пока нет ни одного подключения", 310 | "label_not-found-connections": "Ничего не нашлось", 311 | "label_connections-not-exist": "У вас пока нет ни одного подключения", 312 | "label_connection-loading": "Загрузка", 313 | "label_error-msg": "Ошибка: не удалось загрузить список подключений" 314 | }, 315 | "dataset.dataset-creation-dialog.create": { 316 | "field_connection-title": "Имя подключения", 317 | "field_host-name": "Имя хоста", 318 | "field_port": "Порт", 319 | "field_click-house-port": "Порт HTTP-интерфейса", 320 | "field_username": "Имя пользователя", 321 | "field_password": "Пароль", 322 | "field_encoding": "Кодировка", 323 | "field_delimiter": "Разделитель", 324 | "field_csv-header": "Заголовок таблицы", 325 | "field_token-yt": "Токен YT", 326 | "field_token-metrika": "OAuth-токен Яндекс.Метрики", 327 | "field_get-token": "Получить токен", 328 | "field_counter-id": "Счетчик", 329 | "field_cluster": "Кластер", 330 | "field_clika": "Клика", 331 | "field_counter-source": "Источник счетчика", 332 | "field_dataset-title": "Имя датасета", 333 | "field_yt-table-path": "Полный URL таблицы в YT", 334 | "field_db-name": "База данных", 335 | "field_table-name": "Таблица", 336 | "field_auto-create-dashboard": "Автоматически создать дашборд, чарты и датасет над подключением", 337 | "field_datepicker": "Загружать с", 338 | "button_save-connection": "Сохранить", 339 | "button_create-connection": "Создать подключение", 340 | "button_create": "Создать", 341 | "button_upload": "Загрузить", 342 | "button_setting-permissions": "Настроить права доступа", 343 | "button_verify": "Проверить подключение", 344 | "button_connection-name": "Имя коннектора", 345 | "button_cancel": "Отменить", 346 | "button_save": "Сохранить", 347 | "button_create-dataset": "Создать датасет", 348 | "button_remove": "Удалить", 349 | "button_select-csv-file": "Выбрать CSV-файл", 350 | "button_add-connection": "Создать подключение", 351 | "value_counter-source-visits": "Визиты", 352 | "value_counter-source-hits": "Просмотры", 353 | "value_counter-source-advertising": "Клики", 354 | "value_counter-source-user-param": "Параметры посетителей", 355 | "value_db-connect-method-service-name": "Имя сервиса", 356 | "value_db-connect-method-sid": "SID", 357 | "value_there-is": "Есть", 358 | "value_no": "Нет", 359 | "switch_create-dataset": "Создать датасет", 360 | "switch_edit-connection": "Редактировать", 361 | "switch_delete-connection": "Удалить", 362 | "switch_access-settings": "Настроить доступ", 363 | "column_type": "Тип", 364 | "column_created-at": "Дата создания", 365 | "column_owner": "Владелец", 366 | "column_status": "Статус", 367 | "column_title": "Имя", 368 | "context_where-can-it-be-taken-token-yt": "Получение OAuth-токена для YT", 369 | "section_or-another-variant": "или введите YQL Public Link:", 370 | "section_select-db-and-table": "Выберите базу данных и таблицу:", 371 | "section_connection": "Подключение", 372 | "section_new-connection": "Создание подключения", 373 | "section_selection-connection": "Выбор подключения", 374 | "section_db-name": "Имя базы данных", 375 | "section_drop-csv-here": "Отпустите файл и начнется загрузка", 376 | "section_or-drop-here": "Вы можете загрузить файл размером до 100 Мб, перетащив его на экран", 377 | "section_creation-dataset": "Создание датасета", 378 | "section_connections": "Подключения", 379 | "section_source-replacement": "Замена источника", 380 | "label_database-list-general-error": "Ошибка: не удалось загрузить список доступных баз данных", 381 | "label_database-list-forbidden-error": "У вас нет прав доступа к подключению", 382 | "label_database-list-connection-error": "Ошибка: не удалось установить соединение к подключению", 383 | "label_table-list-general-error": "Ошибка: не удалось загрузить список доступных таблиц", 384 | "label_table-list-forbidden-error": "У вас нет доступа к подключению", 385 | "label_table-list-connection-error": "Ошибка: не удалось установить соединение к подключению", 386 | "label_verify-connection-500": "Ошибка: не удалось проверить подключение", 387 | "label_connections-list-failed": "Ошибка: не удалось загрузить список подключений", 388 | "label_connection-request-failed": "Ошибка: не удалось загрузить подлючение", 389 | "label_csv-preview-request-failed": "Ошибка: не удалось получить превью", 390 | "label_name-clickhouse": "ClickHouse", 391 | "label_name-csv": "CSV", 392 | "label_name-postgres": "PostgreSQL", 393 | "label_name-mysql": "MySQL", 394 | "label_name-mssql": "MS SQL Server", 395 | "label_name-metrika-api": "Metrica", 396 | "label_name-metrika-logs-api": "Metrica Logs API", 397 | "label_name-yt": "YT", 398 | "label_name-ch-over-yt": "CH over YT", 399 | "label_name-oracle": "Oracle Database", 400 | "label_not-found-connections": "Ничего не нашлось", 401 | "label_connections-not-exist": "У вас пока нет ни одного подключения", 402 | "label_db-name": "Имя базы данных", 403 | "label_connections-empty-msg": "У вас пока нет ни одного подключения", 404 | "label_connection-loading": "Загрузка", 405 | "label_no-csv-preview": "Данные для предпросмотра отсутствуют", 406 | "label_loading-csv-preview": "Загрузка данных для предпросмотра", 407 | "label_loading-csv-connection": "Загрузка", 408 | "label_selected-file": "Выбранный файл", 409 | "label_size": "размер", 410 | "label_byte": "байт", 411 | "label_error-msg": "Ошибка: не удалось загрузить список подключений", 412 | "label_connection-csv-name": "Имя подключения", 413 | "label_alias-info": "Указывается название запущенной клики, подробнее в", 414 | "label_documentation": "документации", 415 | "label_csv-type-not-supported": "Данный тип данных не поддерживается для загрузки", 416 | "label_max-size-is-over": "Увы, этот файл превышает допустимый лимит размера в CSV-файла в 100 Мб. Попробуйте загрузить другой файл", 417 | "label_section-caption-counter-settings": "Настройки счетчика", 418 | "label_section-unloading-settings": "Параметры выгрузки", 419 | "label_section-destination-db-credentials": "Целевая база данных", 420 | "label_datepicker-hint": "Дата начальной загрузки задается один раз при создании подключения", 421 | "toast_error-action-label": "Подробнее", 422 | "toast_create-connection-error": "Ошибка: не удалось создать подключение", 423 | "toast_create-dataset-error": "Ошибка: не удалось создать датасет", 424 | "toast_modify-connection-error": "Ошибка: не удалось сохранить подключение", 425 | "toast_verify-error": "Ошибка: не удалось проверить параметры подключения", 426 | "toast_upload-csv-error": "Ошибка: не удалось загрузить csv-файл", 427 | "toast_save-csv-error": "Ошибка: не удалось сохранить csv-файл", 428 | "toast_default-error": "Ошибка" 429 | }, 430 | "dataset.dataset-editor.modify": { 431 | "value_boolean": "Логический", 432 | "value_date": "Дата", 433 | "value_datetime": "Дата и время", 434 | "value_float": "Дробное число", 435 | "value_integer": "Целое число", 436 | "value_string": "Строка", 437 | "value_auto": "Авто", 438 | "value_none": "Нет", 439 | "value_count": "Количество", 440 | "value_countunique": "Количество уникальных", 441 | "value_max": "Максимум", 442 | "value_min": "Минимум", 443 | "value_avg": "Среднее", 444 | "value_sum": "Сумма", 445 | "field_display-rows": "Количество строк:", 446 | "field_find-field": "Имя поля", 447 | "field_display-hidden-fields": "Отображать скрытые поля", 448 | "button_preview": "Предпросмотр", 449 | "button_preview-full": "На весь экран", 450 | "button_preview-bottom": "Развернуть снизу", 451 | "button_preview-right": "Развернуть справа", 452 | "button_preview-close": "Закрыть", 453 | "button_enter-amount-rows": "", 454 | "button_add-field": "Добавить поле", 455 | "button_create-widget": "Создать чарт", 456 | "button_save": "Сохранить", 457 | "button_remove": "Удалить", 458 | "button_duplicate": "Продублировать", 459 | "button_edit": "Редактировать", 460 | "button_open-field-editor": "Редактировать", 461 | "button_hide-field": "Скрыть поле", 462 | "button_display-field": "Показать поле", 463 | "button_ask-access-rights": "Запросить права доступа на датасет", 464 | "button_apply": "Применить", 465 | "button_data": "Данные", 466 | "column_title": "Имя", 467 | "column_cast": "Тип", 468 | "column_aggregation": "Агрегация", 469 | "column_description": "Описание", 470 | "column_field-name": "Имя", 471 | "column_field-cast": "Тип", 472 | "column_field-aggregation": "Агрегация", 473 | "column_filed-description": "Описание", 474 | "section_preview": "Предпросмотр", 475 | "label_max-amount-rows": "не более 10 000", 476 | "label_field": "Поле", 477 | "label_loading-dataset": "Загрузка датасета", 478 | "label_no-data": "Нет данных", 479 | "label_loading-dataset-preview": "Загрузка данных для предпросмотра", 480 | "label_materialization-preview": "Загрузка данных для предпросмотра", 481 | "label_request-dataset-preview-error": "Ошибка: не удалось загрузить данные для предпросмотра", 482 | "label_preview-not-supported": "Для данного типа источника предпросмотр не доступен", 483 | "label_error-500-title": "Ошибка: не удалось загрузить датасет", 484 | "label_error-500-description": "", 485 | "label_error-404-title": "Ошибка: датасет не найден", 486 | "label_error-403-title": "У вас нет доступа к датасету или к его подключению", 487 | "label_error-400-title": "Ошибка: некорректный запрос к датасету", 488 | "label_error-400-description": "", 489 | "label_error-400-no-connection-title": "Ошибка: отсутствует подключение" 490 | }, 491 | "dataset.materialization.modify": { 492 | "field_repeat-until": "Повторять до", 493 | "field_remove-materialization": "Удалить материализацию", 494 | "field_every": "Каждый", 495 | "field_every-2": "Каждая", 496 | "field_each": "Каждого", 497 | "field_time": "Время", 498 | "field_monday-short": "Пн", 499 | "field_tuesday-short": "Вт", 500 | "field_wednesday-short": "Ср", 501 | "field_thursday-short": "Чт", 502 | "field_friday-short": "Пт", 503 | "field_saturday-short": "Сб", 504 | "field_sunday-short": "Вс", 505 | "field_materialization": "Периодичность", 506 | "button_repeat-action": "Повторить", 507 | "button_save": "Сохранить", 508 | "button_cancel": "Отменить", 509 | "button_close": "Закрыть", 510 | "button_load-now": "Загрузить сейчас", 511 | "value_direct-access": "Прямой доступ", 512 | "value_single-full-load": "Единовременная материализация", 513 | "value_periodical-full-load": "Периодическая материализация", 514 | "value_open-connection": "Перейти к подключению", 515 | "value_update-dataset-schema": "Обновить схему", 516 | "value_replace-source": "Заменить источник", 517 | "value_day": "День", 518 | "value_month": "Месяц", 519 | "value_week": "Неделя", 520 | "value_monday": "Понедельник", 521 | "value_tuesday": "Вторник", 522 | "value_wednesday": "Среда", 523 | "value_thursday": "Четверг", 524 | "value_friday": "Пятница", 525 | "value_saturday": "Субббота", 526 | "value_sunday": "Воскресенье", 527 | "section_materialization": "Материализация", 528 | "section_data": "Данные", 529 | "label_next-materialization": "Следующая загрузка", 530 | "label_not-materialized-yet": "Данные в этом датасете еще не загружались. Для загрузки нажмите Сохранить или Загрузить сейчас", 531 | "label_volume": "Объем", 532 | "label_month": "Месяц", 533 | "label_last-materialization": "Обновлено {{date}} в {{time}}", 534 | "label_materialization-failed": "{{date}} в {{time}}", 535 | "label_status": "Статус", 536 | "label_daily": "День", 537 | "label_monthly": "Месяц", 538 | "label_weekly": "Неделя", 539 | "label_occupied": "занято", 540 | "label_initializing": "Инициализация", 541 | "label_copying": "Загрузка данных", 542 | "label_saving-meta": "Сохранение", 543 | "label_loading": "Загрузка данных", 544 | "label_done": "Данные загружены", 545 | "label_failed": "Ошибка", 546 | "label_data-source-fetch-data-failed": "Ошибка: не удалось загрузить данные" 547 | }, 548 | "dataset.notifications.view": { 549 | "toast_create-dataset-msgs-success": "Датасет создан", 550 | "toast_create-dataset-msgs-failure": "Ошибка: не удалось создать датасет", 551 | "toast_field-duplicated-msgs-success": "Поле продублировано", 552 | "toast_field-duplicated-msgs-failure": "", 553 | "toast_field-remove-msgs-success": "Поле удалено", 554 | "toast_field-remove-msgs-failure": "", 555 | "toast_dataset-save-msgs-success": "Датасет сохранен", 556 | "toast_dataset-save-msgs-failure": "Ошибка: не удалось сохранить датасет", 557 | "toast_dataset-fetch-preview-msgs-success": "", 558 | "toast_dataset-fetch-preview-msgs-failure": "Ошибка: не удалось загрузить данные для предпросмотра", 559 | "toast_dataset-validation-msgs-success": "", 560 | "toast_dataset-validation-msgs-failure": "Ошибка: датасет не прошел валидацию", 561 | "toast_preview-not-supported": "Для данного типа источника предпросмотр не доступен", 562 | "toast_fetch-types-msgs-success": "", 563 | "toast_fetch-types-msgs-failure": "Ошибка: не удалось загрузить типы данных", 564 | "toast_fetch-materialization-status-failure": "Ошибка: не удалось получить статус материализации", 565 | "toast_save-data-failure": "Ошибка: не удалось сохранить", 566 | "toast_materialize-dataset-failure": "Ошибка: не удалось произвести материализацию", 567 | "toast_delete-materialization-dataset-failure": "Ошибка: не удалось удалить материализацию", 568 | "toast_click-connection-more-menu-item-failure": "Ошибка: не удалось выполнить запрос", 569 | "toast_update-dataset-schema-failure": "Ошибка: не удалось обновить схему", 570 | "toast_replace-source-failure": "Ошибка: не удалось заменить источник", 571 | "toast_default-error": "Ошибка", 572 | "toast_error-action-label": "Подробнее" 573 | }, 574 | "dash.connections-dialog.edit": { 575 | "label_connections": "Связи", 576 | "label_no-information": "Нет информации", 577 | "label_alias": "Алиас", 578 | "label_alias-influence": "Связанные виджеты", 579 | "label_manage-alias": "Настройка алиаса", 580 | "context_choose-element": "Выберите элемент", 581 | "value_connected": "Связь", 582 | "value_input": "Вх. связь", 583 | "value_output": "Исх. связь", 584 | "value_ignore": "Игнор", 585 | "value_none": "Нет связи", 586 | "button_save": "Сохранить", 587 | "button_add": "Добавить", 588 | "button_cancel": "Отменить", 589 | "button_remove-alias": "Удалить" 590 | }, 591 | "dash.text-dialog.edit": { 592 | "label_text": "Текст", 593 | "context_fill-text": "Введите текст", 594 | "toast_required-field": "Поле должно быть заполнено", 595 | "button_add": "Добавить", 596 | "button_save": "Сохранить", 597 | "button_cancel": "Отменить" 598 | }, 599 | "dash.title-dialog.edit": { 600 | "label_title": "Заголовок", 601 | "value_default": "Заголовок", 602 | "field_show-in-toc": "Отображать в оглавлении", 603 | "context_fill-title": "Введите заголовок", 604 | "toast_required-field": "Поле должно быть заполнено", 605 | "button_add": "Добавить", 606 | "button_save": "Сохранить", 607 | "button_cancel": "Отменить" 608 | }, 609 | "dash.tabs-dialog.edit": { 610 | "label_tabs": "Вкладки", 611 | "value_default": "Вкладка {{index}}", 612 | "button_add-tab": "Добавить", 613 | "button_save": "Сохранить", 614 | "button_cancel": "Отменить" 615 | }, 616 | "dash.widget-dialog.edit": { 617 | "label_widget": "Чарт", 618 | "label_new-param": "Новый параметр", 619 | "label_empty-list": "Список пуст", 620 | "value_title-default": "Заголовок {{index}}", 621 | "field_title": "Заголовок", 622 | "field_widget": "Чарт", 623 | "field_description": "Описание", 624 | "field_params": "Параметры", 625 | "field_param-name": "Имя", 626 | "field_param-value": "Значение", 627 | "context_fill-title": "Введите заголовок", 628 | "context_fill-description": "Введите описание", 629 | "toast_required-field": "Поле должно быть заполнено", 630 | "button_add": "Добавить", 631 | "button_save": "Сохранить", 632 | "button_cancel": "Отменить", 633 | "button_add-param": "Добавить параметр" 634 | }, 635 | "dash.control-dialog.edit": { 636 | "label_control": "Селектор", 637 | "label_acceptable-values": "Возможные значения", 638 | "label_default-value": "Значение по умолчанию", 639 | "label_params": "Параметры", 640 | "label_empty-list": "Список пуст", 641 | "value_source-dataset": "На основе датасета", 642 | "value_source-manual": "Ручной ввод", 643 | "value_source-external": "Внешний селектор", 644 | "value_date-no-limits": "Не ограничено", 645 | "value_date-accepted-start": "Начало допустимого интервала", 646 | "value_date-accepted-end": "Конец допустимого интервала", 647 | "value_date-accepted-all": "Весь допустимый интервал", 648 | "value_date-relative": "Дата относительно текущего дня", 649 | "value_date-manual": "Указать дату", 650 | "value_date-days-ago": "Дней назад: {{value}}", 651 | "value_date-days-ago-from-to": "Начало, дней назад: {{from}}. Конец, дней назад: {{to}}", 652 | "value_undefined": "Не определено", 653 | "value_not-chosen": "Не выбрано", 654 | "value_select-values": "Значений: {{count}}", 655 | "value_element-select": "Список", 656 | "value_element-date": "Календарь", 657 | "value_element-input": "Поле ввода", 658 | "field_dataset": "Датасет", 659 | "field_control": "Селектор", 660 | "field_field": "Поле", 661 | "field_field-name": "Имя поля", 662 | "field_element-type": "Тип элемента", 663 | "field_date-from": "Начало", 664 | "field_date-to": "Конец", 665 | "field_date-range": "Диапазон", 666 | "field_acceptable-values": "Возможные значения", 667 | "field_default-value": "Значение по умолчанию", 668 | "field_show-title": "Показывать", 669 | "field_multiselectable": "Множественный выбор", 670 | "context_title": "Заголовок", 671 | "context_add-value": "Добавить значение", 672 | "context_choose": "Выбрать", 673 | "context_date-days-ago": "Дней назад", 674 | "context_date-from-days-ago": "Начало, дней назад", 675 | "context_date-to-days-ago": "Конец, дней назад", 676 | "toast_required-fields": "Следующие поля должны быть заполнены: Заголовок, Поле/Имя поля", 677 | "button_add": "Добавить", 678 | "button_save": "Сохранить", 679 | "button_apply": "Применить", 680 | "button_cancel": "Отменить", 681 | "button_setup": "Настроить", 682 | "button_add-key": "Добавить ключ", 683 | "button_add-value": "Добавить значение" 684 | }, 685 | "dash.navigation-input.edit": { 686 | "context_fill-link": "Ссылка на чарт", 687 | "toast_incorrect-url": "Некорректный URL", 688 | "toast_error": "Ошибка", 689 | "button_choose": "Выбрать", 690 | "button_use-link": "Указать ссылку" 691 | }, 692 | "dash.table-of-content.view": { 693 | "label_table-of-content": "Оглавление", 694 | "context_table-of-content": "Оглавление" 695 | }, 696 | "dash.error.view": { 697 | "label_error": "Ошибка", 698 | "button_retry": "Повторить" 699 | }, 700 | "dash.main.view": { 701 | "label_loading": "Загрузка", 702 | "label_updating": "Обновление", 703 | "toast_unsaved": "На странице есть несохраненные изменения. Вы уверены?" 704 | }, 705 | "dash.header.view": { 706 | "value_title": "Заголовок", 707 | "value_text": "Текст", 708 | "value_widget": "Чарт", 709 | "value_control": "Селектор", 710 | "toast_error": "Произошла ошибка", 711 | "button_edit": "Редактировать", 712 | "button_save": "Сохранить", 713 | "button_cancel": "Отменить", 714 | "button_request-rights": "Запросить права", 715 | "button_add": "Добавить", 716 | "button_tabs": "Вкладки", 717 | "button_connections": "Связи" 718 | }, 719 | "dash.chartkit-menu.view": { 720 | "button_new-tab": "Открыть в новой вкладке", 721 | "button_edit": "Редактировать" 722 | }, 723 | "dash.dashkit-plugin-control.view": { 724 | "value_undefined": "Не определено" 725 | } 726 | } 727 | -------------------------------------------------------------------------------- /example/keysets/ru.json: -------------------------------------------------------------------------------- 1 | { 2 | "wizard": { 3 | "label_error-widget-no-access": "Нет доступа к чарту", 4 | "label_error-widget-not-found": "Чарт не найден", 5 | "label_error-widget-unknown-error": "Произошла ошибка", 6 | "button_copy-link": "Копировать ссылку", 7 | "label_message-link-copied": "Ссылка скопирована", 8 | "button_toggle-fullscreen": "На весь экран", 9 | "button_save": "Сохранить", 10 | "field_search": "Поиск", 11 | "section_dimensions": "Измерения", 12 | "section_measures": "Показатели", 13 | "label_dataset-blank": "Для начала работы выберите датасет", 14 | "button_access-rights": "Запросить права", 15 | "button_retry": "Повторить", 16 | "button_choose-dataset": "Выберите датасет", 17 | "button_to-dataset": "Перейти к датасету", 18 | "section_filters": "Фильтры", 19 | "section_colors": "Цвета", 20 | "section_sort": "Сортировка", 21 | "label_choose-visualization-type": "Тип чарта", 22 | "button_choose-visualization": "Выберите тип чарта", 23 | "label_chartkit-menu-open-in-new-tab": "Открыть в новой вкладке", 24 | "label_field-not-exist": "Поле отсутствует в датасете", 25 | "label_field-has-wrong-type": "Поле имеет недопустимый тип", 26 | "label_error-dataset-no-access-rights": "У вас нет доступа к датасету", 27 | "label_error-dataset-not-found": "Датасет не найден", 28 | "label_error-dataset-server-error": "Ошибка: не удалось загрузить датасет", 29 | "label_error-dataset-unknown-error": "Ошибка: не удалось загрузить датасет", 30 | "label_visualization-types-all": "Все", 31 | "label_visualization-types-line": "Графики", 32 | "label_visualization-types-column": "Столбчатые", 33 | "label_visualization-types-pie": "Круговые", 34 | "label_visualization-types-table": "Таблицы", 35 | "label_visualization-line": "Линейная диаграмма", 36 | "label_visualization-scatter": "Точечная диаграмма", 37 | "label_visualization-area": "Диаграмма с областями", 38 | "label_visualization-column": "Столбчатая диаграмма", 39 | "label_visualization-area-100p": "100% диаграмма с областями", 40 | "label_visualization-column-100p": "100% cтолбчатая диаграмма", 41 | "label_visualization-pie": "Круговая диаграмма", 42 | "label_visualization-treemap": "Древовидная диаграмма", 43 | "label_visualization-flat-table": "Таблица", 44 | "label_visualization-pivot-table": "Сводная таблица", 45 | "section_x": "X", 46 | "section_y": "Y", 47 | "section_columns": "Столбцы", 48 | "section_rows": "Строки", 49 | "section_points": "Точки", 50 | "section_size": "Размер", 51 | "label_operation-in": "Принадлежит множеству", 52 | "label_operation-nin": "Не принадлежит множеству", 53 | "label_operation-equals": "Равно", 54 | "label_operation-gt": "Больше", 55 | "label_operation-lt": "Меньше", 56 | "label_operation-gte": "Больше или равно", 57 | "label_operation-lte": "Меньше или равно", 58 | "label_operation-nequals": "Не равно", 59 | "label_value": "Значение", 60 | "label_add-value": "Добавить значение", 61 | "label_time-interval": "Временной интервал", 62 | "label_error-loading-filter-values": "Ошибка: не удалось загрузить значения для фильтра", 63 | "button_remove": "Удалить", 64 | "button_apply": "Применить", 65 | "button_cancel": "Отменить", 66 | "label_choose-from-list": "Выбрать значение из списка", 67 | "label_enter-manualy": "Ввести значение вручную", 68 | "label_items-available": "Доступны", 69 | "label_items-selected": "Выбраны", 70 | "button_select-all": "Выбрать все", 71 | "button_select": "Выбрать", 72 | "button_clear": "Очистить", 73 | "label_save-widget": "Сохранить чарт", 74 | "label_new-widget": "Новый чарт", 75 | "label_invalid-name": "Чарт с таким именем уже сущестует!" 76 | }, 77 | "datalens.main-page.view": { 78 | "button_create-dashboards": "Создать дашборд", 79 | "button_connect-data-source": "Создать подключение", 80 | "button_create-dataset": "Создать датасет", 81 | "button_create-widget": "Создать чарт", 82 | "section_examples": "Примеры", 83 | "label_connections": "Подключения", 84 | "label_connect-yours-data-sources": "Подключайте свои источник данных", 85 | "label_datasets": "Датасеты", 86 | "label_manage-datasets": "Формируйте наборы данных с вычисляемыми полями и агрегациями", 87 | "label_widgets": "Чарты", 88 | "label_view-data-in-charts-etc": "Визуализируйте данные в виде диаграмм и таблиц", 89 | "label_dashboard": "Дашборд", 90 | "label_dashboards": "Дашборды", 91 | "label_create-page-with-widgets": "Создавайте страницы с наборами диаграмм, таблиц и фильтров", 92 | "label_example-1": "Дашборд по данным Метрики", 93 | "label_example-2": "Дашборд по данным из ClickHouse", 94 | "label_example-3": "Диаграмма с областями", 95 | "label_example-4": "Круговая диаграмма", 96 | "label_example-5": "Точечная диаграмма", 97 | "label_example-6": "Древовидная диаграмма", 98 | "label_example-7": "Столбчатая диаграмма" 99 | }, 100 | "component.cloud-folder-select.status": { 101 | "label_loading": "Загрузка…", 102 | "label_error": "Ошибка", 103 | "label_not-found": "Ничего не нашлось", 104 | "label_not-active-folders": "У вас нет ни одного каталога с активным DataLens", 105 | "label_placeholder-none": "Каталог" 106 | }, 107 | "component.access-rights.view": { 108 | "section_main-title": "Права доступа", 109 | "section_given-access-title": "Выданные права", 110 | "section_requesting-access-title": "Запрашиваемые права", 111 | "label_self": "себя", 112 | "label_for": "Для", 113 | "label_requested-for": "запрашивает для", 114 | "label_error-not-found-entry": "Данной сущности нет в системе DLS. Обратитесь к администратору.", 115 | "label_error-general": "Что-то пошло не так. Пожалуйста, повторите запрос позже.", 116 | "label_error-get-grant-details": "Не удалось загрузить историю изменения прав. Повторите запрос позже.", 117 | "label_error-get-prev-requests": "Не удалось загрузить предыдущие запросы.", 118 | "label_grant-details-empty": "Отсутствует история изменения прав доступа.", 119 | "section_deny-request-title": "Отклонить запрос", 120 | "button_deny-request": "Отклонить запрос", 121 | "button_accept-request": "Подтвердить запрос", 122 | "section_accept-grant-title": "Выдать права", 123 | "section_change-grant-title": "Сменить права", 124 | "label_placeholder-deny-request": "Причина отзыва прав (не обязательно)", 125 | "label_placeholder-reason-change-grant": "Причина изменения прав (не обязательно)", 126 | "label_placeholder-comment": "Комментарий (не обязательно)", 127 | "button_recursive": "Применить рекурсивно", 128 | "button_add": "Добавить", 129 | "button_repeat": "Повторить", 130 | "section_deny-all-title": "Отказать всем", 131 | "button_deny-all": "Отказать всем", 132 | "button_deny-all-requests": "Отклонить все запросы", 133 | "label_deny-all-requests": "Будут отклонены все запросы на получение прав доступа", 134 | "section_accept-all-title": "Разрешить всем", 135 | "button_accept-all": "Разрешить всем", 136 | "button_accept-all-requests": "Подтвердить все запросы", 137 | "label_accept-all-requests": "Будут выданы права на все запросы", 138 | "button_repeal": "Отмена", 139 | "button_cancel": "Отменить", 140 | "button_save": "Сохранить", 141 | "section_revoke-rights-title": "Отозвать права", 142 | "button_revoke-rights": "Отозвать права", 143 | "label_revoke-rights": "Права будут отозваны у", 144 | "label_subject-revoke": "отзывает права {{permission}}", 145 | "label_subject-requesting": "запрашивает права {{permission}}", 146 | "label_subject-accept": "выдает права {{permission}}", 147 | "label_subject-change": "сменил права {{from}} на {{to}}", 148 | "section_add-participant-title": "Добавить участника", 149 | "label_add-participant-placeholder": "Добавить участника", 150 | "section_request-access-rights-title": "Запрос прав доступа", 151 | "button_to-request": "Запросить", 152 | "label-nope-previous-requests": "Отсутствуют", 153 | "button_add-comment": "Добавить комментарий", 154 | "label_user-not-found": "Пользователь не найден", 155 | "section_previous-requests": "Предыдущие запросы", 156 | "section_requests": "Запросы", 157 | "section_participants": "Участники" 158 | }, 159 | "component.action-bar.view": { 160 | "label_fullscreen-full": "На весь экран", 161 | "label_fullscreen-back": "Вернуться", 162 | "button_save": "Сохранить", 163 | "button_more": "Ещё" 164 | }, 165 | "component.action-panel.view": { 166 | "button_add-favorite": "Добавить в избранное", 167 | "button_remove-favorite": "Удалить из избранного", 168 | "button_open-navigation": "Открыть навигацию" 169 | }, 170 | "component.dialog-create-dashboard.view": { 171 | "section_title": "Создать дашборд", 172 | "label_error": "Не удалось создать дашборд", 173 | "label_tab-name-on-create": "Вкладка 1", 174 | "label_input-placeholder": "Название", 175 | "button_create": "Создать", 176 | "button_cancel": "Отменить" 177 | }, 178 | "component.dialog-save-widget.view": { 179 | "section_title": "Сохранить чарт", 180 | "label_error": "Не удалось сохранить чарт", 181 | "label_widget-name-default": "Новый чарт", 182 | "button_save": "Сохранить", 183 | "button_cancel": "Отменить" 184 | }, 185 | "component.error-content.view": { 186 | "button_console": "Перейти в консоль", 187 | "button_copy": "Копировать", 188 | "button_access-rights": "Запросить права", 189 | "toast_copied": "Скопировано" 190 | }, 191 | "component.field-editor.view": { 192 | "section_title": "Настройка поля", 193 | "button_save": "Сохранить", 194 | "button_cancel": "Отменить", 195 | "button_create": "Создать", 196 | "value_formula": "Формула", 197 | "value_source-from-field": "Поле из источника", 198 | "value_boolean": "Логический", 199 | "value_date": "Дата", 200 | "value_datetime": "Дата и время", 201 | "value_float": "Дробное число", 202 | "value_integer": "Целое число", 203 | "value_string": "Строка", 204 | "value_auto": "Авто", 205 | "value_none": "Нет", 206 | "value_count": "Количество", 207 | "value_countunique": "Количество уникальных", 208 | "value_max": "Максимум", 209 | "value_min": "Минимум", 210 | "value_avg": "Среднее", 211 | "value_sum": "Сумма", 212 | "field_source-field": "Поле источника", 213 | "field_field-type": "Тип поля", 214 | "field_source-data": "Источник данных", 215 | "field_field-name": "Имя", 216 | "button_add-description": "Добавить описание", 217 | "button_manual": "Справочник", 218 | "field_not-display-in-wizard": "Не отображать в визарде", 219 | "field_data-type": "Тип данных", 220 | "field_aggregation": "Агрегация", 221 | "field_description": "Описание", 222 | "label_information-about-syntax-on-wiki": "Справка по синтаксису функций", 223 | "label_placeholder-field": "Поле", 224 | "value_function": "Функция", 225 | "label_field-already-exist": "Поле с таким именем уже существует", 226 | "label_title-is-empty": "Имя поля не должно быть пустым", 227 | "label_source-is-empty": "Для поля должен быть выбран источник", 228 | "label_confirm-question-on-error": "Вы уверены что хотите выйти?", 229 | "label_data-will-be-lost": "Данные будут утеряны", 230 | "button_yes": "Да", 231 | "button_no": "Нет" 232 | }, 233 | "component.entry-context-menu.view": { 234 | "value_rename": "Переименовать", 235 | "value_delete": "Удалить", 236 | "value_move": "Переместить", 237 | "value_copy": "Копировать", 238 | "value_access": "Права доступа", 239 | "value_copy-link": "Копировать ссылку" 240 | }, 241 | "component.support-dialog.view": { 242 | "button_done": "Готово" 243 | }, 244 | "component.error-dialog.view": { 245 | "label_request-id": "Request-ID:", 246 | "button_copy": "Скопировать", 247 | "button_close": "Закрыть" 248 | }, 249 | "component.navigation.view": { 250 | "switch_personal-folder": "Личная папка", 251 | "value_create-folder": "Папку", 252 | "value_create-dataset": "Датасет", 253 | "value_create-widget": "Чарт", 254 | "value_create-dashboard": "Дашборд", 255 | "switch_root": "Все объекты", 256 | "switch_favorites": "Избранное", 257 | "switch_connections": "Подключения", 258 | "switch_latest": "Последние", 259 | "switch_dashboards": "Дашборды", 260 | "switch_datasets": "Датасеты", 261 | "switch_widgets": "Чарты" 262 | }, 263 | "datalens.header-menu.view": { 264 | "switch_documents": "Документация", 265 | "switch_support": "Поддержка", 266 | "switch_console": "Консоль", 267 | "switch_files": "Все объекты", 268 | "switch_favorites": "Избранное", 269 | "switch_connections": "Подключения", 270 | "switch_datasets": "Датасеты", 271 | "switch_dashboards": "Дашборды", 272 | "switch_widgets": "Чарты", 273 | "section_sections": "Разделы" 274 | }, 275 | "datalens.landing.error": { 276 | "label_title-license-not-accepted": "У вас нет доступа к Яндекс.Облаку", 277 | "label_title-auth-failed": "Что-то пошло не так", 278 | "label_description-auth-failed": "Пожалуйста, обратитесь в тех. поддержку", 279 | "label_title-fail": "Что-то пошло не так", 280 | "label_description-fail": "Пожалуйста, обратитесь в тех. поддержку", 281 | "label_title-auth-denied": "У вас нет доступа к Яндекс.Облаку", 282 | "label_description-auth-denied": "Пожалуйста, обратитесь в тех. поддержку", 283 | "label_title-inaccessible-entry-folder": "У вас нет доступа к DataLens в этом каталоге", 284 | "label_title-missing-entry": "Не найдено", 285 | "label_title-missing-current-cloud-folder": "Выберите каталог для работы с DataLens", 286 | "label_title-forbidden-entry": "У вас нет прав доступа к этому объекту", 287 | "label_title-cloud-folder-access-denied": "У вас нет доступа к объектам DataLens в этом каталоге" 288 | }, 289 | "connection.connections-list.modify": { 290 | "field_title": "Имя подключения", 291 | "button_cancel": "Отменить", 292 | "button_repeat": "Повторить", 293 | "button_create-dataset": "Создать датасет", 294 | "button_create": "Создать", 295 | "button_remove": "Удалить", 296 | "button_create-connection": "Создать подключение", 297 | "switch_create-dataset": "Создать датасет", 298 | "switch_edit-connection": "Редактировать", 299 | "switch_delete-connection": "Удалить", 300 | "switch_access-settings": "Настроить доступ", 301 | "value_no": "Нет", 302 | "column_type": "Тип", 303 | "column_created-at": "Дата создания", 304 | "column_owner": "Владелец", 305 | "column_status": "Статус", 306 | "column_title": "Имя", 307 | "section_connections": "Подключения", 308 | "label_accept-or-decline-removing": "Удалить подключение {{connectionName}}?", 309 | "label_connections-empty-msg": "У вас пока нет ни одного подключения", 310 | "label_not-found-connections": "Ничего не нашлось", 311 | "label_connections-not-exist": "У вас пока нет ни одного подключения", 312 | "label_connection-loading": "Загрузка", 313 | "label_error-msg": "Ошибка: не удалось загрузить список подключений" 314 | }, 315 | "dataset.dataset-creation-dialog.create": { 316 | "field_connection-title": "Имя подключения", 317 | "field_host-name": "Имя хоста", 318 | "field_port": "Порт", 319 | "field_click-house-port": "Порт HTTP-интерфейса", 320 | "field_username": "Имя пользователя", 321 | "field_password": "Пароль", 322 | "field_encoding": "Кодировка", 323 | "field_delimiter": "Разделитель", 324 | "field_csv-header": "Заголовок таблицы", 325 | "field_token-yt": "Токен YT", 326 | "field_token-metrika": "OAuth-токен Яндекс.Метрики", 327 | "field_get-token": "Получить токен", 328 | "field_counter-id": "Счетчик", 329 | "field_cluster": "Кластер", 330 | "field_clika": "Клика", 331 | "field_counter-source": "Источник счетчика", 332 | "field_dataset-title": "Имя датасета", 333 | "field_yt-table-path": "Полный URL таблицы в YT", 334 | "field_db-name": "База данных", 335 | "field_table-name": "Таблица", 336 | "field_auto-create-dashboard": "Автоматически создать дашборд, чарты и датасет над подключением", 337 | "field_datepicker": "Загружать с", 338 | "button_save-connection": "Сохранить", 339 | "button_create-connection": "Создать подключение", 340 | "button_create": "Создать", 341 | "button_upload": "Загрузить", 342 | "button_setting-permissions": "Настроить права доступа", 343 | "button_verify": "Проверить подключение", 344 | "button_connection-name": "Имя коннектора", 345 | "button_cancel": "Отменить", 346 | "button_save": "Сохранить", 347 | "button_create-dataset": "Создать датасет", 348 | "button_remove": "Удалить", 349 | "button_select-csv-file": "Выбрать CSV-файл", 350 | "button_add-connection": "Создать подключение", 351 | "value_counter-source-visits": "Визиты", 352 | "value_counter-source-hits": "Просмотры", 353 | "value_counter-source-advertising": "Клики", 354 | "value_counter-source-user-param": "Параметры посетителей", 355 | "value_db-connect-method-service-name": "Имя сервиса", 356 | "value_db-connect-method-sid": "SID", 357 | "value_there-is": "Есть", 358 | "value_no": "Нет", 359 | "switch_create-dataset": "Создать датасет", 360 | "switch_edit-connection": "Редактировать", 361 | "switch_delete-connection": "Удалить", 362 | "switch_access-settings": "Настроить доступ", 363 | "column_type": "Тип", 364 | "column_created-at": "Дата создания", 365 | "column_owner": "Владелец", 366 | "column_status": "Статус", 367 | "column_title": "Имя", 368 | "context_where-can-it-be-taken-token-yt": "Получение OAuth-токена для YT", 369 | "section_or-another-variant": "или введите YQL Public Link:", 370 | "section_select-db-and-table": "Выберите базу данных и таблицу:", 371 | "section_connection": "Подключение", 372 | "section_new-connection": "Создание подключения", 373 | "section_selection-connection": "Выбор подключения", 374 | "section_db-name": "Имя базы данных", 375 | "section_drop-csv-here": "Отпустите файл и начнется загрузка", 376 | "section_or-drop-here": "Вы можете загрузить файл размером до 100 Мб, перетащив его на экран", 377 | "section_creation-dataset": "Создание датасета", 378 | "section_connections": "Подключения", 379 | "section_source-replacement": "Замена источника", 380 | "label_database-list-general-error": "Ошибка: не удалось загрузить список доступных баз данных", 381 | "label_database-list-forbidden-error": "У вас нет прав доступа к подключению", 382 | "label_database-list-connection-error": "Ошибка: не удалось установить соединение к подключению", 383 | "label_table-list-general-error": "Ошибка: не удалось загрузить список доступных таблиц", 384 | "label_table-list-forbidden-error": "У вас нет доступа к подключению", 385 | "label_table-list-connection-error": "Ошибка: не удалось установить соединение к подключению", 386 | "label_verify-connection-500": "Ошибка: не удалось проверить подключение", 387 | "label_connections-list-failed": "Ошибка: не удалось загрузить список подключений", 388 | "label_connection-request-failed": "Ошибка: не удалось загрузить подлючение", 389 | "label_csv-preview-request-failed": "Ошибка: не удалось получить превью", 390 | "label_name-clickhouse": "ClickHouse", 391 | "label_name-csv": "CSV", 392 | "label_name-postgres": "PostgreSQL", 393 | "label_name-mysql": "MySQL", 394 | "label_name-mssql": "MS SQL Server", 395 | "label_name-metrika-api": "Metrica", 396 | "label_name-metrika-logs-api": "Metrica Logs API", 397 | "label_name-yt": "YT", 398 | "label_name-ch-over-yt": "CH over YT", 399 | "label_name-oracle": "Oracle Database", 400 | "label_not-found-connections": "Ничего не нашлось", 401 | "label_connections-not-exist": "У вас пока нет ни одного подключения", 402 | "label_db-name": "Имя базы данных", 403 | "label_connections-empty-msg": "У вас пока нет ни одного подключения", 404 | "label_connection-loading": "Загрузка", 405 | "label_no-csv-preview": "Данные для предпросмотра отсутствуют", 406 | "label_loading-csv-preview": "Загрузка данных для предпросмотра", 407 | "label_loading-csv-connection": "Загрузка", 408 | "label_selected-file": "Выбранный файл", 409 | "label_size": "размер", 410 | "label_byte": "байт", 411 | "label_error-msg": "Ошибка: не удалось загрузить список подключений", 412 | "label_connection-csv-name": "Имя подключения", 413 | "label_alias-info": "Указывается название запущенной клики, подробнее в", 414 | "label_documentation": "документации", 415 | "label_csv-type-not-supported": "Данный тип данных не поддерживается для загрузки", 416 | "label_max-size-is-over": "Увы, этот файл превышает допустимый лимит размера в CSV-файла в 100 Мб. Попробуйте загрузить другой файл", 417 | "label_section-caption-counter-settings": "Настройки счетчика", 418 | "label_section-unloading-settings": "Параметры выгрузки", 419 | "label_section-destination-db-credentials": "Целевая база данных", 420 | "label_datepicker-hint": "Дата начальной загрузки задается один раз при создании подключения", 421 | "toast_error-action-label": "Подробнее", 422 | "toast_create-connection-error": "Ошибка: не удалось создать подключение", 423 | "toast_create-dataset-error": "Ошибка: не удалось создать датасет", 424 | "toast_modify-connection-error": "Ошибка: не удалось сохранить подключение", 425 | "toast_verify-error": "Ошибка: не удалось проверить параметры подключения", 426 | "toast_upload-csv-error": "Ошибка: не удалось загрузить csv-файл", 427 | "toast_save-csv-error": "Ошибка: не удалось сохранить csv-файл", 428 | "toast_default-error": "Ошибка" 429 | }, 430 | "dataset.dataset-editor.modify": { 431 | "value_boolean": "Логический", 432 | "value_date": "Дата", 433 | "value_datetime": "Дата и время", 434 | "value_float": "Дробное число", 435 | "value_integer": "Целое число", 436 | "value_string": "Строка", 437 | "value_auto": "Авто", 438 | "value_none": "Нет", 439 | "value_count": "Количество", 440 | "value_countunique": "Количество уникальных", 441 | "value_max": "Максимум", 442 | "value_min": "Минимум", 443 | "value_avg": "Среднее", 444 | "value_sum": "Сумма", 445 | "field_display-rows": "Количество строк:", 446 | "field_find-field": "Имя поля", 447 | "field_display-hidden-fields": "Отображать скрытые поля", 448 | "button_preview": "Предпросмотр", 449 | "button_preview-full": "На весь экран", 450 | "button_preview-bottom": "Развернуть снизу", 451 | "button_preview-right": "Развернуть справа", 452 | "button_preview-close": "Закрыть", 453 | "button_enter-amount-rows": "", 454 | "button_add-field": "Добавить поле", 455 | "button_create-widget": "Создать чарт", 456 | "button_save": "Сохранить", 457 | "button_remove": "Удалить", 458 | "button_duplicate": "Продублировать", 459 | "button_edit": "Редактировать", 460 | "button_open-field-editor": "Редактировать", 461 | "button_hide-field": "Скрыть поле", 462 | "button_display-field": "Показать поле", 463 | "button_ask-access-rights": "Запросить права доступа на датасет", 464 | "button_apply": "Применить", 465 | "button_data": "Данные", 466 | "column_title": "Имя", 467 | "column_cast": "Тип", 468 | "column_aggregation": "Агрегация", 469 | "column_description": "Описание", 470 | "column_field-name": "Имя", 471 | "column_field-cast": "Тип", 472 | "column_field-aggregation": "Агрегация", 473 | "column_filed-description": "Описание", 474 | "section_preview": "Предпросмотр", 475 | "label_max-amount-rows": "не более 10 000", 476 | "label_field": "Поле", 477 | "label_loading-dataset": "Загрузка датасета", 478 | "label_no-data": "Нет данных", 479 | "label_loading-dataset-preview": "Загрузка данных для предпросмотра", 480 | "label_materialization-preview": "Загрузка данных для предпросмотра", 481 | "label_request-dataset-preview-error": "Ошибка: не удалось загрузить данные для предпросмотра", 482 | "label_preview-not-supported": "Для данного типа источника предпросмотр не доступен", 483 | "label_error-500-title": "Ошибка: не удалось загрузить датасет", 484 | "label_error-500-description": "", 485 | "label_error-404-title": "Ошибка: датасет не найден", 486 | "label_error-403-title": "У вас нет доступа к датасету или к его подключению", 487 | "label_error-400-title": "Ошибка: некорректный запрос к датасету", 488 | "label_error-400-description": "", 489 | "label_error-400-no-connection-title": "Ошибка: отсутствует подключение" 490 | }, 491 | "dataset.materialization.modify": { 492 | "field_repeat-until": "Повторять до", 493 | "field_remove-materialization": "Удалить материализацию", 494 | "field_every": "Каждый", 495 | "field_every-2": "Каждая", 496 | "field_each": "Каждого", 497 | "field_time": "Время", 498 | "field_monday-short": "Пн", 499 | "field_tuesday-short": "Вт", 500 | "field_wednesday-short": "Ср", 501 | "field_thursday-short": "Чт", 502 | "field_friday-short": "Пт", 503 | "field_saturday-short": "Сб", 504 | "field_sunday-short": "Вс", 505 | "field_materialization": "Периодичность", 506 | "button_repeat-action": "Повторить", 507 | "button_save": "Сохранить", 508 | "button_cancel": "Отменить", 509 | "button_close": "Закрыть", 510 | "button_load-now": "Загрузить сейчас", 511 | "value_direct-access": "Прямой доступ", 512 | "value_single-full-load": "Единовременная материализация", 513 | "value_periodical-full-load": "Периодическая материализация", 514 | "value_open-connection": "Перейти к подключению", 515 | "value_update-dataset-schema": "Обновить схему", 516 | "value_replace-source": "Заменить источник", 517 | "value_day": "День", 518 | "value_month": "Месяц", 519 | "value_week": "Неделя", 520 | "value_monday": "Понедельник", 521 | "value_tuesday": "Вторник", 522 | "value_wednesday": "Среда", 523 | "value_thursday": "Четверг", 524 | "value_friday": "Пятница", 525 | "value_saturday": "Субббота", 526 | "value_sunday": "Воскресенье", 527 | "section_materialization": "Материализация", 528 | "section_data": "Данные", 529 | "label_next-materialization": "Следующая загрузка", 530 | "label_not-materialized-yet": "Данные в этом датасете еще не загружались. Для загрузки нажмите Сохранить или Загрузить сейчас", 531 | "label_volume": "Объем", 532 | "label_month": "Месяц", 533 | "label_last-materialization": "Обновлено {{date}} в {{time}}", 534 | "label_materialization-failed": "{{date}} в {{time}}", 535 | "label_status": "Статус", 536 | "label_daily": "День", 537 | "label_monthly": "Месяц", 538 | "label_weekly": "Неделя", 539 | "label_occupied": "занято", 540 | "label_initializing": "Инициализация", 541 | "label_copying": "Загрузка данных", 542 | "label_saving-meta": "Сохранение", 543 | "label_loading": "Загрузка данных", 544 | "label_done": "Данные загружены", 545 | "label_failed": "Ошибка", 546 | "label_data-source-fetch-data-failed": "Ошибка: не удалось загрузить данные" 547 | }, 548 | "dataset.notifications.view": { 549 | "toast_create-dataset-msgs-success": "Датасет создан", 550 | "toast_create-dataset-msgs-failure": "Ошибка: не удалось создать датасет", 551 | "toast_field-duplicated-msgs-success": "Поле продублировано", 552 | "toast_field-duplicated-msgs-failure": "", 553 | "toast_field-remove-msgs-success": "Поле удалено", 554 | "toast_field-remove-msgs-failure": "", 555 | "toast_dataset-save-msgs-success": "Датасет сохранен", 556 | "toast_dataset-save-msgs-failure": "Ошибка: не удалось сохранить датасет", 557 | "toast_dataset-fetch-preview-msgs-success": "", 558 | "toast_dataset-fetch-preview-msgs-failure": "Ошибка: не удалось загрузить данные для предпросмотра", 559 | "toast_dataset-validation-msgs-success": "", 560 | "toast_dataset-validation-msgs-failure": "Ошибка: датасет не прошел валидацию", 561 | "toast_preview-not-supported": "Для данного типа источника предпросмотр не доступен", 562 | "toast_fetch-types-msgs-success": "", 563 | "toast_fetch-types-msgs-failure": "Ошибка: не удалось загрузить типы данных", 564 | "toast_fetch-materialization-status-failure": "Ошибка: не удалось получить статус материализации", 565 | "toast_save-data-failure": "Ошибка: не удалось сохранить", 566 | "toast_materialize-dataset-failure": "Ошибка: не удалось произвести материализацию", 567 | "toast_delete-materialization-dataset-failure": "Ошибка: не удалось удалить материализацию", 568 | "toast_click-connection-more-menu-item-failure": "Ошибка: не удалось выполнить запрос", 569 | "toast_update-dataset-schema-failure": "Ошибка: не удалось обновить схему", 570 | "toast_replace-source-failure": "Ошибка: не удалось заменить источник", 571 | "toast_default-error": "Ошибка", 572 | "toast_error-action-label": "Подробнее" 573 | }, 574 | "dash.connections-dialog.edit": { 575 | "label_connections": "Связи", 576 | "label_no-information": "Нет информации", 577 | "label_alias": "Алиас", 578 | "label_alias-influence": "Связанные виджеты", 579 | "label_manage-alias": "Настройка алиаса", 580 | "context_choose-element": "Выберите элемент", 581 | "value_connected": "Связь", 582 | "value_input": "Вх. связь", 583 | "value_output": "Исх. связь", 584 | "value_ignore": "Игнор", 585 | "value_none": "Нет связи", 586 | "button_save": "Сохранить", 587 | "button_add": "Добавить", 588 | "button_cancel": "Отменить", 589 | "button_remove-alias": "Удалить" 590 | }, 591 | "dash.text-dialog.edit": { 592 | "label_text": "Текст", 593 | "context_fill-text": "Введите текст", 594 | "toast_required-field": "Поле должно быть заполнено", 595 | "button_add": "Добавить", 596 | "button_save": "Сохранить", 597 | "button_cancel": "Отменить" 598 | }, 599 | "dash.title-dialog.edit": { 600 | "label_title": "Заголовок", 601 | "value_default": "Заголовок", 602 | "field_show-in-toc": "Отображать в оглавлении", 603 | "context_fill-title": "Введите заголовок", 604 | "toast_required-field": "Поле должно быть заполнено", 605 | "button_add": "Добавить", 606 | "button_save": "Сохранить", 607 | "button_cancel": "Отменить" 608 | }, 609 | "dash.tabs-dialog.edit": { 610 | "label_tabs": "Вкладки", 611 | "value_default": "Вкладка {{index}}", 612 | "button_add-tab": "Добавить", 613 | "button_save": "Сохранить", 614 | "button_cancel": "Отменить" 615 | }, 616 | "dash.widget-dialog.edit": { 617 | "label_widget": "Чарт", 618 | "label_new-param": "Новый параметр", 619 | "label_empty-list": "Список пуст", 620 | "value_title-default": "Заголовок {{index}}", 621 | "field_title": "Заголовок", 622 | "field_widget": "Чарт", 623 | "field_description": "Описание", 624 | "field_params": "Параметры", 625 | "field_param-name": "Имя", 626 | "field_param-value": "Значение", 627 | "context_fill-title": "Введите заголовок", 628 | "context_fill-description": "Введите описание", 629 | "toast_required-field": "Поле должно быть заполнено", 630 | "button_add": "Добавить", 631 | "button_save": "Сохранить", 632 | "button_cancel": "Отменить", 633 | "button_add-param": "Добавить параметр" 634 | }, 635 | "dash.control-dialog.edit": { 636 | "label_control": "Селектор", 637 | "label_acceptable-values": "Возможные значения", 638 | "label_default-value": "Значение по умолчанию", 639 | "label_params": "Параметры", 640 | "label_empty-list": "Список пуст", 641 | "value_source-dataset": "На основе датасета", 642 | "value_source-manual": "Ручной ввод", 643 | "value_source-external": "Внешний селектор", 644 | "value_date-no-limits": "Не ограничено", 645 | "value_date-accepted-start": "Начало допустимого интервала", 646 | "value_date-accepted-end": "Конец допустимого интервала", 647 | "value_date-accepted-all": "Весь допустимый интервал", 648 | "value_date-relative": "Дата относительно текущего дня", 649 | "value_date-manual": "Указать дату", 650 | "value_date-days-ago": "Дней назад: {{value}}", 651 | "value_date-days-ago-from-to": "Начало, дней назад: {{from}}. Конец, дней назад: {{to}}", 652 | "value_undefined": "Не определено", 653 | "value_not-chosen": "Не выбрано", 654 | "value_select-values": "Значений: {{count}}", 655 | "value_element-select": "Список", 656 | "value_element-date": "Календарь", 657 | "value_element-input": "Поле ввода", 658 | "field_dataset": "Датасет", 659 | "field_control": "Селектор", 660 | "field_field": "Поле", 661 | "field_field-name": "Имя поля", 662 | "field_element-type": "Тип элемента", 663 | "field_date-from": "Начало", 664 | "field_date-to": "Конец", 665 | "field_date-range": "Диапазон", 666 | "field_acceptable-values": "Возможные значения", 667 | "field_default-value": "Значение по умолчанию", 668 | "field_show-title": "Показывать", 669 | "field_multiselectable": "Множественный выбор", 670 | "context_title": "Заголовок", 671 | "context_add-value": "Добавить значение", 672 | "context_choose": "Выбрать", 673 | "context_date-days-ago": "Дней назад", 674 | "context_date-from-days-ago": "Начало, дней назад", 675 | "context_date-to-days-ago": "Конец, дней назад", 676 | "toast_required-fields": "Следующие поля должны быть заполнены: Заголовок, Поле/Имя поля", 677 | "button_add": "Добавить", 678 | "button_save": "Сохранить", 679 | "button_apply": "Применить", 680 | "button_cancel": "Отменить", 681 | "button_setup": "Настроить", 682 | "button_add-key": "Добавить ключ", 683 | "button_add-value": "Добавить значение" 684 | }, 685 | "dash.navigation-input.edit": { 686 | "context_fill-link": "Ссылка на чарт", 687 | "toast_incorrect-url": "Некорректный URL", 688 | "toast_error": "Ошибка", 689 | "button_choose": "Выбрать", 690 | "button_use-link": "Указать ссылку" 691 | }, 692 | "dash.table-of-content.view": { 693 | "label_table-of-content": "Оглавление", 694 | "context_table-of-content": "Оглавление" 695 | }, 696 | "dash.error.view": { 697 | "label_error": "Ошибка", 698 | "button_retry": "Повторить" 699 | }, 700 | "dash.main.view": { 701 | "label_loading": "Загрузка", 702 | "label_updating": "Обновление", 703 | "toast_unsaved": "На странице есть несохраненные изменения. Вы уверены?" 704 | }, 705 | "dash.header.view": { 706 | "value_title": "Заголовок", 707 | "value_text": "Текст", 708 | "value_widget": "Чарт", 709 | "value_control": "Селектор", 710 | "toast_error": "Произошла ошибка", 711 | "button_edit": "Редактировать", 712 | "button_save": "Сохранить", 713 | "button_cancel": "Отменить", 714 | "button_request-rights": "Запросить права", 715 | "button_add": "Добавить", 716 | "button_tabs": "Вкладки", 717 | "button_connections": "Связи" 718 | }, 719 | "dash.chartkit-menu.view": { 720 | "button_new-tab": "Открыть в новой вкладке", 721 | "button_edit": "Редактировать" 722 | }, 723 | "dash.dashkit-plugin-control.view": { 724 | "value_undefined": "Не определено" 725 | } 726 | } 727 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('ts-jest/dist/types').InitialOptionsTsJest} */ 2 | module.exports = { 3 | preset: 'ts-jest', 4 | testEnvironment: 'node', 5 | setupFiles: ['/test-utils/setup-tests.ts'], 6 | }; -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@gravity-ui/i18n", 3 | "description": "i18n library for Gravity UI services", 4 | "version": "1.8.0", 5 | "license": "MIT", 6 | "main": "build/cjs/index.js", 7 | "module": "build/esm/index.js", 8 | "types": "build/esm/index.d.ts", 9 | "repository": "git@github.com:gravity-ui/i18n", 10 | "scripts": { 11 | "build": "npm run build:clean && npm run build:compile", 12 | "build:compile": "tsc -p tsconfig.json && tsc -p tsconfig.cjs.json", 13 | "build:clean": "rm -rf build", 14 | "lint": "eslint src/*", 15 | "prepublish": "npm run build", 16 | "test": "jest", 17 | "typecheck": "tsc --noEmit" 18 | }, 19 | "files": [ 20 | "example", 21 | "build" 22 | ], 23 | "publishConfig": { 24 | "access": "public" 25 | }, 26 | "devDependencies": { 27 | "@commitlint/cli": "18.2.0", 28 | "@commitlint/config-conventional": "18.1.0", 29 | "@gravity-ui/eslint-config": "^3.1.1", 30 | "@gravity-ui/prettier-config": "^1.1.0", 31 | "@gravity-ui/tsconfig": "^1.0.0", 32 | "@types/jest": "29.5.8", 33 | "eslint": "8.53.0", 34 | "jest": "29.7.0", 35 | "prettier": "^3.4.2", 36 | "ts-jest": "29.1.1", 37 | "typescript": "5.2.2" 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /src/consts.ts: -------------------------------------------------------------------------------- 1 | export const KEYSET_SEPARATOR = '::'; 2 | 3 | export const MAX_NESTING_DEPTH = 1 4 | export const getNestingTranslationsRegExp = () => new RegExp(/\$t{([^}]+)}/g) -------------------------------------------------------------------------------- /src/index.spec.ts: -------------------------------------------------------------------------------- 1 | import {I18N} from './index'; 2 | 3 | let i18n: I18N; 4 | 5 | beforeEach(() => { 6 | i18n = new I18N(); 7 | }); 8 | 9 | describe('has', () => { 10 | it('should return false w/out any data available', () => { 11 | expect(i18n.has('notification', 'title')).toBe(false); 12 | }); 13 | 14 | it('should return false when keyset is missing', () => { 15 | i18n.setLang('ru'); 16 | i18n.registerKeyset('ru', 'notification', { 17 | title: 'New version', 18 | }); 19 | 20 | expect(i18n.has('button', 'label')).toBe(false); 21 | }); 22 | 23 | it('should return false when key is missing', () => { 24 | i18n.setLang('ru'); 25 | i18n.registerKeyset('ru', 'notification', { 26 | title: 'New version', 27 | }); 28 | 29 | expect(i18n.has('notification', 'label')).toBe(false); 30 | }); 31 | 32 | it('should return true when key exist', () => { 33 | i18n.setLang('ru'); 34 | i18n.registerKeyset('ru', 'notification', { 35 | title: 'New version', 36 | }); 37 | 38 | expect(i18n.has('notification', 'title')).toBe(true); 39 | }); 40 | }); 41 | 42 | describe('i18n', () => { 43 | it('should return key when translation missing', () => { 44 | i18n.setLang('ru'); 45 | i18n.registerKeyset('ru', 'notification', {}); 46 | expect(i18n.i18n('notification', 'title')).toBe('title'); 47 | }); 48 | 49 | it('should return key when keyset missing', () => { 50 | i18n.setLang('ru'); 51 | i18n.registerKeyset('ru', 'notification', { 52 | title: 'New version', 53 | }); 54 | expect(i18n.i18n('button', 'title')).toBe('title'); 55 | }); 56 | 57 | it('should return key when keyset is empty', () => { 58 | i18n.setLang('ru'); 59 | i18n.registerKeyset('ru', 'notification', {}); 60 | expect(i18n.i18n('notification', 'title')).toBe('title'); 61 | }); 62 | 63 | it('should return key when key missing', () => { 64 | i18n.setLang('ru'); 65 | i18n.registerKeyset('ru', 'notification', { 66 | title: 'New version', 67 | }); 68 | expect(i18n.i18n('notification', 'label')).toBe('label'); 69 | }); 70 | 71 | it('should return translation', () => { 72 | i18n.setLang('ru'); 73 | i18n.registerKeyset('ru', 'notification', { 74 | title: 'New version', 75 | }); 76 | expect(i18n.i18n('notification', 'title')).toBe('New version'); 77 | }); 78 | 79 | it('should return key when plural translation missing "count" param', () => { 80 | i18n.setLang('ru'); 81 | i18n.registerKeyset('ru', 'notification', { 82 | title: ['New version', 'New versions', 'New versions'], 83 | }); 84 | expect(i18n.i18n('notification', 'title', {})).toBe('title'); 85 | }); 86 | 87 | it('should return correct pluralization', () => { 88 | i18n.setLang('ru'); 89 | i18n.registerKeyset('ru', 'notification', { 90 | title: [ 91 | 'New version of {{project}}', 92 | 'New versions of {{project}}', 93 | 'New versions of {{project}}', 94 | 'Is up to date', 95 | ], 96 | }); 97 | const project = 'Cloud'; 98 | 99 | expect( 100 | i18n.i18n('notification', 'title', { 101 | count: 1, 102 | project, 103 | }), 104 | ).toBe('New version of Cloud'); 105 | expect( 106 | i18n.i18n('notification', 'title', { 107 | count: 2, 108 | project, 109 | }), 110 | ).toBe('New versions of Cloud'); 111 | expect( 112 | i18n.i18n('notification', 'title', { 113 | count: 10, 114 | project, 115 | }), 116 | ).toBe('New versions of Cloud'); 117 | expect( 118 | i18n.i18n('notification', 'title', { 119 | count: 0, 120 | project, 121 | }), 122 | ).toBe('Is up to date'); 123 | }); 124 | 125 | it('should interpolate params', () => { 126 | i18n.setLang('ru'); 127 | i18n.registerKeyset('ru', 'notification', { 128 | title: 'Hello, {{username}}!', 129 | }); 130 | 131 | expect( 132 | i18n.i18n('notification', 'title', { 133 | username: 'Joe', 134 | }), 135 | ).toBe('Hello, Joe!'); 136 | }); 137 | 138 | it('should accept dollar-sign in params', () => { 139 | i18n.setLang('ru'); 140 | i18n.registerKeyset('ru', 'notification', { 141 | title: 'Give me {{money}}!', 142 | }); 143 | 144 | expect( 145 | i18n.i18n('notification', 'title', { 146 | money: 'money $ honey', 147 | }), 148 | ).toBe('Give me money $ honey!'); 149 | }); 150 | 151 | it('should return second plural form with count 0 and missing translation', () => { 152 | i18n.setLang('ru'); 153 | i18n.registerKeyset('ru', 'app', { 154 | users: ['{{count}} пользователь', '{{count}} пользователя', '{{count}} пользователей'], 155 | }); 156 | 157 | expect( 158 | i18n.i18n('app', 'users', { 159 | count: 0, 160 | }), 161 | ).toBe('0 пользователей'); 162 | }); 163 | 164 | it('should use pluralization ruleset for the current language', () => { 165 | i18n.registerKeyset('ru', 'cats', { 166 | count: ['{{count}} котик', '{{count}} котика', '{{count}} котиков', 'Нет котиков'], 167 | }); 168 | i18n.registerKeyset('en', 'cats', { 169 | count: ['{{count}} kitty', '', '{{count}} kitties', 'No kitties'], 170 | }); 171 | 172 | i18n.setLang('ru'); 173 | 174 | expect(i18n.i18n('cats', 'count', {count: 0})).toBe('Нет котиков'); 175 | expect(i18n.i18n('cats', 'count', {count: 1})).toBe('1 котик'); 176 | expect(i18n.i18n('cats', 'count', {count: 2})).toBe('2 котика'); 177 | expect(i18n.i18n('cats', 'count', {count: 5})).toBe('5 котиков'); 178 | expect(i18n.i18n('cats', 'count', {count: 10})).toBe('10 котиков'); 179 | expect(i18n.i18n('cats', 'count', {count: 11})).toBe('11 котиков'); 180 | expect(i18n.i18n('cats', 'count', {count: 12})).toBe('12 котиков'); 181 | expect(i18n.i18n('cats', 'count', {count: 21})).toBe('21 котик'); 182 | 183 | i18n.setLang('en'); 184 | 185 | expect(i18n.i18n('cats', 'count', {count: 0})).toBe('No kitties'); 186 | expect(i18n.i18n('cats', 'count', {count: 1})).toBe('1 kitty'); 187 | expect(i18n.i18n('cats', 'count', {count: 2})).toBe('2 kitties'); 188 | expect(i18n.i18n('cats', 'count', {count: 5})).toBe('5 kitties'); 189 | expect(i18n.i18n('cats', 'count', {count: 10})).toBe('10 kitties'); 190 | expect(i18n.i18n('cats', 'count', {count: 11})).toBe('11 kitties'); 191 | expect(i18n.i18n('cats', 'count', {count: 12})).toBe('12 kitties'); 192 | expect(i18n.i18n('cats', 'count', {count: 21})).toBe('21 kitties'); 193 | }); 194 | 195 | it('should allow custom pluralizations', () => { 196 | i18n.setLang('nonexistent'); 197 | i18n.configurePluralization({ 198 | nonexistent: (count, pluralForms) => { 199 | if (count === 10) { 200 | return pluralForms.One; 201 | } 202 | if (count === 20) { 203 | return pluralForms.Few; 204 | } 205 | if (count === 30) { 206 | return pluralForms.Many; 207 | } 208 | return pluralForms.None; 209 | }, 210 | }); 211 | i18n.registerKeyset('nonexistent', 'app', { 212 | title: ['one', 'few', 'many', 'none'], 213 | }); 214 | 215 | expect(i18n.i18n('app', 'title', {count: 10})).toBe('one'); 216 | expect(i18n.i18n('app', 'title', {count: 20})).toBe('few'); 217 | expect(i18n.i18n('app', 'title', {count: 30})).toBe('many'); 218 | expect(i18n.i18n('app', 'title', {count: 100})).toBe('none'); 219 | }); 220 | 221 | it('should allow overriding existing pluralizations', () => { 222 | i18n.setLang('en'); 223 | i18n.configurePluralization({ 224 | en: (count, pluralForms) => { 225 | if (count === 10) { 226 | return pluralForms.One; 227 | } 228 | if (count === 20) { 229 | return pluralForms.Few; 230 | } 231 | if (count === 30) { 232 | return pluralForms.Many; 233 | } 234 | return pluralForms.None; 235 | }, 236 | }); 237 | i18n.registerKeyset('en', 'app', { 238 | title: ['one', 'few', 'many', 'none'], 239 | }); 240 | 241 | expect(i18n.i18n('app', 'title', {count: 10})).toBe('one'); 242 | expect(i18n.i18n('app', 'title', {count: 20})).toBe('few'); 243 | expect(i18n.i18n('app', 'title', {count: 30})).toBe('many'); 244 | expect(i18n.i18n('app', 'title', {count: 100})).toBe('none'); 245 | }); 246 | 247 | it('should fallback to english pluralization for unconfigured languages', () => { 248 | i18n.setLang('nonexistent'); 249 | i18n.registerKeyset('nonexistent', 'app', { 250 | title: ['one', 'few', 'many', 'none'], 251 | }); 252 | 253 | expect(i18n.i18n('app', 'title', {count: 0})).toBe('none'); 254 | expect(i18n.i18n('app', 'title', {count: 1})).toBe('one'); 255 | expect(i18n.i18n('app', 'title', {count: 2})).toBe('many'); 256 | expect(i18n.i18n('app', 'title', {count: 5})).toBe('many'); 257 | expect(i18n.i18n('app', 'title', {count: 11})).toBe('many'); 258 | expect(i18n.i18n('app', 'title', {count: 12})).toBe('many'); 259 | expect(i18n.i18n('app', 'title', {count: 21})).toBe('many'); 260 | }); 261 | 262 | it('should use the same pluralization rules for both positive and negative numbers in russian', () => { 263 | i18n.setLang('ru'); 264 | i18n.registerKeyset('ru', 'scoreboard', { 265 | points: ['одно очко', 'два очка', 'пять очков', 'ноль очков'], 266 | }); 267 | 268 | const positive = [ 269 | i18n.i18n('scoreboard', 'points', {count: 1}), 270 | i18n.i18n('scoreboard', 'points', {count: 2}), 271 | i18n.i18n('scoreboard', 'points', {count: 5}), 272 | i18n.i18n('scoreboard', 'points', {count: 11}), 273 | i18n.i18n('scoreboard', 'points', {count: 12}), 274 | i18n.i18n('scoreboard', 'points', {count: 21}), 275 | ]; 276 | 277 | const negative = [ 278 | i18n.i18n('scoreboard', 'points', {count: -1}), 279 | i18n.i18n('scoreboard', 'points', {count: -2}), 280 | i18n.i18n('scoreboard', 'points', {count: -5}), 281 | i18n.i18n('scoreboard', 'points', {count: -11}), 282 | i18n.i18n('scoreboard', 'points', {count: -12}), 283 | i18n.i18n('scoreboard', 'points', {count: -21}), 284 | ]; 285 | 286 | expect(negative[0]).toBe(positive[0]); 287 | expect(negative[1]).toBe(positive[1]); 288 | expect(negative[2]).toBe(positive[2]); 289 | expect(negative[3]).toBe(positive[3]); 290 | expect(negative[4]).toBe(positive[4]); 291 | expect(negative[5]).toBe(positive[5]); 292 | }); 293 | 294 | it('should use the same pluralization rules for both positive and negative numbers in english', () => { 295 | i18n.setLang('en'); 296 | i18n.registerKeyset('en', 'scoreboard', { 297 | points: ['one point', '', 'some points', 'no points'], 298 | }); 299 | 300 | const positive = [ 301 | i18n.i18n('scoreboard', 'points', {count: 1}), 302 | i18n.i18n('scoreboard', 'points', {count: 2}), 303 | ]; 304 | 305 | const negative = [ 306 | i18n.i18n('scoreboard', 'points', {count: -1}), 307 | i18n.i18n('scoreboard', 'points', {count: -2}), 308 | ]; 309 | 310 | expect(negative[0]).toBe(positive[0]); 311 | expect(negative[1]).toBe(positive[1]); 312 | }); 313 | 314 | it('should warn about unconfigured pluralization', () => { 315 | const logger = {log: jest.fn()}; 316 | i18n = new I18N({logger}); 317 | 318 | i18n.setLang('fr'); 319 | i18n.registerKeyset('fr', 'app', { 320 | title: ['one', 'few', 'many', 'none'], 321 | }); 322 | 323 | const callsLength = logger.log.mock.calls.length; 324 | 325 | i18n.i18n('app', 'title', {count: 1}); 326 | 327 | expect(logger.log).toHaveBeenCalledTimes(callsLength + 1); 328 | }); 329 | 330 | it('basic checks for plurals with Intl.PluralRules', () => { 331 | i18n.setLang('ru'); 332 | i18n.registerKeyset('ru', 'app', { 333 | users: { 334 | zero: 'нет пользователей', 335 | one: '{{count}} пользователь', 336 | few: '{{count}} пользователя', 337 | many: '{{count}} пользователей', 338 | other: '', 339 | }, 340 | }); 341 | 342 | expect( 343 | i18n.i18n('app', 'users', { 344 | count: 0, 345 | }), 346 | ).toBe('нет пользователей'); 347 | 348 | expect( 349 | i18n.i18n('app', 'users', { 350 | count: 1, 351 | }), 352 | ).toBe('1 пользователь'); 353 | 354 | expect( 355 | i18n.i18n('app', 'users', { 356 | count: 2, 357 | }), 358 | ).toBe('2 пользователя'); 359 | 360 | expect( 361 | i18n.i18n('app', 'users', { 362 | count: 3, 363 | }), 364 | ).toBe('3 пользователя'); 365 | 366 | expect( 367 | i18n.i18n('app', 'users', { 368 | count: 5, 369 | }), 370 | ).toBe('5 пользователей'); 371 | 372 | expect( 373 | i18n.i18n('app', 'users', { 374 | count: 11, 375 | }), 376 | ).toBe('11 пользователей'); 377 | }); 378 | 379 | it('should use `other` form when no other forms are specified', () => { 380 | i18n.setLang('ru'); 381 | i18n.registerKeyset('ru', 'app', { 382 | users: {other: '{{count}} пользователей'}, 383 | }); 384 | 385 | expect( 386 | i18n.i18n('app', 'users', { 387 | count: 21, 388 | }), 389 | ).toBe('21 пользователей'); 390 | 391 | expect( 392 | i18n.i18n('app', 'users', { 393 | count: 0, 394 | }), 395 | ).toBe('0 пользователей'); 396 | 397 | expect( 398 | i18n.i18n('app', 'users', { 399 | count: 10, 400 | }), 401 | ).toBe('10 пользователей'); 402 | 403 | expect( 404 | i18n.i18n('app', 'users', { 405 | count: 2, 406 | }), 407 | ).toBe('2 пользователей'); 408 | 409 | expect( 410 | i18n.i18n('app', 'users', { 411 | count: 1, 412 | }), 413 | ).toBe('1 пользователей'); 414 | }); 415 | 416 | it('should use `other` form when no other forms are specified', () => { 417 | i18n.setLang('ru'); 418 | i18n.registerKeyset('ru', 'app', { 419 | users: {other: '{{count}} пользователей'}, 420 | articles: {one: '{{count}} статья', other: '{{count}} статей'}, 421 | }); 422 | 423 | expect( 424 | i18n.i18n('app', 'users', { 425 | count: 21, 426 | }), 427 | ).toBe('21 пользователей'); 428 | 429 | expect( 430 | i18n.i18n('app', 'users', { 431 | count: 0, 432 | }), 433 | ).toBe('0 пользователей'); 434 | 435 | expect( 436 | i18n.i18n('app', 'users', { 437 | count: 10, 438 | }), 439 | ).toBe('10 пользователей'); 440 | 441 | expect( 442 | i18n.i18n('app', 'users', { 443 | count: 2, 444 | }), 445 | ).toBe('2 пользователей'); 446 | 447 | expect( 448 | i18n.i18n('app', 'users', { 449 | count: 1, 450 | }), 451 | ).toBe('1 пользователей'); 452 | 453 | expect( 454 | i18n.i18n('app', 'articles', { 455 | count: 1, 456 | }), 457 | ).toBe('1 статья'); 458 | 459 | expect( 460 | i18n.i18n('app', 'articles', { 461 | count: 21, 462 | }), 463 | ).toBe('21 статья'); 464 | 465 | expect( 466 | i18n.i18n('app', 'articles', { 467 | count: 0, 468 | }), 469 | ).toBe('0 статей'); 470 | 471 | expect( 472 | i18n.i18n('app', 'articles', { 473 | count: 5, 474 | }), 475 | ).toBe('5 статей'); 476 | 477 | expect( 478 | i18n.i18n('app', 'articles', { 479 | count: 3, 480 | }), 481 | ).toBe('3 статей'); 482 | }); 483 | 484 | it('compare results between old and new plural formats', () => { 485 | i18n.setLang('ru'); 486 | i18n.registerKeyset('ru', 'app', { 487 | usersOldPlural: [ 488 | '{{count}} пользователь', 489 | '{{count}} пользователя', 490 | '{{count}} пользователей', 491 | 'нет пользователей', 492 | ], 493 | users: { 494 | zero: 'нет пользователей', 495 | one: '{{count}} пользователь', 496 | few: '{{count}} пользователя', 497 | many: '{{count}} пользователей', 498 | other: '', 499 | }, 500 | }); 501 | 502 | expect( 503 | i18n.i18n('app', 'users', { 504 | count: 0, 505 | }), 506 | ).toBe( 507 | i18n.i18n('app', 'usersOldPlural', { 508 | count: 0, 509 | }), 510 | ); 511 | 512 | expect( 513 | i18n.i18n('app', 'users', { 514 | count: 1, 515 | }), 516 | ).toBe( 517 | i18n.i18n('app', 'usersOldPlural', { 518 | count: 1, 519 | }), 520 | ); 521 | 522 | expect( 523 | i18n.i18n('app', 'users', { 524 | count: 2, 525 | }), 526 | ).toBe( 527 | i18n.i18n('app', 'usersOldPlural', { 528 | count: 2, 529 | }), 530 | ); 531 | 532 | expect( 533 | i18n.i18n('app', 'users', { 534 | count: 3, 535 | }), 536 | ).toBe( 537 | i18n.i18n('app', 'usersOldPlural', { 538 | count: 3, 539 | }), 540 | ); 541 | 542 | expect( 543 | i18n.i18n('app', 'users', { 544 | count: 5, 545 | }), 546 | ).toBe( 547 | i18n.i18n('app', 'usersOldPlural', { 548 | count: 5, 549 | }), 550 | ); 551 | 552 | expect( 553 | i18n.i18n('app', 'users', { 554 | count: 11, 555 | }), 556 | ).toBe( 557 | i18n.i18n('app', 'usersOldPlural', { 558 | count: 11, 559 | }), 560 | ); 561 | }); 562 | }); 563 | 564 | describe('constructor options', () => { 565 | describe('lang', () => { 566 | it('should return translation [set lang via options.lang]', () => { 567 | i18n = new I18N({lang: 'en'}); 568 | i18n.registerKeyset('en', 'notification', { 569 | title: 'New version', 570 | }); 571 | expect(i18n.i18n('notification', 'title')).toBe('New version'); 572 | }); 573 | 574 | it('should return translation [set lang via i18n.setLang]', () => { 575 | i18n.setLang('en'); 576 | i18n.registerKeyset('en', 'notification', { 577 | title: 'New version', 578 | }); 579 | expect(i18n.i18n('notification', 'title')).toBe('New version'); 580 | }); 581 | }); 582 | 583 | describe('data', () => { 584 | it('should return translation [set data via options.data]', () => { 585 | i18n = new I18N({lang: 'en', data: {en: {notification: {title: 'New version'}}}}); 586 | expect(i18n.i18n('notification', 'title')).toBe('New version'); 587 | }); 588 | 589 | it('should return translation [set data via i18n.registerKeyset]', () => { 590 | i18n = new I18N({lang: 'en'}); 591 | i18n.registerKeyset('en', 'notification', { 592 | title: 'New version', 593 | }); 594 | expect(i18n.i18n('notification', 'title')).toBe('New version'); 595 | }); 596 | }); 597 | 598 | describe('fallbackLang', () => { 599 | it('should return translation from default language in case of language data absence', () => { 600 | i18n = new I18N({ 601 | lang: 'sr', 602 | fallbackLang: 'en', 603 | data: {en: {notification: {title: 'New version'}}}, 604 | }); 605 | expect(i18n.i18n('notification', 'title')).toBe('New version'); 606 | }); 607 | 608 | it('should return fallback from default language in case of language data absence', () => { 609 | i18n = new I18N({lang: 'sr', fallbackLang: 'en', data: {en: {notification: {}}}}); 610 | expect(i18n.i18n('notification', 'title')).toBe('title'); 611 | }); 612 | 613 | it('should return translation from default language in case of empty keyset', () => { 614 | i18n = new I18N({ 615 | lang: 'sr', 616 | fallbackLang: 'en', 617 | data: { 618 | en: {notification: {title: 'New version'}}, 619 | sr: {notification: {}}, 620 | }, 621 | }); 622 | expect(i18n.i18n('notification', 'title')).toBe('New version'); 623 | }); 624 | 625 | it('should return translation from default language in case of missing key', () => { 626 | i18n = new I18N({ 627 | lang: 'sr', 628 | fallbackLang: 'en', 629 | data: { 630 | en: {notification: {title: 'New version'}}, 631 | sr: {notification: {hey: 'Zdravo!'}}, 632 | }, 633 | }); 634 | expect(i18n.i18n('notification', 'title')).toBe('New version'); 635 | }); 636 | 637 | it('should return fallback from default language in case of missing key', () => { 638 | i18n = new I18N({ 639 | lang: 'sr', 640 | fallbackLang: 'en', 641 | data: { 642 | en: {notification: {hey: 'Hello!'}}, 643 | sr: {notification: {hey: 'Zdravo!'}}, 644 | }, 645 | }); 646 | expect(i18n.i18n('notification', 'title')).toBe('title'); 647 | }); 648 | }); 649 | }); 650 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import {replaceParams} from './replace-params'; 2 | import { 3 | ErrorCode, 4 | getPluralValues, 5 | hasNestingTranslations, 6 | mapErrorCodeToMessage, 7 | } from './translation-helpers'; 8 | import type {ErrorCodeType} from './translation-helpers'; 9 | import {isPluralValue} from './types'; 10 | import type {KeysData, KeysetData, Logger, Params, Pluralizer} from './types'; 11 | 12 | import pluralizerEn from './plural/en'; 13 | import pluralizerRu from './plural/ru'; 14 | import {getPluralValue} from './plural/general'; 15 | import {KEYSET_SEPARATOR, MAX_NESTING_DEPTH, getNestingTranslationsRegExp} from './consts'; 16 | 17 | export * from './types'; 18 | 19 | type I18NOptions = { 20 | /** 21 | * Keysets mapped data. 22 | * @example 23 | * ``` 24 | import {I18N} from '@gravity-ui/i18n'; 25 | 26 | let i18n = new I18N({ 27 | lang: 'en', 28 | data: { 29 | en: {notification: {title: 'New version'}}, 30 | sr: {notification: {title: 'Нова верзија'}}, 31 | }, 32 | }); 33 | // Equivalent approach via public api of i18n instance 34 | i18n = new I18N(); 35 | i18n.setLang('en'); 36 | i18n.registerKeysets('en', {notification: {title: 'New version'}}); 37 | i18n.registerKeysets('sr', {notification: {title: 'Нова верзија'}}); 38 | * ``` 39 | */ 40 | data?: Record; 41 | /** 42 | * Language used as fallback in case there is no translation in the target language. 43 | * @example 44 | * ``` 45 | import {I18N} from '@gravity-ui/i18n'; 46 | 47 | const i18n = new I18N({ 48 | lang: 'sr', 49 | fallbackLang: 'en', 50 | data: { 51 | en: {notification: {title: 'New version'}}, 52 | sr: {notification: {}}, 53 | }, 54 | }); 55 | i18n.i18n('notification', 'title'); // 'New version' 56 | // Equivalent approach via public api of i18n instance 57 | i18n = new I18N(); 58 | i18n.setLang('sr'); 59 | i18n.setFallbackLang('en'); 60 | i18n.registerKeysets('en', {notification: {title: 'New version'}}); 61 | i18n.registerKeysets('sr', {notification: {}}); 62 | i18n.i18n('notification', 'title'); // 'New version' 63 | * ``` 64 | */ 65 | fallbackLang?: string; 66 | /** 67 | * Target language for the i18n instance. 68 | * @example 69 | * ``` 70 | import {I18N} from '@gravity-ui/i18n'; 71 | 72 | let i18n = new I18N({lang: 'en'}); 73 | // Equivalent approach via public api of i18n instance 74 | i18n = new I18N(); 75 | i18n.setLang('en'); 76 | * ``` 77 | */ 78 | lang?: string; 79 | logger?: Logger; 80 | }; 81 | 82 | type ErrorDetails = { 83 | details?: { 84 | code: ErrorCodeType; 85 | keysetName?: string; 86 | key?: string; 87 | }; 88 | }; 89 | 90 | type SearchTranslationData = { 91 | text?: string; 92 | } & ErrorDetails; 93 | 94 | type SearchKeysetData = { 95 | data?: KeysData; 96 | } & ErrorDetails; 97 | 98 | export class I18N { 99 | data: Record = {}; 100 | pluralizers: Record = { 101 | en: pluralizerEn, 102 | ru: pluralizerRu, 103 | }; 104 | logger: Logger | null = null; 105 | fallbackLang?: string; 106 | lang?: string; 107 | 108 | constructor(options: I18NOptions = {}) { 109 | const {data, fallbackLang, lang, logger = null} = options; 110 | this.fallbackLang = fallbackLang; 111 | this.lang = lang; 112 | this.logger = logger; 113 | 114 | if (data) { 115 | Object.entries(data).forEach(([keysetLang, keysetData]) => { 116 | this.registerKeysets(keysetLang, keysetData); 117 | }); 118 | } 119 | } 120 | 121 | setLang(lang: string) { 122 | this.lang = lang; 123 | } 124 | 125 | setFallbackLang(fallbackLang: string) { 126 | this.fallbackLang = fallbackLang; 127 | } 128 | 129 | /** 130 | * @deprecated Plurals automatically used from Intl.PluralRules. You can safely remove this call. Will be removed in v2. 131 | */ 132 | configurePluralization(pluralizers: Record) { 133 | this.pluralizers = Object.assign({}, this.pluralizers, pluralizers); 134 | } 135 | 136 | registerKeyset(lang: string, keysetName: string, data: KeysData = {}) { 137 | const isAlreadyRegistered = 138 | this.data[lang] && Object.prototype.hasOwnProperty.call(this.data[lang], keysetName); 139 | 140 | if (isAlreadyRegistered && process.env.NODE_ENV !== 'production') { 141 | this.warn(`Keyset '${keysetName}' is already registered.`); 142 | } 143 | 144 | this.data[lang] = Object.assign({}, this.data[lang], {[keysetName]: data}); 145 | } 146 | 147 | registerKeysets(lang: string, data: KeysetData) { 148 | Object.keys(data).forEach((keysetName) => { 149 | this.registerKeyset(lang, keysetName, data[keysetName]); 150 | }); 151 | } 152 | 153 | has(keysetName: string, key: string, lang?: string) { 154 | const languageData = this.getLanguageData(lang); 155 | 156 | return Boolean(languageData && languageData[keysetName] && languageData[keysetName]?.[key]); 157 | } 158 | 159 | i18n(keysetName: string, key: string, params?: Params): string { 160 | if (!this.lang && !this.fallbackLang) { 161 | throw new Error( 162 | 'Language is not specified. You should set at least one of these: "lang", "fallbackLang"', 163 | ); 164 | } 165 | 166 | let text: string | undefined; 167 | 168 | if (this.lang) { 169 | text = this._i18n(keysetName, key, this.lang, params); 170 | } else { 171 | this.warn('Target language is not specified.'); 172 | } 173 | 174 | if (text === undefined && this.fallbackLang && this.fallbackLang !== this.lang) { 175 | text = this._i18n(keysetName, key, this.fallbackLang, params); 176 | } 177 | 178 | return text ?? key; 179 | } 180 | 181 | keyset(keysetName: string) { 182 | return (key: TKey, params?: Params): string => { 183 | return this.i18n(keysetName, key, params); 184 | }; 185 | } 186 | 187 | warn(msg: string, keyset?: string, key?: string) { 188 | let cacheKey = ''; 189 | 190 | if (keyset) { 191 | cacheKey += keyset; 192 | 193 | if (key) { 194 | cacheKey += `.${key}`; 195 | } 196 | } else { 197 | cacheKey = 'languageData'; 198 | } 199 | 200 | this.logger?.log(`I18n: ${msg}`, { 201 | level: 'info', 202 | logger: cacheKey, 203 | extra: { 204 | type: 'i18n', 205 | }, 206 | }); 207 | } 208 | 209 | getLanguageData(lang?: string): KeysetData | undefined { 210 | const langCode = lang || this.lang; 211 | return langCode ? this.data[langCode] : undefined; 212 | } 213 | 214 | private _i18n(keysetName: string, key: string, lang: string, params?: Params) { 215 | const {text, details} = new I18NTranslation( 216 | this, 217 | lang, 218 | key, 219 | keysetName, 220 | params, 221 | ).getTranslationData(); 222 | 223 | if (details) { 224 | const message = mapErrorCodeToMessage({ 225 | code: details.code, 226 | lang, 227 | fallbackLang: this.fallbackLang === lang ? undefined : this.fallbackLang, 228 | }); 229 | this.warn(message, details.keysetName, details.key); 230 | } 231 | 232 | return text; 233 | } 234 | } 235 | 236 | class I18NTranslation { 237 | private i18n: I18N; 238 | private lang: string; 239 | private key: string; 240 | private keysetName: string; 241 | private params?: Params; 242 | private nestingDepth: number; 243 | 244 | constructor( 245 | i18n: I18N, 246 | lang: string, 247 | key: string, 248 | keysetName: string, 249 | params?: Params, 250 | nestingDepth?: number, 251 | ) { 252 | this.i18n = i18n; 253 | this.lang = lang; 254 | this.key = key; 255 | this.keysetName = keysetName; 256 | this.params = params; 257 | this.nestingDepth = nestingDepth ?? 0; 258 | } 259 | 260 | getTranslationData(): SearchTranslationData { 261 | const {data: keyset, details} = this.getKeyset(); 262 | 263 | if (details) { 264 | return {details}; 265 | } 266 | 267 | const keyValue = keyset && keyset[this.key]; 268 | const result: SearchTranslationData = {}; 269 | 270 | if (keyValue === undefined) { 271 | return this.getTranslationDataError(ErrorCode.MissingKey); 272 | } 273 | 274 | if (isPluralValue(keyValue)) { 275 | // Limit nesting plural due to the difficulties of translations inlining 276 | const isNested = this.nestingDepth > 0; 277 | const isPluralValueHasNestingTranslations = getPluralValues(keyValue).some((kv) => 278 | hasNestingTranslations(kv), 279 | ); 280 | if (isNested || isPluralValueHasNestingTranslations) { 281 | return this.getTranslationDataError(ErrorCode.NestedPlural); 282 | } 283 | 284 | const count = Number(this.params?.count); 285 | 286 | if (Number.isNaN(count)) { 287 | return this.getTranslationDataError(ErrorCode.MissingKeyParamsCount); 288 | } 289 | 290 | result.text = getPluralValue({ 291 | key: this.key, 292 | value: keyValue, 293 | count, 294 | lang: this.lang || 'en', 295 | pluralizers: this.i18n.pluralizers, 296 | log: (message) => this.i18n.warn(message, this.keysetName, this.key), 297 | }); 298 | } else { 299 | result.text = String(keyValue); 300 | } 301 | 302 | if (this.params) { 303 | result.text = replaceParams(String(result.text), this.params); 304 | } 305 | 306 | const replaceTranslationsInheritanceResult = this.replaceTranslationsInheritance({ 307 | keyValue: String(result.text), 308 | }); 309 | if (!replaceTranslationsInheritanceResult.text) { 310 | return replaceTranslationsInheritanceResult; 311 | } 312 | result.text = replaceTranslationsInheritanceResult.text; 313 | 314 | return result; 315 | } 316 | 317 | private getTranslationDataError(errorCode: ErrorCode): SearchTranslationData { 318 | return {details: {code: errorCode, keysetName: this.keysetName, key: this.key}}; 319 | } 320 | 321 | private getKeyset(): SearchKeysetData { 322 | const languageData = this.i18n.getLanguageData(this.lang); 323 | 324 | if (typeof languageData === 'undefined') { 325 | return this.getTranslationDataError(ErrorCode.NoLanguageData); 326 | } 327 | 328 | if (Object.keys(languageData).length === 0) { 329 | return this.getTranslationDataError(ErrorCode.EmptyLanguageData); 330 | } 331 | 332 | const keyset = languageData[this.keysetName]; 333 | 334 | if (!keyset) { 335 | return this.getTranslationDataError(ErrorCode.KeysetNotFound); 336 | } 337 | 338 | if (Object.keys(keyset).length === 0) { 339 | return this.getTranslationDataError(ErrorCode.EmptyKeyset); 340 | } 341 | 342 | return {data: keyset}; 343 | } 344 | 345 | private replaceTranslationsInheritance(args: {keyValue: string}): SearchTranslationData { 346 | const {keyValue} = args; 347 | const NESTING_PREGEXP = getNestingTranslationsRegExp(); 348 | let result = ''; 349 | 350 | let lastIndex = (NESTING_PREGEXP.lastIndex = 0); 351 | let match; 352 | while ((match = NESTING_PREGEXP.exec(keyValue))) { 353 | if (lastIndex !== match.index) { 354 | result += keyValue.slice(lastIndex, match.index); 355 | } 356 | lastIndex = NESTING_PREGEXP.lastIndex; 357 | 358 | const [all, key] = match; 359 | if (key) { 360 | if (this.nestingDepth + 1 > MAX_NESTING_DEPTH) { 361 | return this.getTranslationDataError(ErrorCode.ExceedTranslationNestingDepth); 362 | } 363 | 364 | let [inheritedKey, inheritedKeysetName]: [string, string | undefined] = [ 365 | key, 366 | undefined, 367 | ]; 368 | 369 | const parts = key.split(KEYSET_SEPARATOR); 370 | if (parts.length > 1) { 371 | [inheritedKeysetName, inheritedKey] = [parts[0], parts[1]!]; 372 | } 373 | 374 | if (!inheritedKey) { 375 | return this.getTranslationDataError(ErrorCode.MissingInheritedKey); 376 | } 377 | 378 | // Not support nested params 379 | const data = new I18NTranslation( 380 | this.i18n, 381 | this.lang, 382 | inheritedKey, 383 | inheritedKeysetName ?? this.keysetName, 384 | undefined, 385 | this.nestingDepth + 1, 386 | ).getTranslationData(); 387 | 388 | if (data.details) { 389 | return this.getTranslationDataError(ErrorCode.MissingInheritedKey); 390 | } 391 | result += data.text; 392 | } else { 393 | result += all; 394 | } 395 | } 396 | if (lastIndex < keyValue.length) { 397 | result += keyValue.slice(lastIndex); 398 | } 399 | 400 | return {text: result}; 401 | } 402 | } 403 | -------------------------------------------------------------------------------- /src/plural/en.ts: -------------------------------------------------------------------------------- 1 | import type {PluralForm} from '../types'; 2 | 3 | export default function(count: number, pluralForms: typeof PluralForm): PluralForm { 4 | if (count === 0) { 5 | return pluralForms.None; 6 | } 7 | 8 | if (count === 1 || count === -1) { 9 | return pluralForms.One; 10 | } 11 | 12 | return pluralForms.Many; 13 | } 14 | -------------------------------------------------------------------------------- /src/plural/general.ts: -------------------------------------------------------------------------------- 1 | import type { DeprecatedPluralValue, PluralValue, Pluralizer } from "../types"; 2 | import {PluralForm} from '../types'; 3 | 4 | export function getPluralViaIntl(value: PluralValue, count: number, lang: string) { 5 | if (value.zero && count === 0) { 6 | return value.zero; 7 | } 8 | 9 | if (!Intl.PluralRules) { 10 | throw new Error('Intl.PluralRules is not available. Use polyfill.'); 11 | } 12 | 13 | const pluralRules = new Intl.PluralRules(lang); 14 | 15 | const form = pluralRules.select(count); 16 | 17 | if (form === 'other' && typeof value.other === 'undefined') { 18 | return value.many || value.few; 19 | } 20 | 21 | return value[form] || value.other; 22 | } 23 | 24 | type FormatPluralArgs = { 25 | key: string; 26 | value: DeprecatedPluralValue | PluralValue; 27 | fallbackValue?: string; 28 | count: number; 29 | lang: string; 30 | pluralizers?: Record; 31 | log: (message: string) => void; 32 | } 33 | 34 | export function getPluralValue({value, count, lang, pluralizers, log, key}: FormatPluralArgs) { 35 | if (!Array.isArray(value)) { 36 | return getPluralViaIntl(value, count, lang) || key; 37 | } 38 | 39 | if (!pluralizers) { 40 | log('Can not use deprecated plural format without pluralizers'); 41 | return key; 42 | } 43 | 44 | if (!pluralizers[lang]) { 45 | log(`Pluralization is not configured for language '${lang}', falling back to the english ruleset`); 46 | } 47 | 48 | if (value.length < 3) { 49 | log('Missing required plurals'); 50 | return key; 51 | } 52 | 53 | const pluralizer = pluralizers[lang] || pluralizers['en']; 54 | 55 | if (!pluralizer) { 56 | log('Fallback pluralization is not configured!'); 57 | return key; 58 | } 59 | 60 | return value[pluralizer(count, PluralForm)] || value[PluralForm.Many] || key; 61 | } 62 | -------------------------------------------------------------------------------- /src/plural/ru.ts: -------------------------------------------------------------------------------- 1 | import type {PluralForm} from '../types'; 2 | 3 | export default function(count: number, pluralForms: typeof PluralForm): PluralForm { 4 | // the rules for negative numbers are the same 5 | const lastDigit = Math.abs(count % 10); 6 | const last2Digits = Math.abs(count % 100); 7 | 8 | if (count === 0) { 9 | return pluralForms.None; 10 | } 11 | 12 | if (lastDigit === 1 && last2Digits !== 11) { 13 | return pluralForms.One; 14 | } 15 | 16 | if ((lastDigit > 1 && lastDigit < 5) && (last2Digits < 10 || last2Digits > 20)) { 17 | return pluralForms.Few; 18 | } 19 | 20 | return pluralForms.Many; 21 | } 22 | -------------------------------------------------------------------------------- /src/replace-params.spec.ts: -------------------------------------------------------------------------------- 1 | import {replaceParams} from './replace-params'; 2 | 3 | describe('replaceParams', () => { 4 | it('should substitute params', () => { 5 | expect(replaceParams('{{test}}', {test: 'text'})).toBe('text'); 6 | expect(replaceParams('{{test}}{{test}}', {test: 'text'})).toBe('texttext'); 7 | expect(replaceParams('some {{test}}', {test: 'text'})).toBe('some text'); 8 | expect(replaceParams('some {{test}} text', {test: 'cool'})).toBe('some cool text'); 9 | expect(replaceParams('{{test}} text', {test: 'cool'})).toBe('cool text'); 10 | expect(replaceParams('some {{test}} text {{test2}} !!', {test: 'cool', test2: 'hey'})).toBe('some cool text hey !!'); 11 | }); 12 | it('should not replace missing params', () => { 13 | expect(replaceParams('{{test}}', {})).toBe('{{test}}'); 14 | expect(replaceParams('some {{test}}', {})).toBe('some {{test}}'); 15 | expect(replaceParams('some {{test}} text', {})).toBe('some {{test}} text'); 16 | expect(replaceParams('{{test}} text', {})).toBe('{{test}} text'); 17 | expect(replaceParams('some {{test}} text {{test2}} !!', {test: 'cool'})).toBe('some cool text {{test2}} !!'); 18 | }) 19 | it('should correctly substitute content with specials', () => { 20 | expect(replaceParams('{{test}}', {test: '$'})).toBe('$'); 21 | expect(replaceParams('{{test}}', {test: '$$'})).toBe('$$'); 22 | expect(replaceParams('{{test1}} {{test2}}', {test1: '{{test2}}', test2: '{{test3}}', test3: 'content'})).toBe( 23 | '{{test2}} {{test3}}', 24 | ); 25 | }); 26 | }); 27 | -------------------------------------------------------------------------------- /src/replace-params.ts: -------------------------------------------------------------------------------- 1 | import {Params} from './types'; 2 | 3 | const PARAM_REGEXP = /{{(.*?)}}/g; 4 | 5 | export function replaceParams(keyValue: string, params: Params): string { 6 | let result = ''; 7 | 8 | let lastIndex = (PARAM_REGEXP.lastIndex = 0); 9 | let match; 10 | while ((match = PARAM_REGEXP.exec(keyValue))) { 11 | if (lastIndex !== match.index) { 12 | result += keyValue.slice(lastIndex, match.index); 13 | } 14 | lastIndex = PARAM_REGEXP.lastIndex; 15 | 16 | const [all, key] = match; 17 | if (key && Object.prototype.hasOwnProperty.call(params, key)) { 18 | result += params[key]; 19 | } else { 20 | result += all; 21 | } 22 | } 23 | if (lastIndex < keyValue.length) { 24 | result += keyValue.slice(lastIndex); 25 | } 26 | 27 | return result; 28 | } 29 | -------------------------------------------------------------------------------- /src/translation-helpers.ts: -------------------------------------------------------------------------------- 1 | import { getNestingTranslationsRegExp } from "./consts"; 2 | import { KeyData } from "./types"; 3 | 4 | export enum ErrorCode { 5 | EmptyKeyset = 'EMPTY_KEYSET', 6 | EmptyLanguageData = 'EMPTY_LANGUAGE_DATA', 7 | KeysetNotFound = 'KEYSET_NOT_FOUND', 8 | MissingKey = 'MISSING_KEY', 9 | MissingKeyFor0 = 'MISSING_KEY_FOR_0', 10 | MissingKeyParamsCount = 'MISSING_KEY_PARAMS_COUNT', 11 | MissingKeyPlurals = 'MISSING_KEY_PLURALS', 12 | MissingInheritedKey = 'MISSING_INHERITED_KEY', 13 | NestedPlural = 'NESTED_PLURAL', 14 | ExceedTranslationNestingDepth = 'EXCEED_TRANSLATION_NESTING_DEPTH', 15 | NoLanguageData = 'NO_LANGUAGE_DATA', 16 | } 17 | 18 | const codeValues = Object.values(ErrorCode); 19 | export type ErrorCodeType = (typeof codeValues)[number]; 20 | 21 | export function mapErrorCodeToMessage(args: {code: ErrorCodeType; lang: string; fallbackLang?: string}) { 22 | const {code, fallbackLang, lang} = args; 23 | let message = `Using language ${lang}. `; 24 | 25 | switch (code) { 26 | case ErrorCode.EmptyKeyset: { 27 | message += `Keyset is empty.`; 28 | break; 29 | } 30 | case ErrorCode.EmptyLanguageData: { 31 | message += 'Language data is empty.'; 32 | break; 33 | } 34 | case ErrorCode.KeysetNotFound: { 35 | message += 'Keyset not found.'; 36 | break; 37 | } 38 | case ErrorCode.MissingKey: { 39 | message += 'Missing key.'; 40 | break; 41 | } 42 | case ErrorCode.MissingKeyFor0: { 43 | message += 'Missing key for 0'; 44 | return message 45 | } 46 | case ErrorCode.MissingKeyParamsCount: { 47 | message += 'Missing params.count for key.'; 48 | break; 49 | } 50 | case ErrorCode.MissingKeyPlurals: { 51 | message += 'Missing required plurals.'; 52 | break; 53 | } 54 | case ErrorCode.NoLanguageData: { 55 | message = `Language "${lang}" is not defined, make sure you call setLang for the same language you called registerKeysets for!`; 56 | } 57 | } 58 | 59 | if (fallbackLang) { 60 | message += ` Trying to use fallback language "${fallbackLang}"...`; 61 | } 62 | 63 | return message; 64 | } 65 | 66 | export const hasNestingTranslations = (keyValue: string): boolean => { 67 | const NESTING_PREGEXP = getNestingTranslationsRegExp(); 68 | const match = NESTING_PREGEXP.exec(keyValue) 69 | return (match?.length ?? 0) > 0 70 | } 71 | 72 | export const getPluralValues = (keyValue: KeyData): string[] => { 73 | if (keyValue instanceof Array) { 74 | return keyValue 75 | } else if (keyValue instanceof Object) { 76 | return Object.values(keyValue) 77 | } 78 | 79 | return [] 80 | } -------------------------------------------------------------------------------- /src/translations-nesting.spec.ts: -------------------------------------------------------------------------------- 1 | import {I18N} from './index'; 2 | import { hasNestingTranslations } from './translation-helpers'; 3 | 4 | let i18n: I18N; 5 | 6 | beforeEach(() => { 7 | i18n = new I18N(); 8 | }); 9 | 10 | describe('nesting translations', () => { 11 | it('has nesting translations', () => { 12 | expect(hasNestingTranslations("Welcome to $t{service}")).toBe(true); 13 | expect(hasNestingTranslations("Welcome to $t{inheritance::service}")).toBe(true); 14 | }); 15 | 16 | it('has not nesting translations', () => { 17 | expect(hasNestingTranslations("Welcome")).toBe(false); 18 | expect(hasNestingTranslations("Welcome {{data}}")).toBe(false); 19 | }); 20 | 21 | it('should return correct translation with inherited translations from same keyset', () => { 22 | i18n = new I18N({ 23 | lang: 'en', 24 | data: { 25 | en: { 26 | inheritance: { 27 | service: "Service", 28 | welcome1: "Welcome to $t{service}", 29 | welcome2: "Welcome to $t{inheritance::service}", 30 | 31 | nesting1: "1 $t{nesting2}", 32 | nesting2: "2", 33 | } 34 | } 35 | }, 36 | }); 37 | i18n.setLang('en'); 38 | 39 | expect(i18n.i18n('inheritance', 'welcome1')).toBe('Welcome to Service'); 40 | expect(i18n.i18n('inheritance', 'welcome2')).toBe('Welcome to Service'); 41 | expect(i18n.i18n('inheritance', 'nesting1')).toBe('1 2'); 42 | }); 43 | 44 | it('should return correct translation with inherited translations from other keyset', () => { 45 | i18n = new I18N({ 46 | lang: 'en', 47 | data: { 48 | en: { 49 | global: { 50 | "app-name": "I18N" 51 | }, 52 | inheritance: { 53 | welcome: "Welcome to $t{global::app-name}", 54 | } 55 | } 56 | }, 57 | }); 58 | i18n.setLang('en'); 59 | 60 | expect(i18n.i18n('inheritance', 'welcome')).toBe('Welcome to I18N'); 61 | }); 62 | 63 | it('should return translation key if translations nesting depth exceed 1', () => { 64 | i18n = new I18N({ 65 | lang: 'en', 66 | data: { 67 | en: { 68 | inheritance: { 69 | nesting1: "1 $t{nesting2}", 70 | nesting2: "2 $t{nesting3}", 71 | nesting3: "3", 72 | } 73 | } 74 | }, 75 | }); 76 | i18n.setLang('en'); 77 | 78 | expect(i18n.i18n('inheritance', 'nesting1')).toBe('nesting1'); 79 | }); 80 | 81 | it('should return translation key if translation is plural and has nested translations', () => { 82 | i18n.setLang('en'); 83 | i18n.registerKeyset('en', 'inheritance', { 84 | service: "Service", 85 | nesting_plural_1: { 86 | 'zero': 'нет $t{service}', 87 | 'one': '{{count}} $t{service}', 88 | 'other': '', 89 | }, 90 | 91 | nesting_plural_2: [ 92 | 'нет $t{service}', 93 | '{{count}} $t{service}' 94 | ], 95 | }); 96 | 97 | expect(i18n.i18n('inheritance', 'nesting_plural_1', { 98 | count: 1 99 | })).toBe('nesting_plural_1'); 100 | 101 | expect(i18n.i18n('inheritance', 'nesting_plural_2', { 102 | count: 1 103 | })).toBe('nesting_plural_2'); 104 | }); 105 | 106 | it('should return translation key if nested translation is plural', () => { 107 | i18n.setLang('en'); 108 | i18n.registerKeyset('en', 'inheritance', { 109 | service: "Service: $t{users}", 110 | users: { 111 | 'zero': 'нет пользователей', 112 | 'one': '{{count}} пользователь', 113 | 'few': '{{count}} пользователя', 114 | 'many': '{{count}} пользователей', 115 | 'other': '', 116 | }, 117 | }); 118 | 119 | expect(i18n.i18n('inheritance', 'service', { 120 | count: 1 121 | })).toBe('service'); 122 | }); 123 | }); -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | export type KeyData = string | DeprecatedPluralValue | PluralValue; 2 | export type KeysData = Record; 3 | export type KeysetData = Record; 4 | 5 | type NoEnumLikeStringLiteral = string extends T ? T : never; 6 | 7 | export type I18NFn = { 8 | ( 9 | keysetName: K, 10 | key: G | NoEnumLikeStringLiteral, 11 | params?: {[key: string]: any}, 12 | ): S extends G ? T[K][G] : string; 13 | keyset( 14 | keysetName: K, 15 | ): ( 16 | key: G | NoEnumLikeStringLiteral, 17 | params?: {[key: string]: any}, 18 | ) => S extends G ? T[K][G] : string; 19 | i18n( 20 | keysetName: K, 21 | key: G | NoEnumLikeStringLiteral, 22 | ): () => S extends G ? T[K][G] : string; 23 | has( 24 | keysetName: K, 25 | key: string 26 | ): () => boolean; 27 | bind( 28 | thisArg: any, 29 | ): ( 30 | keysetName: K, 31 | key: G | NoEnumLikeStringLiteral, 32 | params?: {[key: string]: any}, 33 | ) => S extends G ? T[K][G] : string; 34 | bind( 35 | thisArg: any, 36 | keysetName: K, 37 | ): ( 38 | key: G | NoEnumLikeStringLiteral, 39 | params?: {[key: string]: any}, 40 | ) => S extends G ? T[K][G] : string; 41 | bind( 42 | thisArg: any, 43 | keysetName: K, 44 | key: G | NoEnumLikeStringLiteral, 45 | ): (params?: {[key: string]: any}) => S extends G ? T[K][G] : string; 46 | bind( 47 | thisArg: any, 48 | keysetName: K, 49 | key: G | NoEnumLikeStringLiteral, 50 | params?: {[key: string]: any}, 51 | ): () => S extends G ? T[K][G] : string; 52 | }; 53 | 54 | // Recursive helper for finding path parameters 55 | type KeyParam = 56 | Path extends `${infer L}{{${infer K}}}${infer R}` 57 | ? K | KeyParam | KeyParam 58 | : never; 59 | 60 | type StringKey = string; 61 | 62 | type RequiredPluralValue = { 63 | count: number; 64 | } 65 | 66 | export type TypedParams = ( 67 | K extends StringKey 68 | ? Record, V> 69 | : ( 70 | K extends PluralValue 71 | ? Record> | KeyParam> | KeyParam> | KeyParam> | KeyParam> | KeyParam>, V> & RequiredPluralValue 72 | : unknown 73 | ) 74 | ); 75 | 76 | export type TypedParamsI18NFn = { 77 | ( 78 | keysetName: K, 79 | key: G | NoEnumLikeStringLiteral, 80 | params?: TypedParams, 81 | ): string; 82 | keyset( 83 | keysetName: K, 84 | ): ( 85 | key: G | NoEnumLikeStringLiteral, 86 | params?: TypedParams, 87 | ) => string; 88 | i18n( 89 | keysetName: K, 90 | key: G | NoEnumLikeStringLiteral, 91 | ): () => string; 92 | has( 93 | keysetName: K, 94 | key: string 95 | ): () => boolean; 96 | bind( 97 | thisArg: any, 98 | ): ( 99 | keysetName: K, 100 | key: G | NoEnumLikeStringLiteral, 101 | params?: TypedParams, 102 | ) => string; 103 | bind( 104 | thisArg: any, 105 | keysetName: K, 106 | ): ( 107 | key: G | NoEnumLikeStringLiteral, 108 | params?: TypedParams, 109 | ) => string; 110 | bind( 111 | thisArg: any, 112 | keysetName: K, 113 | key: G | NoEnumLikeStringLiteral, 114 | ): (params?: TypedParams) => string; 115 | bind( 116 | thisArg: any, 117 | keysetName: K, 118 | key: G | NoEnumLikeStringLiteral, 119 | params?: TypedParams, 120 | ): () => string; 121 | }; 122 | 123 | export type Params = {[key: string]: any}; 124 | 125 | export type Pluralizer = (count: number, pluralForms: typeof PluralForm) => PluralForm; 126 | 127 | export enum PluralForm { 128 | One, 129 | Few, 130 | Many, 131 | None 132 | } 133 | 134 | /** 135 | * @deprecated Old plurals format. Use new format from type PluralValue. Will be removed in v2. 136 | */ 137 | export type DeprecatedPluralValue = string[] 138 | 139 | export type PluralValue = { 140 | zero?: string; 141 | one?: string; 142 | two?: string; 143 | few?: string; 144 | many?: string; 145 | other?: string; 146 | } 147 | 148 | export function isPluralValue(value: KeyData): value is DeprecatedPluralValue | PluralValue { 149 | return typeof value !== 'string'; 150 | } 151 | 152 | export interface Logger { 153 | log(message: string, options?: {level?: string; logger?: string; extra?: Record}): void; 154 | } 155 | -------------------------------------------------------------------------------- /test-utils/setup-tests.ts: -------------------------------------------------------------------------------- 1 | process.env.NODE_ENV = 'production'; 2 | -------------------------------------------------------------------------------- /tsconfig.cjs.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@gravity-ui/tsconfig/tsconfig", 3 | "compilerOptions": { 4 | "outDir": "build/cjs", 5 | "declaration": true 6 | }, 7 | "include": ["src/*.ts"], 8 | "exclude": ["**/*.spec.ts"] 9 | } 10 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@gravity-ui/tsconfig/tsconfig", 3 | "compilerOptions": { 4 | "outDir": "build/esm", 5 | "module": "esnext", 6 | "baseUrl": ".", 7 | "importHelpers": true, 8 | "declaration": true, 9 | "noUncheckedIndexedAccess": true 10 | }, 11 | "include": ["src/*.ts"], 12 | "exclude": ["**/*.spec.ts"] 13 | } 14 | --------------------------------------------------------------------------------