├── .eslintrc.js ├── .github └── workflows │ └── ci.yml ├── .gitignore ├── .prettierignore ├── .prettierrc ├── LICENSE ├── README.md ├── __tests__ └── basic.js ├── babel.config.js ├── example ├── .babelrc ├── .gitignore ├── README.md ├── components │ ├── namespace.js │ └── title.js ├── locales │ ├── de.json │ ├── en.json │ └── namespace.json ├── next.config.js ├── package.json ├── pages │ ├── 404.js │ ├── [slug] │ │ └── index.js │ ├── _app.js │ ├── dashboard.js │ ├── index.js │ └── namespace.js └── yarn.lock ├── jest.config.js ├── package.json ├── setupTests.js ├── src ├── hooks │ └── use-i18n.js ├── i18n.js └── index.js ├── types ├── index.d.ts ├── index.test-d.ts └── index.test-d.tsx └── yarn.lock /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | parserOptions: { 4 | ecmaVersion: 2020, 5 | sourceType: 'module', 6 | ecmaFeatures: { 7 | jsx: true 8 | } 9 | }, 10 | settings: { 11 | react: { 12 | version: 'detect' 13 | } 14 | }, 15 | env: { 16 | browser: true, 17 | amd: true, 18 | node: true, 19 | jest: true 20 | }, 21 | plugins: ['simple-import-sort'], 22 | extends: [ 23 | 'eslint:recommended', 24 | 'plugin:react/recommended', 25 | 'plugin:jsx-a11y/recommended', 26 | 'plugin:prettier/recommended', 27 | 'plugin:react-hooks/recommended' 28 | ], 29 | rules: { 30 | 'prettier/prettier': ['error', {}, { usePrettierrc: true }], 31 | 'react/react-in-jsx-scope': 'off', 32 | 'react/prop-types': 'off', 33 | '@typescript-eslint/explicit-function-return-type': 'off', 34 | 'simple-import-sort/imports': 'error', 35 | 'simple-import-sort/exports': 'error', 36 | 'jsx-a11y/anchor-is-valid': [ 37 | 'error', 38 | { 39 | components: ['Link'], 40 | specialLink: ['hrefLeft', 'hrefRight'], 41 | aspects: ['invalidHref', 'preferButton'] 42 | } 43 | ] 44 | } 45 | }; 46 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: Continuous Integration 2 | on: 3 | push: 4 | paths-ignore: 5 | - 'docs/**' 6 | - 'example/**' 7 | - '*.md' 8 | pull_request: 9 | paths-ignore: 10 | - 'docs/**' 11 | - 'example/**' 12 | - '*.md' 13 | 14 | env: 15 | CI: true 16 | 17 | jobs: 18 | build: 19 | runs-on: ${{ matrix.os }} 20 | strategy: 21 | matrix: 22 | node-version: [12.x, 14.x] 23 | os: [ubuntu-latest, windows-latest] 24 | name: Node ${{ matrix.node-version }} 25 | 26 | steps: 27 | - uses: actions/checkout@v2 28 | 29 | - name: Setup node 30 | uses: actions/setup-node@v1 31 | with: 32 | node-version: ${{ matrix.node-version }} 33 | 34 | - name: Install Dependencies 35 | run: yarn 36 | 37 | - name: Tests 38 | run: yarn test 39 | 40 | - name: Build 41 | run: yarn build 42 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dist 3 | .next 4 | .vscode 5 | coverage -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dist 3 | .vscode 4 | .next 5 | coverage -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "semi": true, 3 | "tabWidth": 4, 4 | "printWidth": 100, 5 | "singleQuote": true, 6 | "trailingComma": "none", 7 | "jsxBracketSameLine": true 8 | } -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Dustin Deus 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | next-localization 3 | npm 4 | Continuous Integration 5 |

6 |

The minimalistic localization solution for Next.js, powered by Rosetta
and Next.js 10 Internationalized Routing.

