├── example ├── .npmignore ├── src │ ├── i18n.ts │ ├── index.tsx │ ├── HomePage.tsx │ └── translations.tsx ├── index.html ├── tsconfig.json └── package.json ├── .gitignore ├── .github └── workflows │ └── nodejs.yml ├── tsconfig.json ├── LICENSE ├── package.json ├── test └── lobo-t.test.tsx ├── src └── index.tsx └── README.md /example/.npmignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .cache 3 | dist 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.log 2 | .DS_Store 3 | node_modules 4 | .cache 5 | .rts2_cache_cjs 6 | .rts2_cache_esm 7 | .rts2_cache_umd 8 | .rts2_cache_system 9 | dist 10 | .npmrc 11 | .yarnrc 12 | -------------------------------------------------------------------------------- /example/src/i18n.ts: -------------------------------------------------------------------------------- 1 | import { initLobot } from '../../.'; 2 | 3 | export enum Language { 4 | Norwegian = 'nb', 5 | English = 'en', 6 | } 7 | 8 | const lobot = initLobot(Language.English); 9 | 10 | export const LanguageProvider = lobot.LanguageProvider; 11 | export const useTranslation = lobot.useTranslation; 12 | -------------------------------------------------------------------------------- /example/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Playground 8 | 9 | 10 | 11 |
12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /example/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "allowSyntheticDefaultImports": false, 4 | "target": "es5", 5 | "module": "commonjs", 6 | "jsx": "react", 7 | "moduleResolution": "node", 8 | "noImplicitAny": false, 9 | "noUnusedLocals": false, 10 | "noUnusedParameters": false, 11 | "removeComments": true, 12 | "strictNullChecks": true, 13 | "preserveConstEnums": true, 14 | "sourceMap": true, 15 | "lib": ["es2015", "es2016", "dom"], 16 | "baseUrl": ".", 17 | "types": ["node"] 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /.github/workflows/nodejs.yml: -------------------------------------------------------------------------------- 1 | name: Node CI 2 | 3 | on: [push] 4 | 5 | jobs: 6 | build: 7 | 8 | runs-on: ubuntu-latest 9 | 10 | strategy: 11 | matrix: 12 | node-version: [10.x, 12.x] 13 | 14 | steps: 15 | - uses: actions/checkout@v1 16 | - name: Use Node.js ${{ matrix.node-version }} 17 | uses: actions/setup-node@v1 18 | with: 19 | node-version: ${{ matrix.node-version }} 20 | - name: yarn install, build, and test 21 | run: | 22 | yarn install 23 | yarn lint 24 | yarn build 25 | yarn test 26 | env: 27 | CI: true 28 | -------------------------------------------------------------------------------- /example/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "example", 3 | "version": "1.0.0", 4 | "main": "index.js", 5 | "license": "MIT", 6 | "scripts": { 7 | "start": "parcel index.html", 8 | "build": "parcel build index.html" 9 | }, 10 | "dependencies": { 11 | "react-app-polyfill": "^2.0.0" 12 | }, 13 | "alias": { 14 | "react": "../node_modules/react", 15 | "react-dom": "../node_modules/react-dom/profiling", 16 | "scheduler/tracing": "../node_modules/scheduler/tracing-profiling" 17 | }, 18 | "devDependencies": { 19 | "@types/react": "^16.9.11", 20 | "@types/react-dom": "^16.8.4", 21 | "parcel": "^1.12.3", 22 | "typescript": "^4.0.5" 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "include": ["src", "types"], 3 | "compilerOptions": { 4 | "target": "es5", 5 | "module": "esnext", 6 | "lib": ["dom", "esnext"], 7 | "importHelpers": true, 8 | "declaration": true, 9 | "sourceMap": true, 10 | "rootDir": "./src", 11 | "strict": true, 12 | "noImplicitAny": true, 13 | "strictNullChecks": true, 14 | "strictFunctionTypes": true, 15 | "strictPropertyInitialization": true, 16 | "noImplicitThis": true, 17 | "alwaysStrict": true, 18 | "noUnusedLocals": true, 19 | "noUnusedParameters": true, 20 | "noImplicitReturns": true, 21 | "noFallthroughCasesInSwitch": true, 22 | "moduleResolution": "node", 23 | "baseUrl": "./", 24 | "paths": { 25 | "*": ["src/*", "node_modules/*"] 26 | }, 27 | "jsx": "react", 28 | "esModuleInterop": true 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /example/src/index.tsx: -------------------------------------------------------------------------------- 1 | import 'react-app-polyfill/ie11'; 2 | import * as React from 'react'; 3 | import * as ReactDOM from 'react-dom'; 4 | 5 | import { LanguageProvider, Language } from './i18n'; 6 | import HomePage from './HomePage'; 7 | 8 | const App = () => { 9 | const [currentLanguage, setCurrentLanguage] = React.useState( 10 | Language.English 11 | ); 12 | 13 | return ( 14 | 15 | 16 | 27 | 28 | ); 29 | }; 30 | 31 | ReactDOM.render(, document.getElementById('root')); 32 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Leile 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 | -------------------------------------------------------------------------------- /example/src/HomePage.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | 3 | import translations from './translations'; 4 | import { useTranslation } from './i18n'; 5 | 6 | const HomePage = () => { 7 | const { t, language } = useTranslation(); 8 | return ( 9 |
10 |

{t(translations.title)}

11 |

{t(translations.description)}

12 |

13 | {t(translations.languageLabel)}: {language} 14 |

15 |
16 |

{t(translations.examples.heading)}

17 |

{t(translations.examples.description)}

18 |
    19 |
  • 20 | {t(translations.examples.labels.string)}:{' '} 21 |
    {t(translations.examples.someString)}
    22 |
  • 23 |
  • 24 | {t(translations.examples.labels.numbers)}:{' '} 25 |
    {t(translations.examples.someNumber)}
    26 |
  • 27 |
  • 28 | {t(translations.examples.labels.components)}:{' '} 29 |
    {t(translations.examples.someComponent)}
    30 |
  • 31 |
32 |
33 |
34 | ); 35 | }; 36 | 37 | export default HomePage; 38 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@leile/lobo-t", 3 | "version": "1.0.5", 4 | "license": "MIT", 5 | "description": "A simple library for type-safe translations", 6 | "repository": { 7 | "type": "git", 8 | "url": "https://github.com/leile/lobo-t.git" 9 | }, 10 | "main": "dist/index.js", 11 | "module": "dist/lobo-t.esm.js", 12 | "typings": "dist/index.d.ts", 13 | "files": [ 14 | "dist" 15 | ], 16 | "scripts": { 17 | "start": "tsdx watch", 18 | "build": "tsdx build", 19 | "test": "tsdx test --env=jsdom", 20 | "lint": "tsdx lint", 21 | "version": "yarn build" 22 | }, 23 | "peerDependencies": { 24 | "react": ">=16" 25 | }, 26 | "husky": { 27 | "hooks": { 28 | "pre-commit": "tsdx lint" 29 | } 30 | }, 31 | "prettier": { 32 | "printWidth": 80, 33 | "semi": true, 34 | "singleQuote": true, 35 | "trailingComma": "es5" 36 | }, 37 | "devDependencies": { 38 | "@types/enzyme": "^3.10.3", 39 | "@types/enzyme-adapter-react-16": "^1.0.5", 40 | "@types/jest": "^25.2.3", 41 | "@types/react": "^16.9.13", 42 | "@types/react-dom": "^16.9.4", 43 | "enzyme": "^3.10.0", 44 | "enzyme-adapter-react-16": "^1.15.1", 45 | "husky": "^4.2.5", 46 | "react": "^16.12.0", 47 | "react-dom": "^16.12.0", 48 | "tsdx": "^0.13.2", 49 | "tslib": "^2.0.0", 50 | "typescript": "^3.7.2" 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /example/src/translations.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | 3 | const tranlations = { 4 | title: { 5 | nb: 'Lobo-T', 6 | en: 'Lobo-T', 7 | }, 8 | description: { 9 | nb: 'Lobo-T er et enkelt bibliotek for typesikre oversettelser.', 10 | en: 'Lobo-T is a simple library for type-safe translations', 11 | }, 12 | languageLabel: { 13 | nb: 'Aktivt språk', 14 | en: 'Active language', 15 | }, 16 | examples: { 17 | heading: { 18 | nb: 'Lobo-T bryr seg ikke om hva du oversetter', 19 | en: "Lobo-T doesn't care what you translate", 20 | }, 21 | description: { 22 | nb: 'Det kan være f.eks.:', 23 | en: 'It can be for example:', 24 | }, 25 | labels: { 26 | string: { 27 | nb: 'Strenger', 28 | en: 'Strings', 29 | }, 30 | numbers: { 31 | nb: 'Tall', 32 | en: 'Numbers', 33 | }, 34 | components: { 35 | nb: 'Tilogmed React-komponenter', 36 | en: 'Even React components', 37 | }, 38 | }, 39 | someString: { 40 | nb: 'Dette er en streng', 41 | en: 'This is a string', 42 | }, 43 | someNumber: { 44 | nb: 1234, 45 | en: 5678, 46 | }, 47 | someComponent: { 48 | nb: ( 49 | 50 | Dette er stilig 51 | 52 | ), 53 | en: ( 54 | 55 | This is cool 56 | 57 | ), 58 | }, 59 | }, 60 | }; 61 | 62 | export default tranlations; 63 | -------------------------------------------------------------------------------- /test/lobo-t.test.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import Enzyme, { mount } from 'enzyme'; 3 | import Adapter from 'enzyme-adapter-react-16'; 4 | 5 | import { initLobot, Translatable } from '../src'; 6 | 7 | Enzyme.configure({ adapter: new Adapter() }); 8 | 9 | enum Language { 10 | Norwegian = 'nb', 11 | English = 'en', 12 | } 13 | 14 | const translations = { 15 | heading: { 16 | nb: 'Norsk overskrift', 17 | en: 'Engligh heading', 18 | }, 19 | someNumber: { 20 | nb: 1, 21 | en: 2, 22 | }, 23 | someObject: { 24 | nb: { 25 | lol: 'hei', 26 | }, 27 | en: { 28 | lol: 'hei', 29 | }, 30 | }, 31 | someComponent: { 32 | nb: ( 33 | 34 | Dette er stilig 35 | 36 | ), 37 | en: ( 38 | 39 | This is cool 40 | 41 | ), 42 | }, 43 | }; 44 | 45 | const { LanguageProvider, useTranslation } = initLobot( 46 | Language.English 47 | ); 48 | 49 | function TestComponent({ 50 | str, 51 | id = 'translated', 52 | }: { 53 | str: Translatable; 54 | id?: string; 55 | }) { 56 | const { t } = useTranslation(); 57 | 58 | return
{t(str)}
; 59 | } 60 | 61 | describe('it', () => { 62 | it('renders provider without crashing', () => { 63 | expect(() => 64 | mount() 65 | ).not.toThrow(); 66 | }); 67 | 68 | it('is able to use translation', () => { 69 | const wrapper = mount( 70 | 71 | 72 | 73 | ); 74 | 75 | expect(wrapper.find('#translated').text()).toBe(translations.heading.en); 76 | }); 77 | 78 | it('can change language', () => { 79 | const wrapper = mount( 80 | 81 | 82 | 83 | ); 84 | 85 | expect(wrapper.find('#translated').text()).toBe(translations.heading.en); 86 | 87 | wrapper.setProps({ value: Language.Norwegian }); 88 | 89 | expect(wrapper.find('#translated').text()).toBe(translations.heading.nb); 90 | }); 91 | 92 | it("doesn't care about the type of translated values", () => { 93 | const wrapper = mount( 94 | 95 | 96 | 97 | ); 98 | 99 | expect(wrapper.find('#translated').text()).toBe(translations.heading.en); 100 | 101 | wrapper.setProps({ value: Language.Norwegian }); 102 | 103 | expect(wrapper.find('#translated').text()).toBe(translations.heading.nb); 104 | }); 105 | }); 106 | -------------------------------------------------------------------------------- /src/index.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | 3 | // --- Helper types 4 | 5 | // https://github.com/Microsoft/TypeScript/issues/30611 6 | type StandardEnum = { 7 | [id: string]: T | string; 8 | [nu: number]: string; 9 | }; 10 | 11 | type KeyType = string | number; 12 | 13 | type ValueOf = T[keyof T]; 14 | 15 | export type Translatable, U = unknown> = { 16 | [key in ValueOf]: U; 17 | }; 18 | 19 | export type TFunc> = ( 20 | arg: Translatable 21 | ) => U; 22 | 23 | // --- 24 | 25 | /** The initializing function for Lobot. 26 | * 27 | * It requires you to pass an enum of valid languages, and a default language 28 | * if none is provided. 29 | * 30 | * @example 31 | * enum Language = { 32 | * Norwegian: 'nb', 33 | * English: 'en' 34 | * }; 35 | * const lobot = initLobot(Language.English); 36 | */ 37 | export function initLobot>( 38 | defaultLanguage: ValueOf 39 | ) { 40 | const LanguageContext = React.createContext(defaultLanguage); 41 | 42 | /** A hook that gives you access to the translation function `t` and the 43 | * current language `language`. 44 | * 45 | * @example 46 | * const texts = { 47 | * buttonText: { 48 | * nb: 'Trykk her', 49 | * en: 'Click here', 50 | * } 51 | * }; 52 | * const { t } = useTranslation(); 53 | * return ( 54 | * 57 | * ); 58 | * */ 59 | const useTranslation = (): { 60 | /** The translation function. 61 | * 62 | * Provide it a text object, and it will return you the text in the 63 | * current language. 64 | * 65 | * @example 66 | * const texts = { 67 | * buttonText: { 68 | * nb: 'Trykk her', 69 | * en: 'Click here' 70 | * } 71 | * }; 72 | * const { t } = useTranslation(); 73 | * return ( 74 | * 77 | * ); 78 | */ 79 | t: TFunc; 80 | /** The currently selected language */ 81 | language: ValueOf; 82 | } => { 83 | const language = React.useContext(LanguageContext); 84 | 85 | return { 86 | t: (arg: any) => arg[language], 87 | language, 88 | }; 89 | }; 90 | 91 | return { 92 | /** Wraps your app, and provides the current language through context 93 | * 94 | * `LanguageProvider` accepts a `value` prop, which is the currently active 95 | * language. 96 | * 97 | * @example 98 | * 99 | * 100 | * 101 | */ 102 | LanguageProvider: LanguageContext.Provider, 103 | 104 | useTranslation, 105 | }; 106 | } 107 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Lobo-T 2 | 3 | > A small library for type-safe translations in React applications 4 | 5 | ## Installation 6 | 7 | ```sh 8 | $ npm i @leile/lobo-t 9 | # or 10 | $ yarn add @leile/lobo-t 11 | ``` 12 | 13 | ## `lobo-t` at a glance 14 | 15 | You tell `lobo-t` which languages you support and which language is currently active. 16 | `lobo-t` gives you a React hook that you can use to get a function for translating _text resources_. 17 | A _text resource_ in `lobo-t` terms is an object that has keys for each language you support. 18 | If you try to translate a _text resource_ that is missing one or more languages or a _text resource_ that doesn't exist, `lobo-t` (or rather, TypeScript) will tell you so. 19 | 20 | That's all. 21 | 22 | If you want to know more about why we decided to make this, [see below](#why). 23 | 24 | ## Usage 25 | 26 | ```tsx 27 | // i18n.ts 28 | import { initLobot } from '@leile/lobo-t'; 29 | 30 | // Specify which languages you support 31 | export enum Language { 32 | Norwegian = 'nb', 33 | English = 'en', 34 | } 35 | 36 | const lobot = initLobot(Language.English); 37 | 38 | export const LanguageProvider = lobot.LanguageProvider; 39 | export const useTranslation = lobot.useTranslation; 40 | ``` 41 | 42 | ```tsx 43 | // index.tsx 44 | import { LanguageProvider, Language } from './i18n'; 45 | import App from './App'; 46 | 47 | // You decide how to detect/store the active language. 48 | // In this example, we get it as a prop. 49 | // When it changes, components using the useTranslation hook will re-render with the correct language 50 | export default (props: { activeLanguage: Language }) => ( 51 | 52 | 53 | 54 | ); 55 | ``` 56 | 57 | ```tsx 58 | // App.tsx 59 | import { useTranslation } from './i18n'; 60 | 61 | // Text resources must have a key for each language you support 62 | const texts = { 63 | header: { 64 | nb: 'Dette er en header', 65 | en: 'This is a header', 66 | }, 67 | activeUsers: count => ({ 68 | nb: `Det er nå ${count} aktive brukere`, 69 | en: `There are now ${count} active users`, 70 | }), 71 | // or 72 | // header: { 73 | // [Language.Norwegian]: 'Dette er en header', 74 | // [Language.English]: 'This is a header', 75 | // }, 76 | // activeUsers: count => ({ 77 | // [Language.Norwegian]: `Det er nå ${count} aktive brukere`, 78 | // [Language.English]: `There are now ${count} active users`, 79 | // }), 80 | }; 81 | 82 | export default () => { 83 | const { t } = useTranslation(); 84 | 85 | return ( 86 |
87 |

{t(texts.header)}

88 |

{t(texts.activeUsers(5))}

89 |
90 | ); 91 | }; 92 | ``` 93 | 94 | --- 95 | 96 | ## Why? 97 | 98 | After using other translation libraries for quite some time we realized that we didn't really like them. 99 | They worked, but there were some things that irked us. For example: 100 | 101 | - Some tools store translations in JSON files in separate folders per language. This makes the physical distance between translations too large. In our experience this often lead to texts in different languages communicating different things to users, because they were not written at the same time. 102 | - Accessing text resources by calling a function with a string that coincidentally should correspond to a path in a JSON object is brittle and makes refactoring hard 103 | - String interpolation is brittle when not type-checked. Having `{{somevariablename}}` in strings and passing objects with properties that _should_ match whatever you wrote in the translation file is an error waiting to happen. 104 | - Having styling or more complex contents in text resources is often very complex and requires parsing and/or third-party tools 105 | 106 | So we sat down and came up with a list of what we wanted. 107 | For context, most of our applications are written using [next.js](https://nextjs.org/), all of them in TypeScript, and at the moment we support two languages. 108 | Since we use [next.js](https://nextjs.org/) we get code splitting out of the box, so we don't really need our internationalization tool to have complex lazy-loading features. 109 | Additionally, we, the developers, are the ones writing the translations. 110 | 111 | ## Our list of requirements: 112 | 113 | ### Type-checked 114 | 115 | - Trying to translate a resource that doesn't exist should be a compile-time error 116 | - The compiler should tell you when you're missing translations for one or more of the languages you support 117 | 118 | ### Text resources should be able to live close to where they are used 119 | 120 | We like the way CSS modules allow us to scope our CSS so that changes in one part of the application don't break other stuff. It would be nice to be able to apply the same principle to internationalization. 121 | 122 | ### It should be easy to see where a text resource is used 123 | 124 | - There is a difference between two things being _similar_ and being _the same_. It happens often while refactoring one thing that some other thing stops making sense 125 | - Dead code should be deleted, and so should unused text resources (and your editor/IDE should tell you if it wasn't dead after all) 126 | 127 | ### Translations for different languages should be located close together 128 | 129 | The save button should communicate to the user that it will save, no matter what language the user is using. 130 | It should not say "Save" in English and "Fullfør" (which means "Complete") in Norwegian. 131 | Keeping the English and Norwegian texts close together, rather than in separate files in separate folders, helps us keep semantics in check. 132 | 133 | ### String interpolation should be easy and type-checked 134 | 135 | When using a text resource that expects some external input you should be notified of this before leaving your editor/IDE. 136 | 137 | ### Using some styling in translations should be possible 138 | 139 | Sometimes it makes sense to have some styling in the text resource. 140 | This could be done either with inline HTML/JSX or using markdown in some way. 141 | 142 | For example, in our opinion: 143 | 144 | ```tsx 145 | const myText = String with a bold word. 146 | /// ... 147 |
{myTextResource}
148 | ``` 149 | 150 | is easier and less error-prone than 151 | 152 | ```tsx 153 | const myTextOne = 'String with a'; 154 | const myTextTwo = 'bold'; 155 | const myTextThree = ' word'; 156 | // ... 157 |
158 | {myTextOne} 159 | {myTextTwo} 160 | {myTextThree} 161 |
; 162 | ``` 163 | 164 | In the second example, can you tell at a glance whether this wil render with the correct whitespace or not? 165 | 166 | ## This library is probably not for you if 167 | 168 | - You get your text resources from a CMS or store them someplace other than in your code-base 169 | - The developers are not the ones writing the translations 170 | - You don't use TypeScript 171 | - You don't like the API (but if you have any suggestions on how to improve it, discuss it with us!) 172 | - You need support for many languages - the structure of your translation objects might become messy 173 | - You don't like superheroes that can regenerate from a single cell and claims to know 17,897 different languages from across the galaxy ([Lobo]()) 174 | --------------------------------------------------------------------------------