├── .github ├── FUNDING.yml └── workflows │ └── tests.yml ├── .gitignore ├── LICENSE ├── demo ├── .gitignore ├── README.md ├── package.json ├── public │ ├── favicon.ico │ ├── index.html │ └── manifest.json └── src │ ├── Text.js │ ├── detect.js │ ├── index.css │ ├── index.js │ ├── logo.svg │ ├── normalize.js │ └── serviceWorker.js ├── index.min.js ├── package.json ├── readme.md ├── rollup.config.js └── src ├── find.js ├── index.js ├── index.test.js ├── normalize.js └── normalize.test.js /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | custom: https://www.paypal.me/franciscopresencia/19 2 | -------------------------------------------------------------------------------- /.github/workflows/tests.yml: -------------------------------------------------------------------------------- 1 | name: tests 2 | 3 | on: [push] 4 | 5 | jobs: 6 | build: 7 | runs-on: ubuntu-latest 8 | 9 | strategy: 10 | matrix: 11 | node-version: [14.x, 16.x, 18.x] 12 | 13 | steps: 14 | - uses: actions/checkout@v1 15 | - name: Use Node.js ${{ matrix.node-version }} 16 | uses: actions/setup-node@v1 17 | with: 18 | node-version: ${{ matrix.node-version }} 19 | - name: install dependencies 20 | run: npm install 21 | - name: npm test 22 | run: npm test 23 | env: 24 | CI: true 25 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | 8 | # Only for apps 9 | package-lock.json 10 | 11 | # Runtime data 12 | pids 13 | *.pid 14 | *.seed 15 | *.pid.lock 16 | 17 | # Directory for instrumented libs generated by jscoverage/JSCover 18 | lib-cov 19 | 20 | # Coverage directory used by tools like istanbul 21 | coverage 22 | 23 | # nyc test coverage 24 | .nyc_output 25 | 26 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 27 | .grunt 28 | 29 | # Bower dependency directory (https://bower.io/) 30 | bower_components 31 | 32 | # node-waf configuration 33 | .lock-wscript 34 | 35 | # Compiled binary addons (https://nodejs.org/api/addons.html) 36 | build/Release 37 | 38 | # Dependency directories 39 | node_modules/ 40 | jspm_packages/ 41 | 42 | # TypeScript v1 declaration files 43 | typings/ 44 | 45 | # Optional npm cache directory 46 | .npm 47 | 48 | # Optional eslint cache 49 | .eslintcache 50 | 51 | # Optional REPL history 52 | .node_repl_history 53 | 54 | # Output of 'npm pack' 55 | *.tgz 56 | 57 | # Yarn Integrity file 58 | .yarn-integrity 59 | 60 | # dotenv environment variables file 61 | .env 62 | 63 | # next.js build output 64 | .next 65 | 66 | .DS_Store 67 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 Francisco Presencia 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 | -------------------------------------------------------------------------------- /demo/.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # production 12 | /build 13 | 14 | # misc 15 | .DS_Store 16 | .env.local 17 | .env.development.local 18 | .env.test.local 19 | .env.production.local 20 | 21 | npm-debug.log* 22 | yarn-debug.log* 23 | yarn-error.log* 24 | -------------------------------------------------------------------------------- /demo/README.md: -------------------------------------------------------------------------------- 1 | This project was bootstrapped with [Create React App](https://github.com/facebook/create-react-app). 2 | 3 | ## Available Scripts 4 | 5 | In the project directory, you can run: 6 | 7 | ### `npm start` 8 | 9 | Runs the app in the development mode.
10 | Open [http://localhost:3000](http://localhost:3000) to view it in the browser. 11 | 12 | The page will reload if you make edits.
13 | You will also see any lint errors in the console. 14 | 15 | ### `npm test` 16 | 17 | Launches the test runner in the interactive watch mode.
18 | See the section about [running tests](https://facebook.github.io/create-react-app/docs/running-tests) for more information. 19 | 20 | ### `npm run build` 21 | 22 | Builds the app for production to the `build` folder.
23 | It correctly bundles React in production mode and optimizes the build for the best performance. 24 | 25 | The build is minified and the filenames include the hashes.
26 | Your app is ready to be deployed! 27 | 28 | See the section about [deployment](https://facebook.github.io/create-react-app/docs/deployment) for more information. 29 | 30 | ### `npm run eject` 31 | 32 | **Note: this is a one-way operation. Once you `eject`, you can’t go back!** 33 | 34 | If you aren’t satisfied with the build tool and configuration choices, you can `eject` at any time. This command will remove the single build dependency from your project. 35 | 36 | Instead, it will copy all the configuration files and the transitive dependencies (Webpack, Babel, ESLint, etc) right into your project so you have full control over them. All of the commands except `eject` will still work, but they will point to the copied scripts so you can tweak them. At this point you’re on your own. 37 | 38 | You don’t have to ever use `eject`. The curated feature set is suitable for small and middle deployments, and you shouldn’t feel obligated to use this feature. However we understand that this tool wouldn’t be useful if you couldn’t customize it when you are ready for it. 39 | 40 | ## Learn More 41 | 42 | You can learn more in the [Create React App documentation](https://facebook.github.io/create-react-app/docs/getting-started). 43 | 44 | To learn React, check out the [React documentation](https://reactjs.org/). 45 | -------------------------------------------------------------------------------- /demo/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "demo", 3 | "version": "0.1.0", 4 | "private": true, 5 | "dependencies": { 6 | "react": "^16.3.2", 7 | "react-dom": "^16.6.0", 8 | "react-scripts": "2.1.1", 9 | "react-text-translate": "^0.1.0" 10 | }, 11 | "scripts": { 12 | "start": "react-scripts start", 13 | "build": "react-scripts build", 14 | "test": "react-scripts test", 15 | "eject": "react-scripts eject" 16 | }, 17 | "eslintConfig": { 18 | "extends": "react-app" 19 | }, 20 | "browserslist": [ 21 | ">0.2%", 22 | "not dead", 23 | "not ie <= 11", 24 | "not op_mini all" 25 | ] 26 | } 27 | -------------------------------------------------------------------------------- /demo/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/franciscop/react-text/2b40d7ae3f2329a676ef4202514407b85f2426bd/demo/public/favicon.ico -------------------------------------------------------------------------------- /demo/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 12 | 13 | 22 | React App 23 | 24 | 25 | 28 |
29 | 39 | 40 | 41 | -------------------------------------------------------------------------------- /demo/public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "React App", 3 | "name": "Create React App Sample", 4 | "icons": [ 5 | { 6 | "src": "favicon.ico", 7 | "sizes": "64x64 32x32 24x24 16x16", 8 | "type": "image/x-icon" 9 | } 10 | ], 11 | "start_url": ".", 12 | "display": "standalone", 13 | "theme_color": "#000000", 14 | "background_color": "#ffffff" 15 | } 16 | -------------------------------------------------------------------------------- /demo/src/Text.js: -------------------------------------------------------------------------------- 1 | import normalize from './normalize'; 2 | import detect from './detect'; 3 | import { createContext, createElement } from 'react'; 4 | 5 | const { Provider, Consumer } = createContext({}); 6 | 7 | const find = ({ dictionary, language, ...props }) => { 8 | if (!dictionary) return false; 9 | const keys = Object.entries(props).filter(([k, v]) => typeof v === 'boolean').map(([k]) => k); 10 | if (!keys.length) throw new Error('Please make sure to pass a key'); 11 | if (!keys.length > 1) throw new Error(`Please only pass one key/boolean, detected '${keys}'`); 12 | const key = keys[0]; 13 | if (!dictionary[key]) throw new Error(`Couldn't find the key '${key}' in the dictionary`); 14 | return dictionary[key](language, props); 15 | }; 16 | 17 | export default ({ children, render, component, dictionary = {}, language, ...props }) => { 18 | if (dictionary) dictionary = normalize(dictionary); 19 | if (children) { 20 | return createElement(Consumer, {}, ({ language: oldLang, dictionary: oldDict }) => { 21 | const value = { 22 | language: language || oldLang || detect(), 23 | dictionary: normalize({ ...oldDict, ...dictionary }) 24 | }; 25 | // return {children}; 26 | return createElement(Provider, { value }, children); 27 | }); 28 | } 29 | if (component) { 30 | // return {value => Object.keys(props).includes(language) ? component : null}; 31 | return createElement(Consumer, {}, value => { 32 | return Object.keys(props).includes(value.language || language) ? component : null; 33 | }); 34 | } 35 | if (render) { 36 | // return {value => render(find({ ...value, ...props }))}; 37 | return createElement(Consumer, {}, value => render(find({ ...value, ...props }))); 38 | } 39 | // return {value => find({ ...value, ...props })}; 40 | return createElement(Consumer, {}, value => find({ ...value, ...props })); 41 | } 42 | -------------------------------------------------------------------------------- /demo/src/detect.js: -------------------------------------------------------------------------------- 1 | export default () => { 2 | const lang = window.navigator.userLanguage || window.navigator.language; 3 | return lang.split('-').shift().trim(); 4 | }; 5 | -------------------------------------------------------------------------------- /demo/src/index.css: -------------------------------------------------------------------------------- 1 | body { 2 | margin: 0; 3 | padding: 0; 4 | font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", "Oxygen", 5 | "Ubuntu", "Cantarell", "Fira Sans", "Droid Sans", "Helvetica Neue", 6 | sans-serif; 7 | -webkit-font-smoothing: antialiased; 8 | -moz-osx-font-smoothing: grayscale; 9 | } 10 | 11 | code { 12 | font-family: source-code-pro, Menlo, Monaco, Consolas, "Courier New", 13 | monospace; 14 | } 15 | -------------------------------------------------------------------------------- /demo/src/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom'; 3 | import Text from 'react-text-translate'; 4 | 5 | const dictionary = { 6 | greetings: { 7 | en: 'Hello world!', 8 | es: '¡Hola mundo!', 9 | ja: 'こんにちは、世界' 10 | }, 11 | lang: { 12 | en: '🇬🇧', 13 | es: '🇪🇸', 14 | ja: '🇯🇵' 15 | }, 16 | farewell: { 17 | en: ({ name }) => `Bye ${name}!`, 18 | es: ({ name }) => `¡Adiós, ${name}!`, 19 | ja: ({ name }) => `さよなら、${name.toUpperCase()}さん!` 20 | } 21 | }; 22 | 23 | ReactDOM.render(( 24 | 25 |

Plain case

26 |

27 |

28 | 29 |

Render

30 | {text}} /> 31 | 32 |

Component

33 | 🇬🇧

} /> 34 | 🇪🇸

} /> 35 | 🇯🇵

} /> 36 | 37 | 38 |

Children (sub classing)

39 |

40 |
41 | 42 | 43 |

Extending the dictionary

44 |

45 |
46 |
47 | ), document.getElementById('root')); 48 | -------------------------------------------------------------------------------- /demo/src/logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /demo/src/normalize.js: -------------------------------------------------------------------------------- 1 | export default (...args) => { 2 | // Allow for an array of arguments that are all objects 3 | const dictionary = Object.assign({}, ...args); 4 | 5 | let languages; 6 | 7 | Object.keys(dictionary).forEach(key => { 8 | const entry = dictionary[key]; 9 | 10 | // Already parsed 11 | if (typeof entry === 'function') return; 12 | 13 | // Retrieve a list of the languages, that must be the same in every entry 14 | const langs = Object.keys(entry); 15 | if (!languages) languages = langs; 16 | if (JSON.stringify(languages) !== JSON.stringify(langs)) { 17 | throw new Error(`Wrong amount of languages, expected '${languages}' but got '${langs}'`); 18 | } 19 | 20 | for (let lang of languages) { 21 | const value = dictionary[key][lang]; 22 | if (typeof value === 'string') { 23 | entry[lang] = (() => value); 24 | } 25 | } 26 | 27 | dictionary[key] = (lang, ...args) => { 28 | const fn = entry[lang] || Object.values(entry)[0]; 29 | return fn(Object.assign({}, ...args)); 30 | }; 31 | }); 32 | 33 | return dictionary; 34 | }; 35 | -------------------------------------------------------------------------------- /demo/src/serviceWorker.js: -------------------------------------------------------------------------------- 1 | // This optional code is used to register a service worker. 2 | // register() is not called by default. 3 | 4 | // This lets the app load faster on subsequent visits in production, and gives 5 | // it offline capabilities. However, it also means that developers (and users) 6 | // will only see deployed updates on subsequent visits to a page, after all the 7 | // existing tabs open on the page have been closed, since previously cached 8 | // resources are updated in the background. 9 | 10 | // To learn more about the benefits of this model and instructions on how to 11 | // opt-in, read http://bit.ly/CRA-PWA 12 | 13 | const isLocalhost = Boolean( 14 | window.location.hostname === 'localhost' || 15 | // [::1] is the IPv6 localhost address. 16 | window.location.hostname === '[::1]' || 17 | // 127.0.0.1/8 is considered localhost for IPv4. 18 | window.location.hostname.match( 19 | /^127(?:\.(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)){3}$/ 20 | ) 21 | ); 22 | 23 | export function register(config) { 24 | if (process.env.NODE_ENV === 'production' && 'serviceWorker' in navigator) { 25 | // The URL constructor is available in all browsers that support SW. 26 | const publicUrl = new URL(process.env.PUBLIC_URL, window.location.href); 27 | if (publicUrl.origin !== window.location.origin) { 28 | // Our service worker won't work if PUBLIC_URL is on a different origin 29 | // from what our page is served on. This might happen if a CDN is used to 30 | // serve assets; see https://github.com/facebook/create-react-app/issues/2374 31 | return; 32 | } 33 | 34 | window.addEventListener('load', () => { 35 | const swUrl = `${process.env.PUBLIC_URL}/service-worker.js`; 36 | 37 | if (isLocalhost) { 38 | // This is running on localhost. Let's check if a service worker still exists or not. 39 | checkValidServiceWorker(swUrl, config); 40 | 41 | // Add some additional logging to localhost, pointing developers to the 42 | // service worker/PWA documentation. 43 | navigator.serviceWorker.ready.then(() => { 44 | console.log( 45 | 'This web app is being served cache-first by a service ' + 46 | 'worker. To learn more, visit http://bit.ly/CRA-PWA' 47 | ); 48 | }); 49 | } else { 50 | // Is not localhost. Just register service worker 51 | registerValidSW(swUrl, config); 52 | } 53 | }); 54 | } 55 | } 56 | 57 | function registerValidSW(swUrl, config) { 58 | navigator.serviceWorker 59 | .register(swUrl) 60 | .then(registration => { 61 | registration.onupdatefound = () => { 62 | const installingWorker = registration.installing; 63 | if (installingWorker == null) { 64 | return; 65 | } 66 | installingWorker.onstatechange = () => { 67 | if (installingWorker.state === 'installed') { 68 | if (navigator.serviceWorker.controller) { 69 | // At this point, the updated precached content has been fetched, 70 | // but the previous service worker will still serve the older 71 | // content until all client tabs are closed. 72 | console.log( 73 | 'New content is available and will be used when all ' + 74 | 'tabs for this page are closed. See http://bit.ly/CRA-PWA.' 75 | ); 76 | 77 | // Execute callback 78 | if (config && config.onUpdate) { 79 | config.onUpdate(registration); 80 | } 81 | } else { 82 | // At this point, everything has been precached. 83 | // It's the perfect time to display a 84 | // "Content is cached for offline use." message. 85 | console.log('Content is cached for offline use.'); 86 | 87 | // Execute callback 88 | if (config && config.onSuccess) { 89 | config.onSuccess(registration); 90 | } 91 | } 92 | } 93 | }; 94 | }; 95 | }) 96 | .catch(error => { 97 | console.error('Error during service worker registration:', error); 98 | }); 99 | } 100 | 101 | function checkValidServiceWorker(swUrl, config) { 102 | // Check if the service worker can be found. If it can't reload the page. 103 | fetch(swUrl) 104 | .then(response => { 105 | // Ensure service worker exists, and that we really are getting a JS file. 106 | const contentType = response.headers.get('content-type'); 107 | if ( 108 | response.status === 404 || 109 | (contentType != null && contentType.indexOf('javascript') === -1) 110 | ) { 111 | // No service worker found. Probably a different app. Reload the page. 112 | navigator.serviceWorker.ready.then(registration => { 113 | registration.unregister().then(() => { 114 | window.location.reload(); 115 | }); 116 | }); 117 | } else { 118 | // Service worker found. Proceed as normal. 119 | registerValidSW(swUrl, config); 120 | } 121 | }) 122 | .catch(() => { 123 | console.log( 124 | 'No internet connection found. App is running in offline mode.' 125 | ); 126 | }); 127 | } 128 | 129 | export function unregister() { 130 | if ('serviceWorker' in navigator) { 131 | navigator.serviceWorker.ready.then(registration => { 132 | registration.unregister(); 133 | }); 134 | } 135 | } 136 | -------------------------------------------------------------------------------- /index.min.js: -------------------------------------------------------------------------------- 1 | import e,{createContext as t}from"react";var r=(...e)=>{const t=Object.assign({},...e);let r;return Object.keys(t).reduce((e,n)=>{const o=Object.keys(t[n]);if(r||(r=o),JSON.stringify(r.sort())!==JSON.stringify(o.sort()))throw new Error(`Wrong amount of languages, expected '${r}' but got '${o}'`);return{...e,[n]:r.reduce((e,r)=>{const o=t[n][r];if(o&&o.normalized)return{...e,[r]:o};if("function"==typeof o){const t=(e={})=>o(e);return t.normalized=!0,{...e,[r]:t}}if("string"==typeof o){const t=()=>o;return t.normalized=!0,{...e,[r]:t}}throw new Error(`Value cannot be of type "${typeof o}"`)},{})}},{})},n=({id:e,dictionary:t,language:r,...n})=>{if(!e){const t=Object.entries(n).filter(([e,t])=>"boolean"==typeof t).map(([e])=>e);if(!t.length)throw new Error("Please make sure to pass an id");if(t.length>1)throw new Error(`Please only pass a single id/boolean, detected '${t}'`);e=t[0]}const o=Object.keys(t);if(!o.includes(e))throw new Error(`Couldn't find '${e}' in the dictionary. Available ids: ${o}`);if(void 0===t[e][r])throw new Error(`The language '${r}' is not available (attempted with id '${e}')`);return t[e][r](n)};const{Provider:o,Consumer:i}=t({});let a=({children:e})=>e;if("undefined"!=typeof require)try{a=require("react-native").Text}catch(e){}export default({language:t,dictionary:l={},children:c,render:s,component:u,...d})=>e.createElement(i,null,i=>c?(t=t||i.language,l={...i.dictionary,...r(l)},e.createElement(o,{value:{language:t,dictionary:l}},c)):u?!0===d[i.language]?u:null:s?s(n({...i,...d})):e.createElement(a,null,n({...i,...d}))); 2 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-text", 3 | "version": "3.2.5", 4 | "description": "React translation library with plain objects as dictionaries", 5 | "homepage": "https://github.com/franciscop/react-text#readme", 6 | "repository": "https://github.com/franciscop/react-text.git", 7 | "bugs": "https://github.com/franciscop/react-text/issues", 8 | "funding": "https://www.paypal.me/franciscopresencia/19", 9 | "author": "Francisco Presencia (https://francisco.io/)", 10 | "license": "MIT", 11 | "scripts": { 12 | "build": "rollup -c", 13 | "size": "echo $(gzip -c index.min.js | wc -c) bytes", 14 | "start": "jest --watch", 15 | "test": "jest --coverage" 16 | }, 17 | "keywords": [ 18 | "react", 19 | "translate", 20 | "dictionary", 21 | "language", 22 | "i18n" 23 | ], 24 | "main": "index.min.js", 25 | "files": [], 26 | "devDependencies": { 27 | "@babel/core": "^7.15.0", 28 | "@babel/preset-env": "^7.15.0", 29 | "@babel/preset-react": "^7.14.5", 30 | "babel-loader": "^8.2.2", 31 | "babel-polyfill": "^6.26.0", 32 | "jest": "^28.1.0", 33 | "jest-environment-jsdom": "^28.1.0", 34 | "react": "^18.2.0", 35 | "react-test": "^0.19.0", 36 | "rollup": "^1.32.1", 37 | "rollup-plugin-babel": "^4.4.0", 38 | "rollup-plugin-terser": "^5.2.0" 39 | }, 40 | "peerDependencies": { 41 | "react": ">=16.8.0" 42 | }, 43 | "babel": { 44 | "presets": [ 45 | "@babel/preset-env", 46 | "@babel/preset-react" 47 | ] 48 | }, 49 | "jest": { 50 | "testEnvironment": "jsdom" 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 | # React Text [![npm install react-text](https://img.shields.io/badge/npm%20install-react--text-blue.svg)](https://www.npmjs.com/package/react-text) [![test badge](https://github.com/franciscop/react-text/workflows/tests/badge.svg)](https://github.com/franciscop/react-text/blob/master/.github/workflows/tests.yml) [![gzip size](https://img.badgesize.io/franciscop/react-text/master/index.min.js.svg?compression=gzip)](https://github.com/franciscop/react-text/blob/master/index.min.js) 2 | 3 | React and React Native translation library with plain objects as dictionaries: 4 | 5 | ```js 6 | import Text from 'react-text'; 7 | import dictionary from './dictionary'; 8 | 9 | export default () => ( 10 | 11 |

12 |

13 |
14 | ); 15 | //