7 | 8 | --- 9 | 10 | > **Note**: Are you looking for the next generation API Developer Platform? 🔎 Have a look at: [WunderGraph](https://github.com/wundergraph/wundergraph) 11 | Turn your services, databases and 3rd party APIs into a secure unified API in just a few minutes. 🪄 12 | 13 | --- 14 | 15 | ## ✨ Features 16 | 17 | - Supports all rendering modes: (Static) | ● (SSG) | λ (Server). 18 | - Ideal companion to [Next.js 10 Internationalized Routing](https://nextjs.org/blog/next-10#internationalized-routing) 19 | - Less than 1000 bytes – including dependencies! 20 | - Pluralization support 21 | - No build step, No enforced conventions. 22 | 23 | ## Table of Contents 24 | 25 | - [Installation & Setup](#installation--setup) 26 | - [Basic Usage](#basic-usage) 27 | - [Usage with `getStaticProps`](#usage-with-getstaticprops) 28 | - [Redirect to default language](#redirect-to-default-language) 29 | - [Construct correct links](#construct-correct-links) 30 | - [Internationalization](#internationalization) 31 | - [Pluralization](#pluralization) 32 | - [Datetime, Numbers](#datetime-numbers) 33 | - [Access i18n outside React](#access-i18n-outside-react) 34 | - [Performance considerations](#performance-considerations) 35 | - [Other considerations](#other-considerations) 36 | 37 | ## Installation & Setup 38 | 39 | ``` 40 | yarn add next-localization 41 | ``` 42 | 43 | ## Example 44 | 45 | See [`example`](./example) for full example and locale setup. 46 | 47 | ## Basic Usage 48 | 49 | Your `_app.js`. 50 | 51 | ```js 52 | import { I18nProvider } from 'next-localization'; 53 | import { useRouter } from 'next/router'; 54 | 55 | export default function MyApp({ Component, pageProps }) { 56 | const router = useRouter(); 57 | const { lngDict, ...rest } = pageProps; 58 | 59 | return ( 60 | 61 | 62 | 63 | ); 64 | } 65 | ``` 66 | 67 | Any functional component. 68 | 69 | ```js 70 | import { useI18n } from 'next-localization'; 71 | import { useRouter } from 'next/router'; 72 | import Link from 'next/link'; 73 | 74 | const HomePage = () => { 75 | const router = useRouter(); 76 | const i18n = useI18n(); 77 | // or 78 | const i18nPlural = i18n.withPlural(); 79 | return ( 80 | <> 81 |

82 | {i18n.t('title')}}, {i18n.t('welcome', { username })} 83 |

84 |

{i18nPlural('products_count', { items: 2 })}

