├── .browserslistrc ├── .firebase └── hosting.ZGlzdA.cache ├── .github └── workflows │ └── workflow.yml ├── .gitignore ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── LICENSE.md ├── README.md ├── blueprint.json ├── blueprint.md ├── example.gif ├── karma.conf.js ├── package-lock.json ├── package.json ├── pre-build.js ├── readme ├── 1-define.md ├── 2-register.md ├── 3-set-language.md ├── 4-get-translations.md ├── 5-interpolate.md ├── 6-lit.md ├── async.md ├── customise.md ├── directives.md ├── installation.md ├── typesafe.md └── wait.md ├── rollup.config.js ├── src ├── demo │ ├── assets │ │ ├── favicon.ico │ │ └── i18n │ │ │ ├── da.json │ │ │ ├── demo-component.da.json │ │ │ ├── demo-component.en.json │ │ │ └── en.json │ ├── components │ │ ├── demo-component.scss │ │ └── demo-component.ts │ ├── index.html │ ├── main.ts │ ├── manifest.json │ ├── pages │ │ ├── demo-page.scss │ │ └── demo-page.ts │ ├── styles │ │ └── global.scss │ └── typed-lit-translate.ts ├── lib │ ├── config.ts │ ├── directives │ │ ├── lang-changed-base.ts │ │ ├── lang-changed.ts │ │ ├── translate-unsafe-html.ts │ │ └── translate.ts │ ├── helpers.ts │ ├── index.ts │ ├── typed-keys.ts │ ├── types.ts │ └── util.ts └── test │ ├── mock.ts │ └── translate.test.ts ├── tsconfig.build.json ├── tsconfig.json ├── tslint.json ├── typesafe.gif └── typings.d.ts /.browserslistrc: -------------------------------------------------------------------------------- 1 | last 2 Chrome versions 2 | last 2 Safari versions 3 | last 2 Firefox versions -------------------------------------------------------------------------------- /.firebase/hosting.ZGlzdA.cache: -------------------------------------------------------------------------------- 1 | budget.txt.gz,1627817859192,f9006fccc20056797760ae54046753031d37f5e4696a5d1f010cb61e9c881c02 2 | budget.txt,1627817859009,b6eb250a3abcd6764d81ffb980a2a9676f9eade64698554e3a8df9ea7549a10b 3 | index.html,1627817856988,ad5e408991b8eda5d3a4e6750f0923be757dfab674446b0a34689f75abc7c31a 4 | index.html.gz,1627817859192,48ace1fce0c450e38c79c5ffa7a3ac7c56138f8d4cedef34ed3318feec62020b 5 | licenses.txt.gz,1627817859192,6c363c16adea821ca05b12be94db42c35c92a9fb0c4f1ed9951045049bbd03c7 6 | licenses.txt,1627817856990,d2283ee2f69066f666dc090c408961f6f4c8ef1e742f5eb60fd665e1ff019e36 7 | main-f24b57bc.js.gz,1627817859192,5e81909f6a59e214381ea36a63782ae810e626a6677e8a6d1659a38b4696e129 8 | assets/favicon.ico.gz,1627817859193,0ceefb68f344be0117b3fd4a2b7a3191ae47ce6312e36308e8870b8e4e6ef424 9 | main-f24b57bc.js,1627817857167,406af4ea8ae186be8d2ba36fc0c9a7fc93f8730c5b486c7db9e6ed4f810e1591 10 | assets/i18n/da.json,1627817856983,9475970f6983db463b422e59574207805ab3d5acd7f0f07e9cbdd076a5f12f63 11 | assets/i18n/en.json.gz,1627817859191,c3e950c54d4a8fb115c1557043e27eb4371b39c971732e8e1569829be25a12d7 12 | assets/i18n/en.json,1627817856983,b73023b406e6481d90e18651e8ee49e25b76e38e81020f685aecbb71eae92e1f 13 | assets/i18n/subpage-da.json,1627817856981,ecd98cda7b6676d09cf8536f9a3c0e8aecfb11d99b5a99b6694823a0694936a6 14 | assets/i18n/da.json.gz,1627817859191,8e17ed05dd97c824f2bfe465ad83a11642c71e5a9183a964e30bc594fef780e7 15 | assets/i18n/subpage-en.json.gz,1627817859191,d4f234badb8cb54ea5a8a595384e64a90e681d06ede932a802d9675ef42dfbd8 16 | assets/i18n/subpage-da.json.gz,1627817859191,a7cb14c8b69a19bcc8017af87cb05b115244d92a1e7d760ee668ec581730ea51 17 | assets/i18n/subpage-en.json,1627817856980,ff497b3d242df140b8d921d18c3838dee10f4f3361db60a8e66c06fa05dd3a53 18 | main-f24b57bc.js.map.gz,1627817859194,1993324c553c7b9fd61aefe32710d50aa5a739e902141f9e4fc463a65e8f8eb0 19 | assets/favicon.ico,1627817856986,d86657c14009ecf1db6e94e1b201c24d636daa9ff8c6b2ecfdfa39859b6476f1 20 | stats.html.gz,1627817859194,e19105f84bacb64433aca1bd6f361b4f03716aba277b850692831d67e4e3953f 21 | main-f24b57bc.js.map,1627817857167,447aa343abac34b3a7900474a61b752e09908bab681924b6608f377393fbd2be 22 | stats.html,1627817857164,5275ccf55052fc4f4807e0b28b9a1fc2e6f4239c9c3fcde72cbe407c3a782aa1 23 | -------------------------------------------------------------------------------- /.github/workflows/workflow.yml: -------------------------------------------------------------------------------- 1 | name: Main Workflow 2 | 3 | on: [push, pull_request] 4 | 5 | jobs: 6 | run: 7 | name: Run 8 | 9 | runs-on: ubuntu-latest 10 | 11 | steps: 12 | - name: Checkout code 13 | uses: actions/checkout@master 14 | 15 | - name: Set Node.js 10.x 16 | uses: actions/setup-node@master 17 | with: 18 | node-version: 10.x 19 | 20 | - name: Cache 21 | uses: actions/cache@preview 22 | id: cache 23 | with: 24 | path: node_modules 25 | key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }} 26 | restore-keys: | 27 | ${{ runner.os }}-node- 28 | 29 | - name: Install 30 | if: steps.cache.outputs.cache-hit != 'true' 31 | run: npm ci 32 | 33 | - name: Test 34 | run: npm test -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See http://help.github.com/ignore-files/ for more about ignoring files. 2 | 3 | .DS_Store 4 | ec2-user-key-pair.pem 5 | /tmp 6 | env.json 7 | 8 | # compiled output 9 | /dist 10 | 11 | # dependencies 12 | /node_modules 13 | /functions/node_modules 14 | 15 | # IDEs and editors 16 | /.idea 17 | .project 18 | .classpath 19 | .c9/ 20 | *.launch 21 | .settings/ 22 | *.sublime-workspace 23 | 24 | # IDE - VSCode 25 | .vscode/* 26 | !.vscode/settings.json 27 | !.vscode/tasks.json 28 | !.vscode/launch.json 29 | !.vscode/extensions.json 30 | 31 | # misc 32 | /.sass-cache 33 | /connect.lock 34 | /coverage/* 35 | /libpeerconnection.log 36 | npm-debug.log 37 | testem.log 38 | logfile 39 | 40 | # e2e 41 | /e2e/*.js 42 | /e2e/*.map 43 | 44 | #System Files 45 | .DS_Store 46 | Thumbs.db 47 | dump.rdb 48 | 49 | /compiled/ 50 | /.idea/ 51 | /.cache/ 52 | /.vscode/ 53 | *.log 54 | /logs/ 55 | npm-debug.log* 56 | /lib-cov/ 57 | /coverage/ 58 | /.nyc_output/ 59 | /.grunt/ 60 | *.7z 61 | *.dmg 62 | *.gz 63 | *.iso 64 | *.jar 65 | *.rar 66 | *.tar 67 | *.zip 68 | .tgz 69 | .env 70 | .DS_Store? 71 | ._* 72 | .Spotlight-V100 73 | .Trashes 74 | ehthumbs.db 75 | *.pem 76 | *.p12 77 | *.crt 78 | *.csr 79 | /node_modules/ 80 | /dist/ 81 | /documentation/ -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | In the interest of fostering an open and welcoming environment, we as contributors and maintainers pledge to making participation in our project and our community a harassment-free experience for everyone, regardless of age, body size, disability, ethnicity, gender identity and expression, level of experience, nationality, personal appearance, race, religion, or sexual identity and orientation. 6 | 7 | ## Our Standards 8 | 9 | Examples of behavior that contributes to creating a positive environment include: 10 | 11 | * Using welcoming and inclusive language 12 | * Being respectful of differing viewpoints and experiences 13 | * Gracefully accepting constructive criticism 14 | * Focusing on what is best for the community 15 | * Showing empathy towards other community members 16 | 17 | Examples of unacceptable behavior by participants include: 18 | 19 | * The use of sexualized language or imagery and unwelcome sexual attention or advances 20 | * Trolling, insulting/derogatory comments, and personal or political attacks 21 | * Public or private harassment 22 | * Publishing others' private information, such as a physical or electronic address, without explicit permission 23 | * Other conduct which could reasonably be considered inappropriate in a professional setting 24 | 25 | ## Our Responsibilities 26 | 27 | Project maintainers are responsible for clarifying the standards of acceptable behavior and are expected to take appropriate and fair corrective action in response to any instances of unacceptable behavior. 28 | 29 | Project maintainers have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, or to ban temporarily or permanently any contributor for other behaviors that they deem inappropriate, threatening, offensive, or harmful. 30 | 31 | ## Scope 32 | 33 | This Code of Conduct applies both within project spaces and in public spaces when an individual is representing the project or its community. Examples of representing a project or community include using an official project e-mail address, posting via an official social media account, or acting as an appointed representative at an online or offline event. Representation of a project may be further defined and clarified by project maintainers. 34 | 35 | ## Enforcement 36 | 37 | Instances of abusive, harassing, or otherwise unacceptable behavior may be reported by contacting any of the code of conduct enforcers: [Andreas Mehlsen](mailto:andmehlsen@gmail.com). All complaints will be reviewed and investigated and will result in a response that is deemed necessary and appropriate to the circumstances. The project team is obligated to maintain confidentiality with regard to the reporter of an incident. Further details of specific enforcement policies may be posted separately. 38 | 39 | Project maintainers who do not follow or enforce the Code of Conduct in good faith may face temporary or permanent repercussions as determined by other members of the project's leadership. 40 | 41 | ## Attribution 42 | 43 | This Code of Conduct is adapted from the Contributor Covenant, version 1.4, available at http://contributor-covenant.org/version/1/4/ -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | You are more than welcome to contribute to `lit-translate` in any way you please, including: 2 | 3 | * Updating documentation. 4 | * Fixing spelling and grammar 5 | * Adding tests 6 | * Fixing issues and suggesting new features 7 | * Blogging, tweeting, and creating tutorials about `lit-translate` 8 | * Reaching out to [@AndreasMehlsen](https://twitter.com/AndreasMehlsen) on Twitter 9 | * Submit an issue or a pull request -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright © 2018 Andreas Mehlsen andmehlsen@gmail.com 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 6 | 7 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 8 | 9 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

