├── .prettierrc ├── docs ├── gatsby-browser.js ├── netlify.toml ├── src │ ├── images │ │ └── icon.png │ ├── pages │ │ ├── errors.md │ │ ├── testing.md │ │ ├── examples │ │ │ ├── node.md │ │ │ ├── vue.md │ │ │ ├── react.md │ │ │ ├── vanilla.md │ │ │ └── web-components.md │ │ ├── 404.js │ │ ├── tutorial │ │ │ ├── 03-configuration.md │ │ │ ├── 01-introduction.md │ │ │ ├── 06-translating-in-javascript.md │ │ │ ├── 04-preparing-translations.md │ │ │ ├── 05-translating-in-html.md │ │ │ └── 02-preparing-html.md │ │ ├── index.md │ │ ├── quickstart.md │ │ └── api.md │ ├── components │ │ ├── page-header.module.css │ │ ├── page-header.jsx │ │ ├── layout.module.css │ │ ├── sidebar.module.css │ │ ├── layout.jsx │ │ └── sidebar.jsx │ └── styles │ │ ├── global.css │ │ └── prismjs.css ├── gatsby-config.js ├── package.json ├── gatsby-node.js └── README.md ├── .gitignore ├── .babelrc ├── .eslintrc.js ├── .github ├── dependabot.yml └── workflows │ └── node.js.yml ├── tests ├── __snapshots__ │ └── translator.browser.test.js.snap ├── test-utils.js ├── utils.test.js ├── translator.node.test.js └── translator.browser.test.js ├── LICENSE ├── rollup.config.js ├── package.json ├── src ├── utils.js └── translator.js ├── CONTRIBUTING.md ├── CHANGELOG.md ├── CODE_OF_CONDUCT.md └── README.md /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "trailingComma": "es5", 3 | "singleQuote": true 4 | } 5 | -------------------------------------------------------------------------------- /docs/gatsby-browser.js: -------------------------------------------------------------------------------- 1 | import './src/styles/global.css'; 2 | import './src/styles/prismjs.css'; 3 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | dist/ 3 | coverage/ 4 | 5 | package-lock.json 6 | yarn.lock 7 | .cache/ 8 | public/ -------------------------------------------------------------------------------- /docs/netlify.toml: -------------------------------------------------------------------------------- 1 | [build] 2 | publish = "public" 3 | 4 | [[plugins]] 5 | package = "netlify-plugin-gatsby-cache" -------------------------------------------------------------------------------- /docs/src/images/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/andreasremdt/simple-translator/HEAD/docs/src/images/icon.png -------------------------------------------------------------------------------- /docs/src/pages/errors.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Error Reference 3 | description: tbd 4 | slug: /errors/ 5 | --- 6 | 7 | Coming soon... 8 | -------------------------------------------------------------------------------- /docs/src/pages/testing.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Hello World 3 | description: Some content 4 | slug: /testing/ 5 | --- 6 | 7 | lorem10 8 | -------------------------------------------------------------------------------- /docs/src/pages/examples/node.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Node.js 3 | description: tbd 4 | slug: /examples/node/ 5 | --- 6 | 7 | Coming soon... 8 | -------------------------------------------------------------------------------- /docs/src/pages/examples/vue.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Vue.js 3 | description: tbd 4 | slug: /examples/vue/ 5 | --- 6 | 7 | Coming soon... 8 | -------------------------------------------------------------------------------- /docs/src/pages/examples/react.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: React.js 3 | description: tbd 4 | slug: /examples/react/ 5 | --- 6 | 7 | Coming soon... 8 | -------------------------------------------------------------------------------- /docs/src/pages/examples/vanilla.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Vanilla JavaScript 3 | description: tbd 4 | slug: /examples/vanilla/ 5 | --- 6 | 7 | Coming soon... 8 | -------------------------------------------------------------------------------- /docs/src/pages/examples/web-components.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Web Components 3 | description: tbd 4 | slug: /examples/web-components/ 5 | --- 6 | 7 | Coming soon... 8 | -------------------------------------------------------------------------------- /docs/src/pages/404.js: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import { Link } from "gatsby" 3 | 4 | const NotFoundPage = () =>
404
5 | 6 | export default NotFoundPage 7 | -------------------------------------------------------------------------------- /docs/src/pages/tutorial/03-configuration.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: 3. Configuration 3 | description: Learn how to use Simple Translator in every possible use-case. 4 | slug: /tutorial/03/ 5 | --- 6 | 7 | Coming soon... 8 | -------------------------------------------------------------------------------- /docs/src/components/page-header.module.css: -------------------------------------------------------------------------------- 1 | .description { 2 | font-size: 22px; 3 | font-weight: 300; 4 | margin: 0.5rem 0 2.5rem; 5 | padding-bottom: 2rem; 6 | border-bottom: 3px solid var(--gray-light); 7 | letter-spacing: -1px; 8 | } 9 | -------------------------------------------------------------------------------- /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "test": { 4 | "plugins": [ 5 | "@babel/plugin-transform-modules-commonjs", 6 | "@babel/plugin-proposal-optional-chaining" 7 | ] 8 | }, 9 | "development": { 10 | "presets": ["@babel/preset-env"] 11 | } 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | env: { 3 | browser: true, 4 | es2020: true, 5 | node: true, 6 | jest: true, 7 | }, 8 | extends: ['google', 'eslint:recommended', 'prettier'], 9 | parserOptions: { 10 | ecmaVersion: 11, 11 | sourceType: 'module', 12 | }, 13 | }; 14 | -------------------------------------------------------------------------------- /docs/src/components/page-header.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import * as styles from './page-header.module.css'; 3 | 4 | const PageHeader = ({ title, children }) => ( 5 |
6 |

{title}

7 |

{children}

8 |
9 | ); 10 | 11 | export default PageHeader; 12 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: npm 4 | directory: "/" 5 | schedule: 6 | interval: weekly 7 | time: "04:00" 8 | open-pull-requests-limit: 10 9 | versioning-strategy: increase 10 | ignore: 11 | - dependency-name: husky 12 | versions: 13 | - "> 4.3.8" 14 | - dependency-name: rollup 15 | versions: 16 | - 2.45.1 17 | -------------------------------------------------------------------------------- /tests/__snapshots__/translator.browser.test.js.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`constructor() has sensible default options 1`] = ` 4 | Object { 5 | "debug": false, 6 | "defaultLanguage": "en", 7 | "detectLanguage": true, 8 | "filesLocation": "/i18n", 9 | "persist": false, 10 | "persistKey": "preferred_language", 11 | "registerGlobally": "__", 12 | "selector": "[data-i18n]", 13 | } 14 | `; 15 | -------------------------------------------------------------------------------- /docs/src/components/layout.module.css: -------------------------------------------------------------------------------- 1 | .container { 2 | width: clamp(14rem, 90vw, 70rem); 3 | margin: auto; 4 | display: flex; 5 | padding: 2rem 1rem; 6 | } 7 | 8 | .main { 9 | flex: 0 1 70%; 10 | margin-left: 2rem; 11 | } 12 | 13 | .footer { 14 | display: flex; 15 | justify-content: space-between; 16 | margin: 2.5rem 0 0 0; 17 | padding-top: 2rem; 18 | border-top: 3px solid var(--gray-light); 19 | } 20 | 21 | .button { 22 | background-color: var(--gray-light); 23 | border: unset; 24 | padding: 0.7rem 1.5rem; 25 | border-radius: 4px; 26 | } 27 | 28 | .button:hover { 29 | background-color: var(--rose-medium); 30 | color: white; 31 | } 32 | -------------------------------------------------------------------------------- /docs/gatsby-config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | siteMetadata: { 3 | title: 'Simple Translator Docs', 4 | }, 5 | plugins: [ 6 | 'gatsby-plugin-react-helmet', 7 | { 8 | resolve: 'gatsby-plugin-manifest', 9 | options: { 10 | icon: 'src/images/icon.png', 11 | }, 12 | }, 13 | { 14 | resolve: 'gatsby-transformer-remark', 15 | options: { 16 | plugins: [ 17 | { 18 | resolve: 'gatsby-remark-prismjs', 19 | }, 20 | ], 21 | }, 22 | }, 23 | { 24 | resolve: 'gatsby-source-filesystem', 25 | options: { 26 | name: 'pages', 27 | path: './src/pages/', 28 | }, 29 | }, 30 | ], 31 | }; 32 | -------------------------------------------------------------------------------- /docs/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "simple-translator-docs", 3 | "version": "1.0.0", 4 | "private": true, 5 | "description": "Simple Translator Docs", 6 | "author": "Andreas Remdt", 7 | "keywords": [ 8 | "gatsby" 9 | ], 10 | "scripts": { 11 | "start": "gatsby develop", 12 | "build": "gatsby build", 13 | "serve": "gatsby serve", 14 | "clean": "gatsby clean" 15 | }, 16 | "dependencies": { 17 | "gatsby": "^3.0.1", 18 | "gatsby-plugin-manifest": "^3.0.0", 19 | "gatsby-plugin-react-helmet": "^4.0.0", 20 | "gatsby-remark-prismjs": "^4.1.0", 21 | "gatsby-source-filesystem": "^3.0.0", 22 | "gatsby-transformer-remark": "^3.0.0", 23 | "prismjs": "^1.23.0", 24 | "react": "^17.0.1", 25 | "react-dom": "^17.0.1", 26 | "react-helmet": "^6.1.0" 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /.github/workflows/node.js.yml: -------------------------------------------------------------------------------- 1 | # This workflow will do a clean install of node dependencies, build the source code and run tests across different versions of node 2 | # For more information see: https://help.github.com/actions/language-and-framework-guides/using-nodejs-with-github-actions 3 | 4 | name: Node.js CI 5 | 6 | on: 7 | push: 8 | branches: [ master ] 9 | pull_request: 10 | branches: [ master ] 11 | 12 | jobs: 13 | build: 14 | 15 | runs-on: ubuntu-latest 16 | 17 | strategy: 18 | matrix: 19 | node-version: [12.x, 14.x] 20 | 21 | steps: 22 | - uses: actions/checkout@v2 23 | - name: Use Node.js ${{ matrix.node-version }} 24 | uses: actions/setup-node@v1 25 | with: 26 | node-version: ${{ matrix.node-version }} 27 | - name: Install dependencies 28 | run: npm install 29 | - name: Build and test library 30 | run: npm run validate --if-present 31 | -------------------------------------------------------------------------------- /docs/src/components/sidebar.module.css: -------------------------------------------------------------------------------- 1 | .container { 2 | position: sticky; 3 | top: 0; 4 | flex: 0 0 30%; 5 | height: 100%; 6 | display: flex; 7 | flex-direction: column; 8 | } 9 | 10 | .title { 11 | font-size: 30px; 12 | font-weight: 300; 13 | margin-bottom: 2rem; 14 | color: var(--gray-dark); 15 | } 16 | 17 | .link { 18 | margin-bottom: 0.5rem; 19 | border-bottom: unset; 20 | text-decoration: none; 21 | color: var(--gray-medium); 22 | transition: color 0.1s ease-out, transform 0.25s ease-out; 23 | } 24 | 25 | .link:hover, 26 | .link:focus { 27 | color: var(--rose-medium); 28 | transform: translateX(0.4rem); 29 | } 30 | 31 | .active { 32 | padding-left: 0.4rem; 33 | border-left: 3px solid var(--rose-medium); 34 | color: var(--rose-medium); 35 | } 36 | 37 | .details { 38 | display: flex; 39 | flex-direction: column; 40 | } 41 | 42 | .details > .link { 43 | padding-left: 1rem; 44 | } 45 | 46 | .summary { 47 | cursor: pointer; 48 | margin-bottom: 0.5rem; 49 | } 50 | -------------------------------------------------------------------------------- /docs/gatsby-node.js: -------------------------------------------------------------------------------- 1 | exports.createPages = async ({ actions, graphql, reporter }) => { 2 | const template = require.resolve("./src/components/layout.jsx"); 3 | const result = await graphql(` 4 | { 5 | allMarkdownRemark { 6 | edges { 7 | node { 8 | frontmatter { 9 | slug 10 | title 11 | description 12 | } 13 | } 14 | } 15 | } 16 | } 17 | `); 18 | 19 | if (result.errors) { 20 | reporter.panicOnBuild(`Error while running GraphQL query.`); 21 | return; 22 | } 23 | 24 | result.data.allMarkdownRemark.edges.forEach(({ node }) => { 25 | if (node.frontmatter?.slug) { 26 | actions.createPage({ 27 | path: node.frontmatter.slug, 28 | component: template, 29 | context: { 30 | slug: node.frontmatter.slug, 31 | title: node.frontmatter.title, 32 | description: node.frontmatter.description, 33 | }, 34 | }); 35 | } 36 | }); 37 | }; 38 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright 2019 Andreas Remdt 2 | 3 | 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: 4 | 5 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 6 | 7 | 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. -------------------------------------------------------------------------------- /docs/src/pages/index.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Home 3 | description: Translating websites doesn't have to be complicated. Explore Simple Translator and learn how to get started in minutes. 4 | slug: / 5 | --- 6 | 7 | Welcome! You are on the official documentation for _Simple Translator_, a JavaScript library that helps you translate your websites with ease. 8 | 9 | With _Simple Translator_, you can: 10 | 11 | - Translate single strings 12 | - Translate entire HTML pages 13 | - Easily fetch JSON resource files (containing your translations) 14 | - Make use of global helper functions for a better developer experience 15 | - Detect the user's preferred language automatically 16 | - Use fallback languages 17 | 18 | _Simple Translator_ is a very lightweight (~10 KB minified) JavaScript library available on [NPM](https://www.npmjs.com/package/@andreasremdt/simple-translator) and works natively in the browser and Node.js. 19 | 20 | From here, you can read the [quickstart](/quickstart/) guide to get started quickly or dive into the more advanced [tutorial](/tutorial/). 21 | -------------------------------------------------------------------------------- /tests/test-utils.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Creates some HTML elements for the test runner to 3 | * run on. 4 | * 5 | * @param {String} h1Attributes 6 | * @param {String} pAttributes 7 | * @return {Object} 8 | */ 9 | export function buildHTML({ title, paragraph }) { 10 | const h1 = document.createElement('h1'); 11 | const p = document.createElement('p'); 12 | 13 | h1.setAttribute('data-i18n', title.keys); 14 | p.setAttribute('data-i18n', paragraph.keys); 15 | 16 | if (title.attrs) { 17 | h1.setAttribute('data-i18n-attr', title.attrs); 18 | } 19 | 20 | if (paragraph.attrs) { 21 | p.setAttribute('data-i18n-attr', paragraph.attrs); 22 | } 23 | 24 | h1.textContent = 'Default heading text'; 25 | p.textContent = 'Default content text'; 26 | 27 | document.body.appendChild(h1); 28 | document.body.appendChild(p); 29 | 30 | return { h1, p }; 31 | } 32 | 33 | /** 34 | * Removes a list of HTML elements from the DOM. 35 | * 36 | * @param {...HTMLElement} elements 37 | */ 38 | export function removeHTML(...elements) { 39 | elements.forEach((element) => document.body.removeChild(element)); 40 | } 41 | -------------------------------------------------------------------------------- /docs/src/styles/global.css: -------------------------------------------------------------------------------- 1 | :root { 2 | --gray-dark: #0f172a; 3 | --gray-medium: #334155; 4 | --gray-light: #e2e8f0; 5 | --rose-medium: #f43f5e; 6 | } 7 | 8 | * { 9 | box-sizing: border-box; 10 | } 11 | 12 | body { 13 | margin: unset; 14 | font: 400 16px/1.6 Inter, sans-serif; 15 | color: var(--gray-medium); 16 | } 17 | 18 | a { 19 | color: var(--gray-dark); 20 | text-decoration: none; 21 | border-bottom: 1px solid var(--rose-medium); 22 | transition: all 0.2s ease; 23 | } 24 | 25 | h1, 26 | h2, 27 | h3 { 28 | font-weight: 300; 29 | letter-spacing: -1px; 30 | line-height: 1.1; 31 | color: var(--gray-dark); 32 | } 33 | 34 | h1 { 35 | margin: unset; 36 | font-size: 60px; 37 | } 38 | 39 | h2 { 40 | margin: 3rem 0 1rem 0; 41 | font-size: 30px; 42 | } 43 | 44 | h3 { 45 | margin: 2rem 0 0 0; 46 | font-size: 23px; 47 | } 48 | 49 | table { 50 | width: 100%; 51 | border-collapse: collapse; 52 | } 53 | 54 | thead { 55 | text-align: left; 56 | } 57 | 58 | td, 59 | th { 60 | vertical-align: top; 61 | padding: 0.5rem; 62 | border-bottom: 1px solid var(--gray-light); 63 | } 64 | -------------------------------------------------------------------------------- /rollup.config.js: -------------------------------------------------------------------------------- 1 | import babel from '@rollup/plugin-babel'; 2 | import { terser } from 'rollup-plugin-terser'; 3 | 4 | export default { 5 | input: './src/translator.js', 6 | 7 | output: [ 8 | { 9 | file: './dist/esm/translator.js', 10 | sourcemap: true, 11 | format: 'es', 12 | }, 13 | { 14 | file: './dist/esm/translator.min.js', 15 | sourcemap: true, 16 | format: 'es', 17 | plugins: [terser()], 18 | }, 19 | { 20 | file: './dist/cjs/translator.js', 21 | format: 'cjs', 22 | sourcemap: true, 23 | exports: 'default', 24 | }, 25 | { 26 | file: './dist/cjs/translator.min.js', 27 | format: 'cjs', 28 | sourcemap: true, 29 | exports: 'default', 30 | plugins: [terser()], 31 | }, 32 | { 33 | file: './dist/umd/translator.js', 34 | format: 'umd', 35 | sourcemap: true, 36 | name: 'Translator', 37 | }, 38 | { 39 | file: './dist/umd/translator.min.js', 40 | format: 'umd', 41 | sourcemap: true, 42 | name: 'Translator', 43 | plugins: [terser()], 44 | }, 45 | ], 46 | 47 | plugins: [ 48 | babel({ 49 | exclude: /(node_modules|dist)/, 50 | babelHelpers: 'bundled', 51 | }), 52 | ], 53 | }; 54 | -------------------------------------------------------------------------------- /tests/utils.test.js: -------------------------------------------------------------------------------- 1 | import { logger } from '../src/utils.js'; 2 | 3 | describe('logger()', () => { 4 | let consoleSpy; 5 | 6 | beforeEach(() => { 7 | consoleSpy = jest.spyOn(window.console, 'error').mockImplementation(); 8 | }); 9 | 10 | afterEach(() => { 11 | jest.clearAllMocks(); 12 | }); 13 | 14 | it('should not log when disabled', () => { 15 | logger(false)('SOME_ERROR'); 16 | 17 | expect(consoleSpy).not.toHaveBeenCalled(); 18 | }); 19 | 20 | it('should log a comprehensive error message', () => { 21 | logger(true)('INVALID_PARAM_LANGUAGE'); 22 | 23 | expect(consoleSpy).toHaveBeenCalledTimes(1); 24 | expect(consoleSpy).toHaveBeenCalledWith( 25 | expect.stringContaining('Error code: INVALID_PARAM_LANGUAGE') 26 | ); 27 | expect(consoleSpy).toHaveBeenCalledWith( 28 | expect.stringContaining( 29 | 'https://github.com/andreasremdt/simple-translator#usage' 30 | ) 31 | ); 32 | expect(consoleSpy).toHaveBeenCalledWith( 33 | expect.stringContaining('This error happened in the method') 34 | ); 35 | expect(consoleSpy).toHaveBeenCalledWith( 36 | expect.stringContaining("If you don't want to see these error messages") 37 | ); 38 | }); 39 | 40 | it('should have a default fallback', () => { 41 | logger(true)('SOME_ERROR'); 42 | 43 | expect(consoleSpy).toHaveBeenCalledWith( 44 | expect.stringContaining('Unhandled Error') 45 | ); 46 | }); 47 | }); 48 | -------------------------------------------------------------------------------- /docs/README.md: -------------------------------------------------------------------------------- 1 |

2 | 3 | Gatsby 4 | 5 |

6 |

7 | Gatsby minimal starter 8 |

9 | 10 | ## 🚀 Quick start 11 | 12 | 1. **Create a Gatsby site.** 13 | 14 | Use the Gatsby CLI to create a new site, specifying the minimal starter. 15 | 16 | ```shell 17 | # create a new Gatsby site using the minimal starter 18 | npm init gatsby 19 | ``` 20 | 21 | 2. **Start developing.** 22 | 23 | Navigate into your new site’s directory and start it up. 24 | 25 | ```shell 26 | cd my-gatsby-site/ 27 | npm run develop 28 | ``` 29 | 30 | 3. **Open the code and start customizing!** 31 | 32 | Your site is now running at http://localhost:8000! 33 | 34 | Edit `src/pages/index.js` to see your site update in real-time! 35 | 36 | 4. **Learn more** 37 | 38 | - [Documentation](https://www.gatsbyjs.com/docs/?utm_source=starter&utm_medium=readme&utm_campaign=minimal-starter) 39 | 40 | - [Tutorials](https://www.gatsbyjs.com/tutorial/?utm_source=starter&utm_medium=readme&utm_campaign=minimal-starter) 41 | 42 | - [Guides](https://www.gatsbyjs.com/tutorial/?utm_source=starter&utm_medium=readme&utm_campaign=minimal-starter) 43 | 44 | - [API Reference](https://www.gatsbyjs.com/docs/api-reference/?utm_source=starter&utm_medium=readme&utm_campaign=minimal-starter) 45 | 46 | - [Plugin Library](https://www.gatsbyjs.com/plugins?utm_source=starter&utm_medium=readme&utm_campaign=minimal-starter) 47 | 48 | - [Cheat Sheet](https://www.gatsbyjs.com/docs/cheat-sheet/?utm_source=starter&utm_medium=readme&utm_campaign=minimal-starter) 49 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@andreasremdt/simple-translator", 3 | "version": "2.0.4", 4 | "description": "Simple client-side translation with pure JavaScript.", 5 | "main": "dist/cjs/translator.min.js", 6 | "module": "dist/esm/translator.min.js", 7 | "publishConfig": { 8 | "access": "public" 9 | }, 10 | "scripts": { 11 | "prebuild": "rm -rf dist/", 12 | "build": "rollup --config", 13 | "dev": "rollup --config --watch", 14 | "lint": "eslint src/*.js --ignore-path .gitignore", 15 | "format": "prettier src/*.js --write", 16 | "test": "jest ./tests/*.test.js", 17 | "validate": "npm-run-all --parallel format lint test" 18 | }, 19 | "repository": { 20 | "type": "git", 21 | "url": "git+https://github.com/andreasremdt/simple-translator.git" 22 | }, 23 | "keywords": [ 24 | "translator", 25 | "tanslation", 26 | "i18n" 27 | ], 28 | "files": [ 29 | "dist/", 30 | "README.md", 31 | "LICENSE", 32 | "CHANGELOG.md" 33 | ], 34 | "author": "Andreas Remdt (https://andreasremdt.com)", 35 | "license": "MIT", 36 | "bugs": { 37 | "url": "https://github.com/andreasremdt/simple-translator/issues" 38 | }, 39 | "homepage": "https://github.com/andreasremdt/simple-translator#readme", 40 | "devDependencies": { 41 | "@babel/core": "^7.16.0", 42 | "@babel/plugin-proposal-optional-chaining": "^7.16.0", 43 | "@babel/plugin-transform-modules-commonjs": "^7.16.0", 44 | "@babel/preset-env": "^7.20.2", 45 | "@rollup/plugin-babel": "^5.3.0", 46 | "eslint": "^8.4.0", 47 | "eslint-config-google": "^0.14.0", 48 | "eslint-config-prettier": "^8.3.0", 49 | "jest": "^26.6.3", 50 | "npm-run-all": "^4.1.5", 51 | "prettier": "^2.5.1", 52 | "rollup": "^2.79.1", 53 | "rollup-plugin-terser": "^7.0.2" 54 | }, 55 | "jest": { 56 | "collectCoverageFrom": [ 57 | "src/*.js" 58 | ] 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /docs/src/pages/tutorial/01-introduction.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: 1. Introduction 3 | description: Learn how to use Simple Translator in every possible use-case. 4 | slug: /tutorial/01/ 5 | next_link: /tutorial/02/ 6 | --- 7 | 8 | This tutorial guides you through building a simple website, translated by _Simple Translator_. We'll take a look at all of the use-cases _Simple Translator_ can handle and explain how to use the API, along with some best-practices. 9 | 10 | Ready? Let's dive in! 11 | 12 | ## Prerequisites 13 | 14 | This tutorial assumes that you're already familiar with JavaScript and HTML, ideally also with Node.js. 15 | 16 | ## System requirements 17 | 18 | - [Node.js](https://nodejs.org/en/) v8.x or later 19 | - npm v6.x or later 20 | - A text editor of your choice, like [VS Code](https://code.visualstudio.com/) or [Sublime Text](https://www.sublimetext.com/) 21 | 22 | ## Setup 23 | 24 | Before we can get started, we need to create a new project and install _Simple Translator_. Create a folder and `cd` into it: 25 | 26 | ```bash 27 | mkdir translator-tutorial 28 | cd translator-tutorial/ 29 | ``` 30 | 31 | Initialize the emtpy folder as a new npm project: 32 | 33 | ```bash 34 | npm init -y 35 | ``` 36 | 37 | > `-y` just means that all defaults are taken and you don't have to type in anything. 38 | 39 | Next, let's install _Simple Translator_: 40 | 41 | ```bash 42 | npm i @andreasremdt/simple-translator 43 | ``` 44 | 45 | You should see a new folder `node_modules`, containing the _Simple Translator_ library. Create an empty `index.html` and `main.js` next: 46 | 47 | ```bash 48 | touch index.html 49 | touch main.js 50 | ``` 51 | 52 | **Hint:** you don't have to install the library via npm. Instead, you can copy the [unpkg link](https://unpkg.com/@andreasremdt/simple-translator@latest/dist/umd/translator.min.js) and place it into a `script` tag in `index.html`. 53 | 54 | ## Conclusion 55 | 56 | With the basic setup out of the way, we are ready to dive into the tutorial. Head on to the next page to learn more about adding HTML attributes to mark content as translatable. 57 | -------------------------------------------------------------------------------- /docs/src/components/layout.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Link, graphql } from 'gatsby'; 3 | import Helmet from 'react-helmet'; 4 | import Sidebar from './sidebar'; 5 | import PageHeader from './page-header'; 6 | import * as styles from './layout.module.css'; 7 | 8 | const Layout = ({ data }) => { 9 | const { frontmatter, html } = data.markdownRemark; 10 | 11 | return ( 12 |
13 | 14 | {frontmatter.title} 15 | 16 | 17 | 18 | 22 | 26 | 27 | 28 |
29 | 30 | {frontmatter.description} 31 | 32 |
33 | 34 | {(frontmatter.prev_link || frontmatter.next_link) && ( 35 |
36 | {frontmatter.prev_link && ( 37 | 38 | « Previous 39 | 40 | )} 41 | {frontmatter.next_link && ( 42 | 43 | Next » 44 | 45 | )} 46 |
47 | )} 48 |
49 |
50 | ); 51 | }; 52 | 53 | export const pageQuery = graphql` 54 | query($slug: String!) { 55 | markdownRemark(frontmatter: { slug: { eq: $slug } }) { 56 | html 57 | frontmatter { 58 | title 59 | description 60 | prev_link 61 | next_link 62 | } 63 | } 64 | } 65 | `; 66 | 67 | export default Layout; 68 | -------------------------------------------------------------------------------- /docs/src/pages/quickstart.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Quickstart 3 | description: Get up and running with Simple Translator in no more than 10 minutes. 4 | slug: /quickstart/ 5 | --- 6 | 7 | This quickstart guide will show you the basic usage of _Simple Translator_. If you want to learn more details, head over to the [tutorial](/tutorial/). 8 | 9 | ## 1. Installation 10 | 11 | Simple Translator can be installed from npm: 12 | 13 | ```bash 14 | # npm 15 | npm install @andreasremdt/simple-translator 16 | 17 | #yarn 18 | yarn add @andreasremdt/simple-translator 19 | ``` 20 | 21 | If you don't want to install a dependency and prefer to directly load _Simple Translator_ into the browser, you can use unpkg: 22 | 23 | ```html 24 | 28 | ``` 29 | 30 | ## 2. Import & Initialization 31 | 32 | Import the `Translator` class into your JavaScript file. Depending on your setup, you can either use ESM or CommonJS. 33 | 34 | ```js 35 | // ESM 36 | import Translator from "@andreasremdt/simple-translator"; 37 | 38 | // CommonJS 39 | var Translator = require("@andreasremdt/simple-translator"); 40 | ``` 41 | 42 | If you loaded the library via unpkg, you can skip this step, as the `Translator` class will be available globally. 43 | 44 | Initialize the `Translator` class and provide some (optional) options to configure its behavior. You don't need to pass any configuration, the default options will be used instead. 45 | 46 | ```js 47 | var translator = new Translator({ 48 | ...options, 49 | }); 50 | ``` 51 | 52 | ## 3. Preparing the HTML 53 | 54 | Add `data-i18n` attributes to all HTML elements that you want to translate. 55 | 56 | ```html 57 |