こんにちは、世界!

16 | //

さよなら、FRANCISCOさん!

17 | ``` 18 | 19 | Contents: 20 | 21 | - [**Getting started**](#getting-started): Introduction to how to start using `react-text` with React. 22 | - [**Dictionary**](#dictionary): define the translations and some basic transformations. 23 | - [**Configuration**](#configuration): set the language and inject the dictionary. 24 | - [**Translate**](#translate): use `` with a id to create translations with optional properties. 25 | - [**Render**](#render): inject a translated string into a React component. Useful for `alt={}` and similar. 26 | - [**Component**](#component): renders only for the right language. 27 | 28 | 29 | 30 | 31 | 32 | 33 | ## Getting started 34 | 35 | First let's install the package with npm: 36 | 37 | ```bash 38 | npm install react-text 39 | ``` 40 | 41 | Then we define [a dictionary](#dictionary) of the text to translate: 42 | 43 | ```js 44 | // ./dictionary.js 45 | export default { 46 | greetings: { 47 | en: 'Hello world!', 48 | es: '¡Hola mundo!', 49 | ja: 'こんにちは、世界!' 50 | } 51 | }; 52 | ``` 53 | 54 | To use those, we need to create a wrapping `` with two options: [the **dictionary** and the **language**](#configuration). Then inside it, we create a self-closing `` tag with the property as the previously defined key of the object: 55 | 56 | ```js 57 | // ./Example.js 58 | import Text from 'react-text'; 59 | import dictionary from './dictionary'; 60 | 61 | export default () => ( 62 | 63 |