lit-translate

2 | 3 |

4 | 5 | 6 | 7 |

8 | 9 |

10 | Downloads per month 11 | NPM Version 12 | Dependencies 13 | Contributors 14 | Published on webcomponents.org 15 | undefined 16 |

17 | 18 |

19 | A blazing-fast and lightweight internationalization (i18n) library for your next web-based project
20 | 21 |

22 | 23 |
24 | 25 | 26 | * Contains a [lit](https://www.npmjs.com/package/lit) directive that automatically updates the translations when the language changes 27 | * Has a simple API that can return a translation for a given key using the dot notation (eg. `get("home.header.title")`) 28 | * Works very well with JSON based translation data-structures 29 | * Can interpolate values into the strings using the {{ key }} syntax out of the box 30 | * Caches the translations for maximum performance 31 | * Has a very small footprint, approximately 800 bytes minified & gzipped (2kb without) 32 | * Extremely customizable, just about everything can be changed (eg. choose your own translations loader, how to interpolate values, empty placeholder and how to look up the strings) 33 | * Check out the playground [here](https://codepen.io/andreasbm/pen/MWWXPNO?editors=1010) 34 | 35 | 36 | [![-----------------------------------------------------](https://raw.githubusercontent.com/andreasbm/readme/master/assets/lines/rainbow.png)](#table-of-contents) 37 | 38 | ## ➤ Table of Contents 39 | 40 | * [➤ Installation](#-installation) 41 | * [➤ 1. Define the translations](#-1-define-the-translations) 42 | * [➤ 2. Register the translate config](#-2-register-the-translate-config) 43 | * [➤ 3. Set the language](#-3-set-the-language) 44 | * [➤ 4. Get the translations](#-4-get-the-translations) 45 | * [➤ 5. Interpolate values](#-5-interpolate-values) 46 | * [➤ 6. Use the `translate` directive with `lit`](#-6-use-the-translate-directive-with-lit) 47 | * [➤ Wait for strings to be loaded before displaying your app](#-wait-for-strings-to-be-loaded-before-displaying-your-app) 48 | * [➤ Advanced Customisation](#-advanced-customisation) 49 | * [Format text with `IntlMessageFormat`](#format-text-with-intlmessageformat) 50 | * [Use the default translations as keys](#use-the-default-translations-as-keys) 51 | * [➤ Typesafe Translations](#-typesafe-translations) 52 | * [1. Add `resolveJsonModule` to your tsconfig](#1-add-resolvejsonmodule-to-your-tsconfig) 53 | * [2. Use the `typedKeysFactory` function](#2-use-the-typedkeysfactory-function) 54 | * [3. Import the typed functions](#3-import-the-typed-functions) 55 | * [➤ `lit` Directives](#-lit-directives) 56 | * [Re-render a value when the language changes with the `langChanged` directive](#re-render-a-value-when-the-language-changes-with-the-langchanged-directive) 57 | * [Create your own `lit` directives that re-renders a value when the language changes](#create-your-own-lit-directives-that-re-renders-a-value-when-the-language-changes) 58 | * [➤ License](#-license) 59 | 60 | 61 | [![-----------------------------------------------------](https://raw.githubusercontent.com/andreasbm/readme/master/assets/lines/rainbow.png)](#installation) 62 | 63 | ## ➤ Installation 64 | 65 | ```js 66 | npm i lit-translate 67 | ``` 68 | 69 | [![-----------------------------------------------------](https://raw.githubusercontent.com/andreasbm/readme/master/assets/lines/rainbow.png)](#1-define-the-translations) 70 | 71 | ## ➤ 1. Define the translations 72 | 73 | Create a `.json` file for each language you want to support. Heres an example of how `en.json` could look like. 74 | 75 | ```json 76 | { 77 | "header": { 78 | "title": "Hello", 79 | "subtitle": "World" 80 | }, 81 | "cta": { 82 | "awesome": "{{ animals }} are awesome!", 83 | "cats": "Cats" 84 | }, 85 | "footer": { 86 | "html": "Bold text" 87 | } 88 | } 89 | ``` 90 | 91 | 92 | [![-----------------------------------------------------](https://raw.githubusercontent.com/andreasbm/readme/master/assets/lines/rainbow.png)](#2-register-the-translate-config) 93 | 94 | ## ➤ 2. Register the translate config 95 | 96 | Use the `registerTranslateConfig` function to register a loader that loads translations based on the selected language. In the example below, a loader is registered that uses the [fetch API](https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API) to load a `.json` file for the selected language. 97 | 98 | ```typescript 99 | import { registerTranslateConfig } from "lit-translate"; 100 | 101 | registerTranslateConfig({ 102 | loader: lang => fetch(`${lang}.json`).then(res => res.json()) 103 | }); 104 | ``` 105 | 106 | 107 | [![-----------------------------------------------------](https://raw.githubusercontent.com/andreasbm/readme/master/assets/lines/rainbow.png)](#3-set-the-language) 108 | 109 | ## ➤ 3. Set the language 110 | 111 | Set the language with the `use` function. When called it will use the registered loader from [step 2](#-2-register-the-translate-config) to load the strings for the selected language. 112 | 113 | ```typescript 114 | import { use } from "lit-translate"; 115 | 116 | use("en"); 117 | ``` 118 | 119 | 120 | [![-----------------------------------------------------](https://raw.githubusercontent.com/andreasbm/readme/master/assets/lines/rainbow.png)](#4-get-the-translations) 121 | 122 | ## ➤ 4. Get the translations 123 | 124 | Get translations with the `get` function. Give this function a string of keys (separated with `.`) that points to the desired translation in the JSON structure. The example below is based on the translations defined in [step 1](#-1-define-the-translations) and registered in [step 2](#-2-register-the-translate-config). 125 | 126 | ```typescript 127 | import { get } from "lit-translate"; 128 | 129 | get("header.title"); // "Hello" 130 | get("header.subtitle"); // "World" 131 | ``` 132 | 133 | 134 | [![-----------------------------------------------------](https://raw.githubusercontent.com/andreasbm/readme/master/assets/lines/rainbow.png)](#5-interpolate-values) 135 | 136 | ## ➤ 5. Interpolate values 137 | 138 | When using the `get` function it is possible to interpolate values (replacing placeholders with content). As default, you can use the `{{ key }}` syntax in your translations and provide an object with values replacing those defined in the translations when using the `get` function. The example below is based on the strings defined in [step 1](#-1-define-the-translations) and registered in [step 2](#-2-register-the-translate-config). 139 | 140 | ```typescript 141 | import { get } from "lit-translate"; 142 | 143 | get("cta.awesome", { animals: get("cta.cats") }); // Cats are awesome! 144 | ``` 145 | 146 | 147 | 148 | [![-----------------------------------------------------](https://raw.githubusercontent.com/andreasbm/readme/master/assets/lines/rainbow.png)](#6-use-the-translate-directive-with-lit) 149 | 150 | ## ➤ 6. Use the `translate` directive with `lit` 151 | 152 | If you are using [lit](https://www.npmjs.com/package/lit) you might want to use the `translate` directive. This directive makes sure to automatically update all the translated parts when the `use` function is called with a new language. If your strings contain HTML you can use the `translateUnsafeHTML` directive. The example below is based on the strings defined in [step 1](#-1-define-the-translations) and registered in [step 2](#-2-register-the-translate-config). 153 | 154 | ```typescript 155 | import { translate, translateUnsafeHTML } from "lit-translate"; 156 | import { LitElement, html } from "lit"; 157 | import { customElement } from "lit/decorators.js"; 158 | 159 | @customElement("my-element") 160 | class MyElement extends LitElement { 161 | render () { 162 | html` 163 |

${translate("header.title")}

164 |

${translate("header.subtitle")}

165 | ${translate("cta.awesome", { animals: () => get("cta.cats") })} 166 | ${translateUnsafeHTML("footer.html")} 167 | `; 168 | } 169 | } 170 | ``` 171 | 172 | 173 | [![-----------------------------------------------------](https://raw.githubusercontent.com/andreasbm/readme/master/assets/lines/rainbow.png)](#wait-for-strings-to-be-loaded-before-displaying-your-app) 174 | 175 | ## ➤ Wait for strings to be loaded before displaying your app 176 | 177 | You might want to avoid empty placeholders being shown initially before any of the translation strings have been loaded. This it how you could defer the first render of your app until the strings have been loaded. 178 | 179 | ```typescript 180 | import { use, translate } from "lit-translate"; 181 | import { LitElement, html, PropertyValues } from "lit"; 182 | import { customElement, state } from "lit/decorators.js"; 183 | 184 | @customElement("my-app") 185 | export class MyApp extends LitElement { 186 | 187 | // Defer the first update of the component until the strings has been loaded to avoid empty strings being shown 188 | @state() hasLoadedStrings = false; 189 | 190 | protected shouldUpdate(props: PropertyValues) { 191 | return this.hasLoadedStrings && super.shouldUpdate(props); 192 | } 193 | 194 | // Load the initial language and mark that the strings has been loaded so the component can render. 195 | async connectedCallback() { 196 | super.connectedCallback(); 197 | 198 | await use("en"); 199 | this.hasLoadedStrings = true; 200 | } 201 | 202 | // Render the component 203 | protected render () { 204 | return html` 205 |

${translate("title")}

206 | `; 207 | } 208 | } 209 | ``` 210 | 211 | 212 | [![-----------------------------------------------------](https://raw.githubusercontent.com/andreasbm/readme/master/assets/lines/rainbow.png)](#advanced-customisation) 213 | 214 | ## ➤ Advanced Customisation 215 | 216 | If you want you can customise just about anything by overwriting the configuration hooks. Below is an example of what you can customise. Try it as a playground [here](https://codepen.io/andreasbm/pen/gOoVGdQ?editors=0010). 217 | 218 | ```typescript 219 | import { registerTranslateConfig, extract, get, use } from "lit-translate"; 220 | 221 | registerTranslateConfig({ 222 | 223 | // Loads the language by returning a JSON structure for a given language 224 | loader: lang => { 225 | switch (lang) { 226 | 227 | // English strings 228 | case "en": 229 | return { 230 | app: { 231 | title: "This is a title", 232 | description: "This description is {placeholder}!" 233 | }, 234 | awesome: "awesome" 235 | }; 236 | 237 | // Danish strings 238 | case "da": 239 | return { 240 | app: { 241 | title: "Dette er en titel", 242 | description: "Denne beskrivelse er {placeholder}!" 243 | }, 244 | awesome: "fed" 245 | }; 246 | 247 | default: 248 | throw new Error(`The language ${lang} is not supported..`); 249 | } 250 | }, 251 | 252 | // Interpolate the values using a key syntax. 253 | interpolate: (text, values) => { 254 | for (const [key, value] of Object.entries(extract(values || {}))) { 255 | text = text.replace(new RegExp(`{.*${key}.*}`, `gm`), String(extract(value))); 256 | } 257 | 258 | 259 | return text; 260 | }, 261 | 262 | // Returns a string for a given key 263 | lookup: (key, config) => { 264 | 265 | // Split the key in parts (example: hello.world) 266 | const parts = key.split(" -> "); 267 | 268 | // Find the string by traversing through the strings matching the chain of keys 269 | let string = config.strings; 270 | 271 | // Shift through all the parts of the key while matching with the strings. 272 | // Do not continue if the string is not defined or if we have traversed all the key parts 273 | while (string != null && parts.length > 0) { 274 | string = string[parts.shift()]; 275 | } 276 | 277 | // Make sure the string is in fact a string! 278 | return string != null ? string.toString() : null; 279 | }, 280 | 281 | // Formats empty placeholders (eg. !da.headline.title!) if lookup returns null 282 | empty: (key, config) => `!${config.lang}.${key}!` 283 | }); 284 | 285 | use("en").then(() => { 286 | get("app -> description", { placeholder: get("awesome") }); // Will return "This description is awesome" 287 | }); 288 | ``` 289 | 290 | ### Format text with `IntlMessageFormat` 291 | 292 | [IntlMessageFormat](https://www.npmjs.com/package/intl-messageformat) is a library that formats ICU message strings with number, date, plural, and select placeholders to create localized messages using [ICU placeholders](https://unicode-org.github.io/icu/userguide/format_parse/messages/). This library is a good addition to `lit-translate`. You can add it to the interpolate hook to get the benefits as shown in the following example. Try the example as a playground [here](https://codepen.io/andreasbm/pen/rNpXGPW?editors=0010). 293 | 294 | ```typescript 295 | import { registerTranslateConfig, extract } from "lit-translate"; 296 | import { IntlMessageFormat } from "intl-messageformat"; 297 | 298 | registerTranslateConfig({ 299 | loader: lang => { 300 | switch (lang) { 301 | case "en": 302 | return { 303 | photos: `You have {numPhotos, plural, =0 {no photos.} =1 {one photo.} other {# photos.}}` 304 | }; 305 | 306 | case "en": 307 | return { 308 | photos: `Du har {numPhotos, plural, =0 {ingen billeder.} =1 {et billede.} other {# billeder.}}` 309 | }; 310 | 311 | default: 312 | throw new Error(`The language ${lang} is not supported..`); 313 | } 314 | }, 315 | 316 | // Use the "intl-messageformat" library for formatting. 317 | interpolate: (text, values, config) => { 318 | const msg = new IntlMessageFormat(text, config.lang); 319 | return msg.format(extract(values)); 320 | } 321 | }); 322 | 323 | use("en").then(() => { 324 | get("photos", {numPhotos: 0}); // Will return "You have no photos" 325 | get("photos", {numPhotos: 1}); // Will return "You have one photo." 326 | get("photos", {numPhotos: 5}); // Will return "You have 5 photos." 327 | }); 328 | ``` 329 | 330 | ### Use the default translations as keys 331 | 332 | Inspired by [GNU gettext](https://en.wikipedia.org/wiki/Gettext) you can use the default translation as keys. The benefit of doing this is that you will save typing time and reduce code clutter. You can use [xgettext](https://www.gnu.org/software/gettext/manual/html_node/xgettext-Invocation.html) to extract the translatable strings from your code and then use [po2json](https://github.com/mikeedwards/po2json) to turn your `.po` files into `.json` files. The following code shows an example of how you could implement this. Try it as a playground [here](https://codepen.io/andreasbm/pen/RwxXjJX?editors=0010). 333 | 334 | ```typescript 335 | import { registerTranslateConfig, use, get } from "lit-translate"; 336 | 337 | registerTranslateConfig({ 338 | loader: lang => { 339 | switch (lang) { 340 | case "da": 341 | return { 342 | "The page is being loaded...": "Siden indlæses..." 343 | }; 344 | default: 345 | return {}; 346 | } 347 | }, 348 | lookup: (key, config) => config.strings != null && config.strings[key] != null ? config.strings[key].toString() : key, 349 | empty: key => key, 350 | }); 351 | 352 | get("The page is being loaded..."); // Will return "The page is being loaded..." 353 | 354 | use("da").then(() => { 355 | get("The page is being loaded..."); // Will return "Siden indlæses..." 356 | }); 357 | ``` 358 | 359 | [![-----------------------------------------------------](https://raw.githubusercontent.com/andreasbm/readme/master/assets/lines/rainbow.png)](#typesafe-translations) 360 | 361 | ## ➤ Typesafe Translations 362 | 363 | 364 | 365 | If you have a lot of translation keys you can quickly lose the overview of your strings. If you use Typescript you can make the keys of your translation keys typesafe - this will also give you autocompletion when you enter the keys. To achieve this you have to do the following: 366 | 367 | 368 | ### 1. Add `resolveJsonModule` to your tsconfig 369 | 370 | Add [resolveJsonModule](https://www.typescriptlang.org/tsconfig#resolveJsonModule) to your `tsconfig` which will allow us to import modules with a `.json` extension. 371 | 372 | ```json 373 | { 374 | ... 375 | "compilerOptions": { 376 | ... 377 | "resolveJsonModule": true 378 | } 379 | } 380 | ``` 381 | 382 | ### 2. Use the `typedKeysFactory` function 383 | 384 | Create a file, for example `typed-lit-translate.ts`. Then use the factory function `typedKeysFactory` and provide it with the type of one of your translation files. Use `typeof import(..)` to import the `.json` file and get the type. Provide this type to the factory function, and it will return a version of `get`, `translate` and `translateUnsafeHTML` where the keys are typed. Export these and make sure to import from your `typed-lit-translate.ts` file instead of `lit-translate`. 385 | 386 | ```typescript 387 | // typed-lit-translate.ts 388 | import { typedKeysFactory } from "lit-translate"; 389 | 390 | const { get, translate, translateUnsafeHTML } = typedKeysFactory(); 391 | export { get, translate, translateUnsafeHTML }; 392 | ``` 393 | 394 | ### 3. Import the typed functions 395 | 396 | Make sure to import the typed versions of `get`, `translate` and `translateUnsafeHTML` that you have created instead of importing from `lit-translate`. 397 | 398 | ```typescript 399 | import { get } from "typed-lit-translate.ts"; 400 | 401 | get("this.key.is.typed"); 402 | ``` 403 | 404 | [![-----------------------------------------------------](https://raw.githubusercontent.com/andreasbm/readme/master/assets/lines/rainbow.png)](#lit-directives) 405 | 406 | ## ➤ `lit` Directives 407 | 408 | ### Re-render a value when the language changes with the `langChanged` directive 409 | 410 | Use the `langChanged` directive to re-render a value when the language changes. 411 | 412 | ```typescript 413 | import { langChanged, translateConfig } from "lit-translate"; 414 | import { html, LitElement, TemplateResult } from "lit"; 415 | import { customElement } from "lit/decorators.js"; 416 | 417 | @customElement("my-component") 418 | export class MyComponent extends LitElement { 419 | protected render(): TemplateResult { 420 | return html` 421 | 422 | `; 423 | } 424 | } 425 | ``` 426 | 427 | ### Create your own `lit` directives that re-renders a value when the language changes 428 | 429 | Extend the `LangChangedDirectiveBase` base class to create your own directives that re-renders a value when the language changes. Below is an example of a directive that localizes assets paths based on the selected language. 430 | 431 | ```typescript 432 | import { LangChangedDirectiveBase, translateConfig } from "lit-translate"; 433 | import { directive } from "lit/directive.js"; 434 | 435 | export const localizeAssetPath = directive(class extends LangChangedDirectiveBase { 436 | render (fileName: string, config = translateConfig) { 437 | return this.renderValue(() => `localized-assets/${config.lang || "en"}/${fileName}`); 438 | } 439 | }); 440 | ``` 441 | 442 | 443 | [![-----------------------------------------------------](https://raw.githubusercontent.com/andreasbm/readme/master/assets/lines/rainbow.png)](#license) 444 | 445 | ## ➤ License 446 | 447 | Licensed under [MIT](https://opensource.org/licenses/MIT). -------------------------------------------------------------------------------- /blueprint.json: -------------------------------------------------------------------------------- 1 | { 2 | "line": "rainbow", 3 | "toc": true, 4 | "placeholder": ["[[", "]]"], 5 | "ids": { 6 | "github": "andreasbm/lit-translate", 7 | "npm": "lit-translate", 8 | "webcomponents": "lit-translate" 9 | }, 10 | "badges": [ 11 | { 12 | "text": "Awesome", 13 | "url": "https://github.com/web-padawan/awesome-lit-html", 14 | "img": "https://awesome.re/badge.svg" 15 | } 16 | ], 17 | "bullets": [ 18 | "Contains a [lit](https://www.npmjs.com/package/lit) directive that automatically updates the translations when the language changes", 19 | "Has a simple API that can return a translation for a given key using the dot notation (eg. `get(\"home.header.title\")`)", 20 | "Works very well with JSON based translation data-structures", 21 | "Can interpolate values into the strings using the {{ key }} syntax out of the box", 22 | "Caches the translations for maximum performance", 23 | "Has a very small footprint, approximately 800 bytes minified & gzipped (2kb without)", 24 | "Extremely customizable, just about everything can be changed (eg. choose your own translations loader, how to interpolate values, empty placeholder and how to look up the strings)", 25 | "Check out the playground [here](https://codepen.io/andreasbm/pen/MWWXPNO?editors=1010)" 26 | ] 27 | } -------------------------------------------------------------------------------- /blueprint.md: -------------------------------------------------------------------------------- 1 | [[ template:title ]] 2 | 3 |

4 | 5 | 6 | 7 |

8 | 9 | [[ template:badges ]] 10 | [[ template:description ]] 11 | 12 | [[ bullets ]] 13 | 14 | [[ template:toc ]] 15 | 16 | [[ load:readme/installation.md ]] 17 | [[ load:readme/1-define.md ]] 18 | [[ load:readme/2-register.md ]] 19 | [[ load:readme/3-set-language.md ]] 20 | [[ load:readme/4-get-translations.md ]] 21 | [[ load:readme/5-interpolate.md ]] 22 | [[ load:readme/6-lit.md ]] 23 | [[ load:readme/wait.md ]] 24 | [[ load:readme/customise.md ]] 25 | [[ load:readme/typesafe.md ]] 26 | [[ load:readme/directives.md ]] 27 | 28 | [[ template:license ]] -------------------------------------------------------------------------------- /example.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/andreasbm/lit-translate/2ec157a9ab83e38b2d2429426c087bdb03802fdc/example.gif -------------------------------------------------------------------------------- /karma.conf.js: -------------------------------------------------------------------------------- 1 | const {defaultResolvePlugins, defaultKarmaConfig} = require("@appnest/web-config"); 2 | 3 | module.exports = (config) => { 4 | config.set({ 5 | ...defaultKarmaConfig({ 6 | rollupPlugins: defaultResolvePlugins() 7 | }), 8 | basePath: "src/test", 9 | logLevel: config.LOG_INFO 10 | }); 11 | }; -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "lit-translate", 3 | "version": "2.0.1", 4 | "license": "MIT", 5 | "module": "index.js", 6 | "author": "Appnest", 7 | "description": "A blazing-fast and lightweight internationalization (i18n) library for your next web-based project", 8 | "bugs": { 9 | "url": "https://github.com/andreasbm/lit-translate/issues" 10 | }, 11 | "homepage": "https://github.com/andreasbm/lit-translate#readme", 12 | "repository": { 13 | "type": "git", 14 | "url": "git+https://github.com/andreasbm/lit-translate.git" 15 | }, 16 | "keywords": [ 17 | "lit-html", 18 | "lit-element", 19 | "lit", 20 | "custom", 21 | "elements", 22 | "web", 23 | "component", 24 | "custom element", 25 | "web component", 26 | "util", 27 | "decorators", 28 | "directives", 29 | "translate", 30 | "localisation", 31 | "localization" 32 | ], 33 | "main": "index.js", 34 | "types": "index.d.ts", 35 | "scripts": { 36 | "start": "npm run s", 37 | "ncu": "ncu -u -a && npm update && npm install", 38 | "test": "karma start karma.conf.js", 39 | "b:lib": "node pre-build.js && tsc -p tsconfig.build.json", 40 | "b:demo:dev": "rollup -c --environment NODE_ENV:dev", 41 | "b:demo:prod": "rollup -c --environment NODE_ENV:prod", 42 | "s:dev": "rollup -c --watch --environment NODE_ENV:dev", 43 | "s:prod": "rollup -c --watch --environment NODE_ENV:prod", 44 | "s": "npm run s:dev", 45 | "readme": "node node_modules/.bin/readme generate", 46 | "postversion": "npm run readme && npm run b:lib", 47 | "publish:patch": "np patch --contents=dist --no-cleanup", 48 | "publish:minor": "np minor --contents=dist --no-cleanup", 49 | "publish:major": "np major --contents=dist --no-cleanup" 50 | }, 51 | "dependencies": { 52 | "lit": "^2.2.2" 53 | }, 54 | "devDependencies": { 55 | "@appnest/readme": "^1.2.7", 56 | "@appnest/web-config": "0.5.4", 57 | "lit": "^2.2.2" 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /pre-build.js: -------------------------------------------------------------------------------- 1 | const rimraf = require("rimraf"); 2 | const path = require("path"); 3 | const fs = require("fs-extra"); 4 | const outLib = "dist"; 5 | 6 | // TODO: Run "tsc -p tsconfig.build.json" from this script and rename it to "build". 7 | 8 | async function preBuild () { 9 | await cleanLib(); 10 | copySync("./package.json", `./${outLib}/package.json`); 11 | copySync("./README.md", `./${outLib}/README.md`); 12 | } 13 | 14 | function cleanLib () { 15 | return new Promise(res => { 16 | rimraf(outLib, res); 17 | }); 18 | } 19 | 20 | function copySync (src, dest) { 21 | fs.copySync(path.resolve(__dirname, src), path.resolve(__dirname, dest)); 22 | } 23 | 24 | preBuild().then(_ => { 25 | console.log(">> Prebuild completed"); 26 | }); 27 | -------------------------------------------------------------------------------- /readme/1-define.md: -------------------------------------------------------------------------------- 1 | ## 1. Define the translations 2 | 3 | Create a `.json` file for each language you want to support. Heres an example of how `en.json` could look like. 4 | 5 | ```json 6 | { 7 | "header": { 8 | "title": "Hello", 9 | "subtitle": "World" 10 | }, 11 | "cta": { 12 | "awesome": "{{ animals }} are awesome!", 13 | "cats": "Cats" 14 | }, 15 | "footer": { 16 | "html": "Bold text" 17 | } 18 | } 19 | ``` 20 | -------------------------------------------------------------------------------- /readme/2-register.md: -------------------------------------------------------------------------------- 1 | ## 2. Register the translate config 2 | 3 | Use the `registerTranslateConfig` function to register a loader that loads translations based on the selected language. In the example below, a loader is registered that uses the [fetch API](https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API) to load a `.json` file for the selected language. 4 | 5 | ```typescript 6 | import { registerTranslateConfig } from "lit-translate"; 7 | 8 | registerTranslateConfig({ 9 | loader: lang => fetch(`${lang}.json`).then(res => res.json()) 10 | }); 11 | ``` 12 | -------------------------------------------------------------------------------- /readme/3-set-language.md: -------------------------------------------------------------------------------- 1 | ## 3. Set the language 2 | 3 | Set the language with the `use` function. When called it will use the registered loader from [step 2](#-2-register-the-translate-config) to load the strings for the selected language. 4 | 5 | ```typescript 6 | import { use } from "lit-translate"; 7 | 8 | use("en"); 9 | ``` 10 | -------------------------------------------------------------------------------- /readme/4-get-translations.md: -------------------------------------------------------------------------------- 1 | ## 4. Get the translations 2 | 3 | Get translations with the `get` function. Give this function a string of keys (separated with `.`) that points to the desired translation in the JSON structure. The example below is based on the translations defined in [step 1](#-1-define-the-translations) and registered in [step 2](#-2-register-the-translate-config). 4 | 5 | ```typescript 6 | import { get } from "lit-translate"; 7 | 8 | get("header.title"); // "Hello" 9 | get("header.subtitle"); // "World" 10 | ``` 11 | -------------------------------------------------------------------------------- /readme/5-interpolate.md: -------------------------------------------------------------------------------- 1 | ## 5. Interpolate values 2 | 3 | When using the `get` function it is possible to interpolate values (replacing placeholders with content). As default, you can use the `{{ key }}` syntax in your translations and provide an object with values replacing those defined in the translations when using the `get` function. The example below is based on the strings defined in [step 1](#-1-define-the-translations) and registered in [step 2](#-2-register-the-translate-config). 4 | 5 | ```typescript 6 | import { get } from "lit-translate"; 7 | 8 | get("cta.awesome", { animals: get("cta.cats") }); // Cats are awesome! 9 | ``` 10 | 11 | -------------------------------------------------------------------------------- /readme/6-lit.md: -------------------------------------------------------------------------------- 1 | ## 6. Use the `translate` directive with `lit` 2 | 3 | If you are using [lit](https://www.npmjs.com/package/lit) you might want to use the `translate` directive. This directive makes sure to automatically update all the translated parts when the `use` function is called with a new language. If your strings contain HTML you can use the `translateUnsafeHTML` directive. The example below is based on the strings defined in [step 1](#-1-define-the-translations) and registered in [step 2](#-2-register-the-translate-config). 4 | 5 | ```typescript 6 | import { translate, translateUnsafeHTML } from "lit-translate"; 7 | import { LitElement, html } from "lit"; 8 | import { customElement } from "lit/decorators.js"; 9 | 10 | @customElement("my-element") 11 | class MyElement extends LitElement { 12 | render () { 13 | html` 14 |

${translate("header.title")}

15 |

${translate("header.subtitle")}

16 | ${translate("cta.awesome", { animals: () => get("cta.cats") })} 17 | ${translateUnsafeHTML("footer.html")} 18 | `; 19 | } 20 | } 21 | ``` 22 | -------------------------------------------------------------------------------- /readme/async.md: -------------------------------------------------------------------------------- 1 | ## Asynchronous and Encapsulated Translations 2 | 3 | If you have a lot of strings it might not make sense to load them all at once. In `lit-translate` you can have as many translation configs as you want to. As shown in the example below, the trick to encapsulating the translations and loading them asynchronously is to create a new translation config and use it instead of the global `translateConfig`. We then make sure to provide it to the library functions (such as `use`, `get` and `translate`) and keep the selected language in sync with the global one by listening for the `langChanged` event. 4 | 5 | ```typescript 6 | import { ITranslateConfig, listenForLangChanged, translateConfig, use, get } from "lit-translate"; 7 | 8 | // Create a new translation config 9 | const asyncTranslateConfig: ITranslateConfig = { 10 | ...translateConfig, 11 | loader: lang => fetch(`my-component.${lang}.json`).then(res => res.json()), 12 | empty: () => "" 13 | } 14 | 15 | // Initially set the language of asyncTranslateConfig to match the language of translateConfig. 16 | // When calling the use function, make sure to provide asyncTranslateConfig. 17 | if (translateConfig.lang != null) { 18 | use(translateConfig.lang, asyncTranslateConfig); 19 | } 20 | 21 | // Whenever the language of translateConfig changes also update the language of the asyncTranslateConfig to load the strings. 22 | listenForLangChanged(({lang}) => { 23 | if (asyncTranslateConfig.lang !== lang) { 24 | use(lang, asyncTranslateConfig); 25 | } 26 | }); 27 | 28 | // When getting translations, provide asyncTranslateConfig to the library functions 29 | get("title", undefined, asyncTranslateConfig); 30 | ``` -------------------------------------------------------------------------------- /readme/customise.md: -------------------------------------------------------------------------------- 1 | ## Advanced Customisation 2 | 3 | If you want you can customise just about anything by overwriting the configuration hooks. Below is an example of what you can customise. Try it as a playground [here](https://codepen.io/andreasbm/pen/gOoVGdQ?editors=0010). 4 | 5 | ```typescript 6 | import { registerTranslateConfig, extract, get, use } from "lit-translate"; 7 | 8 | registerTranslateConfig({ 9 | 10 | // Loads the language by returning a JSON structure for a given language 11 | loader: lang => { 12 | switch (lang) { 13 | 14 | // English strings 15 | case "en": 16 | return { 17 | app: { 18 | title: "This is a title", 19 | description: "This description is {placeholder}!" 20 | }, 21 | awesome: "awesome" 22 | }; 23 | 24 | // Danish strings 25 | case "da": 26 | return { 27 | app: { 28 | title: "Dette er en titel", 29 | description: "Denne beskrivelse er {placeholder}!" 30 | }, 31 | awesome: "fed" 32 | }; 33 | 34 | default: 35 | throw new Error(`The language ${lang} is not supported..`); 36 | } 37 | }, 38 | 39 | // Interpolate the values using a [[ key ]] syntax. 40 | interpolate: (text, values) => { 41 | for (const [key, value] of Object.entries(extract(values || {}))) { 42 | text = text.replace(new RegExp(`{.*${key}.*}`, `gm`), String(extract(value))); 43 | } 44 | 45 | 46 | return text; 47 | }, 48 | 49 | // Returns a string for a given key 50 | lookup: (key, config) => { 51 | 52 | // Split the key in parts (example: hello.world) 53 | const parts = key.split(" -> "); 54 | 55 | // Find the string by traversing through the strings matching the chain of keys 56 | let string = config.strings; 57 | 58 | // Shift through all the parts of the key while matching with the strings. 59 | // Do not continue if the string is not defined or if we have traversed all the key parts 60 | while (string != null && parts.length > 0) { 61 | string = string[parts.shift()]; 62 | } 63 | 64 | // Make sure the string is in fact a string! 65 | return string != null ? string.toString() : null; 66 | }, 67 | 68 | // Formats empty placeholders (eg. !da.headline.title!) if lookup returns null 69 | empty: (key, config) => `!${config.lang}.${key}!` 70 | }); 71 | 72 | use("en").then(() => { 73 | get("app -> description", { placeholder: get("awesome") }); // Will return "This description is awesome" 74 | }); 75 | ``` 76 | 77 | ### Format text with `IntlMessageFormat` 78 | 79 | [IntlMessageFormat](https://www.npmjs.com/package/intl-messageformat) is a library that formats ICU message strings with number, date, plural, and select placeholders to create localized messages using [ICU placeholders](https://unicode-org.github.io/icu/userguide/format_parse/messages/). This library is a good addition to `lit-translate`. You can add it to the interpolate hook to get the benefits as shown in the following example. Try the example as a playground [here](https://codepen.io/andreasbm/pen/rNpXGPW?editors=0010). 80 | 81 | ```typescript 82 | import { registerTranslateConfig, extract } from "lit-translate"; 83 | import { IntlMessageFormat } from "intl-messageformat"; 84 | 85 | registerTranslateConfig({ 86 | loader: lang => { 87 | switch (lang) { 88 | case "en": 89 | return { 90 | photos: `You have {numPhotos, plural, =0 {no photos.} =1 {one photo.} other {# photos.}}` 91 | }; 92 | 93 | case "en": 94 | return { 95 | photos: `Du har {numPhotos, plural, =0 {ingen billeder.} =1 {et billede.} other {# billeder.}}` 96 | }; 97 | 98 | default: 99 | throw new Error(`The language ${lang} is not supported..`); 100 | } 101 | }, 102 | 103 | // Use the "intl-messageformat" library for formatting. 104 | interpolate: (text, values, config) => { 105 | const msg = new IntlMessageFormat(text, config.lang); 106 | return msg.format(extract(values)); 107 | } 108 | }); 109 | 110 | use("en").then(() => { 111 | get("photos", {numPhotos: 0}); // Will return "You have no photos" 112 | get("photos", {numPhotos: 1}); // Will return "You have one photo." 113 | get("photos", {numPhotos: 5}); // Will return "You have 5 photos." 114 | }); 115 | ``` 116 | 117 | ### Use the default translations as keys 118 | 119 | Inspired by [GNU gettext](https://en.wikipedia.org/wiki/Gettext) you can use the default translation as keys. The benefit of doing this is that you will save typing time and reduce code clutter. You can use [xgettext](https://www.gnu.org/software/gettext/manual/html_node/xgettext-Invocation.html) to extract the translatable strings from your code and then use [po2json](https://github.com/mikeedwards/po2json) to turn your `.po` files into `.json` files. The following code shows an example of how you could implement this. Try it as a playground [here](https://codepen.io/andreasbm/pen/RwxXjJX?editors=0010). 120 | 121 | ```typescript 122 | import { registerTranslateConfig, use, get } from "lit-translate"; 123 | 124 | registerTranslateConfig({ 125 | loader: lang => { 126 | switch (lang) { 127 | case "da": 128 | return { 129 | "The page is being loaded...": "Siden indlæses..." 130 | }; 131 | default: 132 | return {}; 133 | } 134 | }, 135 | lookup: (key, config) => config.strings != null && config.strings[key] != null ? config.strings[key].toString() : key, 136 | empty: key => key, 137 | }); 138 | 139 | get("The page is being loaded..."); // Will return "The page is being loaded..." 140 | 141 | use("da").then(() => { 142 | get("The page is being loaded..."); // Will return "Siden indlæses..." 143 | }); 144 | ``` -------------------------------------------------------------------------------- /readme/directives.md: -------------------------------------------------------------------------------- 1 | ## `lit` Directives 2 | 3 | ### Re-render a value when the language changes with the `langChanged` directive 4 | 5 | Use the `langChanged` directive to re-render a value when the language changes. 6 | 7 | ```typescript 8 | import { langChanged, translateConfig } from "lit-translate"; 9 | import { html, LitElement, TemplateResult } from "lit"; 10 | import { customElement } from "lit/decorators.js"; 11 | 12 | @customElement("my-component") 13 | export class MyComponent extends LitElement { 14 | protected render(): TemplateResult { 15 | return html` 16 | 17 | `; 18 | } 19 | } 20 | ``` 21 | 22 | ### Create your own `lit` directives that re-renders a value when the language changes 23 | 24 | Extend the `LangChangedDirectiveBase` base class to create your own directives that re-renders a value when the language changes. Below is an example of a directive that localizes assets paths based on the selected language. 25 | 26 | ```typescript 27 | import { LangChangedDirectiveBase, translateConfig } from "lit-translate"; 28 | import { directive } from "lit/directive.js"; 29 | 30 | export const localizeAssetPath = directive(class extends LangChangedDirectiveBase { 31 | render (fileName: string, config = translateConfig) { 32 | return this.renderValue(() => `localized-assets/${config.lang || "en"}/${fileName}`); 33 | } 34 | }); 35 | ``` -------------------------------------------------------------------------------- /readme/installation.md: -------------------------------------------------------------------------------- 1 | ## Installation 2 | 3 | ```js 4 | npm i [[ ids.npm ]] 5 | ``` -------------------------------------------------------------------------------- /readme/typesafe.md: -------------------------------------------------------------------------------- 1 | ## Typesafe Translations 2 | 3 | 4 | 5 | If you have a lot of translation keys you can quickly lose the overview of your strings. If you use Typescript you can make the keys of your translation keys typesafe - this will also give you autocompletion when you enter the keys. To achieve this you have to do the following: 6 | 7 | 8 | ### 1. Add `resolveJsonModule` to your tsconfig 9 | 10 | Add [resolveJsonModule](https://www.typescriptlang.org/tsconfig#resolveJsonModule) to your `tsconfig` which will allow us to import modules with a `.json` extension. 11 | 12 | ```json 13 | { 14 | ... 15 | "compilerOptions": { 16 | ... 17 | "resolveJsonModule": true 18 | } 19 | } 20 | ``` 21 | 22 | ### 2. Use the `typedKeysFactory` function 23 | 24 | Create a file, for example `typed-lit-translate.ts`. Then use the factory function `typedKeysFactory` and provide it with the type of one of your translation files. Use `typeof import(..)` to import the `.json` file and get the type. Provide this type to the factory function, and it will return a version of `get`, `translate` and `translateUnsafeHTML` where the keys are typed. Export these and make sure to import from your `typed-lit-translate.ts` file instead of `lit-translate`. 25 | 26 | ```typescript 27 | // typed-lit-translate.ts 28 | import { typedKeysFactory } from "lit-translate"; 29 | 30 | const { get, translate, translateUnsafeHTML } = typedKeysFactory(); 31 | export { get, translate, translateUnsafeHTML }; 32 | ``` 33 | 34 | ### 3. Import the typed functions 35 | 36 | Make sure to import the typed versions of `get`, `translate` and `translateUnsafeHTML` that you have created instead of importing from `lit-translate`. 37 | 38 | ```typescript 39 | import { get } from "typed-lit-translate.ts"; 40 | 41 | get("this.key.is.typed"); 42 | ``` -------------------------------------------------------------------------------- /readme/wait.md: -------------------------------------------------------------------------------- 1 | ## Wait for strings to be loaded before displaying your app 2 | 3 | You might want to avoid empty placeholders being shown initially before any of the translation strings have been loaded. This it how you could defer the first render of your app until the strings have been loaded. 4 | 5 | ```typescript 6 | import { use, translate } from "lit-translate"; 7 | import { LitElement, html, PropertyValues } from "lit"; 8 | import { customElement, state } from "lit/decorators.js"; 9 | 10 | @customElement("my-app") 11 | export class MyApp extends LitElement { 12 | 13 | // Defer the first update of the component until the strings has been loaded to avoid empty strings being shown 14 | @state() hasLoadedStrings = false; 15 | 16 | protected shouldUpdate(props: PropertyValues) { 17 | return this.hasLoadedStrings && super.shouldUpdate(props); 18 | } 19 | 20 | // Load the initial language and mark that the strings has been loaded so the component can render. 21 | async connectedCallback() { 22 | super.connectedCallback(); 23 | 24 | await use("en"); 25 | this.hasLoadedStrings = true; 26 | } 27 | 28 | // Render the component 29 | protected render () { 30 | return html` 31 |

${translate("title")}

32 | `; 33 | } 34 | } 35 | ``` 36 | -------------------------------------------------------------------------------- /rollup.config.js: -------------------------------------------------------------------------------- 1 | import path from "path"; 2 | import pkg from "./package.json"; 3 | import { 4 | defaultExternals, 5 | defaultOutputConfig, 6 | defaultPlugins, 7 | defaultProdPlugins, 8 | defaultServePlugins, 9 | isLibrary, 10 | isProd, 11 | isServe 12 | } from "@appnest/web-config"; 13 | 14 | const folders = { 15 | dist: path.resolve(__dirname, "dist"), 16 | src: path.resolve(__dirname, "src/demo"), 17 | src_assets: path.resolve(__dirname, "src/demo/assets"), 18 | dist_assets: path.resolve(__dirname, "dist/assets") 19 | }; 20 | 21 | const files = { 22 | main: path.join(folders.src, "main.ts"), 23 | src_index: path.join(folders.src, "index.html"), 24 | dist_index: path.join(folders.dist, "index.html") 25 | }; 26 | 27 | export default { 28 | input: { 29 | main: files.main 30 | }, 31 | output: [ 32 | defaultOutputConfig({ 33 | format: "esm", 34 | dir: folders.dist 35 | }) 36 | ], 37 | plugins: [ 38 | ...defaultPlugins({ 39 | cleanConfig: { 40 | targets: [ 41 | folders.dist 42 | ] 43 | }, 44 | copyConfig: { 45 | resources: [[folders.src_assets, folders.dist_assets]], 46 | }, 47 | htmlTemplateConfig: { 48 | template: files.src_index, 49 | target: files.dist_index, 50 | include: /main(-.*)?\.js$/ 51 | }, 52 | importStylesConfig: { 53 | globals: ["global.scss"] 54 | } 55 | }), 56 | 57 | // Serve 58 | ...(isServe ? [ 59 | ...defaultServePlugins({ 60 | serveConfig: { 61 | port: 1338, 62 | contentBase: folders.dist 63 | }, 64 | livereloadConfig: { 65 | watch: folders.dist 66 | } 67 | }) 68 | ] : []), 69 | 70 | // Production 71 | ...(isProd ? [ 72 | ...defaultProdPlugins({ 73 | dist: folders.dist, 74 | minifyLitHtmlConfig: { 75 | verbose: false 76 | }, 77 | visualizerConfig: { 78 | filename: path.join(folders.dist, "stats.html") 79 | }, 80 | licenseConfig: { 81 | thirdParty: { 82 | output: path.join(folders.dist, "licenses.txt") 83 | } 84 | }, 85 | budgetConfig: { 86 | sizes: { 87 | ".js": 1024 * 200 88 | } 89 | } 90 | }) 91 | ] : []) 92 | ], 93 | external: [ 94 | ...(isLibrary ? [ 95 | ...defaultExternals(pkg) 96 | ] : []) 97 | ], 98 | treeshake: isProd, 99 | context: "window" 100 | } 101 | -------------------------------------------------------------------------------- /src/demo/assets/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/andreasbm/lit-translate/2ec157a9ab83e38b2d2429426c087bdb03802fdc/src/demo/assets/favicon.ico -------------------------------------------------------------------------------- /src/demo/assets/i18n/da.json: -------------------------------------------------------------------------------- 1 | { 2 | "lang": "da", 3 | "world": "Verden", 4 | "app": { 5 | "title": "Oversæt din app", 6 | "subtitle": "Hej {{ thing }}!", 7 | "html": "Dette er html" 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /src/demo/assets/i18n/demo-component.da.json: -------------------------------------------------------------------------------- 1 | { 2 | "title": "Demo komponent", 3 | "text": "Disse oversættelser blev loaded asynkront" 4 | } -------------------------------------------------------------------------------- /src/demo/assets/i18n/demo-component.en.json: -------------------------------------------------------------------------------- 1 | { 2 | "title": "Demo component", 3 | "text": "These translations were loaded asynchronously" 4 | } 5 | -------------------------------------------------------------------------------- /src/demo/assets/i18n/en.json: -------------------------------------------------------------------------------- 1 | { 2 | "lang": "en", 3 | "world": "World", 4 | "app": { 5 | "title": "Translate your application", 6 | "subtitle": "Hello {{ thing }}!", 7 | "html": "This is html" 8 | } 9 | } -------------------------------------------------------------------------------- /src/demo/components/demo-component.scss: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/andreasbm/lit-translate/2ec157a9ab83e38b2d2429426c087bdb03802fdc/src/demo/components/demo-component.scss -------------------------------------------------------------------------------- /src/demo/components/demo-component.ts: -------------------------------------------------------------------------------- 1 | import { html, LitElement, TemplateResult } from "lit"; 2 | import { customElement } from "lit/decorators.js"; 3 | import { ITranslateConfig, listenForLangChanged, translate, translateConfig, use } from "../../lib"; 4 | 5 | import styles from "./demo-component.scss"; 6 | 7 | /** 8 | * This component is an example of how strings could be asynchronously be loaded and encapsulated. 9 | * The trick is to create a new local translate config, in this example "demoTranslateConfig" 10 | * instead of using the global one from the library "translateConfig". We then make sure to 11 | * keep the local global config in sync with the global one by providing the "demoTranslateConfig" 12 | * to the library functions (for example use function and the translate directive) 13 | */ 14 | 15 | const demoTranslateConfig: ITranslateConfig = { 16 | ...translateConfig, 17 | loader: lang => fetch(`assets/i18n/demo-component.${lang}.json`).then(res => res.json()), 18 | empty: () => "" 19 | } 20 | 21 | // Initially set the demo translate config to the language of the global translate config 22 | if (translateConfig.lang != null) { 23 | use(translateConfig.lang, demoTranslateConfig).then(); 24 | } 25 | 26 | // Whenever the language changes also update the language of the demo translate config 27 | listenForLangChanged(async ({lang}) => { 28 | if (demoTranslateConfig.lang !== lang) { 29 | await use(lang, demoTranslateConfig); 30 | } 31 | }); 32 | 33 | /** 34 | * Demo page. 35 | */ 36 | @customElement("demo-component") 37 | export class DemoPageComponent extends LitElement { 38 | protected render(): TemplateResult { 39 | return html` 40 | 43 |
44 |

${translate("title", undefined, demoTranslateConfig)}

45 | ${translate("text", undefined, demoTranslateConfig)} 46 |
47 | `; 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /src/demo/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | lit-translate 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 23 | 24 | 25 | -------------------------------------------------------------------------------- /src/demo/main.ts: -------------------------------------------------------------------------------- 1 | import "./pages/demo-page"; 2 | import "./styles/global.scss"; 3 | 4 | -------------------------------------------------------------------------------- /src/demo/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "lit-translate", 3 | "short_name": "lit-translate", 4 | "description": "Translate your lit-html based project", 5 | "icons": [ 6 | ], 7 | "theme_color": "#000000", 8 | "background_color": "#FFFFFF", 9 | "display": "minimal-ui", 10 | "start_url": "https://example.com", 11 | "orientation": "portrait-primary", 12 | "lang": "en" 13 | } 14 | -------------------------------------------------------------------------------- /src/demo/pages/demo-page.scss: -------------------------------------------------------------------------------- 1 | :host { 2 | --spacing: 24px; 3 | --border-radius: 6px; 4 | --border-width: 2px; 5 | --dark: #151515; 6 | --light: #fafafa; 7 | 8 | --foreground: var(--dark); 9 | --background: var(--light); 10 | } 11 | 12 | :host { 13 | background: var(--background); 14 | color: var(--foreground); 15 | display: block; 16 | } 17 | 18 | 19 | .box { 20 | padding: var(--spacing); 21 | border-radius: var(--border-radius); 22 | background: var(--background); 23 | border: var(--border-width) solid rgba(0, 0, 0, 0.1); 24 | margin: 0 0 var(--spacing); 25 | } 26 | 27 | h1 { 28 | margin: 0; 29 | } -------------------------------------------------------------------------------- /src/demo/pages/demo-page.ts: -------------------------------------------------------------------------------- 1 | import { html, LitElement, PropertyValues, TemplateResult } from "lit"; 2 | import { customElement, eventOptions, property, state } from "lit/decorators.js"; 3 | import { repeat } from "lit/directives/repeat.js"; 4 | import { registerTranslateConfig, use } from "../../lib"; 5 | import styles from "./demo-page.scss"; 6 | 7 | // Use the typed versions of lit-translate helpers 8 | import { get, translate, translateUnsafeHTML } from "../typed-lit-translate"; 9 | 10 | const languages = [ 11 | "en", 12 | "da" 13 | ]; 14 | 15 | // Register loader 16 | registerTranslateConfig({ 17 | loader: lang => fetch(`assets/i18n/${lang}.json`).then(res => res.json()) 18 | }); 19 | 20 | /** 21 | * Demo page. 22 | */ 23 | @customElement("demo-page-component") 24 | export class DemoPageComponent extends LitElement { 25 | 26 | @property() lang = languages[0]; 27 | @property() thing = ""; 28 | 29 | // Defer the first update of the component until the strings has been loaded to avoid empty strings being shown 30 | @state() hasLoadedStrings = false; 31 | 32 | protected shouldUpdate(props: PropertyValues) { 33 | return this.hasLoadedStrings && super.shouldUpdate(props); 34 | } 35 | 36 | // Load the initial language and mark that the strings has been loaded. 37 | async connectedCallback() { 38 | super.connectedCallback(); 39 | 40 | await use(this.lang); 41 | this.hasLoadedStrings = true; 42 | } 43 | 44 | @eventOptions({capture: true}) 45 | private onLanguageSelected(e: Event) { 46 | this.lang = (e.target as HTMLSelectElement).value; 47 | use(this.lang).then(); 48 | } 49 | 50 | private async loadDemoComponent() { 51 | await import("./../components/demo-component") 52 | } 53 | 54 | protected render(): TemplateResult { 55 | return html` 56 | 59 | 60 |
61 |

lit-translate

62 |

${translate("app.title")}

63 |

${translate(`app.subtitle`, () => ({thing: this.thing || get("world")}))}

64 |

${translateUnsafeHTML("app.html")}

65 | 70 | 72 |
73 |
74 | 75 | 76 | 77 |
78 | View on Github 79 | `; 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /src/demo/styles/global.scss: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/andreasbm/lit-translate/2ec157a9ab83e38b2d2429426c087bdb03802fdc/src/demo/styles/global.scss -------------------------------------------------------------------------------- /src/demo/typed-lit-translate.ts: -------------------------------------------------------------------------------- 1 | import { typedKeysFactory } from "../lib/typed-keys"; 2 | 3 | const { get, translate, translateUnsafeHTML } = typedKeysFactory(); 4 | export { get, translate, translateUnsafeHTML }; -------------------------------------------------------------------------------- /src/lib/config.ts: -------------------------------------------------------------------------------- 1 | import { ITranslateConfig } from "./types"; 2 | import { interpolate, lookup } from "./helpers"; 3 | 4 | /** 5 | * Default configuration object. 6 | */ 7 | export const defaultTranslateConfig: (() => ITranslateConfig) = () => { 8 | return { 9 | loader: () => Promise.resolve({}), 10 | empty: key => `[${key}]`, 11 | lookup: lookup, 12 | interpolate: interpolate, 13 | translationCache: {} 14 | }; 15 | }; 16 | 17 | // The current configuration. 18 | export let translateConfig: ITranslateConfig = defaultTranslateConfig(); 19 | 20 | /** 21 | * Registers a translation config by merging it into the existing one. 22 | * The registered translation config can be accessed through the singleton called translateConfig. 23 | * @param config 24 | */ 25 | export function registerTranslateConfig(config: Partial): ITranslateConfig { 26 | return (translateConfig = { 27 | ...translateConfig, 28 | ...config 29 | }); 30 | } 31 | -------------------------------------------------------------------------------- /src/lib/directives/lang-changed-base.ts: -------------------------------------------------------------------------------- 1 | import { AsyncDirective } from "lit/async-directive.js"; 2 | import { LangChangedSubscription } from "../types"; 3 | import { listenForLangChanged } from "../util"; 4 | 5 | /** 6 | * An abstract lit directive that reacts when the language changes. 7 | */ 8 | export abstract class LangChangedDirectiveBase extends AsyncDirective { 9 | protected langChangedSubscription: LangChangedSubscription | null = null; 10 | protected getValue: (() => unknown) = (() => ""); 11 | 12 | /** 13 | * Sets up the directive by setting the getValue property and subscribing. 14 | * When subclassing LangChangedDirectiveBase this function should be call in the render function. 15 | * @param getValue 16 | */ 17 | renderValue(getValue: (() => unknown)): unknown { 18 | this.getValue = getValue; 19 | this.subscribe(); 20 | return this.getValue(); 21 | } 22 | 23 | /** 24 | * Called when the lang changed event is dispatched. 25 | */ 26 | updateValue() { 27 | this.setValue(this.getValue()); 28 | } 29 | 30 | /** 31 | * Subscribes to the lang changed event. 32 | */ 33 | subscribe() { 34 | if (this.langChangedSubscription == null) { 35 | this.langChangedSubscription = listenForLangChanged(this.updateValue.bind(this)); 36 | } 37 | } 38 | 39 | /** 40 | * Unsubscribes from the lang changed event. 41 | */ 42 | unsubscribe() { 43 | if (this.langChangedSubscription != null) { 44 | this.langChangedSubscription(); 45 | } 46 | } 47 | 48 | /** 49 | * Unsubscribes when disconnected. 50 | */ 51 | disconnected() { 52 | this.unsubscribe(); 53 | } 54 | 55 | /** 56 | * Subscribes when reconnected. 57 | */ 58 | reconnected() { 59 | this.subscribe(); 60 | } 61 | } -------------------------------------------------------------------------------- /src/lib/directives/lang-changed.ts: -------------------------------------------------------------------------------- 1 | import { directive } from "lit/directive.js"; 2 | import { LangChangedDirectiveBase } from "./lang-changed-base"; 3 | 4 | /** 5 | * A lit directive that reacts when the language changes and renders the getValue callback. 6 | */ 7 | export class LangChangedDirective extends LangChangedDirectiveBase { 8 | render(getValue: (() => unknown)): unknown { 9 | return this.renderValue(getValue); 10 | } 11 | } 12 | 13 | export const langChanged = directive(LangChangedDirective); -------------------------------------------------------------------------------- /src/lib/directives/translate-unsafe-html.ts: -------------------------------------------------------------------------------- 1 | import { directive } from "lit/directive.js"; 2 | import { unsafeHTML } from "lit/directives/unsafe-html.js"; 3 | import { ITranslateConfig, Key, Values, ValuesCallback } from "../types"; 4 | import { TranslateDirective } from "./translate"; 5 | import { get } from "../util"; 6 | 7 | /** 8 | * A lit directive that updates the translation as HTML when the language changes. 9 | */ 10 | export class TranslateUnsafeHTMLDirective extends TranslateDirective { 11 | render(key: Key, values?: Values | ValuesCallback | null, config?: ITranslateConfig) { 12 | return this.renderValue(() => unsafeHTML(get(key, values, config))); 13 | } 14 | } 15 | 16 | export const translateUnsafeHTML = directive(TranslateUnsafeHTMLDirective); 17 | -------------------------------------------------------------------------------- /src/lib/directives/translate.ts: -------------------------------------------------------------------------------- 1 | import { directive } from "lit/directive.js"; 2 | import { ITranslateConfig, Key, Values, ValuesCallback } from "../types"; 3 | import { get } from "../util"; 4 | import { LangChangedDirectiveBase } from "./lang-changed-base"; 5 | 6 | /** 7 | * A lit directive that updates the translation when the language changes. 8 | */ 9 | export class TranslateDirective extends LangChangedDirectiveBase { 10 | render(key: T, values?: Values | ValuesCallback | null, config?: ITranslateConfig): unknown { 11 | return this.renderValue(() => get(key, values, config)); 12 | } 13 | } 14 | 15 | export const translate = directive(TranslateDirective); -------------------------------------------------------------------------------- /src/lib/helpers.ts: -------------------------------------------------------------------------------- 1 | import { ITranslateConfig, Key, Strings, Values, ValuesCallback } from "./types"; 2 | 3 | /** 4 | * Interpolates the values into the string using the {{ key }} syntax. 5 | * @param text 6 | * @param values 7 | * @param config 8 | */ 9 | export function interpolate(text: string, values: Values | ValuesCallback | null, config: ITranslateConfig): string { 10 | return Object.entries(extract(values || {})).reduce((text, [key, value]) => 11 | text.replace(new RegExp(`{{[  ]*${key}[  ]*}}`, `gm`), String(extract(value))), text); 12 | } 13 | 14 | /** 15 | * Returns a string based on a chain of keys using the dot notation. 16 | * @param key 17 | * @param config 18 | */ 19 | export function lookup(key: Key, config: ITranslateConfig): string | null { 20 | 21 | // Split the key in parts (example: hello.world) 22 | const parts = key.split("."); 23 | 24 | // Find the string by traversing through the strings matching the chain of keys 25 | let string: Strings | string | undefined = config.strings; 26 | 27 | // Shift through all the parts of the key while matching with the strings. 28 | // Do not continue if the string is not defined or if we have traversed all the key parts 29 | while (string != null && parts.length > 0) { 30 | string = (string as Strings)[parts.shift()!]; 31 | } 32 | 33 | // Make sure the string is in fact a string! 34 | return string != null ? string.toString() : null; 35 | } 36 | 37 | /** 38 | * Extracts either the value from the function or returns the value that was passed in. 39 | * @param obj 40 | */ 41 | export function extract(obj: T | (() => T)): T { 42 | return (typeof obj === "function") ? (obj as (() => T))() : obj; 43 | } -------------------------------------------------------------------------------- /src/lib/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./types"; 2 | export * from "./util"; 3 | export * from "./helpers"; 4 | export * from "./config"; 5 | export * from "./directives/translate"; 6 | export * from "./directives/translate-unsafe-html"; 7 | export * from "./directives/lang-changed"; 8 | export * from "./directives/lang-changed-base"; 9 | -------------------------------------------------------------------------------- /src/lib/typed-keys.ts: -------------------------------------------------------------------------------- 1 | import { ITranslateConfig, Values, ValuesCallback } from "./types"; 2 | import { translateConfig } from "./config"; 3 | import { get } from "./util"; 4 | import { translate } from "./directives/translate"; 5 | import { translateUnsafeHTML } from "./directives/translate-unsafe-html"; 6 | 7 | /** 8 | * A factory function that wraps get, translate and translateUnsafeHTML to make the keys typesafe. 9 | */ 10 | export function typedKeysFactory>() { 11 | return { 12 | get>(key: T, values?: Values | ValuesCallback | null, config: ITranslateConfig = translateConfig) { 13 | return get(key, values, config); 14 | }, 15 | translate>(key: T, values?: Values | ValuesCallback | null, config?: ITranslateConfig) { 16 | return translate(key, values, config); 17 | }, 18 | translateUnsafeHTML>(key: T, values?: Values | ValuesCallback | null, config?: ITranslateConfig) { 19 | return translateUnsafeHTML(key, values, config); 20 | } 21 | } 22 | } 23 | 24 | 25 | // Use this type to extend lit-translate with typed keys 26 | export type ObjectLookupString = ObjectLookupStringHelper; 27 | 28 | type SimpleValue = string | number | bigint | boolean | symbol | RegExp | Date | null | undefined; 29 | type IgnoredLookupValue = 30 | SimpleValue 31 | | CallableFunction 32 | | Set 33 | | WeakSet 34 | | Map 35 | | WeakMap; 36 | 37 | type MaybeSuffixWithSeparator = T extends `` ? T : `${T}${Separator}`; 38 | 39 | // Type that turns flattens the keys of an object into strings 40 | type ObjectLookupStringHelper = { 41 | [Key in keyof T]: Key extends string 42 | ? CurrentDepth extends MaxDepth 43 | ? `${MaybeSuffixWithSeparator}${Key}` 44 | : T[Key] extends IgnoredLookupValue 45 | ? `${MaybeSuffixWithSeparator}${Key}` 46 | : T[Key] extends (infer El)[] | readonly (infer El)[] 47 | ? `${MaybeSuffixWithSeparator}${Key}${Separator}${number}` | El extends IgnoredLookupValue 48 | ? `${MaybeSuffixWithSeparator}${Key}${Separator}${number}` 49 | : ObjectLookupStringHelper}${Key}${Separator}${number}`, MaxDepth, Next> 50 | : 51 | | `${MaybeSuffixWithSeparator}${Key}` 52 | | ObjectLookupStringHelper, `${MaybeSuffixWithSeparator}${Key}`, MaxDepth, Next> 53 | : never; 54 | }[keyof T]; 55 | 56 | // A type used for recursive iterations 57 | export type Next = [ 58 | 1, 59 | 2, 60 | 3, 61 | 4, 62 | 5, 63 | 6, 64 | 7, 65 | 8, 66 | 9, 67 | 10, 68 | 11, 69 | 12, 70 | 13, 71 | 14, 72 | 15, 73 | 16, 74 | 17, 75 | 18, 76 | 19, 77 | 20, 78 | 21, 79 | 22, 80 | 23, 81 | 24, 82 | 25, 83 | 26, 84 | 27, 85 | 28, 86 | 29, 87 | 30, 88 | 31, 89 | 32, 90 | 33, 91 | 34, 92 | 35, 93 | 36, 94 | 37, 95 | 38, 96 | 39, 97 | 40, 98 | 41, 99 | 42, 100 | 43, 101 | 44, 102 | 45, 103 | 46, 104 | 47, 105 | 48, 106 | 49, 107 | 50, 108 | 51, 109 | 52, 110 | 53, 111 | 54, 112 | 55, 113 | 56, 114 | 57, 115 | 58, 116 | 59, 117 | 60, 118 | 61, 119 | 62, 120 | 63, 121 | 64, 122 | 65, 123 | 66, 124 | 67, 125 | 68, 126 | 69, 127 | 70, 128 | 71, 129 | 72, 130 | 73, 131 | 74, 132 | 75, 133 | 76, 134 | 77, 135 | 78, 136 | 79, 137 | 80, 138 | 81, 139 | 82, 140 | 83, 141 | 84, 142 | 85, 143 | 86, 144 | 87, 145 | 88, 146 | 89, 147 | 90, 148 | 91, 149 | 92, 150 | 93, 151 | 94, 152 | 95, 153 | 96, 154 | 97, 155 | 98, 156 | 99, 157 | 100, 158 | 101 159 | ][T]; 160 | -------------------------------------------------------------------------------- /src/lib/types.ts: -------------------------------------------------------------------------------- 1 | export type Value = object | string | number; 2 | export type ValueCallback = () => Value; 3 | export type Values = { [key: string]: Value | ValueCallback }; 4 | export type ValuesCallback = () => Values; 5 | export type Key = string; 6 | export type LanguageIdentifier = string; 7 | export type Translation = string; 8 | export type Strings = { [key: string]: string | Strings }; 9 | export type TranslationCache = { [key: string]: Translation }; 10 | 11 | export type StringsLoader = (lang: LanguageIdentifier, config: ITranslateConfig) => Promise | Strings; 12 | export type InterpolateFunction = (text: string, 13 | values: Values | ValuesCallback | null, 14 | config: ITranslateConfig) => Translation; 15 | export type EmptyFunction = (key: Key, config: ITranslateConfig) => string; 16 | export type LookupFunction = (key: Key, config: ITranslateConfig) => string | null; 17 | 18 | 19 | export type LangChangedEvent = { 20 | strings: Strings; 21 | lang: LanguageIdentifier; 22 | config: ITranslateConfig; 23 | }; 24 | 25 | export interface ITranslateConfig { 26 | loader: StringsLoader; 27 | interpolate: InterpolateFunction; 28 | empty: EmptyFunction; 29 | lookup: LookupFunction; 30 | translationCache: TranslationCache; 31 | lang?: LanguageIdentifier; 32 | strings?: Strings; 33 | } 34 | 35 | export type LangChangedSubscription = (() => void); 36 | 37 | // Extend the global event handlers map with the history related events 38 | declare global { 39 | interface GlobalEventHandlersEventMap { 40 | "langChanged": CustomEvent 41 | } 42 | } 43 | 44 | -------------------------------------------------------------------------------- /src/lib/util.ts: -------------------------------------------------------------------------------- 1 | import { extract } from "./helpers"; 2 | import { 3 | ITranslateConfig, 4 | Key, 5 | LangChangedEvent, 6 | LangChangedSubscription, 7 | LanguageIdentifier, 8 | Translation, 9 | Values, 10 | ValuesCallback 11 | } from "./types"; 12 | import { translateConfig } from "./config"; 13 | 14 | export const LANG_CHANGED_EVENT = "langChanged"; 15 | 16 | /** 17 | * Dispatches a language changed event. 18 | * @param detail 19 | */ 20 | export function dispatchLangChanged(detail: LangChangedEvent) { 21 | window.dispatchEvent(new CustomEvent(LANG_CHANGED_EVENT, {detail})); 22 | } 23 | 24 | /** 25 | * Listens for changes in the language. 26 | * Returns a method for unsubscribing from the event. 27 | * @param callback 28 | * @param options 29 | */ 30 | export function listenForLangChanged(callback: (e: LangChangedEvent) => void, 31 | options?: AddEventListenerOptions): LangChangedSubscription { 32 | const handler = (e: CustomEvent) => callback(e.detail); 33 | window.addEventListener(LANG_CHANGED_EVENT, handler, options); 34 | return () => window.removeEventListener(LANG_CHANGED_EVENT, handler); 35 | } 36 | 37 | /** 38 | * Sets a new current language and dispatches a global language changed event. 39 | * The strings will be shallow merged together. 40 | * @param lang 41 | * @param config 42 | */ 43 | export async function use(lang: LanguageIdentifier, config: ITranslateConfig = translateConfig) { 44 | 45 | // Load the translations and set the cache 46 | const strings = await config.loader(lang, config); 47 | 48 | // Update the config with new information 49 | config.translationCache = {}; 50 | config.strings = strings; 51 | config.lang = lang; 52 | 53 | // Dispatch global language changed event while setting the new values 54 | dispatchLangChanged({lang, strings, config}); 55 | } 56 | 57 | /** 58 | * Translates a key and interpolates if values are defined. 59 | * Uses the current strings and translation cache to fetch the translation. 60 | * @param key (eg. "common.get_started") 61 | * @param values (eg. { count: 42 }) 62 | * @param config 63 | */ 64 | export function get(key: T, 65 | values?: Values | ValuesCallback | null, 66 | config: ITranslateConfig = translateConfig): Translation { 67 | 68 | // Either use the translation from the cache or get it and add it to the cache 69 | const translation = config.translationCache[key] 70 | ?? (config.translationCache[key] = config.lookup(key, config) || config.empty(key, config)); 71 | 72 | // Extract the values 73 | values = values != null ? extract(values) : null; 74 | 75 | // Interpolate the values and return the translation 76 | return values != null ? config.interpolate(translation, values, config) : translation; 77 | } 78 | -------------------------------------------------------------------------------- /src/test/mock.ts: -------------------------------------------------------------------------------- 1 | export const enStrings = { 2 | "lang": "en", 3 | "html": `This is html`, 4 | "header": { 5 | "title": "Hello", 6 | "subtitle": "World" 7 | }, 8 | "cta": { 9 | "awesome": "{{ things }} are awesome!", 10 | "cats": "Cats" 11 | }, 12 | "footer": { 13 | "contact": "Contact us on {{ email }}. It was {{ email }}!" 14 | } 15 | }; 16 | 17 | export const daStrings = { 18 | "lang": "da", 19 | "html": `Dette er html`, 20 | "header": { 21 | "title": "Hej", 22 | "subtitle": "Verden" 23 | }, 24 | "cta": { 25 | "awesome": "{{ things }} er nice!", 26 | "cats": "Katte" 27 | }, 28 | "footer": { 29 | "contact": "Kontakt os på {{ email }}. Det var {{ email }}!" 30 | } 31 | }; 32 | -------------------------------------------------------------------------------- /src/test/translate.test.ts: -------------------------------------------------------------------------------- 1 | import { html, LitElement } from "lit"; 2 | import { customElement, property } from "lit/decorators.js"; 3 | import { repeat } from "lit/directives/repeat.js"; 4 | import { get, LanguageIdentifier, registerTranslateConfig, translate, translateUnsafeHTML, use } from "../lib"; 5 | import { daStrings, enStrings } from "./mock"; 6 | 7 | const expect = chai.expect; 8 | 9 | @customElement("translated-component" as any) 10 | class TranslatedComponent extends LitElement { 11 | 12 | @property() things = ""; 13 | 14 | get title () { 15 | return this.shadowRoot!.querySelector("#title")!.innerText; 16 | } 17 | 18 | get subtitle () { 19 | return this.shadowRoot!.querySelector("#subtitle")!.innerText; 20 | } 21 | 22 | get awesome () { 23 | return this.shadowRoot!.querySelector("#awesome")!.innerText; 24 | } 25 | 26 | get html (): HTMLElement { 27 | return this.shadowRoot!.querySelector("#html")!; 28 | } 29 | 30 | render () { 31 | return html` 32 |

${translate("header.title")}

33 |

${translate("header.subtitle")}

34 | ${translate("cta.awesome", () => { 35 | return {things: this.things}; 36 | })} 37 |

${translateUnsafeHTML("html")}

38 | `; 39 | } 40 | } 41 | 42 | @customElement("stress-component" as any) 43 | class StressComponent extends LitElement { 44 | render () { 45 | return html` 46 | ${repeat(Array(10000), () => html`

${translate("header.title")}

`)} 47 | `; 48 | } 49 | } 50 | 51 | describe("translate", () => { 52 | 53 | let $translatedComponent: TranslatedComponent; 54 | 55 | beforeEach(async () => { 56 | registerTranslateConfig({ 57 | loader: (lang: LanguageIdentifier) => { 58 | switch (lang) { 59 | case "en": 60 | return Promise.resolve(enStrings); 61 | case "da": 62 | return Promise.resolve(daStrings); 63 | } 64 | 65 | throw new Error(`Language '${lang}' not valid.`); 66 | } 67 | }); 68 | 69 | await use("en"); 70 | 71 | $translatedComponent = new TranslatedComponent(); 72 | document.body.appendChild($translatedComponent); 73 | }); 74 | after(() => { 75 | while (document.body.firstChild) { 76 | (document.body.firstChild).remove(); 77 | } 78 | }); 79 | 80 | it("[translate] - should translate and interpolate", async () => { 81 | $translatedComponent.things = get("cta.cats"); 82 | await $translatedComponent.updateComplete; 83 | 84 | expect($translatedComponent.title).to.equal("Hello"); 85 | expect($translatedComponent.subtitle).to.equal("World"); 86 | expect($translatedComponent.awesome).to.equal("Cats are awesome!"); 87 | }); 88 | 89 | it("[translate] - should update translations when new strings are set", async () => { 90 | await use("da"); 91 | $translatedComponent.things = get("cta.cats"); 92 | 93 | await $translatedComponent.updateComplete; 94 | 95 | expect($translatedComponent.title).to.equal("Hej"); 96 | expect($translatedComponent.subtitle).to.equal("Verden"); 97 | expect($translatedComponent.awesome).to.equal("Katte er nice!"); 98 | }); 99 | 100 | it("[translateUnsafeHTML] - should render HTML when new strings are set", async () => { 101 | expect($translatedComponent.html.children.length).to.equal(1); 102 | expect($translatedComponent.html.innerText).to.equal("This is html"); 103 | 104 | await use("da"); 105 | await $translatedComponent.updateComplete; 106 | 107 | expect($translatedComponent.html.children.length).to.equal(1); 108 | expect($translatedComponent.html.innerText).to.equal("Dette er html"); 109 | }); 110 | 111 | it("[get] - should translate keys based on the current language", async () => { 112 | 113 | // English 114 | expect(get("lang")).to.equal("en"); 115 | expect(get("header.title")).to.equal("Hello"); 116 | expect(get("header.subtitle")).to.equal("World"); 117 | 118 | // Danish 119 | await use("da"); 120 | expect(get("lang")).to.equal("da"); 121 | expect(get("header.title")).to.equal("Hej"); 122 | expect(get("header.subtitle")).to.equal("Verden"); 123 | }); 124 | 125 | it("[get] - should show empty placeholder if string does not exist", () => { 126 | expect(get("this.does.not.exist")).to.equal("[this.does.not.exist]"); 127 | }); 128 | 129 | it("[get] - should overwrite empty placeholder if one is defined", () => { 130 | registerTranslateConfig({ 131 | empty: key => `{{ ${key} }}` 132 | }); 133 | expect(get("this.does.not.exist", null)).to.equal("{{ this.does.not.exist }}"); 134 | }); 135 | 136 | it("[get] - should interpolate values correctly", async () => { 137 | expect(get("cta.awesome", {things: get("cta.cats")})).to.equal("Cats are awesome!"); 138 | 139 | await use("da"); 140 | expect(get("cta.awesome", {things: get("cta.cats")})).to.equal("Katte er nice!"); 141 | }); 142 | 143 | it("[get] - should interpolate values correctly with same placeholder used multiple times", async () => { 144 | const email = "test@test.com"; 145 | expect(get("footer.contact", {email})).to.equal(`Contact us on ${email}. It was ${email}!`); 146 | 147 | await use("da"); 148 | expect(get("footer.contact", {email})).to.equal(`Kontakt os på ${email}. Det var ${email}!`); 149 | }); 150 | 151 | /*it("[translate] - should be performant", async () => { 152 | 153 | // Create the stress component 154 | const $stressComponent = document.createElement("stress-component") as StressComponent; 155 | document.body.appendChild($stressComponent); 156 | 157 | // Measure the time it takes to update all of the strings 158 | window.performance.mark("stress_start"); 159 | await use("da").then(); 160 | await $stressComponent.updateComplete; 161 | window.performance.mark("stress_end"); 162 | 163 | // Compute the measurement 164 | window.performance.measure("stress", "stress_start", "stress_end"); 165 | const durationMs = window.performance.getEntriesByName("stress")[0].duration; 166 | 167 | // The 500 mark was set based on performance testing in Chrome 168 | expect(durationMs).to.be.lessThan(500); 169 | });*/ 170 | }); 171 | -------------------------------------------------------------------------------- /tsconfig.build.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./node_modules/@appnest/web-config/tsconfig.json", 3 | "compilerOptions": { 4 | "outDir": "dist", 5 | "declaration": true, 6 | "target": "es2017", 7 | "importHelpers": true, 8 | "lib": [ 9 | "es2015.promise", 10 | "dom", 11 | "es7", 12 | "es6", 13 | "es2017", 14 | "es2017.object", 15 | "es2015.proxy", 16 | "esnext" 17 | ] 18 | }, 19 | "include": [ 20 | "src/lib/**/*" 21 | ] 22 | } -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./node_modules/@appnest/web-config/tsconfig.json", 3 | "compilerOptions": { 4 | "resolveJsonModule": true 5 | } 6 | } -------------------------------------------------------------------------------- /tslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./node_modules/@appnest/web-config/tslint.json" 3 | } -------------------------------------------------------------------------------- /typesafe.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/andreasbm/lit-translate/2ec157a9ab83e38b2d2429426c087bdb03802fdc/typesafe.gif -------------------------------------------------------------------------------- /typings.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | --------------------------------------------------------------------------------