58 | ``` 59 | 60 | This will replace the `textContent` of the paragraph with your translation, coming from the translation resource. You can set `data-i18n` to all HTML elements that can have `textContent`. 61 | 62 | ## 4. Translating the HTML 63 | 64 | Finally, you can use the API to add languages and translate the HTML page. 65 | 66 | ```js 67 | var germanTranslation = { 68 | header: { 69 | title: "Eine Überschrift", 70 | }, 71 | }; 72 | 73 | translator.add("de", germanTranslation).translatePageTo("de"); 74 | ``` 75 | 76 | You can register as many languages as you want. Keep in mind that the JSON structure in each corresponding language file should be consistent, otherwise, things might break. 77 | -------------------------------------------------------------------------------- /docs/src/styles/prismjs.css: -------------------------------------------------------------------------------- 1 | /** 2 | * GHColors theme by Avi Aryan (http://aviaryan.in) 3 | * Inspired by Github syntax coloring 4 | */ 5 | 6 | code[class*='language-'], 7 | pre[class*='language-'] { 8 | color: #393a34; 9 | font-family: 'IBM Plex Mono', 'Consolas', 'Bitstream Vera Sans Mono', 10 | 'Courier New', Courier, monospace; 11 | direction: ltr; 12 | text-align: left; 13 | white-space: pre-wrap; 14 | word-spacing: normal; 15 | word-break: normal; 16 | font-size: 14px; 17 | tab-size: 2; 18 | hyphens: none; 19 | } 20 | 21 | pre[class*='language-']::-moz-selection, 22 | pre[class*='language-'] ::-moz-selection, 23 | code[class*='language-']::-moz-selection, 24 | code[class*='language-'] ::-moz-selection { 25 | background: #b3d4fc; 26 | } 27 | 28 | pre[class*='language-']::selection, 29 | pre[class*='language-'] ::selection, 30 | code[class*='language-']::selection, 31 | code[class*='language-'] ::selection { 32 | background: #b3d4fc; 33 | } 34 | 35 | /* Code blocks */ 36 | pre[class*='language-'] { 37 | padding: 1.5rem; 38 | margin: 1rem 0; 39 | overflow: auto; 40 | background-color: #f9fafb; 41 | border-radius: 4px; 42 | } 43 | 44 | /* Inline code */ 45 | :not(pre) > code[class*='language-'] { 46 | padding: 1px 0.4rem; 47 | background-color: #f9fafb; 48 | } 49 | 50 | .token.comment, 51 | .token.prolog, 52 | .token.doctype, 53 | .token.cdata { 54 | color: #999988; 55 | font-style: italic; 56 | } 57 | 58 | .token.namespace { 59 | opacity: 0.7; 60 | } 61 | 62 | .token.string, 63 | .token.attr-value { 64 | color: #e3116c; 65 | } 66 | 67 | .token.punctuation, 68 | .token.operator { 69 | color: #393a34; /* no highlight */ 70 | } 71 | 72 | .token.entity, 73 | .token.url, 74 | .token.symbol, 75 | .token.number, 76 | .token.boolean, 77 | .token.variable, 78 | .token.constant, 79 | .token.property, 80 | .token.regex, 81 | .token.inserted { 82 | color: #36acaa; 83 | } 84 | 85 | .token.atrule, 86 | .token.keyword, 87 | .token.attr-name, 88 | .language-autohotkey .token.selector { 89 | color: #00a4db; 90 | } 91 | 92 | .token.function, 93 | .token.deleted, 94 | .language-autohotkey .token.tag { 95 | color: #9a050f; 96 | } 97 | 98 | .token.tag, 99 | .token.selector, 100 | .language-autohotkey .token.keyword { 101 | color: #00009f; 102 | } 103 | 104 | .token.important, 105 | .token.function, 106 | .token.bold { 107 | font-weight: bold; 108 | } 109 | 110 | .token.italic { 111 | font-style: italic; 112 | } 113 | -------------------------------------------------------------------------------- /src/utils.js: -------------------------------------------------------------------------------- 1 | const CONSOLE_MESSAGES = { 2 | INVALID_PARAM_LANGUAGE: (param) => 3 | `Invalid parameter for \`language\` provided. Expected a string, but got ${typeof param}.`, 4 | INVALID_PARAM_JSON: (param) => 5 | `Invalid parameter for \`json\` provided. Expected an object, but got ${typeof param}.`, 6 | EMPTY_PARAM_LANGUAGE: () => 7 | `The parameter for \`language\` can't be an empty string.`, 8 | EMPTY_PARAM_JSON: () => 9 | `The parameter for \`json\` must have at least one key/value pair.`, 10 | INVALID_PARAM_KEY: (param) => 11 | `Invalid parameter for \`key\` provided. Expected a string, but got ${typeof param}.`, 12 | NO_LANGUAGE_REGISTERED: (language) => 13 | `No translation for language "${language}" has been added, yet. Make sure to register that language using the \`.add()\` method first.`, 14 | TRANSLATION_NOT_FOUND: (key, language) => 15 | `No translation found for key "${key}" in language "${language}". Is there a key/value in your translation file?`, 16 | INVALID_PARAMETER_SOURCES: (param) => 17 | `Invalid parameter for \`sources\` provided. Expected either a string or an array, but got ${typeof param}.`, 18 | FETCH_ERROR: (response) => 19 | `Could not fetch "${response.url}": ${response.status} (${response.statusText})`, 20 | INVALID_ENVIRONMENT: () => 21 | `You are trying to execute the method \`translatePageTo()\`, which is only available in the browser. Your environment is most likely Node.js`, 22 | MODULE_NOT_FOUND: (message) => message, 23 | MISMATCHING_ATTRIBUTES: (keys, attributes, element) => 24 | `The attributes "data-i18n" and "data-i18n-attr" must contain the same number of keys. 25 | 26 | Values in \`data-i18n\`: (${keys.length}) \`${keys.join(' ')}\` 27 | Values in \`data-i18n-attr\`: (${attributes.length}) \`${attributes.join(' ')}\` 28 | 29 | The HTML element is: 30 | ${element.outerHTML}`, 31 | INVALID_OPTIONS: (param) => 32 | `Invalid config passed to the \`Translator\` constructor. Expected an object, but got ${typeof param}. Using default config instead.`, 33 | }; 34 | 35 | /** 36 | * 37 | * @param {Boolean} isEnabled 38 | * @return {Function} 39 | */ 40 | export function logger(isEnabled) { 41 | return function log(code, ...args) { 42 | if (isEnabled) { 43 | try { 44 | const message = CONSOLE_MESSAGES[code]; 45 | throw new TypeError(message ? message(...args) : 'Unhandled Error'); 46 | } catch (ex) { 47 | const line = ex.stack.split(/\n/g)[1]; 48 | const [method, filepath] = line.split(/@/); 49 | 50 | console.error(`${ex.message} 51 | 52 | This error happened in the method \`${method}\` from: \`${filepath}\`. 53 | 54 | If you don't want to see these error messages, turn off debugging by passing \`{ debug: false }\` to the constructor. 55 | 56 | Error code: ${code} 57 | 58 | Check out the documentation for more details about the API: 59 | https://github.com/andreasremdt/simple-translator#usage 60 | `); 61 | } 62 | } 63 | }; 64 | } 65 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | Thanks for being willing to contribute! 4 | 5 | Is this **your first time** contributing to a different project? You might be interested in learning more about the workflow in [this free course](https://egghead.io/courses/how-to-contribute-to-an-open-source-project-on-github). 6 | 7 | ## Project setup 8 | 9 | 1. Fork and clone the repo 10 | 2. Run `npm install` to install dependencies 11 | 3. Run `npm run validate` to validate the installation 12 | 4. Create a branch for your PR with `git checkout -b pr/your-branch-name` 13 | 14 | If you want to transpile and build the project, run `npm run build` (minified, production build) or `npm run dev` to compile and watch for file changes during development. 15 | 16 | > Tip: Keep your `master` branch pointing at the original repository and make 17 | > pull requests from branches on your fork. To do this, run: 18 | > 19 | > ``` 20 | > git remote add upstream https://github.com/andreasremdt/simple-translator.git 21 | > git fetch upstream 22 | > git branch --set-upstream-to=upstream/master master 23 | > ``` 24 | > 25 | > This will add the original repository as a "remote" called "upstream," Then 26 | > fetch the git information from that remote, then set your local `master` 27 | > branch to use the upstream master branch whenever you run `git pull`. Then you 28 | > can make all of your pull request branches based on this `master` branch. 29 | > Whenever you want to update your version of `master`, do a regular `git pull`. 30 | 31 | ## Committing and pushing changes 32 | 33 | Please make sure to run the tests before you commit your changes. You can run `npm run validate` which will format, test, and lint the code. You won't be able to commit without all of these 3 passing. 34 | 35 | This project follows the [Karma Convention](https://karma-runner.github.io/4.0/dev/git-commit-msg.html). 36 | 37 | ### Short form (only subject line) 38 | 39 | ``` 40 | (): 41 | ``` 42 | 43 | ### Long form (with body) 44 | 45 | ``` 46 | (): 47 | 48 | 49 | ``` 50 | 51 | #### `` 52 | 53 | Allowed `` values: 54 | 55 | - **feat** (new feature) 56 | - **fix** (bug fix) 57 | - **docs** (changes to documentation) 58 | - **style** (formatting, missing semi colons, etc; no code change) 59 | - **refactor** (refactoring production code) 60 | - **test** (adding missing tests, refactoring tests; no production code change) 61 | - **chore** (updating rollup etc; no production code change) 62 | 63 | #### `` 64 | 65 | One word, maxim two connected by dash, describing the area the changes affect. Example values: 66 | 67 | - build 68 | - translator 69 | - utils 70 | - etc. 71 | 72 | #### `` 73 | 74 | - Use imperative, present tense: _change_ not _changed_ nor _changes_ or 75 | _changing_ 76 | - Do not capitalize first letter 77 | - Do not append dot (.) at the end 78 | 79 | ## Help Needed 80 | 81 | Please checkout the [the open issues](https://github.com/andreasremdt/simple-translator/issues). 82 | 83 | Also, please watch the repo and respond to questions/bug reports/feature 84 | requests. Thanks! 85 | -------------------------------------------------------------------------------- /tests/translator.node.test.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @jest-environment node 3 | */ 4 | 5 | import Translator from '../src/translator.js'; 6 | 7 | describe('constructor()', () => { 8 | let translator; 9 | 10 | it('creates a global helper', () => { 11 | translator = new Translator(); 12 | 13 | expect(global.__).toBeDefined(); 14 | 15 | translator = new Translator({ registerGlobally: 't' }); 16 | 17 | expect(global.t).toBeDefined(); 18 | 19 | delete global.__; 20 | delete global.t; 21 | }); 22 | 23 | it('should not create a global helper when turned off', () => { 24 | translator = new Translator({ registerGlobally: false }); 25 | 26 | expect(global.__).toBeUndefined(); 27 | }); 28 | 29 | it('should not try to detect the language on node', () => { 30 | const spy = jest.spyOn(Translator.prototype, '_detectLanguage'); 31 | translator = new Translator(); 32 | 33 | expect(spy).not.toHaveBeenCalled(); 34 | expect(translator.config.defaultLanguage).toBe('en'); 35 | }); 36 | }); 37 | 38 | describe('translatePageTo()', () => { 39 | let translator; 40 | let consoleSpy; 41 | 42 | beforeEach(() => { 43 | translator = new Translator({ debug: true }); 44 | translator.add('de', { title: 'Deutscher Titel' }); 45 | consoleSpy = jest.spyOn(global.console, 'error').mockImplementation(); 46 | }); 47 | 48 | afterEach(() => { 49 | translator = null; 50 | jest.clearAllMocks(); 51 | }); 52 | 53 | it('should not do anything on node', () => { 54 | translator.translatePageTo('de'); 55 | 56 | expect(consoleSpy).toHaveBeenCalledTimes(1); 57 | expect(consoleSpy).toHaveBeenCalledWith( 58 | expect.stringContaining('INVALID_ENVIRONMENT') 59 | ); 60 | consoleSpy.mockClear(); 61 | }); 62 | }); 63 | 64 | describe('fetch()', () => { 65 | let translator; 66 | let consoleSpy; 67 | const RESOURCE_FILES = { 68 | de: { title: 'Deutscher Titel', paragraph: 'Hallo Welt' }, 69 | en: { title: 'English title', paragraph: 'Hello World' }, 70 | }; 71 | 72 | jest.mock('fs', () => ({ 73 | readFileSync: jest.fn((url) => { 74 | if (url.includes('de.json')) { 75 | return JSON.stringify({ 76 | title: 'Deutscher Titel', 77 | paragraph: 'Hallo Welt', 78 | }); 79 | } else if (url.includes('en.json')) { 80 | return JSON.stringify({ 81 | title: 'English title', 82 | paragraph: 'Hello World', 83 | }); 84 | } 85 | }), 86 | })); 87 | 88 | beforeEach(() => { 89 | translator = new Translator({ debug: true }); 90 | consoleSpy = jest.spyOn(global.console, 'error').mockImplementation(); 91 | }); 92 | 93 | it('fetches a single resource using cjs', (done) => { 94 | translator.fetch('de').then((value) => { 95 | expect(value).toMatchObject(RESOURCE_FILES['de']); 96 | expect(translator.languages.size).toBe(1); 97 | expect(translator.languages.get('de')).toMatchObject( 98 | RESOURCE_FILES['de'] 99 | ); 100 | done(); 101 | }); 102 | }); 103 | 104 | it('fetches a multiple resources', (done) => { 105 | translator.fetch(['de', 'en']).then((value) => { 106 | expect(value).toMatchObject([RESOURCE_FILES['de'], RESOURCE_FILES['en']]); 107 | expect(translator.languages.size).toBe(2); 108 | done(); 109 | }); 110 | }); 111 | 112 | it("displays an error when the resource doesn't exist", (done) => { 113 | translator.fetch('nl').then((value) => { 114 | expect(value).toBeUndefined(); 115 | expect(consoleSpy).toHaveBeenCalledTimes(1); 116 | expect(consoleSpy).toHaveBeenCalledWith( 117 | expect.stringContaining('MODULE_NOT_FOUND') 118 | ); 119 | done(); 120 | }); 121 | }); 122 | }); 123 | -------------------------------------------------------------------------------- /docs/src/components/sidebar.jsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useState } from 'react'; 2 | import { Link } from 'gatsby'; 3 | import * as styles from './sidebar.module.css'; 4 | 5 | const TUTORIAL = 'sidebar.tutorial.open'; 6 | const EXAMPLES = 'sidebar.examples.open'; 7 | 8 | const Sidebar = () => { 9 | const [isTutorialOpen, setIsTutorialOpen] = useState(false); 10 | const [isExamplesOpen, setIsExamplesOpen] = useState(false); 11 | 12 | useEffect(() => { 13 | setIsTutorialOpen(Boolean(Number(localStorage.getItem(TUTORIAL)))); 14 | setIsExamplesOpen(Boolean(Number(localStorage.getItem(EXAMPLES)))); 15 | }, []); 16 | 17 | return ( 18 | 140 | ); 141 | }; 142 | 143 | export default Sidebar; 144 | -------------------------------------------------------------------------------- /docs/src/pages/tutorial/06-translating-in-javascript.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: 6. Translating in JavaScript 3 | description: Learn how to use Simple Translator in every possible use-case. 4 | slug: /tutorial/06/ 5 | --- 6 | 7 | In this final section, we will have a look at how to translate content programmatically in JavaScript, without involving any HTML at all. 8 | 9 | Why is this even necessary? Not all your page content might be inside an HTML page. Think about UX flows like confirmation modals, popup notifications, or content that has been added dynamically via JavaScript. These things are usually controlled by a script and have their own text content, which is as important to translate as the rest of the page. 10 | 11 | Another example might be Node.js: _Simple Translator_ can also work with JavaScript that is not executed in the browser, but on the server-side. Say you are building a REST API or a CLI that supports translation - there's no such thing as adding `data` attributes to mark content as translateble. 12 | 13 | ## Using translateForKey() 14 | 15 | That's why _Simple Translator_ offers a method called `.translateForKey`, which allows you to translate a single key from your translations into a specific language: 16 | 17 | ```js 18 | translator.add('en', { 19 | meta: { 20 | description: 'Find the best recipes from all around the world.', 21 | }, 22 | title: 'Recipes of the Day', 23 | }); 24 | 25 | translator.translateForKey('meta.description', 'en'); 26 | // -> "Find the best recipes from all around the world." 27 | 28 | translator.translateForKey('title', 'en'); 29 | // -> "Recipes of the Day" 30 | ``` 31 | 32 | You can only translate one single key at a time, but this method can be used to translate all content that you have in JavaScript. Let's look at an example on how this could be useful: 33 | 34 | ```js 35 | import Translator from '@andreasremdt/simple-translator'; 36 | 37 | var translator = new Translator(); 38 | 39 | translator.add('en', { 40 | confirmation: { 41 | leavePage: 'Are you sure that you want to leave this page?', 42 | }, 43 | }); 44 | 45 | if ( 46 | window.confirm(translator.translateForKey('confirmation.leavePage', 'en')) 47 | ) { 48 | window.open('/logout'); 49 | } 50 | ``` 51 | 52 | ## Using the Global Helper 53 | 54 | Using a lengthy method name `.translateForKey` can be cumbersome in larger applications, that's why _Simple Translator_ offers a global shortcut. By default, you can use the global function `.__` to achieve the same result as above: 55 | 56 | ```js 57 | __('confirmation.leavePage', 'en'); 58 | 59 | // or, using the default language: 60 | __('confirmation.leavePage'); 61 | ``` 62 | 63 | Using two underscores is a common pattern for translations, but if you'd prefer something different, you can customize the helper's name or disable it entirely: 64 | 65 | ```js 66 | import Translator from '@andreasremdt/simple-translator'; 67 | 68 | var translator = new Translator({ 69 | registerGlobally: 't', 70 | }); 71 | 72 | t('confirmation.leavePage'); 73 | 74 | // or pass `false` to disable the helper: 75 | var translator = new Translator({ 76 | registerGlobally: false, 77 | }); 78 | ``` 79 | 80 | The global helper will work in both Node.js and browser environments. Especially when using frameworks like React.js, this might be useful, because you don't want to clutter your JSX markup with those `data-i18n` attributes nor have the translator interfere with React rendering. 81 | 82 | ```jsx 83 | function ConfirmDialog() { 84 | return ( 85 |
86 |

{__('confirmation.leavePage', 'en')} 87 | 88 | 89 |

90 | ); 91 | } 92 | ``` 93 | 94 | ## Conclusion 95 | 96 | Using programmatic translation might be a very useful feature for you, depending on the kind of app you are working on. In SPAs or apps built with frameworks like React.js, Vue.js, or Angular, you might want to make use of it more than `.translatePageTo`, which manipulates DOM nodes and might interfere with how these frameworks render their data. 97 | 98 | If you have a simple, server-side rendered website in plain HTML, using _Simple Translator_ to translate your entire HTML might be the better approach, though. 99 | 100 | And that's it. You made it through this tutorial and learned how to use _Simple Translator_ on your website. Go ahead and build something awesome with it, or have a look at the [examples](/examples/) for different frameworks. If you have any quesitons or issues, feel free to head over to [GitHub](https://github.com/andreasremdt/simple-translator/issues) and open a new issue. 101 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), 4 | and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). 5 | 6 | ## [2.0.4] - 2021-12-20 7 | 8 | ### Fixed 9 | 10 | - Don't depend on the `localStorage` API to exist in the environment. Ensures compatibility with Android WebView. [#154](https://github.com/andreasremdt/simple-translator/pull/154) - [@UshakovVasilii](https://github.com/UshakovVasilii) 11 | 12 | ## [2.0.3] - 2020-09-04 13 | 14 | ### Fixed 15 | 16 | - The `currentLanguage` getter now returns the correct language. If language detection is enabled, it will return the detected language by default or otherwise the default language. 17 | 18 | ## [2.0.2] - 2020-08-04 19 | 20 | ### Changed 21 | 22 | - Added compatibility for older browsers (including Safari 9) by using `Array.from` to convert a NodeList into an array. 23 | 24 | ## [2.0.1] - 2020-07-30 25 | 26 | ### Changed 27 | 28 | - Added more [CodeSandbox examples](<(https://github.com/andreasremdt/simple-translator#examples)>) to the documentation's example section. 29 | 30 | ## [2.0.0] - 2020-07-29 31 | 32 | ### Breaking changes 33 | 34 | - This release is a complete rewrite of the codebase. 35 | - The methods `load()` and `getTranslationByKey()` have been removed in favor of a new API. Use `fetch()`, `translateForKey()`, and `translatePageTo()` instead. 36 | - The config option `languages` has been removed. 37 | - For more documentation on the new API, see the [Usage section](https://github.com/andreasremdt/simple-translator#usage) or the [API Reference](https://github.com/andreasremdt/simple-translator#api-reference). 38 | 39 | ### Added 40 | 41 | - Added a config option `registerGlobally` that, if specified, registers a global helper with the same name as the given value. This allows you to translate single strings using shortcuts like `__('header.title')`. 42 | - Added a config option `persistKey` that specifies the name of the localStorage key. 43 | - Added a config option `debug` that, if set to `true`, prints useful error messages. 44 | - Added `fetch()` for easier JSON fetching. 45 | - Added `add()` to register new languages to the translator. 46 | - Added `remove()` to remove languages from the translator. 47 | - Added `translateForKey()` and `translatePageTo()` to translate single keys or the entire website. 48 | - Added `get currentLanguage` to get the currently used language. 49 | - Transpiled and minified UMD, ESM and CJS builds are available via [unpkg](https://unpkg.com/@andreasremdt/simple-translator@latest/dist/umd/translator.min.js) and [npm](https://www.npmjs.com/package/@andreasremdt/simple-translator). 50 | - Added a build system for easier packaging and testing. 51 | - Added [CONTRIBUTING.md](https://github.com/andreasremdt/simple-translator/CONTRIBUTING.md) 52 | - Added [CODE_OF_CONDUCT.md](https://github.com/andreasremdt/simple-translator/CODE_OF_CONDUCT.md) 53 | 54 | ### Changed 55 | 56 | - The [documentation](https://github.com/andreasremdt/simple-translator/#readme) has been updated and improved. 57 | 58 | ### Removed 59 | 60 | - The option `languages` has been removed. 61 | - The method `load()` has been removed. 62 | - The method `getTranslationByKey()` has been removed. 63 | 64 | ### Dependencies 65 | 66 | - Install `@babel/core@7.10.5`, 67 | - Install `@babel/plugin-proposal-optional-chaining@7.10.4`, 68 | - Install `@babel/plugin-transform-modules-commonjs@7.10.4`, 69 | - Install `@babel/preset-env@7.10.4`, 70 | - Install `@rollup/plugin-babel@5.1.0`, 71 | - Install `eslint-config-google@0.14.0`, 72 | - Install `eslint-config-prettier@6.11.0`, 73 | - Install `husky@4.2.5`, 74 | - Install `jest@26.1.0`, 75 | - Install `npm-run-all@4.1.5`, 76 | - Install `prettier@2.0.5`, 77 | - Install `rollup@2.22.2`, 78 | - Install `rollup-plugin-terser@6.1.0` 79 | 80 | ## [1.2.0] - 2020-07-21 81 | 82 | ### Added 83 | 84 | - `data-i18n-attr` can now translate multiple attributes by providing a space-separated list. Thanks [@gwprice115](https://github.com/gwprice115). 85 | 86 | ## [1.1.1] - 2020-04-05 87 | 88 | ### Changed 89 | 90 | - `getTranslationByKey` uses the fallback language when provided. 91 | - Update the documentation. 92 | 93 | ## [1.1.0] - 2020-04-04 94 | 95 | ### Added 96 | 97 | - Provide a [fallback language](https://github.com/andreasremdt/simple-translator/issues/1) using the `options.defaultLanguage` property. 98 | - Translate all [HTML attributes](https://github.com/andreasremdt/simple-translator/issues/4) like `title` or `placeholder`, not only text. 99 | - Add the method `getTranslationByKey` to translate a single key programmatically. 100 | 101 | ### Changed 102 | 103 | - Use cache to translate faster and save network data. 104 | - Print a warning message when a key was not found in the translation files. Thanks [@andi34](https://github.com/andi34). 105 | 106 | ### Dependencies 107 | 108 | - Bump eslint to 6.8.0 109 | 110 | ## [1.0.0] - 2019-10-16 111 | 112 | - Initial release 113 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our pledge 4 | 5 | We as members, contributors, and leaders pledge to make participation in our community a harassment-free experience for everyone, regardless of age, body size, visible or invisible disability, ethnicity, sex characteristics, gender identity and expression, level of experience, education, socio-economic status, nationality, personal appearance, race, religion, or sexual identity and orientation. 6 | 7 | We pledge to act and interact in ways that contribute to an open, welcoming, diverse, inclusive, and healthy community. 8 | 9 | ## Our standards 10 | 11 | Examples of behavior that contributes to a positive environment for our community include: 12 | 13 | - Demonstrating empathy and kindness toward other people 14 | - Being respectful of differing opinions, viewpoints, and experiences 15 | - Giving and gracefully accepting constructive feedback 16 | - Accepting responsibility and apologizing to those affected by our mistakes, 17 | and learning from the experience 18 | - Focusing on what is best not just for us as individuals, but for the 19 | overall community 20 | 21 | Examples of unacceptable behavior include: 22 | 23 | - The use of sexualized language or imagery, and sexual attention or 24 | advances of any kind 25 | - Trolling, insulting or derogatory comments, and personal or political attacks 26 | - Public or private harassment 27 | - Publishing others' private information, such as a physical or email 28 | address, without their explicit permission 29 | - Other conduct which could reasonably be considered inappropriate in a 30 | professional setting 31 | 32 | ## Enforcement responsibilities 33 | 34 | Community leaders are responsible for clarifying and enforcing our standards of acceptable behavior. They will take appropriate and fair corrective action in response to any behavior that they deem inappropriate, threatening, offensive, or harmful. 35 | 36 | Community leaders have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that don't align with this Code of Conduct. They will communicate reasons for moderation decisions when appropriate. 37 | 38 | ## Scope 39 | 40 | This Code of Conduct applies within all community spaces and applies when an individual officially represents the community in public areas. Examples of representing our community include using an official email address, posting via an official social media account, or acting as an appointed representative at an online or offline event. 41 | 42 | ## Enforcement 43 | 44 | Instances of abusive, harassing, or otherwise, unacceptable behavior may be reported to the community leaders responsible for enforcement at [me@andreasremdt.com](mailto:me@andreasremdt.com). All complaints will be reviewed and investigated promptly and fairly. 45 | 46 | All community leaders are obligated to respect the privacy and security of the reporter of any incident. 47 | 48 | ## Enforcement guidelines 49 | 50 | Community leaders will follow these Community Impact Guidelines in determining the consequences for any action they deem in violation of this Code of Conduct: 51 | 52 | ### 1. Correction 53 | 54 | **Community impact**: Use of inappropriate language or other behavior deemed unprofessional or unwelcome in the community. 55 | 56 | **Consequence**: A private, written warning from community leaders provides clarity around the nature of the violation and an explanation of why the behavior was inappropriate. A public apology may be requested. 57 | 58 | ### 2. Warning 59 | 60 | **Community impact**: A violation through a single incident or series of actions. 61 | 62 | **Consequence**: A warning with consequences for continued behavior. No interaction with the people involved, including unsolicited communication with those enforcing the Code of Conduct, for a specified period. This includes avoiding interactions in community spaces as well as external channels like social media. Violating these terms may lead to a temporary or permanent ban. 63 | 64 | ### 3. Temporary ban 65 | 66 | **Community impact**: A severe violation of community standards, including sustained inappropriate behavior. 67 | 68 | **Consequence**: A temporary ban from any interaction or public communication with the community for a specified period. No public or private interaction with the people involved, including unsolicited interaction with those enforcing the Code of Conduct, is allowed during this period. Violating these terms may lead to a permanent ban. 69 | 70 | ### 4. Permanent ban 71 | 72 | **Community impact**: Demonstrating a pattern of violation of community standards, including sustained inappropriate behavior, harassment of individuals, or aggression toward or disparagement of classes of individuals. 73 | 74 | **Consequence**: A permanent ban from any sort of public interaction within the community. 75 | 76 | ## Attribution 77 | 78 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 2.0, available at https://www.contributor-covenant.org/version/2/0/code_of_conduct.html. 79 | 80 | Community Impact Guidelines were inspired by [Mozilla's code of conduct enforcement ladder](https://github.com/mozilla/diversity). 81 | 82 | [homepage]: https://www.contributor-covenant.org 83 | 84 | For answers to common questions about this code of conduct, see the FAQ at https://www.contributor-covenant.org/faq. Translations are available at https://www.contributor-covenant.org/translations. 85 | -------------------------------------------------------------------------------- /docs/src/pages/tutorial/04-preparing-translations.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: 4. Preparing the Translations 3 | description: Learn how to use Simple Translator in every possible use-case. 4 | slug: /tutorial/04/ 5 | --- 6 | 7 | In the last section, we prepared the HTML by adding the `data-i18n` and `data-i18n-attr` attributes. Now it's time to look into the translations files. 8 | 9 | If you remember, we defined a key for a certain translation: 10 | 11 | ```html 12 |