64 |
65 | ); 66 | // ~> ¡Hola mundo! 67 | ``` 68 | 69 | 70 | 71 | 72 | 73 | 74 | ## Dictionary 75 | 76 | The dictionary is defined as an object of objects. The first level (`greetings`) is what we call the `id`, the second is the language (`en`) and finally we have the values (`Hello world` and functions): 77 | 78 | ```js 79 | // ./dictionary.js 80 | export default { 81 | greetings: { 82 | en: 'Hello world!', 83 | es: '¡Hola mundo!', 84 | ja: 'こんにちは、世界!' 85 | }, 86 | farewell: { 87 | en: ({ name = 'World' }) => `Hello ${name}!`, 88 | es: ({ name = 'Mundo'}) => `¡Adiós ${name}!`, 89 | ja: ({ name = '世界' }) => `さよなら、${name.toUpperCase()}さん!` 90 | } 91 | }; 92 | ``` 93 | 94 | All the languages must be the same in all the entries, otherwise it will throw an error. The order is important as well, since the first language will be considered the default one if it cannot be found otherwise. 95 | 96 | 97 | 98 | 99 | 100 | 101 | ## Configuration 102 | 103 | Once we have the dictionary, we have to determine how and where to inject it, as well as specifying the language. This will be done by creating a `` element **with children**: 104 | 105 | ```js 106 | import Text from 'react-text'; 107 | import dictionary from './dictionary'; 108 | 109 | export default () => ( 110 | 111 | {/* Here the language will be English */} 112 | 113 | ); 114 | ``` 115 | 116 | For React Native, any of the [usual props](https://facebook.github.io/react-native/docs/text#props) of `...` can be passed here. 117 | 118 | They can be set at different levels, which is specially useful if you want to split the dictionary into different pages: 119 | 120 | ```js 121 | import Text from 'react-text'; 122 | import dictionaryA from './dictionaryA'; 123 | import dictionaryB from './dictionaryB'; 124 | 125 | export default () => ( 126 | 127 | 128 | {/* English for Dictionary A */} 129 | 130 |
131 | 132 | {/* English for Dictionary B */} 133 | 134 |
135 |
136 | ); 137 | ``` 138 | 139 | When nesting dictionaries **they will cascade** and the latter ids will override the previous ids. 140 | 141 | ```js 142 | const dictA = { 143 | greetings: { en: 'Hello world' }, 144 | farewell: { en: 'Goodbye world' } 145 | }; 146 | const dictB = { 147 | greetings: { en: 'Hey world!' } 148 | }; 149 | 150 | export default () => ( 151 | 152 |

153 | 154 |

155 |

156 |
157 |
158 | ); 159 | //

