├── src ├── __snapshots__ │ ├── use-localize.test.js.snap │ ├── Message.test.js.snap │ ├── index.test.js.snap │ ├── helpers.test.js.snap │ └── Provider.test.js.snap ├── use-localize.js ├── index.js ├── Message.js ├── index.test.js ├── use-localize.test.js ├── Message.test.js ├── Provider.js ├── helpers.js ├── helpers.test.js └── Provider.test.js ├── .editorconfig ├── .npmignore ├── .gitignore ├── .babelrc ├── .circleci └── config.yml ├── jest.config.js ├── rollup.config.js ├── LICENSE ├── index.d.ts ├── package.json ├── dist └── localize-react.js └── README.md /src/__snapshots__/use-localize.test.js.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`Localize hook translates from key to text 1`] = `"Alex"`; 4 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | indent_style = space 5 | indent_size = 2 6 | 7 | end_of_line = lf 8 | charset = utf-8 9 | trim_trailing_whitespace = true 10 | insert_final_newline = true -------------------------------------------------------------------------------- /src/use-localize.js: -------------------------------------------------------------------------------- 1 | import { useContext } from 'react'; 2 | import { LocalizationContext } from './Provider'; 3 | 4 | export function useLocalize() { 5 | return useContext(LocalizationContext); 6 | } 7 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | .editorconfig 2 | npm-debug.log* 3 | yarn-debug.log* 4 | yarn-error.log* 5 | node_modules/ 6 | coverage/ 7 | .circleci/ 8 | .npm 9 | src/ 10 | webpack.config.js 11 | yarn.lock 12 | jest.config.js 13 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | export { 2 | LocalizationConsumer, 3 | LocalizationContext, 4 | LocalizationProvider, 5 | } from './Provider'; 6 | 7 | export { useLocalize } from './use-localize'; 8 | export { Message } from './Message'; 9 | -------------------------------------------------------------------------------- /src/Message.js: -------------------------------------------------------------------------------- 1 | import { useLocalize } from './use-localize'; 2 | 3 | export function Message({ defaultMessage, descriptor, values }) { 4 | const { translate } = useLocalize(); 5 | return translate(descriptor, values, defaultMessage); 6 | } 7 | -------------------------------------------------------------------------------- /src/index.test.js: -------------------------------------------------------------------------------- 1 | import * as lib from './index'; 2 | 3 | describe('lib', () => { 4 | it('provides non-breaking changes', () => { 5 | const keys = Object.keys(lib); 6 | 7 | expect(keys).toMatchSnapshot(); 8 | }); 9 | }); 10 | -------------------------------------------------------------------------------- /src/__snapshots__/Message.test.js.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`Message translates from key to text 1`] = `"Alex"`; 4 | 5 | exports[`Message translates from key to text with default message 1`] = `"Default Message"`; 6 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | 8 | # Dependency directories 9 | node_modules/ 10 | 11 | # Optional npm cache directory 12 | .npm 13 | 14 | # Optional eslint cache 15 | .eslintcache 16 | 17 | # coverage 18 | coverage/ 19 | -------------------------------------------------------------------------------- /src/__snapshots__/index.test.js.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`lib provides non-breaking changes 1`] = ` 4 | Array [ 5 | "LocalizationConsumer", 6 | "LocalizationContext", 7 | "LocalizationProvider", 8 | "useLocalize", 9 | "Message", 10 | ] 11 | `; 12 | -------------------------------------------------------------------------------- /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "plugins": [ 3 | "@babel/plugin-transform-object-assign", 4 | ], 5 | "presets": [ 6 | [ 7 | "@babel/preset-env", 8 | { 9 | "targets": { 10 | "browsers": [ 11 | "> 1%", 12 | "safari >= 8", 13 | "ie >= 11" 14 | ] 15 | } 16 | } 17 | ], 18 | "@babel/preset-react" 19 | ] 20 | } 21 | -------------------------------------------------------------------------------- /.circleci/config.yml: -------------------------------------------------------------------------------- 1 | version: 2.0 2 | jobs: 3 | build: 4 | docker: 5 | - image: library/node:12.2.0-alpine 6 | steps: 7 | - checkout 8 | - run: 9 | name: Install yarn 10 | command: | 11 | npm config set unsafe-perm true 12 | npm i yarn -g 13 | - run: 14 | name: Install dependencies 15 | command: | 16 | yarn 17 | - run: 18 | name: Tests 19 | command: | 20 | yarn coverage 21 | yarn codecov 22 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | collectCoverage: true, 3 | collectCoverageFrom: [ 4 | '**/src/**.{js,jsx}', 5 | ], 6 | coverageDirectory: './coverage', 7 | coverageReporters: ['json', 'html'], 8 | moduleFileExtensions: [ 9 | 'js', 10 | 'jsx', 11 | 'json', 12 | ], 13 | modulePaths: ['./src'], 14 | testMatch: [`/src/**/*.test.js`], 15 | transform: { 16 | '^.+\\.(js|jsx)$': 'babel-jest', 17 | }, 18 | unmockedModulePathPatterns: [ 19 | 'node_modules/react/', 20 | ], 21 | verbose: true, 22 | }; 23 | -------------------------------------------------------------------------------- /rollup.config.js: -------------------------------------------------------------------------------- 1 | import babel from 'rollup-plugin-babel'; 2 | import resolve from 'rollup-plugin-node-resolve'; 3 | import { uglify } from 'rollup-plugin-uglify'; 4 | import autoExternal from 'rollup-plugin-auto-external'; 5 | import replace from 'rollup-plugin-replace'; 6 | 7 | const config = { 8 | input: 'src/index.js', 9 | output: { 10 | file: './dist/localize-react.js', 11 | format: 'umd', 12 | name: 'localize-react', 13 | globals: { 'react': 'React' }, 14 | }, 15 | plugins: [ 16 | autoExternal(), 17 | replace({ NODE_ENV: process.env.API_KEY }), 18 | resolve(), 19 | babel({ exclude: 'node_modules/**' }), 20 | uglify(), 21 | ] 22 | }; 23 | 24 | export default config; 25 | -------------------------------------------------------------------------------- /src/use-localize.test.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import renderer from 'react-test-renderer'; 3 | import { LocalizationProvider } from './Provider'; 4 | import { useLocalize } from './use-localize'; 5 | 6 | const TRANSLATIONS = { 7 | name: 'Alex', 8 | }; 9 | 10 | describe('Localize hook', () => { 11 | it('translates from key to text', () => { 12 | function Test() { 13 | const { translate } = useLocalize(); 14 | 15 | return translate('name'); 16 | } 17 | 18 | const tree = renderer 19 | .create( 20 | 23 | 24 | 25 | ) 26 | .toJSON(); 27 | 28 | expect(tree).toMatchSnapshot(); 29 | }); 30 | }); 31 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Aliaksandr Yankouski 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 | -------------------------------------------------------------------------------- /src/Message.test.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import renderer from 'react-test-renderer'; 3 | import { LocalizationProvider } from './Provider'; 4 | import { Message } from './Message'; 5 | 6 | const TRANSLATIONS = { 7 | en: { 8 | name: 'Alex', 9 | }, 10 | }; 11 | 12 | describe('Message', () => { 13 | it('translates from key to text', () => { 14 | const tree = renderer 15 | .create( 16 | 20 | 21 | 22 | ) 23 | .toJSON(); 24 | 25 | expect(tree).toMatchSnapshot(); 26 | }); 27 | 28 | it('translates from key to text with default message', () => { 29 | const tree = renderer 30 | .create( 31 | 35 | 39 | 40 | ) 41 | .toJSON(); 42 | 43 | expect(tree).toMatchSnapshot(); 44 | }); 45 | }); 46 | -------------------------------------------------------------------------------- /index.d.ts: -------------------------------------------------------------------------------- 1 | import { Context, ComponentType, Consumer, PropsWithChildren } from 'react'; 2 | 3 | interface Translations { 4 | [key: string]: string | number | Translations; 5 | } 6 | 7 | type TemplateValues = Record; 8 | 9 | type Translate = ( 10 | key: string, 11 | values?: TemplateValues, 12 | defaultMessage?: string 13 | ) => string; 14 | 15 | interface LocalizationContextValue { 16 | locale: string; 17 | translate: Translate; 18 | translations: Translations; 19 | } 20 | 21 | interface LocalizationProviderProps { 22 | locale?: string; 23 | disableCache?: boolean; 24 | translations: Translations; 25 | } 26 | 27 | interface MessageComponentProps { 28 | descriptor: string; 29 | values?: TemplateValues; 30 | defaultMessage?: string; 31 | } 32 | 33 | type UseLocalizeHook = () => LocalizationContextValue; 34 | 35 | type MessageComponent = ComponentType; 36 | 37 | export const useLocalize: UseLocalizeHook; 38 | 39 | export const Message: MessageComponent; 40 | 41 | export const LocalizationContext: Context; 42 | 43 | export const LocalizationProvider: ComponentType>; 44 | 45 | export const LocalizationConsumer: Consumer; 46 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "localize-react", 3 | "version": "1.7.1", 4 | "description": "React Localization Library", 5 | "main": "dist/localize-react.js", 6 | "repository": "git@github.com:yankouskia/localize-react.git", 7 | "author": "yankouskia ", 8 | "license": "MIT", 9 | "peerDependencies": { 10 | "react": ">=16.8.0" 11 | }, 12 | "devDependencies": { 13 | "@babel/core": "^7.5.5", 14 | "@babel/plugin-transform-object-assign": "^7.2.0", 15 | "@babel/preset-env": "^7.5.5", 16 | "@babel/preset-react": "^7.0.0", 17 | "babel-jest": "^24.8.0", 18 | "babel-loader": "^8.0.6", 19 | "codecov": "^3.5.0", 20 | "jest": "^24.8.0", 21 | "react": "^16.8.6", 22 | "react-test-renderer": "^16.8.6", 23 | "rollup": "^1.17.0", 24 | "rollup-plugin-auto-external": "^2.0.0", 25 | "rollup-plugin-babel": "^4.3.3", 26 | "rollup-plugin-commonjs": "^10.0.1", 27 | "rollup-plugin-node-resolve": "^5.2.0", 28 | "rollup-plugin-replace": "^2.2.0", 29 | "rollup-plugin-uglify": "^6.0.2" 30 | }, 31 | "scripts": { 32 | "build": "NODE_ENV=production rollup -c", 33 | "test": "jest", 34 | "coverage": "jest --coverage", 35 | "codecov": "codecov -t 8365e97e-7298-4a00-ad32-740d44db89e5 -f coverage/*.json" 36 | }, 37 | "types": "./index.d.ts" 38 | } 39 | -------------------------------------------------------------------------------- /src/__snapshots__/helpers.test.js.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`helpers buildTranslation builds simple translation with empty values 1`] = `"Hello {{world}}"`; 4 | 5 | exports[`helpers buildTranslation builds simple translation with no values 1`] = `"Hello {{world}}"`; 6 | 7 | exports[`helpers buildTranslation builds translation with multiple values 1`] = `"Hello, my name is Alex. I am 25"`; 8 | 9 | exports[`helpers buildTranslation builds translation with no templates inside 1`] = `"Hello"`; 10 | 11 | exports[`helpers sanitizeLocale returns null is locale is not specified 1`] = `null`; 12 | 13 | exports[`helpers sanitizeLocale returns the lowercase underscore locale if original was not found 1`] = `"en_ca"`; 14 | 15 | exports[`helpers sanitizeLocale returns the original and makes warning if no matching happened 1`] = `"FR-CA"`; 16 | 17 | exports[`helpers sanitizeLocale returns the same locale is that is defined in translations 1`] = `"En-uS"`; 18 | 19 | exports[`helpers sanitizeLocale returns the shorten locale if original was not found 1`] = `"en"`; 20 | 21 | exports[`helpers transformToPairs creates pair of template - value 1`] = ` 22 | Array [ 23 | Array [ 24 | "{{name}}", 25 | "Alex", 26 | ], 27 | ] 28 | `; 29 | 30 | exports[`helpers transformToPairs creates pairs of template - value 1`] = ` 31 | Array [ 32 | Array [ 33 | "{{name}}", 34 | "Alex", 35 | ], 36 | Array [ 37 | "{{age}}", 38 | 25, 39 | ], 40 | ] 41 | `; 42 | 43 | exports[`helpers transformToPairs creates pairs with same value if no correspondent value found 1`] = ` 44 | Array [ 45 | Array [ 46 | "{{name}}", 47 | "Alex", 48 | ], 49 | Array [ 50 | "{{age}}", 51 | 25, 52 | ], 53 | Array [ 54 | "{{not_found}}", 55 | "{{not_found}}", 56 | ], 57 | ] 58 | `; 59 | -------------------------------------------------------------------------------- /src/Provider.js: -------------------------------------------------------------------------------- 1 | import React, { useEffect } from 'react'; 2 | import { 3 | buildTranslation, 4 | clearCache, 5 | memoize, 6 | sanitizeLocale, 7 | } from './helpers'; 8 | 9 | export const LocalizationContext = React.createContext(); 10 | 11 | export function LocalizationProvider({ children, disableCache, locale, translations = {} }) { 12 | const sanitizedLocale = sanitizeLocale(locale, translations); 13 | const pureTranslations = sanitizedLocale ? translations[sanitizedLocale] : translations; 14 | 15 | useEffect(() => { 16 | clearCache(); 17 | }, [locale, translations]); 18 | 19 | function pureTranslateFn(key, values, defaultMessage) { 20 | if (!pureTranslations || !key) return defaultMessage || key; 21 | 22 | const fallbackTranslation = typeof defaultMessage === 'string' ? defaultMessage : key; 23 | const possibleValue = pureTranslations[key]; 24 | 25 | if (typeof possibleValue === 'string') { 26 | if (!values) return possibleValue; 27 | 28 | return buildTranslation(possibleValue, values); 29 | } 30 | 31 | const complexKeyArray = key.split('.'); 32 | if (complexKeyArray.length === 1) return buildTranslation(fallbackTranslation, values); 33 | 34 | let finalValue = pureTranslations[complexKeyArray[0]]; 35 | for (let i = 1; i < complexKeyArray.length; i++) { 36 | if (finalValue) { 37 | finalValue = finalValue[complexKeyArray[i]]; 38 | } 39 | } 40 | 41 | return typeof finalValue === 'string' ? buildTranslation(finalValue, values) : buildTranslation(fallbackTranslation, values); 42 | } 43 | 44 | const translate = disableCache ? pureTranslateFn : memoize(pureTranslateFn); 45 | 46 | return ( 47 | 48 | {children} 49 | 50 | ); 51 | } 52 | 53 | export const LocalizationConsumer = LocalizationContext.Consumer; 54 | -------------------------------------------------------------------------------- /dist/localize-react.js: -------------------------------------------------------------------------------- 1 | !function(e,t){"object"==typeof exports&&"undefined"!=typeof module?t(exports,require("react")):"function"==typeof define&&define.amd?define(["exports","react"],t):t((e=e||self)["localize-react"]={},e.React)}(this,function(e,s){"use strict";var p="default"in s?s.default:s;function v(e){return(v="function"==typeof Symbol&&"symbol"==typeof Symbol.iterator?function(e){return typeof e}:function(e){return e&&"function"==typeof Symbol&&e.constructor===Symbol&&e!==Symbol.prototype?"symbol":typeof e})(e)}var y={},d="[LOCALIZE-REACT]: There are no translations for specified locale",a="[LOCALIZE-REACT] Looks like template is being used, but no value passed for ",u=/{{([^{]+[^}])}}/g;function b(e,t){if(!t)return e;if(0===Object.keys(t).length)return e;var r,n,o,i=e.match(u);return i&&0!==i.length?(r=i,n=t,o=Object.keys(n),r.map(function(e){var t=Array.prototype.slice.call(e,2,-2).join(""),r=o.find(function(e){return e===t});return r?[e,n[r]]:(console.warn(a,e),[e,e])})).reduce(function(e,t){var r=new RegExp(t[0],"gi");return e.replace(r,t[1])},e):e}var g=p.createContext();var t=g.Consumer;function o(){return s.useContext(g)}e.LocalizationConsumer=t,e.LocalizationContext=g,e.LocalizationProvider=function(e){var t=e.children,r=e.disableCache,n=e.locale,o=e.translations,i=void 0===o?{}:o,a=function(e,t){if(!e)return null;if("object"===v(t[e]))return e;var r=e.toLowerCase().replace(/-/g,"_");if("object"===v(t[r]))return r;var n=r.split("_")[0];return"object"===v(t[n])?n:(console.warn(d,e),e)}(n,i),l=a?i[a]:i;function u(e,t,r){if(!l||!e)return r||e;var n="string"==typeof r?r:e,o=l[e];if("string"==typeof o)return t?b(o,t):o;var i=e.split(".");if(1===i.length)return b(n,t);for(var a=l[i[0]],u=1;u { 50 | const correspondentKey = Array.prototype.slice.call(tpl, 2, -2).join(''); 51 | const rightKey = valuesKeys.find(valueKey => valueKey === correspondentKey); 52 | 53 | if (!rightKey) { 54 | console.warn(NO_TEMPLATE_VALUE_MESSAGE, tpl); 55 | return [tpl, tpl]; 56 | } 57 | 58 | return [tpl, values[rightKey]]; 59 | }); 60 | } 61 | 62 | export function buildTranslation(str, values) { 63 | if (!values) return str; 64 | 65 | const keys = Object.keys(values); 66 | if (keys.length === 0) return str; 67 | 68 | const templates = str.match(PARSE_TEMPLATE_REGEXP); 69 | if (!templates || templates.length === 0) return str; 70 | 71 | const templatePairs = transformToPairs(templates, values); 72 | 73 | return templatePairs.reduce((result, templatePair) => { 74 | const replaceRegexp = new RegExp(templatePair[0], 'gi'); 75 | 76 | return result.replace(replaceRegexp, templatePair[1]); 77 | }, str); 78 | } 79 | -------------------------------------------------------------------------------- /src/__snapshots__/Provider.test.js.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`Provider Default message should render default message if no translation exists 1`] = ` 4 | 5 | Default Message 6 | 7 | `; 8 | 9 | exports[`Provider LocalizationConsumer clears cache after props update 1`] = ` 10 |
11 | 12 | C 13 | 14 |
15 | `; 16 | 17 | exports[`Provider LocalizationConsumer provides empty object translations by default 1`] = `"any key"`; 18 | 19 | exports[`Provider LocalizationConsumer provides key translation if nested key was not found 1`] = ` 20 |
21 | 22 | 23 | 24 |
25 | `; 26 | 27 | exports[`Provider LocalizationConsumer provides nested key translation without locale 1`] = ` 28 |
29 | 30 | translation 31 | 32 |
33 | `; 34 | 35 | exports[`Provider LocalizationConsumer provides translation for empty key as the same key 1`] = ` 36 |
37 | 38 | 39 | 40 |
41 | `; 42 | 43 | exports[`Provider LocalizationConsumer provides translation for empty message 1`] = ` 44 |
45 | 46 | 47 | 48 |
49 | `; 50 | 51 | exports[`Provider LocalizationConsumer provides translation for key with values 1`] = ` 52 |
53 | 54 | Hello Alex 55 | 56 |
57 | `; 58 | 59 | exports[`Provider LocalizationConsumer provides translation for not found key with values 1`] = ` 60 |
61 | 62 | 63 | 64 |
65 | `; 66 | 67 | exports[`Provider LocalizationConsumer provides translation for set locale 1`] = `"Alex"`; 68 | 69 | exports[`Provider LocalizationConsumer provides translation the same as key for unexisting key 1`] = ` 70 |
71 | 72 | 73 | 74 |
75 | `; 76 | 77 | exports[`Provider LocalizationConsumer provides translation with nested key 1`] = ` 78 |
79 | 80 | yankouskia 81 | 82 |
83 | `; 84 | 85 | exports[`Provider LocalizationContext can be used in classes 1`] = ` 86 | 87 | https://github.com/yankouskia 88 | 89 | `; 90 | 91 | exports[`Provider LocalizationProvider renders children 1`] = `"children as a text"`; 92 | 93 | exports[`Provider calculate translation each time when disableCache is passed 1`] = ` 94 |
95 | 96 | translation 97 | 98 |
99 | `; 100 | 101 | exports[`Provider calculate translation each time when disableCache is passed 2`] = ` 102 |
103 | 104 | Changed Translation 105 | 106 |
107 | `; 108 | -------------------------------------------------------------------------------- /src/helpers.test.js: -------------------------------------------------------------------------------- 1 | import { 2 | buildTranslation, 3 | clearCache, 4 | memoize, 5 | sanitizeLocale, 6 | transformToPairs, 7 | NO_TEMPLATE_VALUE_MESSAGE, 8 | NO_TRANSLATION_WARNING_MESSAGE, 9 | } from './helpers'; 10 | 11 | describe('helpers', () => { 12 | describe('sanitizeLocale', () => { 13 | it('returns null is locale is not specified', () => { 14 | const sanitizedLocale = sanitizeLocale(null, {}); 15 | expect(sanitizedLocale).toMatchSnapshot(); 16 | }); 17 | 18 | it('returns the same locale is that is defined in translations', () => { 19 | const sanitizedLocale = sanitizeLocale('En-uS', { 'En-uS': { name: 'Alex' } }); 20 | expect(sanitizedLocale).toMatchSnapshot(); 21 | }); 22 | 23 | it('returns the lowercase underscore locale if original was not found', () => { 24 | const sanitizedLocale = sanitizeLocale('En-cA', { en_ca: { name: 'Alex' } }); 25 | expect(sanitizedLocale).toMatchSnapshot(); 26 | }); 27 | 28 | it('returns the shorten locale if original was not found', () => { 29 | const sanitizedLocale = sanitizeLocale('En-Fr', { en: { name: 'Alex' } }); 30 | expect(sanitizedLocale).toMatchSnapshot(); 31 | }); 32 | 33 | it('returns the original and makes warning if no matching happened', () => { 34 | global.console = { warn: jest.fn() }; 35 | 36 | const sanitizedLocale = sanitizeLocale('FR-CA', { en: { name: 'Alex' } }); 37 | expect(sanitizedLocale).toMatchSnapshot(); 38 | expect(global.console.warn).toHaveBeenCalled(); 39 | expect(global.console.warn).toHaveBeenCalledWith(NO_TRANSLATION_WARNING_MESSAGE, 'FR-CA'); 40 | }); 41 | }); 42 | 43 | describe('memoize', () => { 44 | it('memoizes value', () => { 45 | const fn = jest.fn(_ => _); 46 | 47 | const memoizedFn = memoize(fn); 48 | const firstResult = memoizedFn(1); 49 | const secondResult = memoizedFn(1); 50 | 51 | clearCache(); 52 | 53 | expect(firstResult).toEqual(secondResult); 54 | expect(fn).toHaveBeenCalledTimes(1); 55 | }); 56 | }); 57 | 58 | describe('transformToPairs', () => { 59 | it('creates pair of template - value', () => { 60 | const pairs = transformToPairs(['{{name}}'], { name: 'Alex' }); 61 | expect(pairs).toMatchSnapshot(); 62 | }); 63 | 64 | it('creates pairs of template - value', () => { 65 | const pairs = transformToPairs(['{{name}}', '{{age}}'], { name: 'Alex', age: 25 }); 66 | expect(pairs).toMatchSnapshot(); 67 | }); 68 | 69 | it('creates pairs with same value if no correspondent value found', () => { 70 | global.console = { warn: jest.fn() }; 71 | 72 | const pairs = transformToPairs(['{{name}}', '{{age}}', '{{not_found}}'], { name: 'Alex', age: 25 }); 73 | expect(pairs).toMatchSnapshot(); 74 | 75 | expect(global.console.warn).toHaveBeenCalled(); 76 | expect(global.console.warn).toHaveBeenCalledWith(NO_TEMPLATE_VALUE_MESSAGE, '{{not_found}}'); 77 | }); 78 | }); 79 | 80 | describe('buildTranslation', () => { 81 | it('builds simple translation with no values', () => { 82 | const result = buildTranslation('Hello {{world}}'); 83 | expect(result).toMatchSnapshot(); 84 | }); 85 | 86 | it('builds simple translation with empty values', () => { 87 | const result = buildTranslation('Hello {{world}}', {}); 88 | expect(result).toMatchSnapshot(); 89 | }); 90 | 91 | it('builds translation with no templates inside', () => { 92 | const result = buildTranslation('Hello', { name: 'Alex' }); 93 | expect(result).toMatchSnapshot(); 94 | }); 95 | 96 | it('builds translation with multiple values', () => { 97 | const result = buildTranslation('Hello, my name is {{name}}. I am {{age}}', { age: 25, name: 'Alex' }); 98 | expect(result).toMatchSnapshot(); 99 | }); 100 | }); 101 | }); 102 | 103 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![CircleCI](https://circleci.com/gh/yankouskia/localize-react.svg?style=shield)](https://circleci.com/gh/yankouskia/localize-react) [![Codecov Coverage](https://img.shields.io/codecov/c/github/yankouskia/localize-react/master.svg?style=flat-square)](https://codecov.io/gh/yankouskia/localize-react/) [![PRs Welcome](https://img.shields.io/badge/PRs-welcome-brightgreen.svg)](https://github.com/yankouskia/localize-react/pulls) [![GitHub license](https://img.shields.io/badge/license-MIT-blue.svg)](https://github.com/yankouskia/localize-react/blob/master/LICENSE) ![GitHub stars](https://img.shields.io/github/stars/yankouskia/localize-react.svg?style=social) 2 | 3 | [![NPM](https://nodei.co/npm/localize-react.png?downloads=true)](https://www.npmjs.com/package/localize-react) 4 | 5 | # localize-react 6 | 7 | ✈️ Lightweight React Localization Library 🇺🇸 8 | 9 | ## Motivation 10 | 11 | Creating really simple lightweight library for localization in React applications without any dependencies, which is built on top of new [React Context Api](https://reactjs.org/docs/context.html) 12 | 13 | Library has just **737 Bytes** gzipped size 14 | 15 | 16 | ## Installation 17 | 18 | npm: 19 | 20 | ```sh 21 | npm install localize-react --save 22 | ``` 23 | 24 | yarn: 25 | 26 | ```sh 27 | yarn add localize-react 28 | ``` 29 | 30 | ## API 31 | 32 | ### Provider & Consumer 33 | 34 | `LocalizationProvider` is used to provide data for translations into React context. The root application component should be wrapped into `LocalizationProvider`. Component has the next props: 35 | - `children` - children to render 36 | - `locale` - [OPTIONAL] locale to be used for translations. If locale is not specified regular translations object will be used as map of `{ key: translations }` 37 | - `translations` - object with translations 38 | - `disableCache` - boolean variable to disable cache on runtime (`false` by default). Setting this to `true` could affect runtime performance, but could be useful for development. 39 | 40 | Example: 41 | 42 | ```js 43 | import React from 'react'; 44 | import ReactDOM from 'react-dom'; 45 | import { LocalizationConsumer, LocalizationProvider } from 'localize-react'; 46 | 47 | const TRANSLATIONS = { 48 | en: { 49 | name: 'Alex', 50 | }, 51 | }; 52 | 53 | const App = () => ( 54 | 59 | 60 | {({ translate }) => translate('name')} 61 | 62 | 63 | ); 64 | 65 | ReactDOM.render(, node); // "Alex" will be rendered 66 | ``` 67 | 68 | ### Message 69 | 70 | `Message` component is used to provide translated message by specified key, which should be passed via props. Component has the next props: 71 | - `descriptor` - translation key (descriptor) 72 | - `defaultMessage` - message to be used in case translation is not provided (values object are applied to default message as well) 73 | - `values` - possible values to use with template string (Template should be passed in next format: `Hello {{name}}`) 74 | 75 | Example: 76 | 77 | ```js 78 | import React from 'react'; 79 | import ReactDOM from 'react-dom'; 80 | import { LocalizationProvider, Message } from 'localize-react'; 81 | 82 | const TRANSLATIONS = { 83 | en: { 84 | name: 'Alex', 85 | }, 86 | }; 87 | 88 | const App = () => ( 89 | 93 | 94 | 95 | ); 96 | 97 | ReactDOM.render(, node); // "Alex" will be rendered 98 | ``` 99 | 100 | To use with templates: 101 | 102 | ```js 103 | import React from 'react'; 104 | import ReactDOM from 'react-dom'; 105 | import { LocalizationProvider, Message } from 'localize-react'; 106 | 107 | const TRANSLATIONS = { 108 | en: { 109 | name: 'Hello, {{name}}!', 110 | }, 111 | }; 112 | 113 | const App = () => ( 114 | 118 | 119 | 120 | ); 121 | 122 | ReactDOM.render(, node); // "Alex" will be rendered 123 | ``` 124 | 125 | To use with default message: 126 | 127 | ```js 128 | import React from 'react'; 129 | import ReactDOM from 'react-dom'; 130 | import { LocalizationProvider, Message } from 'localize-react'; 131 | 132 | const TRANSLATIONS = { 133 | en: {}, 134 | }; 135 | 136 | const App = () => ( 137 | 141 | 146 | 147 | ); 148 | 149 | ReactDOM.render(, node); // "Alex" will be rendered 150 | ``` 151 | 152 | ### useLocalize 153 | 154 | `useLocalize` hook is used to provide localization context, which can be used for translation. 155 | 156 | **NOTE** 157 | 158 | Keep in mind, that hooks are not supported in class components! 159 | 160 | Example: 161 | 162 | ```js 163 | import React from 'react'; 164 | import ReactDOM from 'react-dom'; 165 | import { LocalizationProvider, useLocalize } from 'localize-react'; 166 | 167 | const TRANSLATIONS = { 168 | en: { 169 | name: 'Alex', 170 | }, 171 | }; 172 | 173 | function Test() { 174 | const { translate } = useLocalize(); 175 | 176 | return translate('name'); 177 | } 178 | 179 | const App = () => { 180 | 181 | return ( 182 | 186 | 187 | 188 | ); 189 | } 190 | 191 | ReactDOM.render(, node); // "Alex" will be rendered 192 | ``` 193 | 194 | ### Templates 195 | 196 | It's possible to use templates inside translation strings with highlighting templates using double curly braces. To pass correpospondent values: 197 | 198 | ```js 199 | const translation = translate('My name is {{name}}. I am {{age}}', { name: 'Alex', age: 25 }); 200 | ``` 201 | 202 | Or with React component: 203 | 204 | ```js 205 | 206 | ``` 207 | 208 | ### contextType 209 | 210 | Alternative way of usage inside class components: 211 | 212 | ```js 213 | import React from 'react'; 214 | import { LocalizationContext, LocalizationProvider } from 'localize-react'; 215 | 216 | const TRANSLATIONS = { 217 | en: { 218 | name: 'Alex', 219 | }, 220 | }; 221 | 222 | 223 | class Translation extends React.PureComponent { 224 | render() { 225 | return ( 226 | 227 | {this.context.translate('name')} 228 | 229 | ) 230 | } 231 | } 232 | 233 | Translation.contextType = LocalizationContext; 234 | 235 | const App = () => { 236 | return ( 237 | 241 | 242 | 243 | ); 244 | } 245 | 246 | ReactDOM.render(, node); // "Alex" will be rendered 247 | ``` 248 | 249 | ### locale 250 | Locale could be passed in short or long option. 251 | 252 | 253 | Valid examples: 254 | 255 | ``` 256 | en-us 257 | EN_US 258 | en 259 | eN-uS 260 | ``` 261 | 262 | ### translations 263 | Translations could be passed in any object form (plain or with deep properties) 264 | 265 | Valid examples: 266 | 267 | ```js 268 | const translations = { 269 | n: { 270 | a: { 271 | m: { 272 | e: 'Alex', 273 | }, 274 | }, 275 | }, 276 | }, 277 | ``` 278 | 279 | You could use key with dot delimiter to access that property: 280 | 281 | ```js 282 | // will print "Alex" 283 | ``` 284 | 285 | If there is no exact match in translations, then the value of locale will be sanitized and formatted to **lower_case_separate_by_underscore**. Make sure you provide translations object with keys in this format. If translations for long locale will not be found, and translations will be found for shorten alternative - that version will be used 286 | 287 | ## Restriction 288 | 289 | At least `React 16.8.0` is required to use this library, because new React Context API & React Hooks 290 | 291 | ## Contributing 292 | 293 | `localize-react` is open-source library, opened for contributions 294 | 295 | ### Tests 296 | 297 | **Current test coverage is 100%** 298 | 299 | `jest` is used for tests. To run tests: 300 | 301 | ```sh 302 | yarn test 303 | ``` 304 | 305 | ### License 306 | 307 | localize-react is [MIT licensed](https://github.com/yankouskia/localize-react/blob/master/LICENSE) 308 | -------------------------------------------------------------------------------- /src/Provider.test.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import renderer from 'react-test-renderer'; 3 | import { 4 | LocalizationConsumer, 5 | LocalizationContext, 6 | LocalizationProvider, 7 | } from './Provider'; 8 | 9 | const TRANSLATIONS = { 10 | en: { 11 | name: 'Alex', 12 | emptyMessage: '', 13 | n: { 14 | i: { 15 | c: { 16 | k: 'yankouskia', 17 | } 18 | } 19 | } 20 | }, 21 | fr: { 22 | github: 'https://github.com/yankouskia', 23 | templateHello: 'Hello {{name}}', 24 | }, 25 | }; 26 | 27 | describe('Provider', () => { 28 | describe('LocalizationProvider', () => { 29 | it('renders children', () => { 30 | const tree = renderer 31 | .create( 32 | 36 | children as a text 37 | 38 | ) 39 | .toJSON(); 40 | 41 | expect(tree).toMatchSnapshot(); 42 | }); 43 | }); 44 | 45 | describe('LocalizationConsumer', () => { 46 | it('provides translation for set locale', () => { 47 | const tree = renderer 48 | .create( 49 | 53 | 54 | {({ translate }) => translate('name')} 55 | 56 | 57 | ) 58 | .toJSON(); 59 | 60 | expect(tree).toMatchSnapshot(); 61 | }); 62 | 63 | it('provides empty object translations by default', () => { 64 | const tree = renderer 65 | .create( 66 | 69 | 70 | {({ translate }) => translate('any key')} 71 | 72 | 73 | ) 74 | .toJSON(); 75 | 76 | expect(tree).toMatchSnapshot(); 77 | }); 78 | 79 | it('provides translation for empty message', () => { 80 | const tree = renderer 81 | .create( 82 | 86 | 87 | {({ translate }) => ( 88 |
89 | {translate('emptyMessage')} 90 |
91 | )} 92 |
93 |
94 | ) 95 | .toJSON(); 96 | 97 | expect(tree).toMatchSnapshot(); 98 | }); 99 | 100 | it('provides translation the same as key for unexisting key', () => { 101 | const tree = renderer 102 | .create( 103 | 107 | 108 | {({ translate }) => ( 109 |
110 | {translate('unexistingKey')} 111 |
112 | )} 113 |
114 |
115 | ) 116 | .toJSON(); 117 | 118 | expect(tree).toMatchSnapshot(); 119 | }); 120 | 121 | it('provides translation for empty key as the same key', () => { 122 | const tree = renderer 123 | .create( 124 | 128 | 129 | {({ translate }) => ( 130 |
131 | {translate('')} 132 |
133 | )} 134 |
135 |
136 | ) 137 | .toJSON(); 138 | 139 | expect(tree).toMatchSnapshot(); 140 | }); 141 | 142 | it('provides translation for not found key with values', () => { 143 | const tree = renderer 144 | .create( 145 | 149 | 150 | {({ translate }) => ( 151 |
152 | {translate('Hello, {{name}}', { name: 'Alex' })} 153 |
154 | )} 155 |
156 |
157 | ) 158 | .toJSON(); 159 | 160 | expect(tree).toMatchSnapshot(); 161 | }); 162 | 163 | it('provides translation for key with values', () => { 164 | const tree = renderer 165 | .create( 166 | 170 | 171 | {({ translate }) => ( 172 |
173 | {translate('templateHello', { name: 'Alex' })} 174 |
175 | )} 176 |
177 |
178 | ) 179 | .toJSON(); 180 | 181 | expect(tree).toMatchSnapshot(); 182 | }); 183 | 184 | it('provides translation with nested key', () => { 185 | const tree = renderer 186 | .create( 187 | 191 | 192 | {({ translate }) => ( 193 |
194 | {translate('n.i.c.k')} 195 |
196 | )} 197 |
198 |
199 | ) 200 | .toJSON(); 201 | 202 | expect(tree).toMatchSnapshot(); 203 | }); 204 | 205 | it('provides key translation if nested key was not found', () => { 206 | const tree = renderer 207 | .create( 208 | 212 | 213 | {({ translate }) => ( 214 |
215 | {translate('n.i.c.k.not.found')} 216 |
217 | )} 218 |
219 |
220 | ) 221 | .toJSON(); 222 | 223 | expect(tree).toMatchSnapshot(); 224 | }); 225 | 226 | it('provides nested key translation without locale', () => { 227 | const tree = renderer 228 | .create( 229 | 232 | 233 | {({ translate }) => ( 234 |
235 | {translate('a.b')} 236 |
237 | )} 238 |
239 |
240 | ) 241 | .toJSON(); 242 | 243 | expect(tree).toMatchSnapshot(); 244 | }); 245 | 246 | it('clears cache after props update', () => { 247 | const tree = renderer 248 | .create( 249 | 253 | 254 | {({ translate }) => ( 255 |
256 | {translate('b')} 257 |
258 | )} 259 |
260 |
261 | ); 262 | 263 | renderer.act(() => { 264 | tree.update( 265 | 269 | 270 | {({ translate }) => ( 271 |
272 | {translate('b')} 273 |
274 | )} 275 |
276 |
277 | ); 278 | }); 279 | 280 | expect(tree.toJSON()).toMatchSnapshot(); 281 | }); 282 | }); 283 | 284 | it('calculate translation each time when disableCache is passed', () => { 285 | const translations = { a: { b: 'translation' } }; 286 | 287 | const tree = renderer 288 | .create( 289 | 293 | 294 | {({ translate }) => ( 295 |
296 | {translate('a.b')} 297 |
298 | )} 299 |
300 |
301 | ); 302 | 303 | expect(tree.toJSON()).toMatchSnapshot(); 304 | 305 | translations.a.b = 'Changed Translation'; 306 | 307 | renderer.act(() => { 308 | tree.update( 309 | 313 | 314 | {({ translate }) => ( 315 |
316 | {translate('a.b')} 317 |
318 | )} 319 |
320 |
321 | ); 322 | }); 323 | 324 | expect(tree.toJSON()).toMatchSnapshot(); 325 | }); 326 | 327 | describe('LocalizationContext', () => { 328 | it('can be used in classes', () => { 329 | class Translation extends React.PureComponent { 330 | render() { 331 | return ( 332 | 333 | {this.context.translate('github')} 334 | 335 | ) 336 | } 337 | } 338 | 339 | Translation.contextType = LocalizationContext; 340 | 341 | const tree = renderer 342 | .create( 343 | 347 | 348 | 349 | ) 350 | .toJSON(); 351 | 352 | expect(tree).toMatchSnapshot(); 353 | }); 354 | }); 355 | 356 | describe('Default message', () => { 357 | it('should render default message if no translation exists', () => { 358 | class Translation extends React.PureComponent { 359 | render() { 360 | return ( 361 | 362 | {this.context.translate('not.existing', null, 'Default Message')} 363 | 364 | ) 365 | } 366 | } 367 | 368 | Translation.contextType = LocalizationContext; 369 | 370 | const tree = renderer 371 | .create( 372 | 376 | 377 | 378 | ) 379 | .toJSON(); 380 | 381 | expect(tree).toMatchSnapshot(); 382 | }); 383 | }); 384 | }); 385 | --------------------------------------------------------------------------------