Recipes of the Day

13 | ``` 14 | 15 | The key (`title`) is what _Simple Translator_ will use to find a matching string, which will eventuelly replace the text content. There are two options to define translations: 16 | 17 | - A plain JavaScript object with key/value pairs 18 | - An external JSON file that you `require` or `fetch` from the server 19 | 20 | ## Option 1: JavaScript Objects 21 | 22 | The fastest way is to create a new object that contains all key/value pairs needed by the app. In our example recipe site, the object would look like that: 23 | 24 | ```js 25 | var english = { 26 | meta: { 27 | description: 'Find the best recipes from all around the world.', 28 | title: 'Delicious Recipes', 29 | }, 30 | title: 'Recipes of the Day', 31 | subtitle: 32 | 'This curated list contains some fresh recipe recommendations from our chefs, ready for your kitchen.', 33 | recipes: { 34 | '1': { 35 | title: 'Rasperry Milkshake with Ginger', 36 | image: 'Image of rasperry milkshake with ginger', 37 | meta: '5 min - Easy - Shakes', 38 | }, 39 | '2': { 40 | title: 'Fluffy Banana Pancakes', 41 | image: 'Image of fluffy banana pancakes', 42 | meta: '15 min - Easy - Breakfast', 43 | }, 44 | }, 45 | button: 'Read more', 46 | }; 47 | ``` 48 | 49 | You can create one object per language to store your translations. If you are using a bundler and EcmaScript imports (or Node.js), you can create separate files and import them where needed: 50 | 51 | ```js 52 | // this file is named en.js 53 | var english = { ... }; 54 | 55 | export default english; 56 | ``` 57 | 58 | ```js 59 | import english from './lang/en.js'; 60 | import german from './lang/de.js'; 61 | // add more languages if needed 62 | ``` 63 | 64 | If your app is bigger, you can even split the translation files into separate, smaller ones. Using `Object.assign` or the [spread syntax](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/Spread_syntax), you can merge them when needed. Just keep in mind that keys must be unique. 65 | 66 | ```js 67 | var englishHomePage = { ... }; 68 | var englishAboutPage = { ... }; 69 | var english = { ...englishHomePage, ... englishAboutPage }; 70 | 71 | // alternatively: 72 | var english = Object.assign({}, englishHomePage, englishAboutPage); 73 | ``` 74 | 75 | ### Pros 76 | 77 | - Quick and easy way to get started, especially for smaller projects. 78 | - No fetching means no additional requests to the server and a better performance. 79 | - Translations can be manipulated dynamically with JavaScript. 80 | - Can be organized via imports. 81 | 82 | ### Cons 83 | 84 | - Translations get bundled into the final JavaScript output and might blow up the bundle size. 85 | - Depending on the folder structure, lots of translations might clutter the source code. 86 | 87 | ## Option 2: External JSON 88 | 89 | An alternative to having the translations directly as part of your JavaScript is to fetch them on demand, for example when a user switches the languages. In this case, they are stored as JSON files with the same structure as you saw above: 90 | 91 | ```json 92 | { 93 | "meta": { 94 | "description": "Find the best recipes from all around the world.", 95 | "title": "Delicious Recipes" 96 | }, 97 | "title": "Recipes of the Day", 98 | "subtitle": "This curated list contains some fresh recipe recommendations from our chefs, ready for your kitchen.", 99 | "recipes": { 100 | "1": { 101 | "title": "Rasperry Milkshake with Ginger", 102 | "image": "Image of rasperry milkshake with ginger", 103 | "meta": "5 min - Easy - Shakes" 104 | }, 105 | "2": { 106 | "title": "Fluffy Banana Pancakes", 107 | "image": "Image of fluffy banana pancakes", 108 | "meta": "15 min - Easy - Breakfast" 109 | } 110 | }, 111 | "button": "Read more" 112 | } 113 | ``` 114 | 115 | Again, if your app uses a bundler or if you are working with Node.js, you could directly import these files using EcmaScript modules or CommonJS. However, we already looked at this above. 116 | 117 | The goal here is to _fetch_ the files by either using JavaScript's Fetch API, axios, or _Simple Translator_ itself. _Simple Translator_ offers a handy method that takes care of fetching the files for you, we'll cover this soon. 118 | 119 | ### Pros 120 | 121 | - Translations are fetched on demand, lowering the initial bandwidth usage. Users only download the language(s) they need. 122 | - Translations are separated from your source code and won't get bundled, resulting in a better separation of content and code. 123 | - Can alternatively be imported via EcmaScript modules or CommonJS. 124 | 125 | ### Cons 126 | 127 | - Translations can't be manipulated dynamically via JavaScript. 128 | - Depending on the internet speed, it might take a while to fetch and update the page. 129 | - Slightly bigger overhead when getting started with _Simple Translator_. 130 | 131 | ## Conclusion 132 | 133 | With the above 2 options explained, you can go ahead and make the best decision for your project. 134 | 135 | If you are just getting started or if you need to dynamically manipulate translations and don't care about the translations ending up in your bundled output, it's recommended to use JavaScript objects directly in your source code. 136 | 137 | If you want to fetch the translations on demand, don't care about being able to manipulate your translations or don't want translations as part of your bundled output, use external JSON files and fetch them when needed. 138 | 139 | Now that we have our translations ready, let's jump to the interesting part: initializing and configuring the _Simple Translator_. 140 | -------------------------------------------------------------------------------- /docs/src/pages/tutorial/05-translating-in-html.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: 5. Translating HTML 3 | description: Learn how to use Simple Translator in every possible use-case. 4 | slug: /tutorial/05/ 5 | --- 6 | 7 | In the previous sections of this tutorial, we looked into how to prepare the HTML and translation sources, containing our text data in different languages. Now, it's time to finally use _Simple Translator_'s API to do something useful. 8 | 9 | ## Initialization 10 | 11 | In order to use the library, you have to import it first (you can skip this step if you are using the **unpkg** link): 12 | 13 | ```js 14 | import Translator from '@andreasremdt/simple-translator'; 15 | 16 | // alternatively, for Node.js: 17 | var translator = require('@andreasremdt/simple-translator'); 18 | ``` 19 | 20 | The package `@andreasremdt/simple-translator` only contains one default export, which is the translator's class. Next, you have to create a new instance of this class: 21 | 22 | ```js 23 | var translator = new Translator(); 24 | ``` 25 | 26 | By default, you don't have to provide any options, but you could. Whenever you want to customize the translator's behavior, you can provide an object with some properties, like so: 27 | 28 | ```js 29 | var translator = new Translator({ 30 | defaultLanguage: 'en', 31 | detectLanguage: true, 32 | selector: '[data-i18n]', 33 | debug: false, 34 | registerGlobally: '__', 35 | persist: false, 36 | persistKey: 'preferred_language', 37 | filesLocation: '/i18n', 38 | }); 39 | ``` 40 | 41 | You can find an overview of all available options, their default values, and what they do in the [API reference](/api/). 42 | 43 | With that out of the way, you have the `translator` instance ready to do something for you. Let's have a detailed look. 44 | 45 | ## Registering Languages 46 | 47 | Before you can translate your HTML into a certain language, you first have to register it with the translator. Otherwise, it wouldn't know where to pick the translation data from. You can register as many languages as you want. 48 | 49 | There are two ways of doing so: directly providing the JSON or fetching it from the server. 50 | 51 | ### Without Fetching 52 | 53 | If you chose to provide your translations in your JavaScript code as an object, you can use the `.add` method to register a new language: 54 | 55 | ```js 56 | translator.add('de', json); 57 | ``` 58 | 59 | The first argument of `.add` is the [language code](https://en.wikipedia.org/wiki/List_of_ISO_639-1_codes) (like _en_, _de_, or _es_), the second argument is the actual JSON which contains all translations. 60 | 61 | You can also register many languages at once using method chaining: 62 | 63 | ```js 64 | translator.add('de', germanJSON).add('en', englishJSON).add('es', spanishJSON); 65 | ``` 66 | 67 | You can use the translation files from the previous section and put them in here. 68 | 69 | ### With Fetching 70 | 71 | If you prefer to fetch your translations from the server using asynchronous code, _Simple Translator_ provides a handy `.fetch` method for that: 72 | 73 | ```js 74 | translator.fetch('en').then(() => { 75 | ... 76 | }); 77 | ``` 78 | 79 | `.fetch` returns a Promise, because under the hood, JavaScript's Fetch API is used. This means that the process of fetching your translations could take a little bit, and they won't be available immediately. If you want to use your translations afterward, you can either add a `.then` handler or use `async/await`: 80 | 81 | ```js 82 | // Using `.then` 83 | translator.fetch('en').then((englishJSON) => { 84 | console.log(englishJSON); 85 | }); 86 | 87 | // Using `async/await` 88 | var englishJSON = await translator.fetch('en'); 89 | ``` 90 | 91 | But what happens if you want to fetch more than one language from the server? Well, `.fetch` got you covered by allowing you to pass an array of languages as the first argument: 92 | 93 | ```js 94 | translator.fetch(['en', 'de', 'es']).then((languages) => { 95 | // languages[0] -> 'en' 96 | // languages[1] -> 'de' 97 | // languages[2] -> 'es' 98 | }); 99 | ``` 100 | 101 | The `.fetch` method automatically registers each language after it has been loaded, using `.add` internally. You don't have to do anything else. If you want to disable this behavior and instead register the languages manually, you can provide `false` as the second argument: 102 | 103 | ```js 104 | translator.fetch('en', false).then((englishJSON) => { 105 | translator.add('en', englishJSON); 106 | }); 107 | ``` 108 | 109 | ## Translate The Page 110 | 111 | Now it's time to call `.translatePageTo`, which is the method that will make _Simple Translator_ translate all your HTML elements that have been marked with an `data-i18n` attribute. 112 | 113 | ```js 114 | translator.translatePageTo('de'); 115 | ``` 116 | 117 | If you provided a default language the option `defaultLanguage` or if you set `detectLanguage` to `true`, you can omit the argument and just call the method like so: 118 | 119 | ```js 120 | translator.translatePageTo(); 121 | ``` 122 | 123 | This will either choose the detected or default language. 124 | 125 | Once you call that method, you'll notice that the text on the page has changed. If the `data-i18n` attributes where set correctly and the JSON contained all keys, you should see that the elements have been translated properly. This action can be triggered after the user interacted with a button for example: 126 | 127 | ```html 128 | 129 | 130 | 131 | ``` 132 | 133 | ```js 134 | for (let button of document.querySelectorAll('button')) { 135 | button.addEventListener('click', (evt) => { 136 | translator.translatePageTo(evt.target.dataset.lang); 137 | }); 138 | } 139 | ``` 140 | 141 | ## Conclusion 142 | 143 | Let's recap what we learned in this section. After importing the translator class, you can initialize it with some (optional) config and use `.add` to register languages synchronously or `.fetch` to register them asynchronously: 144 | 145 | **Synchronously** 146 | 147 | ```js 148 | import Translator from '@andreasremdt/simple-translator'; 149 | 150 | var germanJSON = { 151 | header: { 152 | title: 'Eine Überschrift', 153 | subtitle: 'Dieser Untertitel ist nur für Demozwecke', 154 | }, 155 | }; 156 | 157 | var translator = new Translator(); 158 | 159 | translator.add('de', germanJSON).translatePageTo('de'); 160 | ``` 161 | 162 | **Asynchronously** 163 | 164 | ```js 165 | import Translator from '@andreasremdt/simple-translator'; 166 | 167 | // The option `filesLocation` is "/i18n" by default, but you can 168 | // override it 169 | var translator = new Translator({ 170 | filesLocation: '/i18n', 171 | }); 172 | 173 | // This will fetch "/i18n/de.json" and "/i18n/en.json" 174 | translator.fetch(['de', 'en']).then(() => { 175 | // You now have both languages available to you 176 | translator.translatePageTo('de'); 177 | }); 178 | ``` 179 | 180 | In the last section of this tutorial, we'll have a look at programmatically translating strings using the `.translateForKey` method. This might come in handy when you don't have HTML to translate, but some strings inside your JavaScript code that need translation. 181 | -------------------------------------------------------------------------------- /docs/src/pages/api.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: API Reference 3 | description: This reference provides you with an overview of all methods and configuration options. 4 | slug: /api/ 5 | --- 6 | 7 | This API reference will provide you with an overview of what methods and options 8 | are available with Simple Translator. If you'd rather have a 9 | step-by-step guide on how to integrate this library into your app, [have a look here](/tutorial/). 10 | 11 | ## new Translator(Object?: options) 12 | 13 | Creates a new instance of the translator. You can define multiple instances, although this should not be a use-case. Only accepts one 14 | parameter, a JavaScript `Object`, with [some custom options](#options). 15 | 16 | ```js 17 | import Translator from '@andreasremdt/simple-translator'; 18 | 19 | var translator = new Translator(); 20 | 21 | // or with options: 22 | var translator = new Translator({ 23 | ... 24 | }); 25 | ``` 26 | 27 | ### Options 28 | 29 | When initializing the `Translator` class, you can pass an object for configuration. By default, the following values apply: 30 | 31 | ```js 32 | var translator = new Translator({ 33 | defaultLanguage: 'en', 34 | detectLanguage: true, 35 | selector: '[data-i18n]', 36 | debug: false, 37 | registerGlobally: '__', 38 | persist: false, 39 | persistKey: 'preferred_language', 40 | filesLocation: '/i18n', 41 | }); 42 | ``` 43 | 44 | | Key | Type | Default | Description | 45 | | ------------------ | --------------------- | ------------------ | ---------------------------------------------------------------------------------------------------------------------- | 46 | | `defaultLanguage` | `String` | en' | The default language, in case nothing else has been specified. | 47 | | `detectLanguage` | `Boolean` | `true` | If set to `true`, it tries to determine the user's desired language based on the browser | 48 | | `selector` | `String` | [data-i18n] | Elements that match this selector will be translated. | 49 | | `debug` | `Boolean` | `false` | When set to `true`, helpful logs will be printed to the console. Valuable for debugging and problem-solving. | 50 | | `registerGlobally` | `String` or `Boolean` | '\_\_' | When set to a `String`, it will create a global helper with the same name. When set to `false`, it won't register | 51 | | `persist` | `Boolean` | `false` | When set to `true`, the last language that was used is saved to localStorage. | 52 | | `persistKey` | `String` | preferred_language | Only valid when `persist` is set to `true`. This is the name of the key with which the last used language is stored in | 53 | | `filesLocation` | `String` | /i18n | The absolute path (from your project's root) to your localization files. | 54 | 55 | ## translateForKey(String: key, String?: language) 56 | 57 | Translates a single translation string into the desired language. If no second language parameter is provided, then: 58 | 59 | - It utilizes the last used language (accessible via the getter `currentLanguage`, but only after calling `translatePageTo()` at least once. 60 | - If no previously used language was set and the `detectLanguage` option is enabled, it uses the browser's preferred language. 61 | - If `detectLanguage` is disabled, it will fall back to the `defaultLanguage` option, which by default is `en`. 62 | 63 | ```js 64 | var translator = new Translator({ 65 | defaultLanguage: 'de', 66 | }); 67 | 68 | // -> translates to English (en) 69 | translator.translateForKey('header.title', 'en'); 70 | 71 | // -> translates to German (de) 72 | translator.translateForKey('header.title'); 73 | ``` 74 | 75 | ## translatePageTo(String?: language) 76 | 77 | _Note that this method is only available in the browser and will throw an error in Node.js._ 78 | 79 | Translates all DOM elements that match the selector (`'[data-i18n]'`by default) into the specified language. If no language is passed into the method, the`defaultLanguage`will be used. After this method has been called, the`Simple Translator`instance will remember the language and make it accessible via the getter`currentLanguage`. 80 | 81 | ```js 82 | var translator = new Translator({ 83 | defaultLanguage: 'de', 84 | }); 85 | 86 | // -> translates the page to English (en) 87 | translator.translatePageTo('en'); 88 | 89 | // -> translates the page to German (de) 90 | translator.translatePageTo(); 91 | ``` 92 | 93 | ## add(String: language, Object: translation) 94 | 95 | Registers a new language to the translator. It must receive the language as the first and an object, containing the translation, as the second parameter. The method `add()`returns the instance of `Translator`, meaning that it can be chained. 96 | 97 | ```js 98 | translator 99 | .add('de', {...}) 100 | .add('es', {...}) 101 | .translatePageTo(...); 102 | ``` 103 | 104 | ## remove(String: language) 105 | 106 | Removes a registered language from the translator. It accepts only the language code as a parameter. The method `remove()`returns the instance of `Translator`, meaning that it can be chained. 107 | 108 | ```js 109 | translator.remove('de'); 110 | ``` 111 | 112 | ## fetch(String|Array: languageFiles, Boolean?: save) 113 | 114 | Fetches either one or multiple JSON files from your project by utilizing the [Fetch API](https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API). By default, fetched languages are also registered to the translator instance, making them available for use. If you just want to get the JSON content, pass `false` as an optional, second parameter. 115 | 116 | You don't have to pass the entire file path or file extension (although you could). The`filesLocation` option will determine the folder. It's sufficient just to pass the language code. 117 | 118 | ```js 119 | var translator = new Translator({ 120 | filesLocation: '/i18n' 121 | }); 122 | 123 | // Fetches /i18n/de.json 124 | translator.fetch('de').then((json) => { 125 | console.log(json); 126 | }); 127 | 128 | // Fetches "/i18n/de.json" and "/i18n/en.json" 129 | translator.fetch(['de', 'en']).then(...); 130 | 131 | // async/await 132 | // The second parameter is set to `false`, so the fetched language 133 | // will not be registered. 134 | var json = await translator.fetch('de', false); 135 | console.log(json); 136 | ``` 137 | 138 | ## get currentLanguage 139 | 140 | By default, this returns the `defaultLanguage`. After calling `translatePageTo()`, this getter will return the last used language. 141 | 142 | ```js 143 | var translator = new Translator({ 144 | defaultLanguage: 'de', 145 | }); 146 | 147 | console.log(translator.currentLanguage); 148 | // -> "de" 149 | 150 | // Calling this methods sets the current language. 151 | translator.translatePageTo('en'); 152 | 153 | console.log(translator.currentLanguage); 154 | // -> "en" 155 | ``` 156 | -------------------------------------------------------------------------------- /docs/src/pages/tutorial/02-preparing-html.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: 2. Preparing the HTML 3 | description: Learn how to prepare your HTML elements for translation by adding just two attributes. 4 | slug: /tutorial/02/ 5 | prev_link: /tutorial/01/ 6 | next_link: /tutorial/03/ 7 | --- 8 | 9 | Before you dive into translating your website, you should look at your content and be clear about what you want to translate and what not. Not all elements might need a translation; others might need translated attributes (e.g., `title`, `placeholder`), and so on. 10 | 11 | ## Determining the Elements 12 | 13 | Let's have a look at the below HTML structure, a recipe app, as an example: 14 | 15 | ```html 16 | 17 | 18 | 19 | 20 | 21 | 25 | Delicious Recipes 26 | 27 | 28 |