Hello world

160 | //

Hey world!

161 | //

Goodbye world

162 | ``` 163 | 164 | The language would normally be a variable that comes from your own code: 165 | 166 | ```js 167 | import Text from 'react-text'; 168 | import dictionary from './dictionary'; 169 | 170 | export default ({ language = 'en' }) => ( 171 | 172 | {/* Here the language will be English */} 173 | 174 | ); 175 | ``` 176 | 177 | The language can also be nested, and it will use the most specific (innermost): 178 | 179 | ```js 180 | const dictionary = { greetings: { 181 | en: 'Hello world!', 182 | es: '¡Hola mundo!', 183 | ja: 'こんにちは、世界!' 184 | }}; 185 | 186 | export default () => ( 187 | 188 |

189 | 190 |

191 | 192 |

193 |
194 |
195 |
196 | ); 197 | //

Hello world!

198 | //

こんにちは、世界!

199 | //

¡Hola mundo!

200 | ``` 201 | 202 | While nesting dictionaries is totally fine and expected, nesting languages might get messy and it's recommended to avoid it if possible. Use a global store like Redux to handle the language instead and inject it at the root level: 203 | 204 | ```js 205 | // LanguagePicker.js 206 | // Example implementation with Redux and an action creator 207 | const setLanguage = payload => ({ type: 'SET_LANGUAGE', payload }); 208 | export default connect(({ language }) => ({ language }))(({ language, dispatch }) => { 209 | 210 | 211 | 212 | 213 |