85 | 86 | Change language to (en) 87 | 88 | 89 | ); 90 | }; 91 | ``` 92 | 93 | ## Usage with [`getStaticProps`](https://nextjs.org/docs/basic-features/data-fetching#getstaticprops-static-generation) 94 | 95 | Checkout the [full example](example). 96 | 97 | _The same steps works with [`getServerSideProps`](https://nextjs.org/docs/basic-features/data-fetching#getserversideprops-server-side-rendering)._ 98 | 99 | ## Redirect to default language 100 | 101 | Built-in with [Next.js 10 Internationalized Routing](https://nextjs.org/docs/advanced-features/i18n-routing#automatic-locale-detection) 102 | 103 | ## Construct correct links 104 | 105 | Built-in with [Next.js 10 Internationalized Routing](https://nextjs.org/docs/advanced-features/i18n-routing#transition-between-locales) 106 | 107 | ## Internationalization 108 | 109 | We rely on the native platform api [`Intl`](https://developer.mozilla.org/de/docs/Web/JavaScript/Reference/Global_Objects/Intl#Locale_negotiation). If you need to support older browsers (e.g IE11) use polyfills. 110 | 111 | ### Pluralization 112 | 113 | We provide a small pluralization `i18n.withPlural` utility function. It returns the same `ì18n` interface but handles number values as pluralization. The implementation uses [`Intl.PluralRules`](https://developer.mozilla.org/de/docs/Web/JavaScript/Reference/Global_Objects/Intl/PluralRules). 114 | 115 | ```js 116 | import { useRouter } from 'next/router'; 117 | import { I18nProvider, useI18n } from 'next-localization'; 118 | 119 | function Root() { 120 | const router = useRouter(); 121 | return ( 122 | 133 | 134 | 135 | ); 136 | } 137 | 138 | function Child() { 139 | const i18n = useI18n(); 140 | const router = useRouter(); 141 | const t = i18n.withPlural(); 142 | return

{t('warning', { birds: 2 })}

; // WARNING: two birds 143 | } 144 | ``` 145 | 146 | ### Datetime, Numbers 147 | 148 | Use [`DateTimeFormat`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/DateTimeFormat/DateTimeFormat), [`NumberFormat`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/NumberFormat/NumberFormat) directly or rely on an external library. The integration will look very similiar. 149 | 150 | ```js 151 | import { useRouter } from 'next/router'; 152 | import { I18nProvider } from 'next-localization'; 153 | 154 | function Root() { 155 | return ( 156 | 161 | 162 | 163 | ); 164 | } 165 | 166 | function Child() { 167 | const router = useRouter(); 168 | const date = new Intl.DateTimeFormat(router.locale).format(new Date()); 169 | return

{t('copyright', { date })}

; // Copyright: 8/30/2020 170 | } 171 | ``` 172 | 173 | ## Access i18n outside React 174 | 175 | If you need access to the `i18n` outside of react or react hooks, you can create a custom `i18n` instance and pass it to the `I18nProvider`. 176 | It's the same interface as `useI18n` returns. 177 | 178 | ```js 179 | import { I18nProvider } from 'next-localization'; 180 | import { useRouter } from 'next/router'; 181 | 182 | const i18n = I18n({ 183 | en: { hello: 'Hello, world!' } 184 | }); 185 | 186 | export default function MyApp({ Component, pageProps }) { 187 | const router = useRouter(); 188 | const { lngDict, ...rest } = pageProps; 189 | 190 | return ( 191 | 192 | 193 | 194 | ); 195 | } 196 | ``` 197 | 198 | ## Performance considerations 199 | 200 | Don't forget that a locale change will rerender all components under the `I18nProvider` provider. 201 | It's safe to create multiple providers with different language dictionaries. This can be useful if you want to split it into different namespaces. 202 | 203 | [Here](example/pages/namespace.js) you can see an example how to lazy-load a component with a different locale file. Code splitting is ensured by embedding the JSON file via the babel macro [json.macro](https://github.com/ifiokjr/json.macro). 204 | 205 | ## Other considerations 206 | 207 | Depending on your application `next-localization` might not be sufficient to internationalize your application. You still need to consider: 208 | 209 | - Format [times](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/DateTimeFormat/DateTimeFormat) and [numbers](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/NumberFormat/NumberFormat). 210 | 211 | With some effort those points are very easy to solve and you can still base on a very lightweight localization strategy. 212 | -------------------------------------------------------------------------------- /__tests__/basic.js: -------------------------------------------------------------------------------- 1 | import { render, waitFor } from '@testing-library/react'; 2 | import React, { useEffect } from 'react'; 3 | 4 | import { I18n, I18nProvider, useI18n } from './../src/index'; 5 | 6 | test('Should render key', () => { 7 | function Root() { 8 | return ( 9 | 10 | 11 | 12 | ); 13 | } 14 | function Child() { 15 | const i18n = useI18n(); 16 | return

{i18n.t('hello')}

; 17 | } 18 | 19 | const { getByText } = render(); 20 | expect(getByText('Hello, world!')).toBeInTheDocument(); 21 | }); 22 | 23 | test('Should interpolate key', () => { 24 | function Root() { 25 | return ( 26 | 27 | 28 | 29 | ); 30 | } 31 | function Child() { 32 | const i18n = useI18n(); 33 | return

{i18n.t('welcome', { username: 'Bernd' })}

; 34 | } 35 | 36 | const { getByText } = render(); 37 | expect(getByText('Welcome, Bernd!')).toBeInTheDocument(); 38 | }); 39 | 40 | test('Should print current locale', () => { 41 | function Root() { 42 | return ( 43 | 44 | 45 | 46 | ); 47 | } 48 | function Child() { 49 | const i18n = useI18n(); 50 | return

{i18n.locale()}

; 51 | } 52 | 53 | const { getByText } = render(); 54 | expect(getByText('en')).toBeInTheDocument(); 55 | }); 56 | 57 | test('Should pluralize', () => { 58 | function Root() { 59 | return ( 60 | 71 | 72 | 73 | ); 74 | } 75 | function Child() { 76 | const i18n = useI18n(); 77 | const t = i18n.withPlural(); 78 | return

{t('warning', { birds: 2, foo: 'bar' })}

; 79 | } 80 | 81 | const { getByText } = render(); 82 | expect(getByText('WARNING: two birds, bar')).toBeInTheDocument(); 83 | }); 84 | 85 | test('Should fallback to default behaviour when no number is passed', () => { 86 | function Root() { 87 | return ( 88 | 99 | 100 | 101 | ); 102 | } 103 | function Child() { 104 | const i18n = useI18n(); 105 | const t = i18n.withPlural(); 106 | return

{t('warning', { birds: 'no-number' })}

; 107 | } 108 | 109 | const { getByText } = render(); 110 | expect(getByText('WARNING: no-number')).toBeInTheDocument(); 111 | }); 112 | 113 | test('Should be able to pass a custom i18n instance', () => { 114 | const i18nInstance = I18n({ 115 | en: { hello: 'Hello, world!' } 116 | }); 117 | function Root() { 118 | return ( 119 | 120 | 121 | 122 | ); 123 | } 124 | function Child() { 125 | const i18n = useI18n(); 126 | return

{i18n.t('hello')}

; 127 | } 128 | 129 | const { getByText } = render(); 130 | expect(getByText('Hello, world!')).toBeInTheDocument(); 131 | }); 132 | 133 | test('Should be able to pass a custom i18n instance and langDict', () => { 134 | const i18nInstance = I18n(); 135 | function Root() { 136 | return ( 137 | 141 | 142 | 143 | ); 144 | } 145 | function Child() { 146 | const i18n = useI18n(); 147 | return

{i18n.t('hello')}

; 148 | } 149 | 150 | const { getByText } = render(); 151 | expect(i18nInstance.table('en')).toEqual({ hello: 'Hello, world!' }); 152 | expect(getByText('Hello, world!')).toBeInTheDocument(); 153 | }); 154 | 155 | test('Should be able to change locale', async () => { 156 | const i18nInstance = I18n({ 157 | en: { hello: 'Hello, world!' } 158 | }); 159 | function Root() { 160 | return ( 161 | 162 | 163 | 164 | ); 165 | } 166 | function Child() { 167 | const i18n = useI18n(); 168 | 169 | useEffect(() => { 170 | i18n.set('de', { hello: 'Hello, Welt!' }); 171 | i18n.locale('de'); 172 | // eslint-disable-next-line react-hooks/exhaustive-deps 173 | }, []); 174 | 175 | return

{i18n.t('hello')}

; 176 | } 177 | 178 | const { getByText } = render(); 179 | 180 | await waitFor(() => { 181 | expect(i18nInstance.locale()).toEqual('de'); 182 | expect(i18nInstance.table('en')).toEqual({ hello: 'Hello, world!' }); 183 | expect(i18nInstance.table('de')).toEqual({ hello: 'Hello, Welt!' }); 184 | expect(getByText('Hello, Welt!')).toBeInTheDocument(); 185 | }); 186 | }); 187 | 188 | test('Should be able to set new keys without changing locale', () => { 189 | const i18nInstance = I18n({ 190 | en: { hello: 'Hello, world!' } 191 | }); 192 | function Root() { 193 | return ( 194 | 195 | 196 | 197 | ); 198 | } 199 | function Child() { 200 | const i18n = useI18n(); 201 | 202 | useEffect(() => { 203 | i18n.set('de', { hello: 'Hello, Welt!' }); 204 | // eslint-disable-next-line react-hooks/exhaustive-deps 205 | }, []); 206 | 207 | return

{i18n.t('hello')}

; 208 | } 209 | 210 | const { getByText } = render(); 211 | expect(i18nInstance.table('de')).toEqual({ hello: 'Hello, Welt!' }); 212 | expect(getByText('Hello, world!')).toBeInTheDocument(); 213 | }); 214 | -------------------------------------------------------------------------------- /babel.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | env: { 3 | test: { 4 | presets: [ 5 | [ 6 | '@babel/preset-env', 7 | { 8 | targets: { 9 | node: 'current' 10 | } 11 | } 12 | ], 13 | '@babel/preset-react' 14 | ] 15 | } 16 | } 17 | }; 18 | -------------------------------------------------------------------------------- /example/.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": ["next/babel"], 3 | "plugins": ["macros"] 4 | } 5 | -------------------------------------------------------------------------------- /example/.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # next.js 12 | /.next/ 13 | /out/ 14 | 15 | # production 16 | /build 17 | 18 | # misc 19 | .DS_Store 20 | *.pem 21 | 22 | # debug 23 | npm-debug.log* 24 | yarn-debug.log* 25 | yarn-error.log* 26 | 27 | # local env files 28 | .env.local 29 | .env.development.local 30 | .env.test.local 31 | .env.production.local 32 | 33 | # vercel 34 | .vercel 35 | -------------------------------------------------------------------------------- /example/README.md: -------------------------------------------------------------------------------- 1 | # next-localization example 2 | 3 | This example uses [next-localization](https://github.com/StarpTech/next-localization) to provide a SSR, SSG, CSR compatible i18n solution. 4 | 5 | In `next.config.js` you can configure the locales and routing strategy. 6 | 7 | ## Development 8 | 9 | 1. Run `yarn link` in the root directory. 10 | 2. Run `yarn build` in the root directory. 11 | 3. Run `yarn` here to update the `next-localization` package with the latest updates. 12 | 4. Run `yarn dev` here to run the application. 13 | 5. Repeat step 2) if you want to update `next-localization`. 14 | -------------------------------------------------------------------------------- /example/components/namespace.js: -------------------------------------------------------------------------------- 1 | import { loadJson } from 'json.macro'; 2 | import { I18nProvider, useI18n } from 'next-localization'; 3 | import { useRouter } from 'next/router'; 4 | import { useMemo } from 'react'; 5 | 6 | const translations = loadJson('../locales/namespace.json'); 7 | 8 | export default function Namespace() { 9 | const router = useRouter(); 10 | const map = useMemo(() => { 11 | return translations[router.locale]; 12 | }, [router.locale]); 13 | 14 | return ( 15 | 16 | 17 | </I18nProvider> 18 | ); 19 | } 20 | 21 | export function Title() { 22 | const i18n = useI18n(); 23 | return <h1>{i18n.t('title')}</h1>; 24 | } 25 | -------------------------------------------------------------------------------- /example/components/title.js: -------------------------------------------------------------------------------- 1 | import { useI18n } from 'next-localization'; 2 | 3 | export default function Title({ username }) { 4 | const i18n = useI18n(); 5 | return <h1>{i18n.t('intro.welcome', { username })}</h1>; 6 | } 7 | -------------------------------------------------------------------------------- /example/locales/de.json: -------------------------------------------------------------------------------- 1 | { 2 | "intro": { 3 | "welcome": "Willkommen, {{username}}!", 4 | "text": "Ich hoffe, du findest das nützlich.", 5 | "description": "Das Beispiel zeigt, wie man die Sprache für SSG und SSG optimierte Seiten wechselt." 6 | }, 7 | "dashboard": { 8 | "description": "Das Beispiel zeigt, wie man die Sprache nur Frontendseitig verändert. Nützlich für Dashboards wo SEO nicht relevant ist." 9 | }, 10 | "warning": "WARNING: {{birds}}", 11 | "birds": { 12 | "other": "Vögel", 13 | "one": "Vogel", 14 | "two": "zwei Vögel", 15 | "few": "einige Vögel" 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /example/locales/en.json: -------------------------------------------------------------------------------- 1 | { 2 | "intro": { 3 | "welcome": "Welcome, {{username}}!", 4 | "text": "I hope you find this useful.", 5 | "description": "This example demonstrate how to change the language for SSG and SSR optimized pages." 6 | }, 7 | "dashboard": { 8 | "description": "This example demonstrate how to lazy-load the language on the client side." 9 | }, 10 | "warning": "WARNING: {{birds}}", 11 | "birds": { 12 | "other": "birds", 13 | "one": "bird", 14 | "two": "two birds", 15 | "few": "some birds" 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /example/locales/namespace.json: -------------------------------------------------------------------------------- 1 | { 2 | "de": { 3 | "title": "Dynamic Hallo" 4 | }, 5 | "en": { 6 | "title": "Dynamic Hello" 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /example/next.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | i18n: { 3 | // These are all the locales you want to support in 4 | // your application 5 | locales: ['en', 'de'], 6 | // This is the default locale you want to be used when visiting 7 | // a non-locale prefixed path e.g. `/hello` 8 | defaultLocale: 'en' 9 | } 10 | }; 11 | -------------------------------------------------------------------------------- /example/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "with-rosetta", 3 | "version": "1.0.0", 4 | "scripts": { 5 | "dev": "next", 6 | "build": "next build", 7 | "start": "next start", 8 | "postinstall": "yarn link 'next-localization' && rm -rf node_modules/react" 9 | }, 10 | "dependencies": { 11 | "next": "latest", 12 | "next-localization": "latest", 13 | "react": "^17.0.1", 14 | "react-dom": "^17.0.1" 15 | }, 16 | "license": "ISC", 17 | "devDependencies": { 18 | "babel-plugin-macros": "^2.8.0", 19 | "json.macro": "^1.3.0" 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /example/pages/404.js: -------------------------------------------------------------------------------- 1 | import { useRouter } from 'next/router'; 2 | 3 | const NotFoundPage = () => { 4 | const router = useRouter(); 5 | return ( 6 | <div> 7 | <h1>Not found</h1> 8 | <div>Current locale: {router.locale}</div> 9 | </div> 10 | ); 11 | }; 12 | 13 | export default NotFoundPage; 14 | -------------------------------------------------------------------------------- /example/pages/[slug]/index.js: -------------------------------------------------------------------------------- 1 | import { useI18n } from 'next-localization'; 2 | import Link from 'next/link'; 3 | import { useRouter } from 'next/router'; 4 | 5 | import Title from '../../components/title'; 6 | 7 | const PostPage = () => { 8 | const router = useRouter(); 9 | const i18n = useI18n(); 10 | const tp = i18n.withPlural(); 11 | 12 | return ( 13 | <div> 14 | <Title username="Peter" /> 15 | <h2>{i18n.t('intro.text')}</h2> 16 | <h3>{i18n.t('intro.description')}</h3> 17 | <div>Current locale: {router.locale}</div> 18 | <div>Pluralization: {tp('warning', { birds: 2 })}</div> 19 | <div> 20 | <Link href="/foo" locale="en"> 21 | <a>Change language to (en)</a> 22 | </Link> 23 | </div> 24 | <div> 25 | <Link href="/foo" locale="de"> 26 | <a>Change language to (de)</a> 27 | </Link> 28 | </div> 29 | </div> 30 | ); 31 | }; 32 | 33 | export async function getStaticProps({ locale }) { 34 | const { default: lngDict = {} } = await import(`../../locales/${locale}.json`); 35 | 36 | return { 37 | props: { lngDict } 38 | }; 39 | } 40 | 41 | export async function getStaticPaths({ locales }) { 42 | return { 43 | paths: locales.map((l) => ({ params: { slug: `foo` }, locale: l })), 44 | fallback: false 45 | }; 46 | } 47 | 48 | export default PostPage; 49 | -------------------------------------------------------------------------------- /example/pages/_app.js: -------------------------------------------------------------------------------- 1 | import { I18nProvider } from 'next-localization'; 2 | import { useRouter } from 'next/router'; 3 | 4 | export default function MyApp({ Component, pageProps }) { 5 | const router = useRouter(); 6 | const { lngDict, ...rest } = pageProps; 7 | 8 | return ( 9 | <I18nProvider lngDict={lngDict} locale={router.locale}> 10 | <Component {...rest} /> 11 | </I18nProvider> 12 | ); 13 | } 14 | -------------------------------------------------------------------------------- /example/pages/dashboard.js: -------------------------------------------------------------------------------- 1 | import { useI18n } from 'next-localization'; 2 | import Link from 'next/link'; 3 | import { useRouter } from 'next/router'; 4 | import { useEffect } from 'react'; 5 | 6 | import Title from '../components/title'; 7 | 8 | const Dashboard = () => { 9 | const router = useRouter(); 10 | const i18n = useI18n(); 11 | 12 | useEffect(() => { 13 | async function changeLocale() { 14 | if (router.locale === 'en') { 15 | i18n.set('en', await import('../locales/en.json')); 16 | i18n.locale('en'); 17 | } else if (router.locale === 'de') { 18 | i18n.set('de', await import('../locales/de.json')); 19 | i18n.locale('de'); 20 | } 21 | } 22 | changeLocale(); 23 | // eslint-disable-next-line react-hooks/exhaustive-deps 24 | }, [router.locale]); 25 | 26 | return ( 27 | <div> 28 | <Title username="Peter" /> 29 | <h2>{i18n.t('intro.text')}</h2> 30 | <h3>{i18n.t('dashboard.description')}</h3> 31 | <div>Current locale: {router.locale}</div> 32 | <div> 33 | <Link href="/dashboard" locale="en"> 34 | <a>Change language to (en)</a> 35 | </Link> 36 | </div> 37 | <div> 38 | <Link href="/dashboard" locale="de"> 39 | <a>Change language to (de)</a> 40 | </Link> 41 | </div> 42 | </div> 43 | ); 44 | }; 45 | 46 | export default Dashboard; 47 | -------------------------------------------------------------------------------- /example/pages/index.js: -------------------------------------------------------------------------------- 1 | import Link from 'next/link'; 2 | 3 | const HomePage = () => { 4 | return ( 5 | <div> 6 | <h1>next-localization</h1> 7 | <div> 8 | <Link href="/dashboard"> 9 | <a>Go to CSR example (/dashboard)</a> 10 | </Link> 11 | </div> 12 | <div> 13 | <Link href="/foo"> 14 | <a>Go to SSG example (/foo)</a> 15 | </Link> 16 | </div> 17 | <div> 18 | <Link href="/namespace"> 19 | <a>Go to code-splitting example (/namespace)</a> 20 | </Link> 21 | </div> 22 | </div> 23 | ); 24 | }; 25 | 26 | export default HomePage; 27 | -------------------------------------------------------------------------------- /example/pages/namespace.js: -------------------------------------------------------------------------------- 1 | import dynamic from 'next/dynamic'; 2 | import Link from 'next/link'; 3 | import { useRouter } from 'next/router'; 4 | 5 | const DynamicComponent = dynamic(() => import('../components/namespace')); 6 | 7 | const Dashboard = () => { 8 | const router = useRouter(); 9 | 10 | return ( 11 | <div> 12 | <DynamicComponent /> 13 | <div>Current locale: {router.locale}</div> 14 | <div> 15 | <Link href="/namespace" locale="en"> 16 | <a>Change language to (en)</a> 17 | </Link> 18 | </div> 19 | <div> 20 | <Link href="/namespace" locale="de"> 21 | <a>Change language to (de)</a> 22 | </Link> 23 | </div> 24 | </div> 25 | ); 26 | }; 27 | 28 | export default Dashboard; 29 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | 3 | module.exports = { 4 | roots: [path.resolve(__dirname)], 5 | testEnvironment: 'jest-environment-jsdom-sixteen', 6 | displayName: 'local', 7 | testURL: 'http://localhost', 8 | setupFilesAfterEnv: [path.resolve(__dirname, './setupTests.js')] 9 | }; 10 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "next-localization", 3 | "version": "0.12.0", 4 | "main": "dist/index.js", 5 | "source": "src/index.js", 6 | "module": "dist/index.modern.js", 7 | "unpkg": "dist/index.umd.js", 8 | "repository": "git@github.com:StarpTech/next-localization.git", 9 | "types": "types/index.d.ts", 10 | "author": { 11 | "name": "Dustin Deus", 12 | "email": "deusdustin@gmail.com", 13 | "url": "https://starptech.de" 14 | }, 15 | "license": "MIT", 16 | "files": [ 17 | "types/*.d.ts", 18 | "dist" 19 | ], 20 | "scripts": { 21 | "build": "microbundle --no-sourcemap --no-compress --jsx React.createElement", 22 | "tsd": "tsd", 23 | "dev": "microbundle watch", 24 | "test": "yarn lint && jest --collectCoverage", 25 | "lint": "eslint --fix ./src/**/* && yarn tsd", 26 | "format": "prettier --write \"./**/*.{js,jsx,ts,tsx,css,md,json}\" --config ./.prettierrc", 27 | "release": "yarn build && yarn lint && release-it" 28 | }, 29 | "husky": { 30 | "hooks": { 31 | "pre-commit": "pretty-quick --staged && yarn test" 32 | } 33 | }, 34 | "browserslist": { 35 | "development": [ 36 | "last 2 chrome versions", 37 | "last 2 firefox versions", 38 | "last 2 edge versions" 39 | ], 40 | "production": [ 41 | ">1%", 42 | "last 4 versions", 43 | "Firefox ESR", 44 | "not ie < 11" 45 | ] 46 | }, 47 | "dependencies": { 48 | "rosetta": "1.1.0" 49 | }, 50 | "peerDependencies": { 51 | "react": ">=17.0.1" 52 | }, 53 | "devDependencies": { 54 | "eslint": "^7.16.0", 55 | "@babel/preset-env": "^7.12.11", 56 | "@babel/preset-react": "^7.12.10", 57 | "@testing-library/jest-dom": "^5.11.8", 58 | "@testing-library/react": "^13.0.1", 59 | "@types/react": "^18.0.4", 60 | "babel-jest": "^26.6.3", 61 | "eslint-config-prettier": "^7.1.0", 62 | "eslint-plugin-jsx-a11y": "^6.4.1", 63 | "eslint-plugin-prettier": "^3.3.0", 64 | "eslint-plugin-react": "^7.22.0", 65 | "eslint-plugin-react-hooks": "^4.2.0", 66 | "eslint-plugin-simple-import-sort": "^7.0.0", 67 | "husky": "^4.3.6", 68 | "jest": "^26.6.3", 69 | "jest-environment-jsdom-sixteen": "^1.0.3", 70 | "microbundle": "^0.12.4", 71 | "prettier": "^2.2.1", 72 | "pretty-quick": "^3.1.0", 73 | "react": "^18.0.0", 74 | "react-dom": "^18.0.0", 75 | "react-test-renderer": "^18.0.0", 76 | "release-it": "^14.2.2", 77 | "tsd": "^0.14.0" 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /setupTests.js: -------------------------------------------------------------------------------- 1 | import '@testing-library/jest-dom/extend-expect'; 2 | 3 | const editableFn = (_value) => ({ 4 | get: () => _value, 5 | set: (v) => (_value = v) 6 | }); 7 | 8 | Object.defineProperty(navigator, 'languages', editableFn(['en-US', 'en'])); 9 | -------------------------------------------------------------------------------- /src/hooks/use-i18n.js: -------------------------------------------------------------------------------- 1 | import { useContext } from 'react'; 2 | 3 | import { I18nContext } from '../i18n'; 4 | 5 | export default function useI18n() { 6 | const i18n = useContext(I18nContext); 7 | return i18n; 8 | } 9 | -------------------------------------------------------------------------------- /src/i18n.js: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useMemo } from 'react'; 2 | import { createContext, useState } from 'react'; 3 | import rosetta from 'rosetta'; 4 | // import rosetta from 'rosetta/debug'; 5 | 6 | export const I18nContext = createContext(); 7 | 8 | export const I18n = function (rosettaOpts) { 9 | const r = rosetta(rosettaOpts); 10 | return { 11 | /** 12 | * Triggers a render cycle 13 | */ 14 | _onUpdate() {}, 15 | /** 16 | * Retrieve a translation segment for the active language 17 | */ 18 | t: r.t, 19 | /** 20 | * Get the table of translations for a language 21 | */ 22 | table: r.table, 23 | /** 24 | * Define or extend the language table 25 | */ 26 | set: r.set, 27 | /** 28 | * Set a locale or returns the active locale 29 | */ 30 | locale(locale) { 31 | if (locale === undefined) { 32 | return r.locale(); 33 | } 34 | const activelocale = r.locale(locale); 35 | this._onUpdate(); 36 | return activelocale; 37 | }, 38 | /** 39 | * Returns an i18n instance that treats number values as pluralization 40 | */ 41 | withPlural(pluralRulesOptions = { type: 'ordinal' }) { 42 | const PR = new Intl.PluralRules(r.locale(), pluralRulesOptions); 43 | return (key, params, ...args) => { 44 | Object.keys(params).map((k) => { 45 | if (typeof params[k] === 'number') { 46 | let pkey = PR.select(params[k]); 47 | params[k] = this.t(`${k}.${pkey}`); 48 | } 49 | }); 50 | return this.t(key, params, ...args); 51 | }; 52 | } 53 | }; 54 | }; 55 | 56 | export default function I18nProvider({ children, locale = 'en', lngDict, i18nInstance }) { 57 | const [, setTick] = useState(0); 58 | const i18n = useMemo(() => { 59 | const instance = i18nInstance ?? I18n(); 60 | instance._onUpdate = () => setTick((tick) => tick + 1); 61 | instance.set(locale, lngDict); 62 | instance.locale(locale); 63 | return instance; 64 | // eslint-disable-next-line react-hooks/exhaustive-deps 65 | }, [i18nInstance]); 66 | 67 | useEffect(() => { 68 | i18n.set(locale, lngDict); 69 | i18n.locale(locale); 70 | // eslint-disable-next-line react-hooks/exhaustive-deps 71 | }, [locale, lngDict]); 72 | 73 | return <I18nContext.Provider value={{ ...i18n }}>{children}</I18nContext.Provider>; 74 | } 75 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | export { I18n, default as I18nProvider } from './i18n'; 2 | export { default as useI18n } from './hooks/use-i18n'; 3 | -------------------------------------------------------------------------------- /types/index.d.ts: -------------------------------------------------------------------------------- 1 | interface I18n<T> { 2 | /** Get/Set the language key */ 3 | locale(lang?: string): string; 4 | /** Define or extend the language table */ 5 | set(lang: string, table: T): void; 6 | /** Get the table of translations for a language */ 7 | table(lang: string): T | void; 8 | /** Retrieve a translation segment for the current language */ 9 | t<X extends Record<string, any> | any[]>( 10 | key: string | (string | number)[], 11 | params?: X, 12 | lang?: string 13 | ): string; 14 | withPlural<T>( 15 | pluralOptions?: object 16 | ): <X extends Record<string, any> | any[]>( 17 | key: string | (string | number)[], 18 | params?: X, 19 | lang?: string 20 | ) => string; 21 | } 22 | 23 | export function useI18n<T>(): I18n<T>; 24 | export function I18n<T>(table?: T): I18n<T>; 25 | export function I18nProvider<T extends object>(props: ProviderProps<T>): React.ReactElement; 26 | 27 | interface ProviderProps<T> { 28 | lngDict?: T; 29 | locale: string; 30 | i18nInstance?: I18n<T>; 31 | children: JSX.Element; 32 | } 33 | interface II18nProvider<T> extends React.FC<ProviderProps<T>> {} 34 | -------------------------------------------------------------------------------- /types/index.test-d.ts: -------------------------------------------------------------------------------- 1 | import { useI18n, I18n } from '.'; 2 | 3 | let i18n = I18n(); 4 | i18n.locale('de'); 5 | 6 | i18n = I18n({ en: { foo: 'bar' } }); 7 | i18n.locale('de'); 8 | 9 | i18n = useI18n<Record<string, Object | string | number>>(); 10 | 11 | i18n.locale('en'); 12 | i18n.locale(); 13 | 14 | i18n.set('en', { foo: 'bar' }); 15 | 16 | i18n.t('a.b', { foo: 3 }); 17 | 18 | i18n.table('de'); 19 | 20 | const tp = i18n.withPlural(); 21 | 22 | tp('a.b', { foo: 3 }); 23 | -------------------------------------------------------------------------------- /types/index.test-d.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { I18nProvider } from '.'; 3 | 4 | function Component() { 5 | return null; 6 | } 7 | 8 | export default function App() { 9 | return ( 10 | <I18nProvider locale="de" lngDict={{}}> 11 | <Component /> 12 | </I18nProvider> 13 | ); 14 | } 15 | --------------------------------------------------------------------------------