Recipes of the Day

29 |

30 | This curated list contains some fresh recipe recommendations from our 31 | chefs, ready for your kitchen. 32 |

33 | 34 |
35 |

Rasperry Milkshake with Ginger

36 | Rasperry Milkshake with Ginger 40 |

5 min - Easy - Shakes

41 | 42 |
43 |
44 |

Fluffy Banana Pancakes

45 | Fluffy Banana Pancakes 49 |

15 min - Easy - Breakfast

50 | 51 |
52 | 53 | 54 | ``` 55 | 56 | We need to mark the content that needs translation. It doesn't matter if your app is built with a UI framework like React or handcrafted HTML. In the end, it's the HTML elements with text content that we will hook into. 57 | 58 | Looking at the code, we can see all candidates for translation: 59 | 60 | - The `title` and `description` in the HTML's head. 61 | - The `h1` and `p`, since they put out what this page is about. 62 | - Each recipe, which is wrapped by an `article` element. A recipe has a title, some meta-information like the duration and difficulty, an image, and a button to open it. 63 | 64 | Note that there are things like the page's `author`, which we don't need nor want to translate. 65 | 66 | Coming back to the recipe (`article`) itself, you might notice that not everything that we need to translate is text content: 67 | 68 | ```html 69 | Fluffy Banana Pancakes 70 | ``` 71 | 72 | ```html 73 | 74 | ``` 75 | 76 | Especially when considering accessibility, we also want to include attributes like `alt` or `aria-label`, because screen reader users might suffer from the lack of language consistency. 77 | 78 | ## Adding Attributes 79 | 80 | Now that we have an overview of all elements that need touching, we can go ahead and add the attributes that _Simple Translator_ will use to apply translations. 81 | 82 | For that, we have two different attributes available: 83 | 84 | - `data-i18n`: Specifies the key which is used to find a translation. 85 | - `data-i18n-attr`: Specifies which attributes should be translated instead of the text content. 86 | 87 | ### `data-i18n` 88 | 89 | Apply this attribute to all HTML elements that you want to translate, no matter if it's text content or an attribute's value: 90 | 91 | ```html 92 |