Current language: {language}

214 |
215 | }); 216 | 217 | // reducers/index.js 218 | export default combineReducers({ 219 | // ... 220 | language: (state = 'en', { type, payload }) => { 221 | return (type === 'SET_LANGUAGE') ? payload : state; 222 | } 223 | }); 224 | ``` 225 | 226 | 227 | 228 | 229 | 230 | 231 | ## Translate 232 | 233 | With the dictionary and language injected, use `` with a **self-closing tag** and the right id: 234 | 235 | ```js 236 | const dictionary = { 237 | greetings: { 238 | en: 'Hello world!', 239 | es: '¡Hola mundo!', 240 | ja: 'こんにちは、世界!' 241 | } 242 | }; 243 | 244 | // Usage; the prop 'greetings' will correspond to the dictionary id 'greetings' 245 | export default () => ( 246 | 247 |

248 |
249 | ); 250 | // ~>

こんにちは、世界!

251 | ``` 252 | 253 | **Valid id names**: any prop except `id`, [`children`](#configuration), [`render`](#render) and [`component`](#component) since these have special meaning in React-Text. Click on those keywords to see how they are used. The ids are case-sensitive. 254 | 255 | The dictionary can also be a function, which will be called when rendering. The advantage is that it will receive any prop that you pass to the element. You can then localize the text properly depending on the language, and even provide defaults easily: 256 | 257 | ```js 258 | const dictionary = { 259 | greetings: { 260 | en: ({ name = 'World' }) => `Hello ${name}!`, 261 | es: ({ name = 'Mundo' }) => `¡Hola ${name}!`, 262 | ja: ({ name = '世界' }) => `こんにちは、${name.toUpperCase()}さん!` 263 | } 264 | }; 265 | 266 | // The prop passed as `name` will be received in the dictionary 267 | export default () => ( 268 | 269 |

