├── .babelrc ├── .circleci └── config.yml ├── .editorconfig ├── .eslintrc ├── .gitignore ├── CONTRIBUTING.md ├── LICENSE.md ├── README.md ├── gifs ├── table.gif └── warnings.gif ├── jest.config.js ├── jsconfig.json ├── package-lock.json ├── package.json ├── rollup.config.js ├── scripts ├── config.js ├── publish.js └── version.js └── src ├── Dictionary.js ├── Reporting.js ├── __tests__ ├── Dictionary.spec.js ├── Reporting.spec.js ├── dictionary.fixture.js ├── index.es.spec.js ├── index.node.spec.js ├── mapUtilities.spec.js └── validateTranslations.spec.js ├── constants.js ├── env.js ├── index.js ├── mapUtilities.js ├── utils ├── __tests__ │ └── get.spec.js └── get.js └── validateTranslations.js /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | [ 4 | "@babel/preset-env", 5 | { 6 | "targets": { 7 | "browsers": [ 8 | "last 2 versions", 9 | "safari >= 7" 10 | ], 11 | "node": "8" 12 | } 13 | } 14 | ] 15 | ], 16 | "plugins": [ 17 | "@babel/plugin-proposal-class-properties" 18 | ] 19 | } 20 | -------------------------------------------------------------------------------- /.circleci/config.yml: -------------------------------------------------------------------------------- 1 | # Javascript Node CircleCI 2.0 configuration file 2 | # 3 | # Check https://circleci.com/docs/2.0/language-javascript/ for more details 4 | 5 | defaults: &defaults 6 | working_directory: ~/repo 7 | docker: 8 | - image: circleci/node:10.13.0 9 | 10 | tag_matcher: &tag_matcher 11 | tags: 12 | only: /^v.*/ 13 | branches: 14 | ignore: /.*/ 15 | 16 | version: 2 17 | jobs: 18 | test: 19 | <<: *defaults 20 | 21 | steps: 22 | - checkout 23 | 24 | # Download and cache dependencies 25 | - restore_cache: 26 | keys: 27 | - v1-dependencies-{{ checksum "package.json" }} 28 | 29 | - run: 30 | name: 'Install dependencies' 31 | command: npm install 32 | 33 | - save_cache: 34 | paths: 35 | - node_modules 36 | key: v1-dependencies-{{ checksum "package.json" }} 37 | 38 | - run: 39 | name: 'Lint project' 40 | command: npm run lint -- --format junit -o reports/eslint/results.xml 41 | 42 | - run: 43 | name: 'Run tests' 44 | environment: 45 | JEST_JUNIT_OUTPUT: reports/jest/results.xml 46 | command: npm run test:all -- --runInBand --ci --reporters=default --reporters=jest-junit 47 | 48 | - store_test_results: 49 | path: reports/ 50 | 51 | - store_artifacts: 52 | path: reports/ 53 | 54 | - persist_to_workspace: 55 | root: ~/repo 56 | paths: . 57 | 58 | build: 59 | <<: *defaults 60 | 61 | steps: 62 | - attach_workspace: 63 | at: ~/repo 64 | 65 | - run: npm run build 66 | 67 | - persist_to_workspace: 68 | root: ~/repo 69 | paths: . 70 | 71 | deploy: 72 | <<: *defaults 73 | 74 | steps: 75 | - attach_workspace: 76 | at: ~/repo 77 | - run: 78 | name: Authenticate with registry 79 | command: echo "//registry.npmjs.org/:_authToken=$NPM_TOKEN" > ~/repo/.npmrc 80 | - run: 81 | name: Release/Publish to github and npm 82 | command: npm run publish 83 | 84 | workflows: 85 | version: 2 86 | test-build: 87 | jobs: 88 | - test 89 | - build: 90 | requires: 91 | - test 92 | 93 | test-build-and-publish: 94 | jobs: 95 | - test: 96 | filters: 97 | <<: *tag_matcher 98 | - build: 99 | requires: 100 | - test 101 | filters: 102 | <<: *tag_matcher 103 | # - approve-deployment: 104 | # type: approval 105 | # requires: 106 | # - build 107 | # filters: 108 | # <<: *tag_matcher 109 | - deploy: 110 | requires: 111 | - build 112 | filters: 113 | <<: *tag_matcher 114 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # http://editorconfig.org 2 | root = true 3 | 4 | [*] 5 | end_of_line = lf 6 | indent_style = space 7 | indent_size = 2 8 | charset = utf-8 9 | trim_trailing_whitespace = true 10 | insert_final_newline = true 11 | 12 | [*.cmd] 13 | end_of_line = crlf 14 | 15 | [*.md] 16 | trim_trailing_whitespace = false 17 | 18 | [{package.json}] 19 | indent_style = space 20 | indent_size = 2 21 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "parser": "babel-eslint", 3 | "parserOptions": { 4 | "ecmaFeatures": { 5 | "jsx": true, 6 | "modules": true 7 | } 8 | }, 9 | "plugins": [ 10 | "jest" 11 | ], 12 | "extends": [ 13 | "eslint:recommended", 14 | "airbnb-base" 15 | ], 16 | "rules": { 17 | "import/prefer-default-export": "off", 18 | "no-restricted-globals": "off", 19 | "no-plusplus": "off" 20 | }, 21 | "env": { 22 | "jest/globals": true 23 | }, 24 | "globals": { 25 | "__DEV__": true, 26 | "TEST": true 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/ignore-files/ for more about ignoring files. 2 | 3 | # IDES and tools 4 | .vscode/ 5 | .idea/ 6 | .DS_Store 7 | 8 | # eslint 9 | .eslintcache 10 | 11 | # Builds and temps 12 | lib/ 13 | .coverage/ 14 | .reports/ 15 | 16 | # dependencies 17 | /node_modules 18 | 19 | npm-debug.log* 20 | yarn-debug.log* 21 | yarn-error.log* 22 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing to reword-js 2 | 3 | We love your input! We want to make contributing to this project as easy and transparent as possible, whether it's: 4 | 5 | - Reporting a bug 6 | - Discussing the current state of the code 7 | - Submitting a fix 8 | - Proposing new features 9 | 10 | ## We Develop with Github 11 | 12 | We use github to host code, to track issues and feature requests, as well as accept pull requests. 13 | 14 | ## We Use [Github Flow](https://guides.github.com/introduction/flow/index.html), So All Code Changes Happen Through Pull Requests 15 | 16 | Pull requests are the best way to propose changes to the codebase (we use [Github Flow](https://guides.github.com/introduction/flow/index.html)). We actively welcome your pull requests: 17 | 18 | 1. Create your branch from `master`. 19 | 2. If you've added code that should be tested, add tests. 20 | 3. If you've added or changed APIs, update the documentation. 21 | 4. Ensure the test suite passes. 22 | 5. Make sure your code lints. 23 | 6. Issue that pull request! 24 | 25 | ## Any contributions you make will be under the MIT Software License 26 | 27 | In short, when you submit code changes, your submissions are understood to be under the same [MIT License](http://choosealicense.com/licenses/mit/) that covers the project. Feel free to contact the maintainers if that's a concern. 28 | 29 | ## Report bugs using Github's [issues](https://github.com/briandk/transcriptase-atom/issues) 30 | 31 | We use GitHub issues to track public bugs. Report a bug by [opening a new issue](); it's that easy! 32 | 33 | ## Write bug reports with detail, background, and sample code 34 | 35 | [This is an example](http://stackoverflow.com/q/12488905/180626) of a bug report I wrote, and I think it's not a bad model. Here's [another example](http://www.openradar.me/11905408). 36 | 37 | **Great Bug Reports** tend to have: 38 | 39 | - A quick summary and/or background 40 | - Steps to reproduce 41 | - Be specific! 42 | - Give sample code if you can 43 | - What you expected would happen 44 | - What actually happens 45 | - Notes (possibly including why you think this might be happening, or stuff you tried that didn't work) 46 | 47 | People _love_ thorough bug reports. I'm not even kidding. 48 | 49 | ## Use a Consistent Coding Style 50 | 51 | - Use prettier with eslint integration for formatting code 52 | - Use an `editor config` extendsion for your editor to respect indentation rules. (2 spaces) 53 | - You can try running `npm run lint` for style unification 54 | 55 | ## License 56 | 57 | By contributing, you agree that your contributions will be licensed under its MIT License. 58 | 59 | ## References 60 | 61 | This document was adapted from the open-source contribution guidelines for [Facebook's Draft](https://github.com/facebook/draft-js/blob/a9316a723f9e918afde44dea68b5f9f39b7d9b00/CONTRIBUTING.md) 62 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2018 Devbridge group, https://www.devbridge.com 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 all 13 | 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 THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # reword-js · ![GitHub release](https://img.shields.io/github/release/devbridge/reword-js.svg) ![CircleCI branch](https://img.shields.io/circleci/project/github/devbridge/reword-js/master.svg) ![David](https://img.shields.io/david/dev/devbridge/reword-js.svg) ![GitHub issues](https://img.shields.io/github/issues-raw/devbridge/reword-js.svg) ![GitHub](https://img.shields.io/github/license/devbridge/reword-js.svg) ![GitHub stars](https://img.shields.io/github/stars/devbridge/reword-js.svg?style=social&label=Stars) 2 | 3 | **reword-js** is a zero dependency, minimal translation library written in JavaScript. Working in the browser and nodejs applications, **reword-js** provides an easy way to translate texts either by key or text content, with possibility to add dynamic parameters. 4 | 5 | # Installation 6 | 7 | via [npm](https://www.npmjs.com/) 8 | 9 | `$ npm install reword-js` 10 | 11 | via [yarn](https://yarnpkg.com/lang/en/) 12 | 13 | `$ yarn add reword-js` 14 | 15 | # Usage 16 | 17 | As a node module 18 | 19 | ```js 20 | const reword = require('reword-js'); 21 | 22 | reword.config( 23 | { 24 | /* ...dictionary */ 25 | }, 26 | { 27 | /* ...options */ 28 | } 29 | ); 30 | 31 | reword.translate`Default language text`; // translated language text 32 | reword.translateKey('someKey'); // translated language text 33 | ``` 34 | 35 | As an ecmascript module 36 | 37 | ```js 38 | import { config, translate, translateKey } from 'reword-js'; 39 | 40 | config( 41 | { 42 | /* ...dictionary */ 43 | }, 44 | { 45 | /* ...options */ 46 | } 47 | ); 48 | 49 | translate`Default language text`; // translated language text 50 | translateKey('someKey'); // translated language text 51 | ``` 52 | 53 | As a script tag 54 | 55 | ```html 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 76 | 77 | 78 | ``` 79 | 80 | # Initialization 81 | 82 | Reword can be used as a global instance, or separate instances created manually. 83 | 84 | Example: 85 | 86 | ```js 87 | import { Dictionary, config } from 'reword-js'; 88 | 89 | //initialize as a standalone instance 90 | const reword = new Dictionary(dictionary, options); 91 | 92 | //initialize as a global instance 93 | config(dictionary, options); 94 | ``` 95 | 96 | # Translation 97 | 98 | Translating text can be acomplished in two ways. Either by key or by actual text. Reasoning behind text translation is that we have a natural fallback if translation key does not exist, interpolation also works just like a plain string which is more easier to read. Key translation is implemented for convenience as it is commonly used throughout other packages/languages. 99 | 100 | Example: 101 | 102 | ```js 103 | import { translate, translateKey } from 'reword-js'; 104 | 105 | const dictionary = { 106 | 'en-US': { 107 | example: 'Translated language text' 108 | }, 109 | 'xx-XX': { 110 | example: 'Translated other language text' 111 | } 112 | }; 113 | 114 | config(dictionary, { locale: 'xx-XX' }); 115 | 116 | // Translate by text. 117 | reword.translate`Translated language text`; // Translated language text 118 | 119 | // Translate by key. 120 | reword.translateKey('example'); // Translated other language text 121 | ``` 122 | 123 | # Interpolation 124 | 125 | Adding dynamic values to translations is as easy as adding them to a hard coded string. Reword will also change variable order if the destination language has them ordered differently. 126 | 127 | Example: 128 | 129 | ```js 130 | import { translate, translateKey, config } from 'reword-js'; 131 | 132 | const dictionary = { 133 | 'en-US': { 134 | example: 'Text with param {one} and param {two}' 135 | }, 136 | 'xx-XX': { 137 | example: 'Text replaced with param {two} and param {one}' 138 | } 139 | }; 140 | 141 | config(dictionary, { locale: 'xx-XX' }); 142 | 143 | const one = 'Foo'; 144 | const two = 'Bar'; 145 | 146 | // Text based translation 147 | translate`Text with param ${one} and param ${two}`; // Text replaced with param Bar and param Foo 148 | 149 | // Key based translation 150 | translateKey('example', one, two); // Text replaced with param Bar and param Foo 151 | ``` 152 | 153 | # Dictionary object 154 | 155 | Dictionary is the primary object that holds all of the languages and translations. Must include a default language (see options object) and at least one of the translations. dictionary object can be nested as well. 156 | 157 | Example: 158 | 159 | ```js 160 | const dictionary = { 161 | // Default language which is specified in options 162 | 'en-US': { 163 | example: 'Translated language text', 164 | nested: { 165 | example: 'Translated nested language text' 166 | } 167 | }, 168 | // One or more languages with coresponding keys. 169 | 'xx-XX': { 170 | example: 'Translated other language text', 171 | nested: { 172 | example: 'Translated nested other language text' 173 | } 174 | } 175 | }; 176 | ``` 177 | 178 | # Options object 179 | 180 | `config` or `Dictionary` instance accepts an `options` object, which is shallow merged with the default options: 181 | 182 | - `defaultLocale`: Sets base locale for reword all of the translations are based on the default language (defaults to `en-US`) 183 | - `locale`: Sets initial locale so reword know which is the destination language (defaults to `en-US`) 184 | - `variableMatcher`: Regular expression pattern which identifies variables for interpolation (defaults to `/\{(.*?)\}/g`) 185 | - `translationNotFoundMessage`: Content display when a translation is not found. Only applies when translating by key. (defaults to `TRANSLATION_NOT_FOUND`) 186 | - `debug`: Debugging option which provides information on missing translations/parameters see [Debugging](#debugging). (defaults to `production` when used as a `umd` module and respects `process.env.NODE_ENV` while using `cjs` or `es` modules) 187 | 188 | Example: 189 | 190 | ```js 191 | import { Dictionary, config } from 'reword-js'; 192 | 193 | const dictionary = {}; 194 | const options = { 195 | defaultLocale: 'en-US', // defaults to en-US 196 | locale: 'es-ES' 197 | variableMatcher: /\{(.*?)\}/g, 198 | translationNotFoundMessage: 'Could not find any key to translate' 199 | debug: true // or false 200 | }; 201 | 202 | // Using with global instance 203 | config(dictionary, options) 204 | 205 | // Using with dedicated instance 206 | const translations = new Dictionary(dictionary, options); 207 | ``` 208 | 209 | # API Reference 210 | 211 | Reword public instance is initialized on module import thus contains all of the methods described in the api refrence. 212 | 213 | ### `Dictionary.prototype.config(dictionary, options)` 214 | 215 | Works like a constructor method, used to re-initialize the dictionary. 216 | 217 | ### `Dictionary.prototype.changeLocale(localeString)` 218 | 219 | Changes destination language to a desired one. 220 | 221 | ### `Dictionary.prototype.updateDictionary(dictionary)` 222 | 223 | Overwrites dictionary with a new one. Does not update any options. 224 | 225 | ### `Dictionary.prototype.translate(string, [...parameters])` 226 | 227 | Can be called as template string or as a regular function. 228 | 229 | ```js 230 | const translateString = `String with {numberOfParams}`; 231 | 232 | translate`String with ${numberOfParams}`; 233 | 234 | translate(translateString, numberOfParams); 235 | ``` 236 | 237 | ### `Dictionary.prototype.translateKey(key, [...parameters])` 238 | 239 | A dictionary key can be provided to translate via key instead of text. If no key was found it will show text defined in the options object see [Options object](#options-object) 240 | 241 | ```js 242 | translateKey('example'); // Translated text is returned; 243 | 244 | translateKey('example', param1, param2); // Translated text with parameters returned; 245 | ``` 246 | 247 | # Debugging 248 | 249 | **reword-js** provides some debugging capabilities if `debug` option is enabled. see [Options object](#options-object). 250 | 251 | ## Console warning 252 | 253 | If translation is not found **reword-js** will throw a `console.warn` message. 254 | 255 | Example: 256 | 257 | ![some](https://github.com/devbridge/reword-js/raw/master/gifs/warnings.gif) 258 | 259 | ## Console table 260 | 261 | When loading up dictionary it's being validated and outputs information on what's missing. 262 | 263 | Example: 264 | 265 | ![some](https://github.com/devbridge/reword-js/raw/master/gifs/table.gif) 266 | 267 | # Integration 268 | 269 | ## React application 270 | 271 | Since **reword-js** is not tied to the state or store in react applications, thus it does not trigger a re-render. The easiest way is to trigger a re-render when language changes is by setting a `key` prop on the top most component in your React application. Once the key changes, React will re-render the DOM tree underneath. 272 | 273 | Example: 274 | 275 | ```js 276 | import React, { PureComponent } from 'react'; 277 | import { config, translate, changeLocale } from 'reword-js'; 278 | 279 | class App extends PureComponent { 280 | constructor() { 281 | super(); 282 | config({}, { locale: this.state.locale }); 283 | } 284 | 285 | state = { 286 | locale: 'en-US' 287 | }; 288 | 289 | changeLanguage = ({ target }) => { 290 | changeLocale(target.value); 291 | this.setState({ 292 | locale: target.value 293 | }); 294 | }; 295 | 296 | render() { 297 | return ( 298 |
299 | 302 | 305 | {translate`Translated text`} 306 |
307 | ); 308 | } 309 | } 310 | ``` 311 | -------------------------------------------------------------------------------- /gifs/table.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/devbridge/reword-js/f290a66fb7b8702f324594785e14c245b94a8add/gifs/table.gif -------------------------------------------------------------------------------- /gifs/warnings.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/devbridge/reword-js/f290a66fb7b8702f324594785e14c245b94a8add/gifs/warnings.gif -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | coverageDirectory: '.coverage', 3 | collectCoverageFrom: ['src/**/*.{js}', '!src/**/index.js'], 4 | setupFiles: [], 5 | testMatch: ['/src/**/__tests__/**/*.spec.js', '/src/**/?(*.)(spec|test).js'], 6 | testEnvironment: 'node', 7 | transform: { 8 | '^.+\\.(js|jsx|mjs)$': '/node_modules/babel-jest', 9 | }, 10 | transformIgnorePatterns: ['[/\\\\]node_modules[/\\\\].+\\.(js|jsx|mjs)$'], 11 | moduleFileExtensions: ['js', 'json', 'node', 'mjs'], 12 | }; 13 | -------------------------------------------------------------------------------- /jsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "exclude": ["node_modules"] 3 | } 4 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "reword-js", 3 | "version": "0.1.3", 4 | "description": "", 5 | "main": "lib/reword-js.cjs.js", 6 | "module": "lib/reword-js.esm.js", 7 | "browser": "lib/reword-js.umd.js", 8 | "repository": { 9 | "type": "git", 10 | "url": "https://github.com/devbridge/reword-js" 11 | }, 12 | "scripts": { 13 | "build": "rollup -c", 14 | "dev": "rollup -c -w", 15 | "lint": "eslint src", 16 | "test": "jest --watch", 17 | "test:all": "jest --ci", 18 | "coverage": "jest --coverage", 19 | "version": "node scripts/version.js", 20 | "publish": "node scripts/publish -n --no-increment" 21 | }, 22 | "keywords": [], 23 | "author": "", 24 | "license": "MIT", 25 | "dependencies": {}, 26 | "devDependencies": { 27 | "@babel/core": "^7.4.5", 28 | "@babel/plugin-proposal-class-properties": "^7.4.4", 29 | "@babel/preset-env": "^7.4.5", 30 | "babel-core": "^7.0.0-bridge.0", 31 | "babel-eslint": "^9.0.0", 32 | "babel-jest": "^23.6.0", 33 | "eslint": "^5.16.0", 34 | "eslint-config-airbnb-base": "^13.2.0", 35 | "eslint-plugin-import": "^2.18.0", 36 | "eslint-plugin-jest": "^21.27.2", 37 | "jest": "^24.8.0", 38 | "jest-junit": "^6.4.0", 39 | "release-it": "^7.6.3", 40 | "rollup": "^0.65.2", 41 | "rollup-plugin-babel": "^4.3.3", 42 | "rollup-plugin-commonjs": "^9.3.4", 43 | "rollup-plugin-node-resolve": "^3.4.0", 44 | "rollup-plugin-replace": "^2.2.0" 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /rollup.config.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable import/no-extraneous-dependencies */ 2 | 3 | import resolve from 'rollup-plugin-node-resolve'; 4 | import commonjs from 'rollup-plugin-commonjs'; 5 | import babel from 'rollup-plugin-babel'; 6 | import replace from 'rollup-plugin-replace'; 7 | import pkg from './package.json'; 8 | 9 | const env = { 10 | 'process.env.NODE_ENV': JSON.stringify('production'), 11 | }; 12 | 13 | export default [ 14 | // browser-friendly UMD build 15 | { 16 | input: 'src/index.js', 17 | output: { 18 | file: pkg.browser, 19 | format: 'umd', 20 | name: 'reword', 21 | }, 22 | plugins: [ 23 | replace(env), 24 | resolve(), // so Rollup can find `ms` 25 | babel({ 26 | exclude: ['node_modules/**'], 27 | }), 28 | commonjs(), // so Rollup can convert `ms` to an ES module 29 | ], 30 | }, 31 | 32 | // CommonJS (for Node) and ES module (for bundlers) build. 33 | // (We could have three entries in the configuration array 34 | // instead of two, but it's quicker to generate multiple 35 | // builds from a single configuration where possible, using 36 | // the `targets` option which can specify `dest` and `format`) 37 | { 38 | input: 'src/index.js', 39 | output: [ 40 | { 41 | file: pkg.main, 42 | format: 'cjs', 43 | }, 44 | { 45 | file: pkg.module, 46 | format: 'es', 47 | }, 48 | ], 49 | plugins: [ 50 | babel({ 51 | exclude: ['node_modules/**'], 52 | }), 53 | ], 54 | }, 55 | ]; 56 | -------------------------------------------------------------------------------- /scripts/config.js: -------------------------------------------------------------------------------- 1 | module.exports = ({ publish = false } = {}) => { 2 | const options = { 3 | 'non-interactive': publish, 4 | // debugging only option 5 | // verbose: true, 6 | 'dry-run': false, 7 | requireCleanWorkingDir: false, 8 | requireUpstream: false, 9 | changelogCommand: 10 | 'git log --pretty=format:"* %s (%h)" $(git describe --tags --abbrev=0 HEAD^)..HEAD', 11 | src: { 12 | commit: !publish, 13 | commitMessage: 'Release v%s', 14 | tag: !publish, 15 | tagName: 'v%s', 16 | tagAnnotation: 'Release v%s', 17 | push: !publish, 18 | pushRepo: 'origin', 19 | }, 20 | github: { 21 | release: publish, 22 | }, 23 | npm: { 24 | publish, 25 | }, 26 | }; 27 | 28 | return options; 29 | }; 30 | -------------------------------------------------------------------------------- /scripts/publish.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable import/no-extraneous-dependencies */ 2 | 3 | const releaseIt = require('release-it'); 4 | const getConfig = require('./config'); 5 | 6 | releaseIt(getConfig({ publish: true })); 7 | -------------------------------------------------------------------------------- /scripts/version.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable import/no-extraneous-dependencies */ 2 | 3 | const releaseIt = require('release-it'); 4 | const getConfig = require('./config'); 5 | 6 | releaseIt(getConfig()); 7 | -------------------------------------------------------------------------------- /src/Dictionary.js: -------------------------------------------------------------------------------- 1 | import { 2 | DEFAULT_LOCALE, VAR_REGEX, PLACEHOLDER, PLACEHOLDER_REGEX, TEXT, 3 | } from './constants'; 4 | import { isDevelopment } from './env'; 5 | import { getTextWithPlaceholders, generateTranslationMap } from './mapUtilities'; 6 | import Reporting from './Reporting'; 7 | import validateTranslations from './validateTranslations'; 8 | import get from './utils/get'; 9 | 10 | function fillWithValues(text, interpolations = [], positions = []) { 11 | let index = 0; 12 | 13 | return text.replace(PLACEHOLDER_REGEX, () => { 14 | const position = typeof positions[index] === 'undefined' ? index : positions[index]; 15 | const interpolation = typeof interpolations[position] === 'undefined' ? '' : interpolations[position]; 16 | index += 1; 17 | 18 | return interpolation; 19 | }); 20 | } 21 | 22 | const defaultOptions = { 23 | defaultLocale: DEFAULT_LOCALE, 24 | locale: DEFAULT_LOCALE, 25 | variableMatcher: VAR_REGEX, 26 | placeholderMatcher: PLACEHOLDER_REGEX, 27 | placeholder: PLACEHOLDER, 28 | debug: isDevelopment(), 29 | translationNotFoundMessage: TEXT.TRANSLATION_NOT_FOUND, 30 | }; 31 | 32 | export default class Dictionary { 33 | constructor(dictionary = {}, options = {}) { 34 | this.config(dictionary, options); 35 | this.initialised = true; 36 | } 37 | 38 | config = (dictionary = {}, options = {}) => { 39 | this.options = { ...defaultOptions, ...options }; 40 | this.reporting = new Reporting({ debug: this.options.debug }); 41 | this.updateDictionary(dictionary); 42 | this.translationMap = generateTranslationMap(this.dictionary, this.options); 43 | }; 44 | 45 | changeLocale = (locale) => { 46 | this.options.locale = locale; 47 | this.translationMap = generateTranslationMap(this.dictionary, this.options); 48 | }; 49 | 50 | updateDictionary = (dictionary) => { 51 | this.dictionary = dictionary; 52 | 53 | const errors = validateTranslations(dictionary); 54 | 55 | if (errors.length) { 56 | this.reporting.table(errors); 57 | } 58 | 59 | this.translationMap = generateTranslationMap(this.dictionary, this.options); 60 | }; 61 | 62 | translate = (strings, ...interpolations) => { 63 | const fragments = Array.isArray(strings) 64 | ? strings 65 | : [getTextWithPlaceholders(strings, this.options)]; 66 | const template = fragments.join(PLACEHOLDER).trim(); 67 | const translation = this.translationMap[template]; 68 | 69 | if (!translation && this.initialised) { 70 | this.reporting.warn( 71 | `Translation not found for template "${template}" current locale "${this.options.locale}"`, 72 | ); 73 | } 74 | 75 | if (!translation) { 76 | return fillWithValues(fragments.join(PLACEHOLDER), interpolations); 77 | } 78 | 79 | const { text, positions } = translation; 80 | 81 | return fillWithValues(text, interpolations, positions); 82 | }; 83 | 84 | translateKey = (key, ...interpolations) => { 85 | const defaultTranslation = get(this.dictionary[this.options.defaultLocale], key, ''); 86 | const translation = this.translationMap[ 87 | getTextWithPlaceholders(defaultTranslation, this.options) 88 | ]; 89 | 90 | if (!translation) { 91 | return this.options.translationNotFoundMessage; 92 | } 93 | 94 | const { text, positions } = translation; 95 | 96 | return fillWithValues(text, interpolations, positions); 97 | }; 98 | } 99 | -------------------------------------------------------------------------------- /src/Reporting.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-console */ 2 | 3 | export default class Reporting { 4 | constructor({ debug = false } = {}) { 5 | this.debug = debug; 6 | } 7 | 8 | get showError() { 9 | return this.debug; 10 | } 11 | 12 | warn = (...args) => { 13 | if (this.showError && args.length) { 14 | console.warn(...args); 15 | } 16 | }; 17 | 18 | table = (...args) => { 19 | if (this.showError && args.length) { 20 | console.table(...args); 21 | } 22 | }; 23 | } 24 | -------------------------------------------------------------------------------- /src/__tests__/Dictionary.spec.js: -------------------------------------------------------------------------------- 1 | import Dictionary from '../Dictionary'; 2 | import dictionary, { 3 | SPANISH_LOCALE, 4 | englishDictionary, 5 | spanshiDictionary, 6 | } from './dictionary.fixture'; 7 | import { TEXT } from '../constants'; 8 | 9 | let translations; 10 | 11 | describe('Translation module', () => { 12 | beforeEach(() => { 13 | translations = new Dictionary(dictionary); 14 | }); 15 | 16 | describe('when trannslating by text', () => { 17 | it('should show provided text if translation is not found', () => { 18 | expect(translations.translate`Test text`).toBe('Test text'); 19 | }); 20 | 21 | it('should show differen text when language is changed', () => { 22 | translations.changeLocale(SPANISH_LOCALE); 23 | 24 | expect(translations.translate(englishDictionary.title)).toBe(spanshiDictionary.title); 25 | }); 26 | 27 | it('should insert parameters to placeholders for both languages', () => { 28 | const lastName = 'Wells'; 29 | const firstName = 'Greg'; 30 | 31 | expect(translations.translate`Title with ${lastName} ${firstName} English`).toBe( 32 | `Title with ${lastName} ${firstName} English`, 33 | ); 34 | 35 | translations.changeLocale(SPANISH_LOCALE); 36 | 37 | expect(translations.translate`Title with ${lastName} ${firstName} English`).toBe( 38 | `Title with ${lastName} ${firstName} Spanish`, 39 | ); 40 | }); 41 | 42 | it('should insert parameters in different order based on the language setting', () => { 43 | const lastName = 'Wells'; 44 | const firstName = 'Greg'; 45 | 46 | expect( 47 | translations.translate`Title with mixed parameters ${lastName} ${firstName} English`, 48 | ).toBe(`Title with mixed parameters ${lastName} ${firstName} English`); 49 | 50 | translations.changeLocale(SPANISH_LOCALE); 51 | 52 | expect( 53 | translations.translate`Title with mixed parameters ${lastName} ${firstName} English`, 54 | ).toBe(`Title with mixed parameters ${firstName} ${lastName} Spanish`); 55 | }); 56 | 57 | it('should replace parameters provided as functiion parameters', () => { 58 | const lastName = 'Wells'; 59 | const firstName = 'Greg'; 60 | 61 | expect(translations.translate(englishDictionary.titleWithParams, lastName, firstName)).toBe( 62 | `Title with ${lastName} ${firstName} English`, 63 | ); 64 | }); 65 | 66 | it('should insert lastName twice if required by translation', () => { 67 | const lastName = 'Wells'; 68 | const firstName = 'Greg'; 69 | 70 | translations.changeLocale(SPANISH_LOCALE); 71 | 72 | expect( 73 | translations.translate`Title with double params ${lastName} ${firstName} English`, 74 | ).toBe(`Title with double params ${lastName} ${firstName} ${lastName} Spanish`); 75 | }); 76 | 77 | it('should return translation form a deeply nested object', () => { 78 | translations.changeLocale(SPANISH_LOCALE); 79 | 80 | expect(translations.translate`Nested translation English`).toEqual( 81 | 'Nested translation Spanish', 82 | ); 83 | }); 84 | }); 85 | 86 | describe('when translating by key', () => { 87 | it(`should show ${TEXT.TRANSLATION_NOT_FOUND} if no key was found`, () => { 88 | expect(translations.translateKey('nonexistentKey')).toBe(TEXT.TRANSLATION_NOT_FOUND); 89 | }); 90 | 91 | it('should show default text based on locale', () => { 92 | expect(translations.translateKey('title')).toBe(englishDictionary.title); 93 | }); 94 | 95 | it('should show selected locale text', () => { 96 | translations.changeLocale(SPANISH_LOCALE); 97 | 98 | expect(translations.translateKey('title')).toBe(spanshiDictionary.title); 99 | }); 100 | 101 | it('should insert parameters to placeholders for both languages', () => { 102 | const lastName = 'Wells'; 103 | const firstName = 'Greg'; 104 | 105 | expect(translations.translateKey('titleWithParams', lastName, firstName)).toBe( 106 | `Title with ${lastName} ${firstName} English`, 107 | ); 108 | 109 | translations.changeLocale(SPANISH_LOCALE); 110 | 111 | expect(translations.translateKey('titleWithParams', lastName, firstName)).toBe( 112 | `Title with ${lastName} ${firstName} Spanish`, 113 | ); 114 | }); 115 | 116 | it('should insert parameters in different order based on the language setting', () => { 117 | const lastName = 'Wells'; 118 | const firstName = 'Greg'; 119 | 120 | expect(translations.translateKey('titleWithMixedParams', lastName, firstName)).toBe( 121 | `Title with mixed parameters ${lastName} ${firstName} English`, 122 | ); 123 | 124 | translations.changeLocale(SPANISH_LOCALE); 125 | 126 | expect(translations.translateKey('titleWithMixedParams', lastName, firstName)).toBe( 127 | `Title with mixed parameters ${firstName} ${lastName} Spanish`, 128 | ); 129 | }); 130 | 131 | it('should insert lastName twice if required by translation', () => { 132 | const lastName = 'Wells'; 133 | const firstName = 'Greg'; 134 | 135 | translations.changeLocale(SPANISH_LOCALE); 136 | 137 | expect(translations.translateKey('titleWithDoubleParams', lastName, firstName)).toBe( 138 | `Title with double params ${lastName} ${firstName} ${lastName} Spanish`, 139 | ); 140 | }); 141 | 142 | it('should return translation form a deeply nested object', () => { 143 | translations.changeLocale(SPANISH_LOCALE); 144 | 145 | expect(translations.translateKey('nested.translation')).toEqual('Nested translation Spanish'); 146 | }); 147 | }); 148 | }); 149 | -------------------------------------------------------------------------------- /src/__tests__/Reporting.spec.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-console */ 2 | import Reporting from '../Reporting'; 3 | 4 | let reporting; 5 | 6 | jest.spyOn(console, 'warn'); 7 | jest.spyOn(console, 'table'); 8 | 9 | describe('Reporting', () => { 10 | beforeEach(() => { 11 | console.warn.mockImplementation(() => null); 12 | console.table.mockImplementation(() => null); 13 | }); 14 | 15 | describe('when in debug mode', () => { 16 | beforeEach(() => { 17 | reporting = new Reporting({ debug: true }); 18 | }); 19 | 20 | it('should call console.warn with specified parameters', () => { 21 | const parameters = ['param1', 'param2']; 22 | 23 | reporting.warn(...parameters); 24 | 25 | expect(console.warn).toHaveBeenCalledWith(...parameters); 26 | }); 27 | 28 | it('should call console.table with specified parameters', () => { 29 | const parameters = ['param1', 'param2']; 30 | 31 | reporting.table(...parameters); 32 | 33 | expect(console.table).toHaveBeenCalledWith(...parameters); 34 | }); 35 | 36 | it('should not call console.table if no parameters are supplied', () => { 37 | reporting.table(); 38 | 39 | expect(console.table).not.toHaveBeenCalled(); 40 | }); 41 | 42 | it('should not call console.warn if no parameters are supplied', () => { 43 | reporting.warn(); 44 | 45 | expect(console.warn).not.toHaveBeenCalled(); 46 | }); 47 | }); 48 | 49 | describe('when in debug mode', () => { 50 | beforeEach(() => { 51 | reporting = new Reporting({ debug: false }); 52 | }); 53 | 54 | it('should not call console.table if no parameters are supplied', () => { 55 | reporting.table(); 56 | 57 | expect(console.table).not.toHaveBeenCalled(); 58 | }); 59 | 60 | it('should not call console.warn if no parameters are supplied', () => { 61 | reporting.warn(); 62 | 63 | expect(console.warn).not.toHaveBeenCalled(); 64 | }); 65 | 66 | it('should not call console.table if parameters are supplied', () => { 67 | reporting.table('some', 'param'); 68 | 69 | expect(console.table).not.toHaveBeenCalled(); 70 | }); 71 | 72 | it('should not call console.warn if parameters are supplied', () => { 73 | reporting.warn('some', 'param'); 74 | 75 | expect(console.warn).not.toHaveBeenCalled(); 76 | }); 77 | }); 78 | 79 | afterEach(() => { 80 | jest.resetAllMocks(); 81 | }); 82 | }); 83 | -------------------------------------------------------------------------------- /src/__tests__/dictionary.fixture.js: -------------------------------------------------------------------------------- 1 | import { DEFAULT_LOCALE, VAR_REGEX } from '../constants'; 2 | 3 | export const SPANISH_LOCALE = 'es-Es'; 4 | 5 | export const englishDictionary = { 6 | title: 'Some Title English', 7 | titleWithParams: 'Title with {lastName} {firstName} English', 8 | titleWithMixedParams: 'Title with mixed parameters {lastName} {firstName} English', 9 | titleWithDoubleParams: 'Title with double params {lastName} {firstName} English', 10 | nested: { 11 | translation: 'Nested translation English', 12 | }, 13 | }; 14 | 15 | export const spanshiDictionary = { 16 | title: 'Some Title Spanish', 17 | titleWithParams: 'Title with {lastName} {firstName} Spanish', 18 | titleWithMixedParams: 'Title with mixed parameters {firstName} {lastName} Spanish', 19 | titleWithDoubleParams: 'Title with double params {lastName} {firstName} {lastName} Spanish', 20 | nested: { 21 | translation: 'Nested translation Spanish', 22 | }, 23 | }; 24 | 25 | export const options = { 26 | defaultLocale: DEFAULT_LOCALE, 27 | locale: SPANISH_LOCALE, 28 | variableMatcher: VAR_REGEX, 29 | }; 30 | 31 | export default { 32 | [DEFAULT_LOCALE]: englishDictionary, 33 | [SPANISH_LOCALE]: spanshiDictionary, 34 | }; 35 | -------------------------------------------------------------------------------- /src/__tests__/index.es.spec.js: -------------------------------------------------------------------------------- 1 | import { 2 | translate, 3 | updateDictionary, 4 | changeLocale, 5 | Dictionary, 6 | config, 7 | translateKey, 8 | } from '../index'; 9 | import DictionaryOriginal from '../Dictionary'; 10 | 11 | describe('library', () => { 12 | it('should have all the methods as a require import', () => { 13 | expect(typeof translate).toEqual('function'); 14 | expect(typeof updateDictionary).toEqual('function'); 15 | expect(typeof changeLocale).toEqual('function'); 16 | expect(typeof config).toEqual('function'); 17 | expect(typeof translateKey).toEqual('function'); 18 | expect(Dictionary).toEqual(DictionaryOriginal); 19 | }); 20 | }); 21 | -------------------------------------------------------------------------------- /src/__tests__/index.node.spec.js: -------------------------------------------------------------------------------- 1 | const reword = require('../index'); 2 | const Dictionary = require('../Dictionary').default; 3 | 4 | describe('library', () => { 5 | it('should have all the methods as a require import', () => { 6 | expect(typeof reword.translate).toEqual('function'); 7 | expect(typeof reword.updateDictionary).toEqual('function'); 8 | expect(typeof reword.changeLocale).toEqual('function'); 9 | expect(typeof reword.config).toEqual('function'); 10 | expect(typeof reword.translateKey).toEqual('function'); 11 | expect(reword.Dictionary).toEqual(Dictionary); 12 | }); 13 | }); 14 | -------------------------------------------------------------------------------- /src/__tests__/mapUtilities.spec.js: -------------------------------------------------------------------------------- 1 | import { generateTranslationMap, getTextWithPlaceholders } from '../mapUtilities'; 2 | import dictionary, { 3 | SPANISH_LOCALE, 4 | englishDictionary, 5 | spanshiDictionary, 6 | } from './dictionary.fixture'; 7 | import { DEFAULT_LOCALE, VAR_REGEX } from '../constants'; 8 | 9 | const options = { 10 | defaultLocale: DEFAULT_LOCALE, 11 | locale: SPANISH_LOCALE, 12 | variableMatcher: VAR_REGEX, 13 | }; 14 | 15 | describe('mapUtilities', () => { 16 | describe('when current locale is different', () => { 17 | it('should create a mapping english -> spanish', () => { 18 | const result = generateTranslationMap(dictionary, options); 19 | 20 | expect(result[englishDictionary.title].text).toEqual(spanshiDictionary.title); 21 | }); 22 | 23 | it('should calculate parameter positions for english -> spanish', () => { 24 | const result = generateTranslationMap(dictionary, options); 25 | 26 | expect( 27 | result[getTextWithPlaceholders(englishDictionary.titleWithMixedParams, options)].positions, 28 | ).toEqual([1, 0]); 29 | }); 30 | }); 31 | 32 | describe('when current locale is the same as default', () => { 33 | it('should create a mapping english -> english', () => { 34 | const result = generateTranslationMap(dictionary, { ...options, locale: DEFAULT_LOCALE }); 35 | 36 | expect(result[englishDictionary.title].text).toEqual(englishDictionary.title); 37 | }); 38 | 39 | it('should return correct parameter positions as they are in original', () => { 40 | const result = generateTranslationMap(dictionary, { ...options, locale: DEFAULT_LOCALE }); 41 | 42 | expect( 43 | result[getTextWithPlaceholders(englishDictionary.titleWithMixedParams, options)].positions, 44 | ).toEqual([0, 1]); 45 | }); 46 | }); 47 | }); 48 | -------------------------------------------------------------------------------- /src/__tests__/validateTranslations.spec.js: -------------------------------------------------------------------------------- 1 | import valdiateTranslations from '../validateTranslations'; 2 | import dictionary, { SPANISH_LOCALE, spanshiDictionary } from './dictionary.fixture'; 3 | 4 | describe('validateTranslations()', () => { 5 | it('should return an empty array if no errors are found', () => { 6 | expect(valdiateTranslations(dictionary)).toEqual([]); 7 | }); 8 | 9 | it('should return error for all of the keys missing in empty dictionary', () => { 10 | const result = valdiateTranslations({ 11 | ...dictionary, 12 | [SPANISH_LOCALE]: { 13 | nested: {}, 14 | }, 15 | }); 16 | 17 | expect(result.length).toEqual(5); 18 | }); 19 | 20 | it('should return only missing values from the provided dictionary', () => { 21 | const result = valdiateTranslations({ 22 | ...dictionary, 23 | [SPANISH_LOCALE]: { 24 | title: spanshiDictionary.title, 25 | titleWithDoubleParams: spanshiDictionary.titleWithDoubleParams, 26 | }, 27 | }); 28 | 29 | expect(result.length).toEqual(3); 30 | }); 31 | 32 | it('should return parameters missing error', () => { 33 | const result = valdiateTranslations({ 34 | ...dictionary, 35 | [SPANISH_LOCALE]: { 36 | ...spanshiDictionary, 37 | titleWithParams: 'Only {firstName}', 38 | }, 39 | }); 40 | 41 | expect(result[0].missingParams).toEqual('{lastName}'); 42 | }); 43 | 44 | it('should return exess parameter error', () => { 45 | const result = valdiateTranslations({ 46 | ...dictionary, 47 | [SPANISH_LOCALE]: { 48 | ...spanshiDictionary, 49 | titleWithParams: `${spanshiDictionary.titleWithParams} {middleName}`, 50 | }, 51 | }); 52 | 53 | expect(result[0].exessParams).toEqual('{middleName}'); 54 | }); 55 | }); 56 | -------------------------------------------------------------------------------- /src/constants.js: -------------------------------------------------------------------------------- 1 | export const DEFAULT_LOCALE = 'en-US'; 2 | export const VAR_REGEX = /\{(.*?)\}/g; 3 | export const PLACEHOLDER_REGEX = /\{\}/g; 4 | export const PLACEHOLDER = '{}'; 5 | export const TEXT = { 6 | TRANSLATION_NOT_FOUND: 'TRANSLATION_NOT_FOUND', 7 | }; 8 | -------------------------------------------------------------------------------- /src/env.js: -------------------------------------------------------------------------------- 1 | export const isProduction = () => process.env.NODE_ENV === 'production'; 2 | export const isDevelopment = () => process.env.NODE_ENV === 'development'; 3 | export const isTest = () => process.env.NODE_ENV === 'test'; 4 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | import Dictionary from './Dictionary'; 2 | 3 | const translations = new Dictionary(); 4 | const { 5 | translate, updateDictionary, changeLocale, config, translateKey, 6 | } = translations; 7 | 8 | export { 9 | translate, translateKey, updateDictionary, changeLocale, config, Dictionary, 10 | }; 11 | -------------------------------------------------------------------------------- /src/mapUtilities.js: -------------------------------------------------------------------------------- 1 | import { PLACEHOLDER } from './constants'; 2 | 3 | export function getLocaleDictionary(dictionary, locale) { 4 | return dictionary[locale] || {}; 5 | } 6 | 7 | export function getTextWithPlaceholders(text, options) { 8 | return text.replace(options.variableMatcher, PLACEHOLDER); 9 | } 10 | 11 | function getParameterPositions(defaultText, text, options) { 12 | const defaultPositions = defaultText.match(options.variableMatcher) || []; 13 | const translationPositions = text.match(options.variableMatcher) || []; 14 | 15 | return translationPositions.map(val => defaultPositions.indexOf(val)); 16 | } 17 | 18 | function traverseDictionary(defaultDictionary, localeDictionary, result = {}, options) { 19 | return Object.keys(defaultDictionary).reduce((agg, key) => { 20 | const defaultText = defaultDictionary[key]; 21 | const text = localeDictionary[key]; 22 | 23 | if (typeof text === 'object') { 24 | return traverseDictionary(defaultDictionary[key], localeDictionary[key], agg, options); 25 | } 26 | 27 | return { 28 | ...agg, 29 | [getTextWithPlaceholders(defaultText, options)]: { 30 | text: getTextWithPlaceholders(text, options), 31 | positions: getParameterPositions(defaultText, text, options), 32 | }, 33 | }; 34 | }, result); 35 | } 36 | 37 | export function generateTranslationMap(dictionary, options) { 38 | const defaultDictionary = getLocaleDictionary(dictionary, options.defaultLocale); 39 | const localeDictionary = getLocaleDictionary(dictionary, options.locale); 40 | 41 | if (!localeDictionary) { 42 | return {}; 43 | } 44 | 45 | return traverseDictionary(defaultDictionary, localeDictionary, {}, options); 46 | } 47 | -------------------------------------------------------------------------------- /src/utils/__tests__/get.spec.js: -------------------------------------------------------------------------------- 1 | import get from '../get'; 2 | 3 | const testObject = { 4 | value1: 'value1', 5 | value2: { 6 | value3: 'value3', 7 | value4: { 8 | value5: 'value5', 9 | }, 10 | arrayValue: ['arrayValue1'], 11 | }, 12 | }; 13 | 14 | describe('get()', () => { 15 | it('should return objects property value', () => { 16 | expect(get(testObject, 'value1')).toEqual('value1'); 17 | }); 18 | 19 | it('should return second nested object value', () => { 20 | expect(get(testObject, 'value2.value3')).toEqual('value3'); 21 | }); 22 | 23 | it('should return third nested object value', () => { 24 | expect(get(testObject, 'value2.value4.value5')).toEqual('value5'); 25 | }); 26 | 27 | it('should return a nested array value', () => { 28 | expect(get(testObject, 'value2.arrayValue.0')).toEqual('arrayValue1'); 29 | }); 30 | }); 31 | -------------------------------------------------------------------------------- /src/utils/get.js: -------------------------------------------------------------------------------- 1 | export default function get(object, key, defaultValue) { 2 | const [first, ...rest] = key.replace(/ .[\d]/g, '.').split('.'); 3 | 4 | if (!rest.length) { 5 | return object[first] || defaultValue; 6 | } 7 | 8 | return get(object[first], rest.join('.')); 9 | } 10 | -------------------------------------------------------------------------------- /src/validateTranslations.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-console */ 2 | 3 | import { DEFAULT_LOCALE, VAR_REGEX } from './constants'; 4 | 5 | function validateParams(defaultText, text) { 6 | const defaultParams = defaultText.match(VAR_REGEX) || []; 7 | const params = text.match(VAR_REGEX) || []; 8 | 9 | const exessParams = params.filter(param => defaultParams.every(p => p !== param)); 10 | const missingParams = defaultParams.filter(param => params.every(p => p !== param)); 11 | 12 | if (exessParams.length || missingParams.length) { 13 | return { 14 | exessParams, 15 | missingParams, 16 | }; 17 | } 18 | 19 | return null; 20 | } 21 | 22 | function buildError({ 23 | locale, 24 | key, 25 | error = 'Unknow error', 26 | missingParams = [], 27 | exessParams = [], 28 | } = {}) { 29 | return { 30 | locale, 31 | key, 32 | error, 33 | missingParams: missingParams.join(','), 34 | exessParams: exessParams.join(','), 35 | }; 36 | } 37 | 38 | function validateKeys(defaultDictionary, dictionary, locale, previousKey = '', result = []) { 39 | return Object.keys(defaultDictionary).reduce((agg, key) => { 40 | const translateText = dictionary[key]; 41 | 42 | if (!translateText) { 43 | return [ 44 | ...agg, 45 | buildError({ 46 | locale, 47 | key: `${previousKey}${key}`, 48 | error: 'Translation does not exist', 49 | }), 50 | ]; 51 | } 52 | 53 | if (typeof translateText === 'object') { 54 | return validateKeys(defaultDictionary[key], dictionary[key], locale, `${key}.`, agg); 55 | } 56 | 57 | const paramErrors = validateParams(defaultDictionary[key], translateText); 58 | 59 | if (paramErrors) { 60 | return [ 61 | ...agg, 62 | buildError({ 63 | locale, 64 | key: `${previousKey}${key}`, 65 | error: 'Params missing', 66 | ...paramErrors, 67 | }), 68 | ]; 69 | } 70 | 71 | return agg; 72 | }, result); 73 | } 74 | 75 | function validateDictionary(translations, locale) { 76 | const defaultDictionary = translations[DEFAULT_LOCALE]; 77 | const dictionary = translations[locale]; 78 | 79 | return validateKeys(defaultDictionary, dictionary, locale); 80 | } 81 | 82 | export default function validateTranslations(translations) { 83 | const errors = Object.keys(translations) 84 | .filter(key => key !== DEFAULT_LOCALE) 85 | .map(key => ({ 86 | locale: key, 87 | errors: validateDictionary(translations, key), 88 | })); 89 | 90 | return errors.reduce((agg, err) => { 91 | if (err.errors.length) { 92 | return [...agg, ...err.errors]; 93 | } 94 | 95 | return agg; 96 | }, []); 97 | } 98 | --------------------------------------------------------------------------------