Recipes of the Day

93 |

94 | This curated list contains some fresh recipe recommendations from our chefs, 95 | ready for your kitchen. 96 |

97 | ``` 98 | 99 | You can pass in any string as the value, it will be used to match the proper translation, so be mindful about it. Translations are either JSON or JavaScript objects, so they can be nested many levels deep. This means that the following syntax is also okay: 100 | 101 | ```html 102 |

Recipes of the Day

103 | ``` 104 | 105 | Keep the original text hardcoded inside the HTML elements since it will be used as a fallback. Leaving it out would result in a flash of missing content when the page loads, which is considered bad practice. 106 | 107 | ### `data-i18n-attr` 108 | 109 | The above attribute is fine for translating text content, but what if we want to change the image's `alt` attribute? That's what `data-i18n-attr` is used for: 110 | 111 | ```html 112 | Fluffy Banana Pancakes 118 | ``` 119 | 120 | You just need to define the attribute that you want to translate. In this example, it's the image's `alt` attribute, hence we put it as the value of `data-i18n-attr`. In theory, you can provide any HTML attribute as a value, although not all of them might make sense, like `src`. 121 | 122 | The same applies for the button: 123 | 124 | ```html 125 | 132 | ``` 133 | 134 | ### Translating Multiple Attributes 135 | 136 | Sometimes, you might find yourself in a situation where you need to translate more than one attribute, say for an input element: 137 | 138 | ```html 139 | 140 | ``` 141 | 142 | Luckily, you can translate as many attributes as you want with `data-i18n-attr`. Just separate them with whitespaces: 143 | 144 | ```html 145 | 152 | ``` 153 | 154 | Be careful to have the same amount of keys for `data-i18n` and `data-i18n-attr`. The order matters as well; make sure that you pass them in the same order as you want to translate them. Passing just one key to `data-i18n` but two attributes to `data-i18n-attr` will most likely result in unexpected behavior. 155 | 156 | ## Conclusion 157 | 158 | Let's have a look at our markup after applying the new attributes: 159 | 160 | ```html 161 | 162 | 163 | 164 | 165 | 166 | 172 | Delicious Recipes 173 | 174 | 175 |

Recipes of the Day

176 |

177 | This curated list contains some fresh recipe recommendations from our 178 | chefs, ready for your kitchen. 179 |

180 | 181 |
182 |

Rasperry Milkshake with Ginger

183 | Rasperry Milkshake with Ginger 189 |

5 min - Easy - Shakes

190 | 193 |
194 |
195 |

Fluffy Banana Pancakes

196 | Fluffy Banana Pancakes 202 |

15 min - Easy - Breakfast