270 |
271 | ); 272 | // ~> こんにちは、FRANCISCOさん! 273 | ``` 274 | 275 | You can also use the `id` prop instead of just writing the id as a prop. These two work exactly the same: 276 | 277 | ```js 278 | export default () => ( 279 | 280 |

281 |

282 |
283 | ); 284 | ``` 285 | 286 | This however works much better for dynamic ids, since those would be messy otherwise: 287 | 288 | ```js 289 | const key = 'greetings'; 290 | 291 | export default () => ( 292 | 293 |

294 |

295 |
296 | ); 297 | ``` 298 | 299 | > Note: the props that you can pass can be either strings or numbers, but right now you cannot pass a boolean like ``. We might lift this limitation in the future. 300 | 301 | 302 | 303 | 304 | 305 | 306 | ## Render 307 | 308 | Injects the plain text into a function. Useful for those times when you can only pass plain text and not a component: 309 | 310 | ```js 311 | // These both render to the exact same thing: 312 |

313 |

{text}

} /> 314 | // ~>

Hello world

315 | ``` 316 | 317 | The next example can only be achieved with `render()` since it will pass the plain representation as specified in the dictionary: 318 | 319 | ```js 320 | {text}} /> 321 | // ~> Hello world 322 | ``` 323 | 324 | If you try to do the same with `` you will get an unexpected result, since `` renders a React component: 325 | 326 | ```js 327 | // ERROR - this does not work as expected 328 | {<Text} /> 329 | // ~> [Object object] 330 | ``` 331 | 332 | 333 | 334 | 335 | 336 | 337 | ## Component 338 | 339 | When trying to do a switch between more complex fragments, or display one part only for one language, we can do so by using the `component` prop: 340 | 341 | ```js 342 |
343 | 345 | {/* Large block of text in English here */} 346 | 347 | )} /> 348 | 350 | {/* Large block of text in Spanish here */} 351 | 352 | )} /> 353 | 355 | {/* Large block of text in Japanese here */} 356 | 357 | )} /> 358 |
359 | ``` 360 | 361 | Note that, when using `component={...}`, we are using **the language as a key**. This would be the internal equivalent of you doing: 362 | 363 | ```js 364 | // dictionary.js 365 | // NOTE: DO NOT DO THIS, this is just a visualization of how it works internally 366 | export default { 367 | en: { en: ({ component }) => component, es: '', ja: '' }, 368 | es: { en: '', es: ({ component }) => component, ja: '' }, 369 | ja: { en: '', es: '', ja: ({ component }) => component } 370 | }; 371 | ``` 372 | -------------------------------------------------------------------------------- /rollup.config.js: -------------------------------------------------------------------------------- 1 | import babel from "rollup-plugin-babel"; 2 | import { terser } from "rollup-plugin-terser"; 3 | 4 | export default { 5 | input: "src/index.js", 6 | output: { file: "index.min.js", format: "esm" }, 7 | external: ["react"], 8 | plugins: [ 9 | babel({ 10 | exclude: "node_modules/**", 11 | presets: [ 12 | ["@babel/env", { targets: { node: 12 } }], 13 | "@babel/preset-react", 14 | ], 15 | }), 16 | terser(), 17 | ], 18 | }; 19 | -------------------------------------------------------------------------------- /src/find.js: -------------------------------------------------------------------------------- 1 | 2 | export default ({ id, dictionary, language, ...props }) => { 3 | if (!id) { 4 | const keys = Object.entries(props).filter(([k, v]) => typeof v === 'boolean').map(([k]) => k); 5 | if (!keys.length) throw new Error('Please make sure to pass an id'); 6 | if (keys.length > 1) throw new Error(`Please only pass a single id/boolean, detected '${keys}'`); 7 | id = keys[0]; 8 | } 9 | const ids = Object.keys(dictionary); 10 | if (!ids.includes(id)) { 11 | throw new Error(`Couldn't find '${id}' in the dictionary. Available ids: ${ids}`); 12 | } 13 | if (typeof dictionary[id][language] === 'undefined') { 14 | throw new Error(`The language '${language}' is not available (attempted with id '${id}')`); 15 | } 16 | return dictionary[id][language](props); 17 | }; 18 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | import React, { createContext } from "react"; 2 | import normal from "./normalize"; 3 | import find from "./find"; 4 | const { Provider, Consumer } = createContext({}); 5 | 6 | let Text = ({ children }) => children; 7 | // Attempt to load React Native if it's a dependency so that it is wrapped there 8 | if (typeof require !== "undefined") { 9 | try { 10 | Text = require("react-native").Text; 11 | } catch (error) {} 12 | } 13 | 14 | export default ({ 15 | language, 16 | dictionary = {}, 17 | children, 18 | render, 19 | component, 20 | ...props 21 | }) => ( 22 | 23 | {(value) => { 24 | const withText = ({ children }) => {children}; 25 | 26 | // We only care about language or dictionary at the same level when we have children 27 | if (children) { 28 | language = language || value.language; 29 | dictionary = { ...value.dictionary, ...normal(dictionary) }; 30 | return {children}; 31 | } 32 | 33 | // The component prop was passed, render only for the right language 34 | if (component) return props[value.language] === true ? component : null; 35 | 36 | // The render prop was passed, render into the children instead 37 | if (render) return render(find({ ...value, ...props })); 38 | 39 | // Normal id value 40 | return {find({ ...value, ...props })}; 41 | }} 42 | 43 | ); 44 | -------------------------------------------------------------------------------- /src/index.test.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import $ from "react-test"; 3 | import Text from "."; 4 | 5 | const dictionary = { 6 | greetings: { 7 | en: "Hello world!", 8 | es: "¡Hola mundo!", 9 | ja: "こんにちは、世界!", 10 | }, 11 | farewell: { 12 | en: ({ name = "World" }) => `Hello ${name}!`, 13 | es: ({ name = "Mundo" }) => `¡Adiós ${name}!`, 14 | ja: ({ name = "世界" }) => `さよなら、${name.toUpperCase()}さん!`, 15 | }, 16 | }; 17 | 18 | describe("text", () => { 19 | it("can render correctly", () => { 20 | const text = $( 21 | 22 |
23 |