203 | 206 |
207 | 208 | 209 | ``` 210 | 211 | Copy this HTML into the `index.html` that you created on the previous page. We will translate it at the end of the tutorial. 212 | 213 | With that out of the way, you can head over to the next section, which will guide you through the creation of translation files in JSON. 214 | -------------------------------------------------------------------------------- /src/translator.js: -------------------------------------------------------------------------------- 1 | import { logger } from './utils.js'; 2 | 3 | /** 4 | * simple-translator 5 | * A small JavaScript library to translate webpages into different languages. 6 | * https://github.com/andreasremdt/simple-translator 7 | * 8 | * Author: Andreas Remdt (https://andreasremdt.com) 9 | * License: MIT (https://mit-license.org/) 10 | */ 11 | class Translator { 12 | /** 13 | * Initialize the Translator by providing options. 14 | * 15 | * @param {Object} options 16 | */ 17 | constructor(options = {}) { 18 | this.debug = logger(true); 19 | 20 | if (typeof options != 'object' || Array.isArray(options)) { 21 | this.debug('INVALID_OPTIONS', options); 22 | options = {}; 23 | } 24 | 25 | this.languages = new Map(); 26 | this.config = Object.assign(Translator.defaultConfig, options); 27 | 28 | const { debug, registerGlobally, detectLanguage } = this.config; 29 | 30 | this.debug = logger(debug); 31 | 32 | if (registerGlobally) { 33 | this._globalObject[registerGlobally] = this.translateForKey.bind(this); 34 | } 35 | 36 | if (detectLanguage && this._env == 'browser') { 37 | this._detectLanguage(); 38 | } 39 | } 40 | 41 | /** 42 | * Return the global object, depending on the environment. 43 | * If the script is executed in a browser, return the window object, 44 | * otherwise, in Node.js, return the global object. 45 | * 46 | * @return {Object} 47 | */ 48 | get _globalObject() { 49 | if (this._env == 'browser') { 50 | return window; 51 | } 52 | 53 | return global; 54 | } 55 | 56 | /** 57 | * Check and return the environment in which the script is executed. 58 | * 59 | * @return {String} The environment 60 | */ 61 | get _env() { 62 | if (typeof window != 'undefined') { 63 | return 'browser'; 64 | } else if (typeof module !== 'undefined' && module.exports) { 65 | return 'node'; 66 | } 67 | 68 | return 'browser'; 69 | } 70 | 71 | /** 72 | * Detect the users preferred language. If the language is stored in 73 | * localStorage due to a previous interaction, use it. 74 | * If no localStorage entry has been found, use the default browser language. 75 | */ 76 | _detectLanguage() { 77 | const inMemory = window.localStorage 78 | ? localStorage.getItem(this.config.persistKey) 79 | : undefined; 80 | 81 | if (inMemory) { 82 | this.config.defaultLanguage = inMemory; 83 | } else { 84 | const lang = navigator.languages 85 | ? navigator.languages[0] 86 | : navigator.language; 87 | 88 | this.config.defaultLanguage = lang.substr(0, 2); 89 | } 90 | } 91 | 92 | /** 93 | * Get a translated value from a JSON by providing a key. Additionally, 94 | * the target language can be specified as the second parameter. 95 | * 96 | * @param {String} key 97 | * @param {String} toLanguage 98 | * @return {String} 99 | */ 100 | _getValueFromJSON(key, toLanguage) { 101 | const json = this.languages.get(toLanguage); 102 | 103 | return key.split('.').reduce((obj, i) => (obj ? obj[i] : null), json); 104 | } 105 | 106 | /** 107 | * Replace a given DOM nodes' attribute values (by default innerHTML) with 108 | * the translated text. 109 | * 110 | * @param {HTMLElement} element 111 | * @param {String} toLanguage 112 | */ 113 | _replace(element, toLanguage) { 114 | const keys = element.getAttribute('data-i18n')?.split(/\s/g); 115 | const attributes = element?.getAttribute('data-i18n-attr')?.split(/\s/g); 116 | 117 | if (attributes && keys.length != attributes.length) { 118 | this.debug('MISMATCHING_ATTRIBUTES', keys, attributes, element); 119 | } 120 | 121 | keys.forEach((key, index) => { 122 | const text = this._getValueFromJSON(key, toLanguage); 123 | const attr = attributes ? attributes[index] : 'innerHTML'; 124 | 125 | if (text) { 126 | if (attr == 'innerHTML') { 127 | element[attr] = text; 128 | } else { 129 | element.setAttribute(attr, text); 130 | } 131 | } else { 132 | this.debug('TRANSLATION_NOT_FOUND', key, toLanguage); 133 | } 134 | }); 135 | } 136 | 137 | /** 138 | * Translate all DOM nodes that match the given selector into the 139 | * specified target language. 140 | * 141 | * @param {String} toLanguage The target language 142 | */ 143 | translatePageTo(toLanguage = this.config.defaultLanguage) { 144 | if (this._env == 'node') { 145 | this.debug('INVALID_ENVIRONMENT'); 146 | return; 147 | } 148 | 149 | if (typeof toLanguage != 'string') { 150 | this.debug('INVALID_PARAM_LANGUAGE', toLanguage); 151 | return; 152 | } 153 | 154 | if (toLanguage.length == 0) { 155 | this.debug('EMPTY_PARAM_LANGUAGE'); 156 | return; 157 | } 158 | 159 | if (!this.languages.has(toLanguage)) { 160 | this.debug('NO_LANGUAGE_REGISTERED', toLanguage); 161 | return; 162 | } 163 | 164 | const elements = 165 | typeof this.config.selector == 'string' 166 | ? Array.from(document.querySelectorAll(this.config.selector)) 167 | : this.config.selector; 168 | 169 | if (elements.length && elements.length > 0) { 170 | elements.forEach((element) => this._replace(element, toLanguage)); 171 | } else if (elements.length == undefined) { 172 | this._replace(elements, toLanguage); 173 | } 174 | 175 | this._currentLanguage = toLanguage; 176 | document.documentElement.lang = toLanguage; 177 | 178 | if (this.config.persist && window.localStorage) { 179 | localStorage.setItem(this.config.persistKey, toLanguage); 180 | } 181 | } 182 | 183 | /** 184 | * Translate a given key into the specified language if it exists 185 | * in the translation file. If not or if the language hasn't been added yet, 186 | * the return value is `null`. 187 | * 188 | * @param {String} key The key from the language file to translate 189 | * @param {String} toLanguage The target language 190 | * @return {(String|null)} 191 | */ 192 | translateForKey(key, toLanguage = this.config.defaultLanguage) { 193 | if (typeof key != 'string') { 194 | this.debug('INVALID_PARAM_KEY', key); 195 | return null; 196 | } 197 | 198 | if (!this.languages.has(toLanguage)) { 199 | this.debug('NO_LANGUAGE_REGISTERED', toLanguage); 200 | return null; 201 | } 202 | 203 | const text = this._getValueFromJSON(key, toLanguage); 204 | 205 | if (!text) { 206 | this.debug('TRANSLATION_NOT_FOUND', key, toLanguage); 207 | return null; 208 | } 209 | 210 | return text; 211 | } 212 | 213 | /** 214 | * Add a translation resource to the Translator object. The language 215 | * can then be used to translate single keys or the entire page. 216 | * 217 | * @param {String} language The target language to add 218 | * @param {String} json The language resource file as JSON 219 | * @return {Object} Translator instance 220 | */ 221 | add(language, json) { 222 | if (typeof language != 'string') { 223 | this.debug('INVALID_PARAM_LANGUAGE', language); 224 | return this; 225 | } 226 | 227 | if (language.length == 0) { 228 | this.debug('EMPTY_PARAM_LANGUAGE'); 229 | return this; 230 | } 231 | 232 | if (Array.isArray(json) || typeof json != 'object') { 233 | this.debug('INVALID_PARAM_JSON', json); 234 | return this; 235 | } 236 | 237 | if (Object.keys(json).length == 0) { 238 | this.debug('EMPTY_PARAM_JSON'); 239 | return this; 240 | } 241 | 242 | this.languages.set(language, json); 243 | 244 | return this; 245 | } 246 | 247 | /** 248 | * Remove a translation resource from the Translator object. The language 249 | * won't be available afterwards. 250 | * 251 | * @param {String} language The target language to remove 252 | * @return {Object} Translator instance 253 | */ 254 | remove(language) { 255 | if (typeof language != 'string') { 256 | this.debug('INVALID_PARAM_LANGUAGE', language); 257 | return this; 258 | } 259 | 260 | if (language.length == 0) { 261 | this.debug('EMPTY_PARAM_LANGUAGE'); 262 | return this; 263 | } 264 | 265 | this.languages.delete(language); 266 | 267 | return this; 268 | } 269 | 270 | /** 271 | * Fetch a translation resource from the web server. It can either fetch 272 | * a single resource or an array of resources. After all resources are fetched, 273 | * return a Promise. 274 | * If the optional, second parameter is set to true, the fetched translations 275 | * will be added to the Translator object. 276 | * 277 | * @param {String|Array} sources The files to fetch 278 | * @param {Boolean} save Save the translation to the Translator object 279 | * @return {(Promise|null)} 280 | */ 281 | fetch(sources, save = true) { 282 | if (!Array.isArray(sources) && typeof sources != 'string') { 283 | this.debug('INVALID_PARAMETER_SOURCES', sources); 284 | return null; 285 | } 286 | 287 | if (!Array.isArray(sources)) { 288 | sources = [sources]; 289 | } 290 | 291 | const urls = sources.map((source) => { 292 | const filename = source.replace(/\.json$/, '').replace(/^\//, ''); 293 | const path = this.config.filesLocation.replace(/\/$/, ''); 294 | 295 | return `${path}/${filename}.json`; 296 | }); 297 | 298 | if (this._env == 'browser') { 299 | return Promise.all(urls.map((url) => fetch(url))) 300 | .then((responses) => 301 | Promise.all( 302 | responses.map((response) => { 303 | if (response.ok) { 304 | return response.json(); 305 | } 306 | 307 | this.debug('FETCH_ERROR', response); 308 | }) 309 | ) 310 | ) 311 | .then((languageFiles) => { 312 | // If a file could not be fetched, it will be `undefined` and filtered out. 313 | languageFiles = languageFiles.filter((file) => file); 314 | 315 | if (save) { 316 | languageFiles.forEach((file, index) => { 317 | this.add(sources[index], file); 318 | }); 319 | } 320 | 321 | return languageFiles.length > 1 ? languageFiles : languageFiles[0]; 322 | }); 323 | } else if (this._env == 'node') { 324 | return new Promise((resolve) => { 325 | const languageFiles = []; 326 | 327 | urls.forEach((url, index) => { 328 | try { 329 | const json = JSON.parse( 330 | require('fs').readFileSync(process.cwd() + url, 'utf-8') 331 | ); 332 | 333 | if (save) { 334 | this.add(sources[index], json); 335 | } 336 | 337 | languageFiles.push(json); 338 | } catch (err) { 339 | this.debug('MODULE_NOT_FOUND', err.message); 340 | } 341 | }); 342 | 343 | resolve(languageFiles.length > 1 ? languageFiles : languageFiles[0]); 344 | }); 345 | } 346 | } 347 | 348 | /** 349 | * Sets the default language of the translator instance. 350 | * 351 | * @param {String} language 352 | * @return {void} 353 | */ 354 | setDefaultLanguage(language) { 355 | if (typeof language != 'string') { 356 | this.debug('INVALID_PARAM_LANGUAGE', language); 357 | return; 358 | } 359 | 360 | if (language.length == 0) { 361 | this.debug('EMPTY_PARAM_LANGUAGE'); 362 | return; 363 | } 364 | 365 | if (!this.languages.has(language)) { 366 | this.debug('NO_LANGUAGE_REGISTERED', language); 367 | return null; 368 | } 369 | 370 | this.config.defaultLanguage = language; 371 | } 372 | 373 | /** 374 | * Return the currently selected language. 375 | * 376 | * @return {String} 377 | */ 378 | get currentLanguage() { 379 | return this._currentLanguage || this.config.defaultLanguage; 380 | } 381 | 382 | /** 383 | * Returns the current default language; 384 | * 385 | * @return {String} 386 | */ 387 | get defaultLanguage() { 388 | return this.config.defaultLanguage; 389 | } 390 | 391 | /** 392 | * Return the default config object whose keys can be overriden 393 | * by the user's config passed to the constructor. 394 | * 395 | * @return {Object} 396 | */ 397 | static get defaultConfig() { 398 | return { 399 | defaultLanguage: 'en', 400 | detectLanguage: true, 401 | selector: '[data-i18n]', 402 | debug: false, 403 | registerGlobally: '__', 404 | persist: false, 405 | persistKey: 'preferred_language', 406 | filesLocation: '/i18n', 407 | }; 408 | } 409 | } 410 | 411 | export default Translator; 412 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Simple Translator 2 | 3 | > Simple, client-side translation with pure JavaScript. 4 | 5 | ![Node.js CI](https://github.com/andreasremdt/simple-translator/workflows/Node.js%20CI/badge.svg) 6 | ![NPM](https://img.shields.io/npm/l/@andreasremdt/simple-translator) 7 | ![npm (scoped)](https://img.shields.io/npm/v/@andreasremdt/simple-translator) 8 | 9 | ## Table of Contents 10 | 11 | - [The problem](#the-problem) 12 | - [The solution](#the-solution) 13 | - [Installation](#installation) 14 | - [In the browser](#in-the-browser) 15 | - [Using Node.js or bundlers](#using-nodejs-or-bundlers) 16 | - [Examples](#examples) 17 | - [Translate HTML in the browser](#translate-html-in-the-browser) 18 | - [Translate single strings](#translate-single-strings) 19 | - [Fetch JSON from the server](#fetch-json-from-the-server) 20 | - [Usage](#usage) 21 | - [Translating HTML content](#translating-html-content) 22 | - [ Translating HTML attributes](#translating-html-attributes) 23 | - [Translating programmatically](#translating-programmatically) 24 | - [Configuration](#configuration) 25 | - [API reference](#api-reference) 26 | - [new Translator(options)](#new-translatorobject-options) 27 | - _instance_ 28 | - [translateForKey(key, language)](#user-content-translateforkeystring-key-string-language) 29 | - [translatePageTo(language)](#user-content-translatepagetostring-language) 30 | - [add(language, translation)](#user-content-addstring-language-object-translation) 31 | - [remove(language)](#user-content-removestring-language) 32 | - [fetch(languageFiles, save)](#user-content-fetchstringarray-languagefiles-boolean-save) 33 | - [get currentLanguage](#user-content-get-currentlanguage) 34 | - [Browser support](#browser-support) 35 | - [Issues](#issues) 36 | 37 | ## The problem 38 | 39 | You want to make your website available in multiple languages. You perhaps already looked for solutions out there and discovered various [services](https://www.i18next.com/) and [libraries](https://github.com/wikimedia/jquery.i18n), and dozens of other smaller packages that offer more or less what you are looking for. 40 | 41 | Some of them might be too grand for your purpose. You don't want to install a 100 KB dependency just for a simple translation. Or, perhaps you've found smaller libraries but are missing essential features. 42 | 43 | ## The solution 44 | 45 | `Simple Translator` is a very lightweight (~9 KB minified) solution for translating content with pure JavaScript. It works natively in the browser and Node.js. 46 | 47 | - Translate single strings 48 | - Translate entire HTML pages 49 | - Easily fetch JSON resource files (containing your translations) 50 | - Make use of global helper functions 51 | - Detect the user's preferred language automatically 52 | 53 | ## Installation 54 | 55 | ### In the browser 56 | 57 | A UMD build is available via [unpkg](https://unpkg.com). Just paste the following link into your HTML, and you're good to go: 58 | 59 | ```html 60 | 64 | ``` 65 | 66 | ### Using Node.js or bundlers 67 | 68 | This package is distributed via [npm](https://npmjs.com). It's best to install it as one of your project's dependencies: 69 | 70 | ``` 71 | npm i @andreasremdt/simple-translator 72 | ``` 73 | 74 | Or using [yarn](https://yarnpkg.com/): 75 | 76 | ``` 77 | yarn add @andreasremdt/simple-translator 78 | ``` 79 | 80 | ## Examples 81 | 82 | Want to see the bigger picture? Check out the live demos at CodeSandbox and see how you can integrate the library with popular frameworks or in pure JavaScript: 83 | 84 | - [Vanilla JavaScript](https://codesandbox.io/s/simple-translator-vanilllajs-e33ye) 85 | - [React](https://codesandbox.io/s/simple-translator-react-1mtki?file=/src/Content.js:0-27) 86 | - [Vue.js](https://codesandbox.io/s/simple-translator-vuejs-iep2j?file=/src/main.js) 87 | - _Lit-Element (Web Components) currently in progress_ 88 | 89 | ### Translate HTML in the browser 90 | 91 | ```html 92 |
93 |

Translate me

94 |

This subtitle is getting translated as well

95 |
96 | 97 | 98 | 102 | 118 | ``` 119 | 120 | ### Translate single strings 121 | 122 | ```js 123 | // Depending on your environment, you can use CommonJS 124 | var Translator = require('@andreasremdt/simple-translator'); 125 | 126 | // or EcmaScript modules 127 | import Translator from '@andreasremdt/simple-translator'; 128 | 129 | // Provide your translations as JSON / JS objects 130 | var germanTranslation = { 131 | header: { 132 | title: 'Eine Überschrift', 133 | subtitle: 'Dieser Untertitel ist nur für Demozwecke', 134 | }, 135 | }; 136 | 137 | // You can optionally pass options 138 | var translator = new Translator(); 139 | 140 | // Add the language to the translator 141 | translator.add('de', germanTranslation); 142 | 143 | // Provide single keys and the target language 144 | translator.translateForKey('header.title', 'de'); 145 | translator.translateForKey('header.subtitle', 'de'); 146 | ``` 147 | 148 | ### Fetch JSON from the server 149 | 150 | `i18n/de.json`: 151 | 152 | ```json 153 | "header": { 154 | "title": "Eine Überschrift", 155 | "subtitle": "Dieser Untertitel ist nur für Demozwecke", 156 | } 157 | ``` 158 | 159 | `i18n/en.json`: 160 | 161 | ```json 162 | "header": { 163 | "title": "Some Nice Title", 164 | "subtitle": "This Subtitle is Going to Look Good", 165 | } 166 | ``` 167 | 168 | `index.js`: 169 | 170 | ```js 171 | import Translator from '@andreasremdt/simple-translator'; 172 | 173 | // The option `filesLocation` is "/i18n" by default, but you can 174 | // override it 175 | var translator = new Translator({ 176 | filesLocation: '/i18n', 177 | }); 178 | 179 | // This will fetch "/i18n/de.json" and "/i18n/en.json" 180 | translator.fetch(['de', 'en']).then(() => { 181 | // You now have both languages available to you 182 | translator.translatePageTo('de'); 183 | }); 184 | ``` 185 | 186 | ## Usage 187 | 188 | `Simple Translator` can be used to translate entire websites or programmatically via the API in the browser or Node.js. 189 | 190 | ### Translating HTML content 191 | 192 | > Note that this feature is only available in a browser environment and will throw an error in Node.js. 193 | 194 | In your HTML, add the `data-i18n` attribute to all DOM nodes that you want to translate. The attribute holds the key to your translation in dot syntax as if you were accessing a JavaScript object. The key resembles the structure of your translation files. 195 | 196 | ```html 197 |
198 |

Headline

199 |
200 | 201 |

Some introduction content

202 | ``` 203 | 204 | Import and initialize the translator into your project's source code. The constructor accepts an optional object as [configuration](#configuration). 205 | 206 | ```js 207 | import Translator from '@andreasremdt/simple-translator'; 208 | 209 | var translator = new Translator(); 210 | ``` 211 | 212 | Next, you need to register the translation sources. Each language has its own source and must be made available to the translator. You can either fetch them from the server or directly pass them as a JavaScript object: 213 | 214 | ```js 215 | // By using `fetch`, you load the translation sources asynchronously 216 | // from a directory in your project's folder. The resources must 217 | // be in JSON. After they are fetched, you can use the API to 218 | // translate the page. 219 | translator.fetch(['en', 'de']).then(() => { 220 | // -> Translations are ready... 221 | translator.translatePageTo('en'); 222 | }); 223 | 224 | // By using `add`, you pass the translation sources directly 225 | // as JavaScript objects and then use the API either through 226 | // chaining or by using the `translator` instance again. 227 | translator.add('de', jsonObject).translatePageTo('de'); 228 | ``` 229 | 230 | Each translation source consists of a key (the language itself, formatted in the [ISO-639-1 language code](https://en.wikipedia.org/wiki/List_of_ISO_639-1_codes)) and an object, holding key-value pairs with the translated content. 231 | 232 | Using the example above, the translation sources for each language must have the following structure: 233 | 234 | ```js 235 | { 236 | "header": { 237 | "title": "Translated title" 238 | }, 239 | "intro": "The translated intro" 240 | } 241 | ``` 242 | 243 | When fetching the JSON files using `fetch()`, the translator looks for a folder called `i18n` in the root of your web server. You can configure the path in the [configuration](#configuration). 244 | 245 | When a language has been registered and is ready, you can call `translatePageTo()` and provide an optional parameter for the target language, such as "en". 246 | 247 | ```js 248 | translator.translatePageTo(); // Uses the default language 249 | translator.translatePageTo('de'); // Uses German 250 | ``` 251 | 252 | ### Translating HTML attributes 253 | 254 | You can translate the text content of a DOM element (it's `innerHTML`) or any other attribute, such as `title` or `placeholder`. For that, pass `data-i18n-attr` and a space-separated list of attributes to the target DOM node: 255 | 256 | ```html 257 | 263 | ``` 264 | 265 | > Be careful to have the same amount of keys and attributes in `data-i18n` and `data-i18n-attr`. If you want to translate both `placeholder` and `title`, you need to pass two translation keys for it to work. 266 | 267 | By default, if `data-i18n-attr` is not defined, the innerHTML will be translated. 268 | 269 | ### Translating programmatically 270 | 271 | Instead of translating the entire page or some DOM nodes, you can translate a single, given key via `translateForKey()`. The first argument should be a key from your translation sources, such as "header.title", and the second argument should be the target language like "en" or "de". Note that the language must have been registered before calling this method. 272 | 273 | ```js 274 | translator.add('de', jsonObject); 275 | 276 | console.log(translator.translateForKey('header.title', 'de')); 277 | // -> prints the translation 278 | ``` 279 | 280 | By default, `Simple Translator` registers a global helper on the `window` object to help you achieve the same without having to write the method name. 281 | 282 | ```js 283 | __.('header.title', 'de'); 284 | ``` 285 | 286 | > You can change the name of this helper in the [configuration](#configuration). 287 | 288 | ## Configuration 289 | 290 | When initializing the `Translator` class, you can pass an object for configuration. By default, the following values apply: 291 | 292 | ```js 293 | var translator = new Translator({ 294 | defaultLanguage: 'en', 295 | detectLanguage: true, 296 | selector: '[data-i18n]', 297 | debug: false, 298 | registerGlobally: '__', 299 | persist: false, 300 | persistKey: 'preferred_language', 301 | filesLocation: '/i18n', 302 | }); 303 | ``` 304 | 305 | | Key | Type | Default | Description | 306 | | ---------------- | ---------------- | ---------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | 307 | | defaultLanguage | `String` | `'en'` | The default language, in case nothing else has been specified. | 308 | | detectLanguage | `Boolean` | `true` | If set to `true`, it tries to determine the user's desired language based on the browser settings. | 309 | | selector | `String` | `'[data-i18n]'` | Elements that match this selector will be translated. It can be any valid [element selector](https://developer.mozilla.org/en-US/docs/Web/API/Document_object_model/Locating_DOM_elements_using_selectors). | 310 | | debug | `Boolean` | `false` | When set to `true`, helpful logs will be printed to the console. Valuable for debugging and problem-solving. | 311 | | registerGlobally | `String,Boolean` | `'__'` | When set to a `String`, it will create a global helper with the same name. When set to `false`, it won't register anything. | 312 | | persist | `Boolean` | `false` | When set to `true`, the last language that was used is saved to localStorage. | 313 | | persistKey | `String` | `'preferred_language'` | Only valid when `persist` is set to `true`. This is the name of the key with which the last used language is stored in localStorage. | 314 | | filesLocation | `String` | `'/i18n'` | The absolute path (from your project's root) to your localization files. | 315 | 316 | ## API reference 317 | 318 | ### `new Translator(Object?: options)` 319 | 320 | Creates a new instance of the translator. You can define multiple instances, although this should not be a use-case. 321 | 322 | Only accepts one parameter, a JavaScript `Object`, with a [custom config](#configuration). 323 | 324 | ```js 325 | import Translator from '@andreasremdt/simple-translator'; 326 | 327 | var translator = new Translator(); 328 | // or... 329 | var translator = new Translator({ 330 | ... 331 | }); 332 | ``` 333 | 334 | ### `translateForKey(String: key, String?: language)` 335 | 336 | Translates a single translation string into the desired language. If no second language parameter is provided, then: 337 | 338 | - It utilizes the last used language (accessible via the getter `currentLanguage`, but only after calling `translatePageTo()` at least once. 339 | - If no previously used language was set and the `detectLanguage` option is enabled, it uses the browser's preferred language. 340 | - If `detectLanguage` is disabled, it will fall back to the `defaultLanguage` option, which by default is `en`. 341 | 342 | ```js 343 | var translator = new Translator({ 344 | defaultLanguage: 'de', 345 | }); 346 | 347 | translator.translateForKey('header.title', 'en'); 348 | // -> translates to English (en) 349 | translator.translateForKey('header.title'); 350 | // -> translates to German (de) 351 | ``` 352 | 353 | ### `translatePageTo(String?: language)` 354 | 355 | > Note that this method is only available in the browser and will throw an error in Node.js. 356 | 357 | Translates all DOM elements that match the selector (`'[data-i18n]'` by default) into the specified language. If no language is passed into the method, the `defaultLanguage` will be used. 358 | 359 | After this method has been called, the `Simple Translator` instance will remember the language and make it accessible via the getter `currentLanguage`. 360 | 361 | ```js 362 | var translator = new Translator({ 363 | defaultLanguage: 'de', 364 | }); 365 | 366 | translator.translatePageTo('en'); 367 | // -> translates the page to English (en) 368 | translator.translatePageTo(); 369 | // -> translates the page to German (de) 370 | ``` 371 | 372 | ### `add(String: language, Object: translation)` 373 | 374 | Registers a new language to the translator. It must receive the language as the first and an object, containing the translation, as the second parameter. 375 | 376 | The method `add()` returns the instance of `Simple Translator`, meaning that it can be chained. 377 | 378 | ```js 379 | translator 380 | .add('de', {...}) 381 | .add('es', {...}) 382 | .translatePageTo(...); 383 | ``` 384 | 385 | ### `remove(String: language)` 386 | 387 | Removes a registered language from the translator. It accepts only the language code as a parameter. 388 | 389 | The method `remove()` returns the instance of `Simple Translator`, meaning that it can be chained. 390 | 391 | ```js 392 | translator.remove('de'); 393 | ``` 394 | 395 | ### `fetch(String|Array: languageFiles, Boolean?: save)` 396 | 397 | Fetches either one or multiple JSON files from your project by utilizing the [Fetch API](https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API). By default, fetched languages are also registered to the translator instance, making them available for use. If you just want to get the JSON content, pass `false` as an optional, second parameter. 398 | 399 | You don't have to pass the entire file path or file extension (although you could). The `filesLocation` option will determine folder. It's sufficient just to pass the language code. 400 | 401 | ```js 402 | var translator = new Translator({ 403 | filesLocation: '/i18n' 404 | }); 405 | 406 | // Fetches /i18n/de.json 407 | translator.fetch('de').then((json) => { 408 | console.log(json); 409 | }); 410 | 411 | // Fetches "/i18n/de.json" and "/i18n/en.json" 412 | translator.fetch(['de', 'en']).then(...); 413 | 414 | // async/await 415 | // The second parameter is set to `false`, so the fetched language 416 | // will not be registered. 417 | await translator.fetch('de', false); 418 | ``` 419 | 420 | ### `get currentLanguage` 421 | 422 | By default, this returns the `defaultLanguage`. After calling `translatePageTo()`, this getter will return the last used language. 423 | 424 | ```js 425 | var translator = new Translator({ 426 | defaultLanguage: 'de', 427 | }); 428 | 429 | console.log(translator.currentLanguage); 430 | // -> "de" 431 | 432 | // Calling this methods sets the current language. 433 | translator.translatePageTo('en'); 434 | 435 | console.log(translator.currentLanguage); 436 | // -> "en" 437 | ``` 438 | 439 | ## Browser support 440 | 441 | `Simple Translator` already comes minified and transpiled and should work in most browsers. The following browsers are tested: 442 | 443 | - Edge <= 16 444 | - Firefox <= 60 445 | - Chrome <= 61 446 | - Safari <= 10 447 | - Opera <= 48 448 | 449 | ## Issues 450 | 451 | Did you find any issues, bugs, or improvements you'd like to see implemented? Feel free to [open an issue on GitHub](https://github.com/andreasremdt/simple-translator/issues). Any feedback is appreciated. 452 | -------------------------------------------------------------------------------- /tests/translator.browser.test.js: -------------------------------------------------------------------------------- 1 | import Translator from '../src/translator.js'; 2 | import { buildHTML, removeHTML } from './test-utils.js'; 3 | 4 | describe('constructor()', () => { 5 | let translator; 6 | let consoleSpy; 7 | 8 | beforeEach(() => { 9 | consoleSpy = jest.spyOn(window.console, 'error').mockImplementation(); 10 | }); 11 | 12 | afterEach(() => { 13 | translator = null; 14 | jest.clearAllMocks(); 15 | }); 16 | 17 | it('has sensible default options', () => { 18 | expect(Translator.defaultConfig).toMatchSnapshot(); 19 | }); 20 | 21 | it('overrides the default config with user options', () => { 22 | translator = new Translator({ 23 | selector: '__', 24 | persist: true, 25 | }); 26 | 27 | expect(translator.config).toMatchObject({ 28 | selector: '__', 29 | persist: true, 30 | detectLanguage: true, 31 | filesLocation: '/i18n', 32 | }); 33 | }); 34 | 35 | it('creates a global helper', () => { 36 | translator = new Translator(); 37 | 38 | expect(window.__).toBeDefined(); 39 | 40 | translator = new Translator({ registerGlobally: 't' }); 41 | 42 | expect(window.t).toBeDefined(); 43 | 44 | delete window.__; 45 | delete window.t; 46 | }); 47 | 48 | it('should not create a global helper when turned off', () => { 49 | translator = new Translator({ registerGlobally: false }); 50 | 51 | expect(window.__).toBeUndefined(); 52 | }); 53 | 54 | it('detects the correct language automatically', () => { 55 | const languageGetter = jest.spyOn(window.navigator, 'languages', 'get'); 56 | 57 | expect(new Translator().config.defaultLanguage).toBe('en'); 58 | 59 | languageGetter.mockReturnValue(['de-DE', 'de']); 60 | 61 | expect(new Translator().config.defaultLanguage).toBe('de'); 62 | 63 | languageGetter.mockReset(); 64 | }); 65 | 66 | it('should not detect the default language when turned off', () => { 67 | translator = new Translator({ detectLanguage: false }); 68 | 69 | expect(translator.config.defaultLanguage).toBe('en'); 70 | }); 71 | 72 | it('validates the user options', () => { 73 | translator = new Translator([]); 74 | translator = new Translator(false); 75 | translator = new Translator('test'); 76 | 77 | expect(translator.config).toMatchObject(Translator.defaultConfig); 78 | expect(consoleSpy).toHaveBeenCalledTimes(3); 79 | expect(consoleSpy).toHaveBeenCalledWith( 80 | expect.stringContaining('INVALID_OPTIONS') 81 | ); 82 | }); 83 | 84 | it('reads the last used language from localStorage', () => { 85 | localStorage.setItem('preferred_language', 'de'); 86 | 87 | translator = new Translator(); 88 | 89 | expect(translator.config.defaultLanguage).toBe('de'); 90 | 91 | localStorage.removeItem('preferred_language'); 92 | localStorage.setItem('custom_language', 'nl'); 93 | 94 | translator = new Translator({ 95 | persistKey: 'custom_language', 96 | }); 97 | 98 | expect(translator.config.defaultLanguage).toBe('nl'); 99 | 100 | localStorage.removeItem('custom_language'); 101 | }); 102 | 103 | it('should not print console errors when debugging is turned off', () => { 104 | translator = new Translator(); 105 | 106 | translator.add({}); 107 | 108 | expect(consoleSpy).toHaveBeenCalledTimes(0); 109 | }); 110 | }); 111 | 112 | describe('add()', () => { 113 | let translator; 114 | let consoleSpy; 115 | 116 | beforeEach(() => { 117 | translator = new Translator({ debug: true }); 118 | consoleSpy = jest.spyOn(window.console, 'error').mockImplementation(); 119 | }); 120 | 121 | afterEach(() => { 122 | translator = null; 123 | jest.clearAllMocks(); 124 | }); 125 | 126 | it('adds a single language', () => { 127 | expect(translator.languages.size).toBe(0); 128 | 129 | translator.add('de', { title: 'German title' }); 130 | 131 | expect(translator.languages.size).toBe(1); 132 | expect(translator.languages.get('de')).toMatchObject({ 133 | title: 'German title', 134 | }); 135 | }); 136 | 137 | it('adds multiple languages using chaining', () => { 138 | expect(translator.languages.size).toBe(0); 139 | 140 | translator 141 | .add('de', { title: 'German title' }) 142 | .add('en', { title: 'English title' }); 143 | 144 | expect(translator.languages.size).toBe(2); 145 | expect(translator.languages.get('de')).toMatchObject({ 146 | title: 'German title', 147 | }); 148 | }); 149 | 150 | it('requires a valid language key', () => { 151 | translator.add(); 152 | 153 | expect(consoleSpy).toHaveBeenCalledTimes(1); 154 | expect(consoleSpy).toHaveBeenCalledWith( 155 | expect.stringContaining('INVALID_PARAM_LANGUAGE') 156 | ); 157 | consoleSpy.mockClear(); 158 | 159 | translator.add(true).add({}).add([]).add(1); 160 | 161 | expect(consoleSpy).toHaveBeenCalledTimes(4); 162 | expect(consoleSpy).toHaveBeenCalledWith( 163 | expect.stringContaining('INVALID_PARAM_LANGUAGE') 164 | ); 165 | consoleSpy.mockClear(); 166 | 167 | translator.add(''); 168 | 169 | expect(consoleSpy).toHaveBeenCalledTimes(1); 170 | expect(consoleSpy).toHaveBeenCalledWith( 171 | expect.stringContaining('EMPTY_PARAM_LANGUAGE') 172 | ); 173 | expect(translator.languages.size).toBe(0); 174 | }); 175 | 176 | it('requires a valid json translation', () => { 177 | translator 178 | .add('de') 179 | .add('de', true) 180 | .add('de', 1) 181 | .add('de', 'text') 182 | .add('de', []); 183 | 184 | expect(consoleSpy).toHaveBeenCalledTimes(5); 185 | expect(consoleSpy).toHaveBeenCalledWith( 186 | expect.stringContaining('INVALID_PARAM_JSON') 187 | ); 188 | consoleSpy.mockClear(); 189 | 190 | translator.add('de', {}); 191 | 192 | expect(consoleSpy).toHaveBeenCalledTimes(1); 193 | expect(consoleSpy).toHaveBeenCalledWith( 194 | expect.stringContaining('EMPTY_PARAM_JSON') 195 | ); 196 | expect(translator.languages.size).toBe(0); 197 | }); 198 | }); 199 | 200 | describe('remove()', () => { 201 | let translator; 202 | let consoleSpy; 203 | 204 | beforeEach(() => { 205 | translator = new Translator({ debug: true }); 206 | translator 207 | .add('de', { title: 'German title' }) 208 | .add('en', { title: 'English title' }); 209 | consoleSpy = jest.spyOn(window.console, 'error').mockImplementation(); 210 | }); 211 | 212 | afterEach(() => { 213 | translator = null; 214 | jest.clearAllMocks(); 215 | }); 216 | 217 | it('removes an existing language', () => { 218 | translator.remove('de'); 219 | 220 | expect(translator.languages.size).toBe(1); 221 | expect(translator.languages.get('de')).toBeUndefined(); 222 | expect(translator.languages.get('en')).toBeDefined(); 223 | }); 224 | 225 | it('removes multiple existing languages', () => { 226 | translator.remove('de').remove('en'); 227 | 228 | expect(translator.languages.size).toBe(0); 229 | }); 230 | 231 | it("doesn't remove anything when the given language doesn't exist", () => { 232 | translator.remove('nl'); 233 | 234 | expect(translator.languages.size).toBe(2); 235 | expect(translator.languages.get('de')).toBeDefined(); 236 | expect(translator.languages.get('en')).toBeDefined(); 237 | }); 238 | 239 | it('requires a valid language key', () => { 240 | translator.remove(true).remove({}).remove([]).remove(1); 241 | 242 | expect(consoleSpy).toHaveBeenCalledTimes(4); 243 | expect(consoleSpy).toHaveBeenCalledWith( 244 | expect.stringContaining('INVALID_PARAM_LANGUAGE') 245 | ); 246 | consoleSpy.mockClear(); 247 | 248 | translator.remove(''); 249 | 250 | expect(consoleSpy).toHaveBeenCalledTimes(1); 251 | expect(consoleSpy).toHaveBeenCalledWith( 252 | expect.stringContaining('EMPTY_PARAM_LANGUAGE') 253 | ); 254 | expect(translator.languages.size).toBe(2); 255 | }); 256 | }); 257 | 258 | describe('translateForKey()', () => { 259 | let translator; 260 | let consoleSpy; 261 | 262 | beforeEach(() => { 263 | translator = new Translator({ debug: true }); 264 | translator 265 | .add('de', { title: 'German title' }) 266 | .add('en', { title: 'English title' }); 267 | consoleSpy = jest.spyOn(window.console, 'error').mockImplementation(); 268 | }); 269 | 270 | afterEach(() => { 271 | translator = null; 272 | jest.clearAllMocks(); 273 | }); 274 | 275 | it('returns a string with the translated text', () => { 276 | const text = translator.translateForKey('title', 'de'); 277 | 278 | expect(text).toBe('German title'); 279 | }); 280 | 281 | it('uses the default language (en) if no second parameter is provided', () => { 282 | const text = translator.translateForKey('title'); 283 | 284 | expect(text).toBe('English title'); 285 | }); 286 | 287 | it('works with the global helper', () => { 288 | const text = window.__('title', 'de'); 289 | 290 | expect(text).toBe('German title'); 291 | }); 292 | 293 | it('displays an error when no translation has been found', () => { 294 | const text = translator.translateForKey('not.existing'); 295 | 296 | expect(text).toBeNull(); 297 | expect(consoleSpy).toHaveBeenCalledTimes(1); 298 | expect(consoleSpy).toHaveBeenCalledWith( 299 | expect.stringContaining('TRANSLATION_NOT_FOUND') 300 | ); 301 | }); 302 | 303 | it('requires a valid language key', () => { 304 | const texts = [ 305 | translator.translateForKey({}), 306 | translator.translateForKey(false), 307 | translator.translateForKey(1), 308 | translator.translateForKey(''), 309 | ]; 310 | 311 | expect(texts).toMatchObject([null, null, null, null]); 312 | expect(consoleSpy).toHaveBeenCalledTimes(4); 313 | expect(consoleSpy).toHaveBeenCalledWith( 314 | expect.stringContaining('INVALID_PARAM_KEY') 315 | ); 316 | }); 317 | 318 | it("displays an error when the target language doesn't exist", () => { 319 | const text = translator.translateForKey('title', 'nl'); 320 | 321 | expect(text).toBeNull(); 322 | expect(consoleSpy).toHaveBeenCalledTimes(1); 323 | expect(consoleSpy).toHaveBeenCalledWith( 324 | expect.stringContaining('NO_LANGUAGE_REGISTERED') 325 | ); 326 | }); 327 | }); 328 | 329 | describe('translatePageTo()', () => { 330 | let translator; 331 | let consoleSpy; 332 | 333 | beforeEach(() => { 334 | translator = new Translator({ debug: true }); 335 | translator 336 | .add('de', { title: 'Deutscher Titel', paragraph: 'Hallo Welt' }) 337 | .add('en', { title: 'English title', paragraph: 'Hello World' }); 338 | consoleSpy = jest.spyOn(window.console, 'error').mockImplementation(); 339 | }); 340 | 341 | afterEach(() => { 342 | translator = null; 343 | jest.clearAllMocks(); 344 | }); 345 | 346 | it("translates an element's innerHTML to the given language", () => { 347 | // Tests the following: 348 | // 349 | //