24 | 25 |

26 |

27 | 28 |

29 |
30 |
31 | ).text(); 32 | expect(text).toBe("こんにちは、世界!さよなら、FRANCISCOさん!"); 33 | }); 34 | 35 | it("can render a non-translated string", () => { 36 | // Note: need the here to match it properly 37 | const text = $( 38 |
39 | Hello world 40 |
41 | ).text(); 42 | expect(text).toBe("Hello world"); 43 | }); 44 | 45 | it("can render correctly with id", () => { 46 | const text = $( 47 | 48 |

49 | 50 |

51 |
52 | ).text(); 53 | expect(text).toBe("こんにちは、世界!"); 54 | }); 55 | 56 | it("renders with default props", () => { 57 | const text = $( 58 | 59 |

60 | 61 |

62 |
63 | ).text(); 64 | expect(text).toBe("さよなら、世界さん!"); 65 | }); 66 | 67 | it("renders with the passed props", () => { 68 | const text = $( 69 | 70 |

71 | 72 |

73 |
74 | ).text(); 75 | expect(text).toBe("さよなら、FRANCISCOさん!"); 76 | }); 77 | 78 | it("can be nested", () => { 79 | const text = $( 80 | 81 | 82 |

83 | 84 |

85 |
86 |
87 | ).text(); 88 | expect(text).toBe("さよなら、FRANCISCOさん!"); 89 | }); 90 | 91 | it("can accept empty dictionary language", () => { 92 | const text = $( 93 | 94 |

95 | 96 |

97 |
98 | ).text(); 99 | expect(text).toBe(""); 100 | }); 101 | 102 | it.skip("rejects without dictionary", () => { 103 | const text = () => 104 | $( 105 | 106 |

107 | 108 |

109 |
110 | ); 111 | expect(text).toThrow(); 112 | }); 113 | 114 | it.skip("rejects without id", () => { 115 | const text = () => 116 | $( 117 | 118 |

119 | 120 |

121 |
122 | ); 123 | expect(text).toThrow(); 124 | }); 125 | 126 | it.skip("rejects with invalid id", () => { 127 | const text = () => 128 | $( 129 | 130 |

131 | 132 |

133 |
134 | ); 135 | expect(text).toThrow(/Couldn't find/); 136 | }); 137 | 138 | it.skip("rejects with several ids", () => { 139 | const text = () => 140 | $( 141 | 142 |

143 | 144 |

145 |
146 | ); 147 | expect(text).toThrow(/a single id/); 148 | }); 149 | 150 | it.skip("rejects with undefined language", () => { 151 | const text = () => 152 | $( 153 | 154 |

155 | 156 |

157 |
158 | ); 159 | expect(text).toThrow(/The language 'ru' is not available/); 160 | }); 161 | }); 162 | 163 | describe("text with render", () => { 164 | it("can render correctly", () => { 165 | const text = $( 166 | 167 |
168 |

{text}

} /> 169 |

{text}

} /> 170 |
171 |
172 | ).text(); 173 | expect(text).toBe("こんにちは、世界!さよなら、FRANCISCOさん!"); 174 | }); 175 | 176 | it("can render correctly with id", () => { 177 | const text = $( 178 | 179 |

{text}

} /> 180 |
181 | ).text(); 182 | expect(text).toBe("こんにちは、世界!"); 183 | }); 184 | 185 | it("renders with default props", () => { 186 | const text = $( 187 | 188 |

{text}

} /> 189 |
190 | ).text(); 191 | expect(text).toBe("さよなら、世界さん!"); 192 | }); 193 | 194 | it("renders with the passed props", () => { 195 | const text = $( 196 | 197 |

{text}

} /> 198 |
199 | ).text(); 200 | expect(text).toBe("さよなら、FRANCISCOさん!"); 201 | }); 202 | }); 203 | 204 | describe("text with component", () => { 205 | it("can do the component", () => { 206 | const text = $( 207 | 208 |
209 |

210 | 211 |

212 |

213 | 214 |

215 |

216 | 217 |

218 |
219 |
220 | ).text(); 221 | expect(text).toBe("¡Hola mundo!"); 222 | }); 223 | 224 | it("can do the component with explicit language", () => { 225 | const text = $( 226 | 227 |
228 |

229 | 230 |

231 |

232 | 233 |

234 |

235 | 236 |

237 |
238 |
239 | ).text(); 240 | expect(text).toBe("¡Hola mundo!"); 241 | }); 242 | }); 243 | -------------------------------------------------------------------------------- /src/normalize.js: -------------------------------------------------------------------------------- 1 | export default (...args) => { 2 | // Allow for an array of arguments that are all objects 3 | const dictionary = Object.assign({}, ...args); 4 | 5 | let languages; 6 | 7 | return Object.keys(dictionary).reduce((dict, key) => { 8 | // Retrieve a list of the languages, that must be the same in every entry 9 | const langs = Object.keys(dictionary[key]); 10 | if (!languages) languages = langs; 11 | if (JSON.stringify(languages.sort()) !== JSON.stringify(langs.sort())) { 12 | throw new Error(`Wrong amount of languages, expected '${languages}' but got '${langs}'`); 13 | } 14 | 15 | return { 16 | ...dict, 17 | [key]: languages.reduce((obj, lang) => { 18 | const value = dictionary[key][lang]; 19 | if (value && value.normalized) { 20 | return { ...obj, [lang]: value }; 21 | } 22 | if (typeof value === 'function') { 23 | const fn = (props = {}) => value(props); 24 | fn.normalized = true; 25 | return { ...obj, [lang]: fn }; 26 | } 27 | if (typeof value === 'string') { 28 | const fn = () => value; 29 | fn.normalized = true; 30 | return { ...obj, [lang]: fn }; 31 | } 32 | throw new Error(`Value cannot be of type "${typeof value}"`); 33 | }, {}) 34 | }; 35 | }, {}); 36 | }; 37 | -------------------------------------------------------------------------------- /src/normalize.test.js: -------------------------------------------------------------------------------- 1 | import normalize from './normalize'; 2 | 3 | describe('normalize', () => { 4 | it('returns the original for normalized ones', () => { 5 | const dict = {}; 6 | expect(normalize(dict)).toEqual(dict); 7 | }); 8 | 9 | it('works with a simple dictionary', () => { 10 | const dict = normalize({ 11 | greetings: { 12 | en: 'Hello world', 13 | es: 'Hola mundo' 14 | } 15 | }); 16 | expect(dict.greetings.en()).toEqual('Hello world'); 17 | }); 18 | 19 | it('kills the references', () => { 20 | const dictionary = { greetings: { en: 'Hello world' } }; 21 | const dict = normalize(dictionary); 22 | expect(typeof dictionary.greetings.en).toBe('string'); 23 | expect(dict.greetings.en).not.toBe(dictionary.greetings.en); 24 | expect(dict.greetings).not.toBe(dictionary.greetings); 25 | }); 26 | 27 | it('does not change a normalized dictionary', () => { 28 | const dictionary = normalize({ greetings: { en: 'Hello world' } }); 29 | const dict = normalize(dictionary); 30 | expect(typeof dictionary.greetings.en).toBe('function'); 31 | expect(dict.greetings.en).toBe(dictionary.greetings.en); 32 | expect(dict.greetings).not.toBe(dictionary.greetings); 33 | }); 34 | 35 | it('adds the internal flag', () => { 36 | const dictionary = { 37 | greetings: { 38 | en: 'Hello world', 39 | es: 'Hola mundo' 40 | } 41 | }; 42 | const dict = normalize(dictionary); 43 | expect(typeof dictionary.greetings.en.normalized).toBe('undefined'); 44 | expect(dict.greetings.en.normalized).toBe(true); 45 | }); 46 | 47 | it('does nothing for a normalized one', () => { 48 | const dict = normalize({ 49 | greetings: { 50 | en: 'Hello world', 51 | es: 'Hola mundo' 52 | }, 53 | farewell: { 54 | en: 'Bye world', 55 | es: 'Adiós mundo' 56 | } 57 | }); 58 | expect(dict.greetings.en()).toEqual('Hello world'); 59 | expect(dict.farewell.en()).toEqual('Bye world'); 60 | expect(dict.greetings.es()).toEqual('Hola mundo'); 61 | expect(dict.farewell.es()).toEqual('Adiós mundo'); 62 | }); 63 | 64 | it('can use options', () => { 65 | const dict = normalize({ 66 | greetings: { 67 | en: ({ name }) => `Hello ${name}`, 68 | es: ({ name }) => `Hola ${name}` 69 | }, 70 | farewell: { 71 | en: ({ name = 'World' }) => `Bye ${name}`, 72 | es: ({ name = 'Mundo' }) => `Adiós ${name}` 73 | } 74 | }); 75 | 76 | expect(dict.greetings.en()).toEqual('Hello undefined'); 77 | expect(dict.greetings.es()).toEqual('Hola undefined'); 78 | expect(dict.greetings.en({ name: 'Francisco' })).toEqual('Hello Francisco'); 79 | expect(dict.greetings.es({ name: 'Francisco' })).toEqual('Hola Francisco'); 80 | 81 | expect(dict.farewell.en()).toEqual('Bye World'); 82 | expect(dict.farewell.es()).toEqual('Adiós Mundo'); 83 | expect(dict.farewell.en({ name: 'Francisco' })).toEqual('Bye Francisco'); 84 | expect(dict.farewell.es({ name: 'Francisco' })).toEqual('Adiós Francisco'); 85 | }); 86 | 87 | it('throws with inconsistent languages', () => { 88 | const thrower = () => { 89 | normalize({ 90 | greetings: { 91 | en: 'Hello world', 92 | es: 'Hola mundo' 93 | }, 94 | farewell: { 95 | en: 'Bye world', 96 | ja: 'Adiós mundo' 97 | } 98 | }); 99 | }; 100 | expect(thrower).toThrow(); 101 | }); 102 | 103 | it('throws with non-standard values', () => { 104 | expect(() => normalize({ greetings: { en: 25 }})).toThrow(); 105 | expect(() => normalize({ greetings: { en: true }})).toThrow(); 106 | expect(() => normalize({ greetings: { en: new Date() }})).toThrow(); 107 | expect(() => normalize({ greetings: { en: {} }})).toThrow(); 108 | }); 109 | }); 110 | --------------------------------------------------------------------------------