350 | //

351 | const { h1, p } = buildHTML({ 352 | title: { keys: 'title' }, 353 | paragraph: { keys: 'paragraph' }, 354 | }); 355 | 356 | expect(h1.textContent).toBe('Default heading text'); 357 | expect(p.textContent).toBe('Default content text'); 358 | 359 | translator.translatePageTo('de'); 360 | 361 | expect(h1.textContent).toBe('Deutscher Titel'); 362 | expect(p.textContent).toBe('Hallo Welt'); 363 | 364 | translator.translatePageTo('en'); 365 | 366 | expect(h1.textContent).toBe('English title'); 367 | expect(p.textContent).toBe('Hello World'); 368 | 369 | removeHTML(h1, p); 370 | }); 371 | 372 | it("translates an element's innerHTML to the default language", () => { 373 | // Tests the following: 374 | // 375 | //

376 | //

377 | const { h1, p } = buildHTML({ 378 | title: { 379 | keys: 'title', 380 | }, 381 | paragraph: { 382 | keys: 'paragraph', 383 | }, 384 | }); 385 | 386 | translator.translatePageTo(); 387 | 388 | expect(h1.textContent).toBe('English title'); 389 | expect(p.textContent).toBe('Hello World'); 390 | 391 | removeHTML(h1, p); 392 | }); 393 | 394 | it('persists the last used language in localStorage', () => { 395 | // Tests the following: 396 | // 397 | //

398 | const { h1, p } = buildHTML({ 399 | title: { 400 | keys: 'title', 401 | }, 402 | paragraph: { 403 | keys: 'paragraph', 404 | }, 405 | }); 406 | 407 | translator.config.persist = true; 408 | translator.translatePageTo('de'); 409 | 410 | expect(localStorage.getItem('preferred_language')).toBe('de'); 411 | 412 | localStorage.removeItem('preferred_language'); 413 | translator.config.persistKey = 'custom_language'; 414 | translator.translatePageTo('de'); 415 | 416 | expect(localStorage.getItem('preferred_language')).toBeNull(); 417 | expect(localStorage.getItem('custom_language')).toBe('de'); 418 | 419 | localStorage.removeItem('custom_language'); 420 | 421 | removeHTML(h1, p); 422 | }); 423 | 424 | it('uses a custom selector when provided', () => { 425 | // Tests the following: 426 | // 427 | //

428 | //

429 | const { h1, p } = buildHTML({ 430 | title: { 431 | keys: 'title', 432 | }, 433 | paragraph: { 434 | keys: 'paragraph', 435 | }, 436 | }); 437 | 438 | translator.config.selector = document.querySelectorAll('h1'); 439 | translator.translatePageTo('de'); 440 | 441 | expect(h1.textContent).toBe('Deutscher Titel'); 442 | expect(p.textContent).toBe('Default content text'); 443 | 444 | translator.config.selector = document.querySelector('p'); 445 | translator.translatePageTo('de'); 446 | 447 | expect(p.textContent).toBe('Hallo Welt'); 448 | 449 | removeHTML(h1, p); 450 | }); 451 | 452 | it("doesn't do anything when no elements match the selector", () => { 453 | // Tests the following: 454 | // 455 | //

456 | //

457 | const { h1, p } = buildHTML({ 458 | title: { 459 | keys: 'title', 460 | }, 461 | paragraph: { 462 | keys: 'paragraph', 463 | }, 464 | }); 465 | 466 | translator.config.selector = document.querySelectorAll('span'); 467 | translator.translatePageTo('de'); 468 | 469 | expect(h1.textContent).toBe('Default heading text'); 470 | expect(p.textContent).toBe('Default content text'); 471 | 472 | removeHTML(h1, p); 473 | }); 474 | 475 | it("translates an element's custom attribute", () => { 476 | // Tests the following: 477 | // 478 | //

479 | //

480 | const { h1, p } = buildHTML({ 481 | title: { 482 | attrs: 'title', 483 | keys: 'title', 484 | }, 485 | paragraph: { 486 | attrs: 'data-msg', 487 | keys: 'paragraph', 488 | }, 489 | }); 490 | 491 | translator.translatePageTo('de'); 492 | 493 | expect(h1.getAttribute('title')).toBe('Deutscher Titel'); 494 | expect(p.getAttribute('data-msg')).toBe('Hallo Welt'); 495 | 496 | removeHTML(h1, p); 497 | }); 498 | 499 | it('translates multiple custom attributes', () => { 500 | // Tests the following: 501 | // 502 | //

503 | const { h1, p } = buildHTML({ 504 | title: { 505 | attrs: 'title data-msg', 506 | keys: 'title paragraph', 507 | }, 508 | paragraph: { 509 | keys: 'paragraph', 510 | }, 511 | }); 512 | 513 | translator.translatePageTo('de'); 514 | 515 | expect(h1.getAttribute('title')).toBe('Deutscher Titel'); 516 | expect(h1.getAttribute('data-msg')).toBe('Hallo Welt'); 517 | 518 | removeHTML(h1, p); 519 | }); 520 | 521 | it('requires the same amount of keys and attributes to translate', () => { 522 | // Tests the following: 523 | // 524 | //

525 | const { h1, p } = buildHTML({ 526 | title: { 527 | attrs: 'title data-msg', 528 | keys: 'title', 529 | }, 530 | paragraph: { 531 | keys: 'paragraph', 532 | }, 533 | }); 534 | 535 | translator.translatePageTo('de'); 536 | 537 | expect(consoleSpy).toHaveBeenCalledTimes(1); 538 | expect(consoleSpy).toHaveBeenCalledWith( 539 | expect.stringContaining('MISMATCHING_ATTRIBUTES') 540 | ); 541 | consoleSpy.mockClear(); 542 | 543 | expect(h1.getAttribute('title')).toBe('Deutscher Titel'); 544 | expect(h1.getAttribute('data-msg')).toBeNull(); 545 | 546 | removeHTML(h1, p); 547 | }); 548 | 549 | it('displays an error when no translation has been found', () => { 550 | // Tests the following: 551 | // 552 | //

553 | //

554 | const { h1, p } = buildHTML({ 555 | title: { 556 | keys: 'title', 557 | }, 558 | paragraph: { 559 | keys: 'not.existing', 560 | }, 561 | }); 562 | 563 | translator.translatePageTo('de'); 564 | 565 | expect(consoleSpy).toHaveBeenCalledTimes(1); 566 | expect(consoleSpy).toHaveBeenCalledWith( 567 | expect.stringContaining('TRANSLATION_NOT_FOUND') 568 | ); 569 | consoleSpy.mockClear(); 570 | 571 | expect(h1.textContent).toBe('Deutscher Titel'); 572 | expect(p.textContent).toBe('Default content text'); 573 | 574 | removeHTML(h1, p); 575 | }); 576 | 577 | it('requires a valid language key', () => { 578 | translator.translatePageTo(false); 579 | translator.translatePageTo({}); 580 | translator.translatePageTo([]); 581 | 582 | expect(consoleSpy).toHaveBeenCalledTimes(3); 583 | expect(consoleSpy).toHaveBeenCalledWith( 584 | expect.stringContaining('INVALID_PARAM_LANGUAGE') 585 | ); 586 | consoleSpy.mockClear(); 587 | 588 | translator.translatePageTo(''); 589 | 590 | expect(consoleSpy).toHaveBeenCalledTimes(1); 591 | expect(consoleSpy).toHaveBeenCalledWith( 592 | expect.stringContaining('EMPTY_PARAM_LANGUAGE') 593 | ); 594 | consoleSpy.mockClear(); 595 | 596 | translator.translatePageTo('nl'); 597 | 598 | expect(consoleSpy).toHaveBeenCalledTimes(1); 599 | expect(consoleSpy).toHaveBeenCalledWith( 600 | expect.stringContaining('NO_LANGUAGE_REGISTERED') 601 | ); 602 | }); 603 | 604 | it('changes the `lang` attribute', () => { 605 | translator.translatePageTo('de'); 606 | 607 | expect(document.documentElement.lang).toBe('de'); 608 | 609 | translator.translatePageTo(); 610 | 611 | expect(document.documentElement.lang).toBe('en'); 612 | }); 613 | }); 614 | 615 | describe('fetch()', () => { 616 | let translator; 617 | let consoleSpy; 618 | const RESOURCE_FILES = { 619 | de: { title: 'Deutscher Titel', paragraph: 'Hallo Welt' }, 620 | en: { title: 'English title', paragraph: 'Hello World' }, 621 | }; 622 | 623 | beforeEach(() => { 624 | translator = new Translator({ debug: true }); 625 | consoleSpy = jest.spyOn(window.console, 'error').mockImplementation(); 626 | 627 | global.fetch = jest.fn().mockImplementation((url) => { 628 | url = url.replace(/\/i18n\//, '').replace(/\.json/, ''); 629 | 630 | return Promise.resolve({ 631 | ok: url == 'nl' ? false : true, 632 | json: () => Promise.resolve(RESOURCE_FILES[url]), 633 | }); 634 | }); 635 | }); 636 | 637 | afterEach(() => { 638 | translator = null; 639 | jest.clearAllMocks(); 640 | delete global.fetch; 641 | }); 642 | 643 | it('fetches a single resource', (done) => { 644 | translator.fetch('de').then((value) => { 645 | expect(value).toMatchObject(RESOURCE_FILES['de']); 646 | expect(translator.languages.size).toBe(1); 647 | expect(translator.languages.get('de')).toMatchObject( 648 | RESOURCE_FILES['de'] 649 | ); 650 | done(); 651 | }); 652 | }); 653 | 654 | it('fetches a multiple resources', (done) => { 655 | translator.fetch(['de', 'en']).then((value) => { 656 | expect(value).toMatchObject([RESOURCE_FILES['de'], RESOURCE_FILES['en']]); 657 | expect(translator.languages.size).toBe(2); 658 | done(); 659 | }); 660 | }); 661 | 662 | it("displays an error when the resource doesn't exist", (done) => { 663 | translator.fetch('nl').then((value) => { 664 | expect(value).toBeUndefined(); 665 | expect(consoleSpy).toHaveBeenCalledTimes(1); 666 | expect(consoleSpy).toHaveBeenCalledWith( 667 | expect.stringContaining('FETCH_ERROR') 668 | ); 669 | done(); 670 | }); 671 | }); 672 | 673 | it('fetches available resources and displays an error for non-existing resources', (done) => { 674 | translator.fetch(['de', 'nl']).then((value) => { 675 | expect(value).toMatchObject(RESOURCE_FILES['de']); 676 | expect(translator.languages.size).toBe(1); 677 | expect(consoleSpy).toHaveBeenCalledTimes(1); 678 | expect(consoleSpy).toHaveBeenCalledWith( 679 | expect.stringContaining('FETCH_ERROR') 680 | ); 681 | done(); 682 | }); 683 | }); 684 | 685 | it("only fetches and doesn't save the resources", (done) => { 686 | translator.fetch('de', false).then((value) => { 687 | expect(value).toMatchObject(RESOURCE_FILES['de']); 688 | expect(translator.languages.size).toBe(0); 689 | done(); 690 | }); 691 | }); 692 | 693 | it('accepts sources with and without file extension', (done) => { 694 | translator.fetch(['/de.json', 'en']).then((value) => { 695 | expect(value.length).toBe(2); 696 | done(); 697 | }); 698 | }); 699 | 700 | it('requires a valid sources parameter', () => { 701 | translator.fetch(true); 702 | translator.fetch({}); 703 | translator.fetch(1); 704 | translator.fetch(); 705 | 706 | expect(consoleSpy).toHaveBeenCalledTimes(4); 707 | expect(consoleSpy).toHaveBeenCalledWith( 708 | expect.stringContaining('INVALID_PARAMETER_SOURCES') 709 | ); 710 | }); 711 | }); 712 | 713 | describe('setDefaultLanguage()', () => { 714 | let translator; 715 | let consoleSpy; 716 | 717 | beforeEach(() => { 718 | translator = new Translator({ debug: true }); 719 | translator 720 | .add('de', { title: 'Deutscher Titel', paragraph: 'Hallo Welt' }) 721 | .add('en', { title: 'English title', paragraph: 'Hello World' }); 722 | consoleSpy = jest.spyOn(window.console, 'error').mockImplementation(); 723 | }); 724 | 725 | afterEach(() => { 726 | translator = null; 727 | jest.clearAllMocks(); 728 | }); 729 | 730 | it('sets a new default language', () => { 731 | translator.setDefaultLanguage('de'); 732 | 733 | expect(translator.translateForKey('title')).toBe('Deutscher Titel'); 734 | expect(translator.config.defaultLanguage).toBe('de'); 735 | 736 | translator.setDefaultLanguage('en'); 737 | 738 | expect(translator.translateForKey('title')).toBe('English title'); 739 | expect(translator.config.defaultLanguage).toBe('en'); 740 | }); 741 | 742 | it("displays an error when the given language isn't registered", () => { 743 | translator.setDefaultLanguage('es'); 744 | 745 | expect(consoleSpy).toHaveBeenCalledTimes(1); 746 | expect(consoleSpy).toHaveBeenCalledWith( 747 | expect.stringContaining('NO_LANGUAGE_REGISTERED') 748 | ); 749 | }); 750 | 751 | it('displays an error when the given language is invalid', () => { 752 | translator.setDefaultLanguage(); 753 | translator.setDefaultLanguage(''); 754 | translator.setDefaultLanguage(false); 755 | translator.setDefaultLanguage({}); 756 | translator.setDefaultLanguage([]); 757 | 758 | expect(consoleSpy).toHaveBeenCalledTimes(5); 759 | expect(consoleSpy).toHaveBeenCalledWith( 760 | expect.stringContaining('INVALID_PARAM_LANGUAGE') 761 | ); 762 | expect(consoleSpy).toHaveBeenCalledWith( 763 | expect.stringContaining('EMPTY_PARAM_LANGUAGE') 764 | ); 765 | }); 766 | }); 767 | 768 | describe('get currentLanguage()', () => { 769 | let languageGetter; 770 | 771 | beforeEach(() => { 772 | languageGetter = jest.spyOn(window.navigator, 'languages', 'get'); 773 | languageGetter.mockReturnValue(['de-DE', 'de']); 774 | }); 775 | 776 | afterEach(() => { 777 | jest.clearAllMocks(); 778 | }); 779 | 780 | it('returns the correct language code with auto-detection', () => { 781 | const translator = new Translator(); 782 | translator 783 | .add('de', { title: 'Deutscher Titel', paragraph: 'Hallo Welt' }) 784 | .add('en', { title: 'English title', paragraph: 'Hello World' }); 785 | 786 | expect(translator.currentLanguage).toBe('de'); 787 | translator.translatePageTo('en'); 788 | expect(translator.currentLanguage).toBe('en'); 789 | }); 790 | 791 | it('returns the correct language code without auto-detection', () => { 792 | const translator = new Translator({ 793 | detectLanguage: false, 794 | defaultLanguage: 'de', 795 | }); 796 | translator 797 | .add('de', { title: 'Deutscher Titel', paragraph: 'Hallo Welt' }) 798 | .add('en', { title: 'English title', paragraph: 'Hello World' }); 799 | 800 | expect(translator.currentLanguage).toBe('de'); 801 | translator.translatePageTo('en'); 802 | expect(translator.currentLanguage).toBe('en'); 803 | }); 804 | }); 805 | --------------------------------------------------------------------------------