├── .eslintignore ├── .eslintrc ├── .github ├── ISSUE_TEMPLATE │ ├── bug_report.md │ └── feature_request.md └── workflows │ ├── ci.yml │ └── sherif.yml ├── .gitignore ├── .husky └── pre-commit ├── .prettierrc ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── assets ├── logo-black.png └── logo-white.png ├── docs ├── .eslintrc.json ├── .gitignore ├── components │ ├── icons │ │ ├── app.svg │ │ ├── index.ts │ │ └── pages.svg │ └── logo.tsx ├── next.config.js ├── package.json ├── pages │ ├── _app.mdx │ ├── _meta.json │ ├── docs │ │ ├── _meta.json │ │ ├── app-get-change-locale.md │ │ ├── app-middleware-configuration.md │ │ ├── app-plurals.md │ │ ├── app-scoped-translations.md │ │ ├── app-setup.mdx │ │ ├── app-static-rendering.md │ │ ├── examples.md │ │ ├── index.mdx │ │ ├── pages-get-change-locale.md │ │ ├── pages-plurals.md │ │ ├── pages-scoped-translations.md │ │ ├── pages-setup.mdx │ │ ├── pages-static-site-generation.md │ │ ├── rtl-support.mdx │ │ ├── testing.md │ │ └── writing-locales.md │ └── index.mdx ├── public │ ├── favicon.ico │ ├── logo-black.png │ ├── logo-white.png │ └── og.jpg ├── theme.config.jsx └── tsconfig.json ├── examples ├── next-app │ ├── .eslintrc.json │ ├── .gitignore │ ├── README.md │ ├── app │ │ ├── [locale] │ │ │ ├── client.tsx │ │ │ ├── client │ │ │ │ ├── layout.tsx │ │ │ │ └── page.tsx │ │ │ ├── globals.css │ │ │ ├── layout.tsx │ │ │ ├── page.module.css │ │ │ ├── page.tsx │ │ │ ├── provider.tsx │ │ │ ├── subpage │ │ │ │ └── page.tsx │ │ │ └── switch.tsx │ │ └── favicon.ico │ ├── locales │ │ ├── client.ts │ │ ├── en.ts │ │ ├── fr.ts │ │ └── server.ts │ ├── middleware.ts │ ├── next.config.js │ ├── package.json │ ├── public │ │ ├── next.svg │ │ └── vercel.svg │ └── tsconfig.json └── next-pages │ ├── .eslintrc.json │ ├── locales │ ├── en.ts │ ├── fr.ts │ └── index.ts │ ├── next-env.d.ts │ ├── next.config.js │ ├── package.json │ ├── pages │ ├── _app.tsx │ ├── index.tsx │ └── ssr-ssg.tsx │ ├── public │ ├── favicon.ico │ └── vercel.svg │ └── tsconfig.json ├── package.json ├── packages ├── international-types │ ├── README.md │ ├── __tests__ │ │ ├── keys.test.ts │ │ ├── params.test.ts │ │ └── scopes.test.ts │ ├── index.ts │ └── package.json └── next-international │ ├── README.md │ ├── __tests__ │ ├── create-i18n.test.tsx │ ├── fallback-locale.test.tsx │ ├── get-locale-props.test.ts │ ├── i18n-provider.test.tsx │ ├── log.test.ts │ ├── use-change-locale.test.tsx │ ├── use-i18n.test.tsx │ ├── use-scoped-i18n.test.tsx │ └── utils │ │ ├── en.ts │ │ ├── fr.ts │ │ └── index.tsx │ ├── client.d.ts │ ├── middleware.d.ts │ ├── package.json │ ├── server.d.ts │ ├── src │ ├── app │ │ ├── client │ │ │ ├── create-i18n-provider-client.tsx │ │ │ ├── create-use-change-locale.ts │ │ │ ├── create-use-current-locale.ts │ │ │ └── index.ts │ │ ├── middleware │ │ │ └── index.ts │ │ └── server │ │ │ ├── create-get-current-locale.ts │ │ │ ├── create-get-i18n.ts │ │ │ ├── create-get-scoped-i18n.ts │ │ │ ├── create-get-static-params.ts │ │ │ ├── get-locale-cache.tsx │ │ │ └── index.ts │ ├── common │ │ ├── constants.ts │ │ ├── create-define-locale.ts │ │ ├── create-t.ts │ │ ├── create-use-i18n.ts │ │ ├── create-use-scoped-i18n.ts │ │ └── flatten-locale.ts │ ├── helpers │ │ └── log.ts │ ├── index.ts │ ├── pages │ │ ├── create-get-locale-props.ts │ │ ├── create-i18n-provider.tsx │ │ ├── create-use-change-locale.ts │ │ ├── create-use-current-locale.ts │ │ └── index.ts │ └── types.ts │ └── tsconfig.json ├── pnpm-lock.yaml ├── pnpm-workspace.yaml ├── tests └── setup.ts ├── tsconfig.json └── vitest.config.ts /.eslintignore: -------------------------------------------------------------------------------- 1 | dist/ 2 | out/ 3 | .next/ 4 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "parser": "@typescript-eslint/parser", 3 | "parserOptions": { 4 | "ecmaVersion": 12, 5 | "sourceType": "module" 6 | }, 7 | "plugins": ["@typescript-eslint", "import"], 8 | "extends": [ 9 | "eslint:recommended", 10 | "plugin:@typescript-eslint/recommended", 11 | "plugin:react/recommended", 12 | "plugin:react-hooks/recommended", 13 | "plugin:prettier/recommended" 14 | ], 15 | "rules": { 16 | "@typescript-eslint/no-var-requires": "off", 17 | "@typescript-eslint/no-explicit-any": "off", 18 | "@typescript-eslint/consistent-type-imports": ["error", { "fixStyle": "separate-type-imports" }], 19 | "import/consistent-type-specifier-style": ["error", "prefer-top-level"] 20 | }, 21 | "env": { 22 | "browser": true, 23 | "es2021": true 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: bug 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | **To Reproduce** 14 | Steps to reproduce the behavior: 15 | 1. Go to '...' 16 | 2. Click on '....' 17 | 3. Scroll down to '....' 18 | 4. See error 19 | 20 | **Expected behavior** 21 | A clear and concise description of what you expected to happen. 22 | 23 | **Screenshots** 24 | If applicable, add screenshots to help explain your problem. 25 | 26 | **About (please complete the following information):** 27 | - next-international version [e.g. 0.9.5] 28 | - Next.js version [e.g. 13.4.0] 29 | 30 | **Additional context** 31 | Add any other context about the problem here. 32 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: enhancement 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Is your feature request related to a problem? Please describe.** 11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 12 | 13 | **Describe the solution you'd like** 14 | A clear and concise description of what you want to happen. 15 | 16 | **Describe alternatives you've considered** 17 | A clear and concise description of any alternative solutions or features you've considered. 18 | 19 | **Additional context** 20 | Add any other context or screenshots about the feature request here. 21 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | on: 3 | push: 4 | branches: 5 | - main 6 | pull_request: 7 | jobs: 8 | lint: 9 | runs-on: ubuntu-20.04 10 | steps: 11 | - uses: actions/checkout@v3 12 | - uses: pnpm/action-setup@v2 13 | with: 14 | version: 8 15 | - name: Use Node.js 20 16 | uses: actions/setup-node@v3 17 | with: 18 | node-version: 20 19 | cache: 'pnpm' 20 | - name: Install dependencies 21 | run: pnpm install 22 | - name: Run ESLint 23 | run: pnpm lint 24 | test: 25 | runs-on: ubuntu-20.04 26 | steps: 27 | - uses: actions/checkout@v3 28 | - uses: pnpm/action-setup@v2 29 | with: 30 | version: 8 31 | - name: Use Node.js 20 32 | uses: actions/setup-node@v3 33 | with: 34 | node-version: 20 35 | cache: 'pnpm' 36 | - name: Install dependencies 37 | run: pnpm install 38 | - name: Run Vitest 39 | run: pnpm test 40 | typecheck: 41 | runs-on: ubuntu-20.04 42 | steps: 43 | - uses: actions/checkout@v3 44 | - uses: pnpm/action-setup@v2 45 | with: 46 | version: 8 47 | - name: Use Node.js 20 48 | uses: actions/setup-node@v3 49 | with: 50 | node-version: 20 51 | cache: 'pnpm' 52 | - name: Install dependencies 53 | run: pnpm install 54 | - name: Build next-international 55 | run: cd packages/next-international && pnpm build 56 | - name: Run TSC 57 | run: pnpm typecheck 58 | -------------------------------------------------------------------------------- /.github/workflows/sherif.yml: -------------------------------------------------------------------------------- 1 | name: Sherif 2 | on: 3 | pull_request: 4 | jobs: 5 | check: 6 | name: Run Sherif 7 | runs-on: ubuntu-22.04 8 | steps: 9 | - name: Checkout 10 | uses: actions/checkout@v4 11 | - uses: actions/setup-node@v3 12 | with: 13 | node-version: 20 14 | - run: npx sherif@0.3.1 15 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | .next/ 3 | out/ 4 | dist/ 5 | coverage/ 6 | .vscode/ 7 | 8 | .DS_Store 9 | *.log* 10 | .eslintcache 11 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | . "$(dirname -- "$0")/_/husky.sh" 3 | 4 | pnpm lint-staged 5 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "printWidth": 120, 3 | "tabWidth": 2, 4 | "useTabs": false, 5 | "semi": true, 6 | "singleQuote": true, 7 | "trailingComma": "all", 8 | "bracketSpacing": true, 9 | "arrowParens": "avoid" 10 | } -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | ## Contributing 2 | 3 | Thanks for your interest in contributing to next-international! 4 | 5 | ### Requirements 6 | 7 | - `node` >= 18 8 | - `pnpm` >= 8 9 | 10 | ### Getting Started 11 | 12 | 1. Fork the repository, then clone it locally 13 | 2. Run `pnpm install` at the root of the repository 14 | 3. Navigate to `packages/international-types` and run `pnpm build` 15 | 4. Navigate to `packages/next-international` and run `pnpm build` 16 | 17 | Before submitting a PR, run the `test`, `lint`, and `typecheck` scripts from the root of the repository. 18 | 19 | ### Running the example apps 20 | 21 | Make sure you've followed the [Getting Started](#getting-started), then navigate to `examples/next-{app/pages}` and run `pnpm dev`. 22 | 23 | ### Running the documentation 24 | 25 | You don't need to follow the [Getting Started](#getting-started) for the documentation website. Simply navigate to the `docs` folder and run `pnpm dev`. 26 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Tom Lienard 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. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | packages/next-international/README.md -------------------------------------------------------------------------------- /assets/logo-black.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/QuiiBz/next-international/4e873d10a366ed1e9502c470d1765168f493faf6/assets/logo-black.png -------------------------------------------------------------------------------- /assets/logo-white.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/QuiiBz/next-international/4e873d10a366ed1e9502c470d1765168f493faf6/assets/logo-white.png -------------------------------------------------------------------------------- /docs/.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "next/core-web-vitals" 3 | } 4 | -------------------------------------------------------------------------------- /docs/.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 | 30 | # vercel 31 | .vercel 32 | 33 | # typescript 34 | *.tsbuildinfo 35 | next-env.d.ts 36 | -------------------------------------------------------------------------------- /docs/components/icons/app.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /docs/components/icons/index.ts: -------------------------------------------------------------------------------- 1 | export { default as AppIcon } from './app.svg'; 2 | export { default as PagesIcon } from './pages.svg'; 3 | -------------------------------------------------------------------------------- /docs/components/icons/pages.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /docs/components/logo.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import Image from 'next/image'; 4 | import { useTheme } from 'nextra-theme-docs'; 5 | import { useEffect, useState } from 'react'; 6 | 7 | const Logo = () => { 8 | const { resolvedTheme } = useTheme(); 9 | const [src, setSrc] = useState('/logo-black.png'); 10 | 11 | useEffect(() => { 12 | if (resolvedTheme === 'dark') { 13 | setSrc('/logo-white.png'); 14 | return; 15 | } 16 | 17 | setSrc('/logo-black.png'); 18 | }, [resolvedTheme]); 19 | 20 | return Next International logo; 21 | }; 22 | 23 | export default Logo; 24 | -------------------------------------------------------------------------------- /docs/next.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('next').NextConfig} */ 2 | const nextConfig = { 3 | reactStrictMode: true, 4 | webpack(config) { 5 | const allowedSvgRegex = /\.svg$/; 6 | 7 | const fileLoaderRule = config.module.rules.find(rule => rule.test?.test?.('.svg')); 8 | fileLoaderRule.exclude = allowedSvgRegex; 9 | 10 | config.module.rules.push({ 11 | test: allowedSvgRegex, 12 | use: ['@svgr/webpack'], 13 | }); 14 | 15 | return config; 16 | }, 17 | }; 18 | 19 | const withNextra = require('nextra')({ 20 | theme: 'nextra-theme-docs', 21 | themeConfig: './theme.config.jsx', 22 | flexsearch: { 23 | codeblocks: false, 24 | }, 25 | defaultShowCopyCode: true, 26 | }); 27 | 28 | module.exports = withNextra(nextConfig); 29 | -------------------------------------------------------------------------------- /docs/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "docs", 3 | "version": "0.1.0", 4 | "private": true, 5 | "scripts": { 6 | "dev": "next dev", 7 | "build": "next build", 8 | "start": "next start", 9 | "lint": "next lint" 10 | }, 11 | "dependencies": { 12 | "@vercel/analytics": "^1.1.1", 13 | "@vercel/speed-insights": "^1.0.2", 14 | "next": "^15.0.0", 15 | "nextra": "^2.13.2", 16 | "nextra-theme-docs": "^2.13.2", 17 | "react": "^18.2.0", 18 | "react-dom": "^18.2.0" 19 | }, 20 | "devDependencies": { 21 | "@svgr/webpack": "^8.1.0", 22 | "@types/node": "^20.8.7", 23 | "@types/react": "^18.2.45", 24 | "eslint": "^8.52.0", 25 | "eslint-config-next": "^13.5.6", 26 | "typescript": "^5.2.2" 27 | } 28 | } -------------------------------------------------------------------------------- /docs/pages/_app.mdx: -------------------------------------------------------------------------------- 1 | import { Analytics } from '@vercel/analytics/react'; 2 | import { SpeedInsights } from '@vercel/speed-insights/next'; 3 | 4 | export default function App({ Component, pageProps }) { 5 | return ( 6 | <> 7 | 8 | 9 | 10 | 11 | ) 12 | } 13 | -------------------------------------------------------------------------------- /docs/pages/_meta.json: -------------------------------------------------------------------------------- 1 | { 2 | "index": { 3 | "title": "Home", 4 | "type": "page" 5 | }, 6 | "docs": { 7 | "title": "Documentation", 8 | "type": "page" 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /docs/pages/docs/_meta.json: -------------------------------------------------------------------------------- 1 | { 2 | "index": "Get Started", 3 | "writing-locales": "Writing locales", 4 | "rtl-support": "RTL support", 5 | "testing": "Testing", 6 | "examples": "Examples", 7 | "-- App Router": { 8 | "type": "separator", 9 | "title": "App Router" 10 | }, 11 | "app-setup": "Setup", 12 | "app-plurals": "Plurals", 13 | "app-scoped-translations": "Scoped translations", 14 | "app-get-change-locale": "Get and change the locale", 15 | "app-middleware-configuration": "Middleware configuration", 16 | "app-static-rendering": "Static Rendering", 17 | "-- Pages Router": { 18 | "type": "separator", 19 | "title": "Pages Router" 20 | }, 21 | "pages-setup": "Setup", 22 | "pages-plurals": "Plurals", 23 | "pages-scoped-translations": "Scoped translations", 24 | "pages-get-change-locale": "Get and change the locale", 25 | "pages-static-site-generation": "Static Site Generation" 26 | } 27 | -------------------------------------------------------------------------------- /docs/pages/docs/app-get-change-locale.md: -------------------------------------------------------------------------------- 1 | # Get and change the locale 2 | 3 | You can only change the current locale from a Client Component. Export `useChangeLocale` and `useCurrentLocale` from `createI18nClient`, and export `getCurrentLocale` from `createI18nServer`: 4 | 5 | ```ts {3,4,12} 6 | // locales/client.ts 7 | export const { 8 | useChangeLocale, 9 | useCurrentLocale, 10 | ... 11 | } = createI18nClient({ 12 | ... 13 | }) 14 | 15 | // locales/server.ts 16 | export const { 17 | getCurrentLocale, 18 | ... 19 | } = createI18nServer({ 20 | ... 21 | }) 22 | ``` 23 | 24 | Then use these hooks: 25 | 26 | ```tsx {6,7,11-13,22,25} 27 | // Client Component 28 | 'use client' 29 | import { useChangeLocale, useCurrentLocale } from '../../locales/client' 30 | 31 | export default function Page() { 32 | const changeLocale = useChangeLocale() 33 | const locale = useCurrentLocale() 34 | 35 | return ( 36 | <> 37 |

Current locale: {locale}

38 | 39 | 40 | 41 | ) 42 | } 43 | 44 | // Server Component 45 | import { getCurrentLocale } from '../../locales/server' 46 | 47 | export default async function Page() { 48 | // If you are using Next.js < 15, you don't need to await `getCurrentLocale`: 49 | // const locale = getCurrentLocale() 50 | const locale = await getCurrentLocale() 51 | 52 | return ( 53 |

Current locale: {locale}

54 | ) 55 | } 56 | ``` 57 | 58 | ## Preserving search params 59 | 60 | By default, next-international doesn't preserve search params when changing the locale. This is because [`useSearchParams()`](https://nextjs.org/docs/app/api-reference/functions/use-search-params) will [opt-out the page from Static Rendering](https://nextjs.org/docs/app/api-reference/functions/use-search-params#static-rendering) if you don't wrap the component in a `Suspense` boundary. 61 | 62 | If you want to preserve search params, you can manually use the `preserveSearchParams` option inside `useChangeLocale`: 63 | 64 | ```tsx {6} 65 | // Client Component 66 | 'use client' 67 | import { useChangeLocale } from '../../locales/client' 68 | 69 | export function ChangeLocaleButton() { 70 | const changeLocale = useChangeLocale({ preserveSearchParams: true }) 71 | 72 | ... 73 | } 74 | ``` 75 | 76 | Then, don't forget to wrap the component in a `Suspense` boundary to avoid opting out the entire page from Static Rendering: 77 | 78 | ```tsx {6-8} 79 | // Client or Server Component 80 | import { ChangeLocaleButton } from './change-locale-button' 81 | 82 | export default function Page() { 83 | return ( 84 | 85 | 86 | 87 | ) 88 | } 89 | ``` 90 | 91 | ## `basePath` support 92 | 93 | If you have set a [`basePath`](https://nextjs.org/docs/app/api-reference/next-config-js/basePath) option inside `next.config.js`, you'll also need to set it inside `createI18nClient`: 94 | 95 | ```ts {7} 96 | // locales/client.ts 97 | export const { 98 | ... 99 | } = createI18nClient({ 100 | ... 101 | }, { 102 | basePath: '/base' 103 | }) 104 | ``` 105 | 106 | -------------------------------------------------------------------------------- /docs/pages/docs/app-middleware-configuration.md: -------------------------------------------------------------------------------- 1 | # Middleware configuration 2 | 3 | ## Rewrite the URL to hide the locale 4 | 5 | You might have noticed that by default, next-international redirects and shows the locale in the URL (e.g `/en/products`). This is helpful for users, but you can transparently rewrite the URL to hide the locale (e.g `/products`). 6 | 7 | Navigate to the `middleware.ts` file and set the `urlMappingStrategy` to `rewrite` (the default is `redirect`): 8 | 9 | ```ts {5} 10 | // middleware.ts 11 | const I18nMiddleware = createI18nMiddleware({ 12 | locales: ['en', 'fr'], 13 | defaultLocale: 'en', 14 | urlMappingStrategy: 'rewrite' 15 | }) 16 | ``` 17 | 18 | You can also choose to only rewrite the URL for the default locale, and keep others locale in the URL (e.g use `/products` instead of `/en/products`, but keep `/fr/products`) using the `rewriteDefault` strategy: 19 | 20 | ```ts {5} 21 | // middleware.ts 22 | const I18nMiddleware = createI18nMiddleware({ 23 | locales: ['en', 'fr'], 24 | defaultLocale: 'en', 25 | urlMappingStrategy: 'rewriteDefault' 26 | }) 27 | ``` 28 | 29 | ## Override the user's locale resolution 30 | 31 | If needed, you can override the resolution of a locale from a `Request`, which by default will try to extract it from the `Accept-Language` header. This can be useful to force the use of a specific locale regardless of the `Accept-Language` header. Note that this function will only be called if the user doesn't already have a `Next-Locale` cookie. 32 | 33 | Navigate to the `middleware.ts` file and implement a new `resolveLocaleFromRequest` function: 34 | 35 | ```ts {5-8} 36 | // middleware.ts 37 | const I18nMiddleware = createI18nMiddleware({ 38 | locales: ['en', 'fr'], 39 | defaultLocale: 'en', 40 | resolveLocaleFromRequest: request => { 41 | // Do your logic here to resolve the locale 42 | return 'fr' 43 | } 44 | }) 45 | ``` 46 | 47 | -------------------------------------------------------------------------------- /docs/pages/docs/app-plurals.md: -------------------------------------------------------------------------------- 1 | # Plurals 2 | 3 | Plural translations work out of the box without any external dependencies, using the [`Intl.PluralRules`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/PluralRules) API, which is supported in all browsers and Node.js. 4 | 5 | To declare plural translations, append `#` followed by `zero`, `one`, `two`, `few`, `many` or `other`: 6 | 7 | ```ts {3-4} 8 | // locales/en.ts 9 | export default { 10 | 'cows#zero': 'No cows', 11 | 'cows#one': 'A cow', 12 | 'cows#other': '{count} cows' 13 | } as const 14 | ``` 15 | 16 | The correct translation will then be determined automatically using a mandatory `count` parameter. The value of `count` is determined by the union of all suffixes, enabling type safety: 17 | 18 | - `zero` allows 0 19 | - `one` autocompletes 1, 21, 31, 41... but allows any number 20 | - `two` autocompletes 2, 22, 32, 42... but allows any number 21 | - `few`, `many` and `other` allow any number 22 | 23 | This works in both Client and Server Components, and with [scoped translations](/docs/app-scoped-translations): 24 | 25 | ```tsx {7,9} 26 | export default function Page() { 27 | const t = useI18n() // or `getI18n()` in Server Components 28 | 29 | return ( 30 |
31 | {/* Output: No cows */} 32 |

{t('cows', { count: 0 })}

33 | {/* Output: A cow */} 34 |

{t('cows', { count: 1 })}

35 | {/* Output: 3 cows */} 36 |

{t('cows', { count: 3 })}

37 |
38 | ) 39 | } 40 | ``` 41 | 42 | -------------------------------------------------------------------------------- /docs/pages/docs/app-scoped-translations.md: -------------------------------------------------------------------------------- 1 | # Scoped translations 2 | 3 | When you have a lot of keys, you may notice in a file that you always use and duplicate the same scope: 4 | 5 | ```ts 6 | // We always repeat `pages.settings` 7 | t('pages.settings.title') 8 | t('pages.settings.description', { identifier }) 9 | t('pages.settings.cta') 10 | ``` 11 | 12 | We can avoid this using the `useScopedI18n` hook / `getScopedI18n` method. And of course, the scoped key, subsequent keys and params will still be 100% type-safe. 13 | 14 | Export `useScopedI18n` from `createI18nClient` and `getScopedI18n` from `createI18nServer`: 15 | 16 | ```ts {3,11} 17 | // locales/client.ts 18 | export const { 19 | useScopedI18n, 20 | ... 21 | } = createI18nClient({ 22 | ... 23 | }) 24 | 25 | // locales/server.ts 26 | export const { 27 | getScopedI18n, 28 | ... 29 | } = createI18nServer({ 30 | ... 31 | }) 32 | ``` 33 | 34 | Then use it in your components: 35 | 36 | ```tsx {6,10-12,21,25-27} 37 | // Client Component 38 | 'use client' 39 | import { useScopedI18n } from '../../locales/client' 40 | 41 | export default function Page() { 42 | const t = useScopedI18n('pages.settings') 43 | 44 | return ( 45 |
46 |

{t('title')}

47 |

{t('description', { identifier })}

48 |

{t('cta')}

49 |
50 | ) 51 | } 52 | 53 | // Server Component 54 | import { getScopedI18n } from '../../locales/server' 55 | 56 | export default async function Page() { 57 | const t = await getScopedI18n('pages.settings') 58 | 59 | return ( 60 |
61 |

{t('title')}

62 |

{t('description', { identifier })}

63 |

{t('cta')}

64 |
65 | ) 66 | } 67 | ``` 68 | 69 | -------------------------------------------------------------------------------- /docs/pages/docs/app-setup.mdx: -------------------------------------------------------------------------------- 1 | import { Callout, Steps } from 'nextra/components' 2 | 3 | # Setup 4 | 5 | 6 | Make sure you've followed the [Get Started](/docs) documentation before continuing! 7 | 8 | 9 | 10 | 11 | ### Create locale files 12 | 13 | Create `locales/client.ts` and `locales/server.ts` with your locales: 14 | 15 | ```ts 16 | // locales/client.ts 17 | "use client" 18 | import { createI18nClient } from 'next-international/client' 19 | 20 | export const { useI18n, useScopedI18n, I18nProviderClient } = createI18nClient({ 21 | en: () => import('./en'), 22 | fr: () => import('./fr') 23 | }) 24 | 25 | // locales/server.ts 26 | import { createI18nServer } from 'next-international/server' 27 | 28 | export const { getI18n, getScopedI18n, getStaticParams } = createI18nServer({ 29 | en: () => import('./en'), 30 | fr: () => import('./fr') 31 | }) 32 | ``` 33 | 34 | Each locale file should export a default object (don't forget `as const`): 35 | 36 | ```ts 37 | // locales/en.ts 38 | export default { 39 | 'hello': 'Hello', 40 | 'hello.world': 'Hello world!', 41 | 'welcome': 'Hello {name}!' 42 | } as const 43 | ``` 44 | 45 | ### Move your existing files 46 | 47 | Move all your routes inside an `app/[locale]/` folder. For Client Components, wrap the lowest parts of your app with `I18nProviderClient` inside a layout: 48 | 49 | ```tsx 50 | // app/[locale]/client/layout.tsx 51 | import { ReactElement } from 'react' 52 | import { I18nProviderClient } from '../../locales/client' 53 | 54 | // If you are using Next.js < 15, you don't need to await `params`: 55 | // export default function SubLayout({ params: { locale }, children }: { params: { locale: string }, children: ReactElement }) { 56 | export default function SubLayout({ params, children }: { params: Promise<{ locale: string }>, children: ReactElement }) { 57 | const { locale } = await params 58 | 59 | return ( 60 | 61 | {children} 62 | 63 | ) 64 | } 65 | ``` 66 | 67 | You can also provide a `fallback` component prop to show while waiting for the locale to load. 68 | 69 | ### Setup middleware 70 | 71 | Add a `middleware.ts` file at the root of your app, that will redirect the user to the right locale. You can also [rewrite the URL to hide the locale](/docs/app-middleware-configuration#rewrite-the-url-to-hide-the-locale): 72 | 73 | ```ts 74 | // middleware.ts 75 | import { createI18nMiddleware } from 'next-international/middleware' 76 | import { NextRequest } from 'next/server' 77 | 78 | const I18nMiddleware = createI18nMiddleware({ 79 | locales: ['en', 'fr'], 80 | defaultLocale: 'en' 81 | }) 82 | 83 | export function middleware(request: NextRequest) { 84 | return I18nMiddleware(request) 85 | } 86 | 87 | export const config = { 88 | matcher: ['/((?!api|static|.*\\..*|_next|favicon.ico|robots.txt).*)'] 89 | } 90 | ``` 91 | 92 | ### Translate 93 | 94 | Use `useI18n` and `useScopedI18n()` / `getI18n` and `getScopedI18n()` inside your components: 95 | 96 | ```tsx {3,6-7,24,27-28} 97 | // Client Component 98 | 'use client' 99 | import { useI18n, useScopedI18n } from '../../locales/client' 100 | 101 | export default function Page() { 102 | const t = useI18n() 103 | const scopedT = useScopedI18n('hello') 104 | 105 | return ( 106 |
107 |

{t('hello')}

108 | 109 | {/* Both are equivalent: */} 110 |

{t('hello.world')}

111 |

{scopedT('world')}

112 | 113 |

{t('welcome', { name: 'John' })}

114 |

{t('welcome', { name: John })}

115 |
116 | ) 117 | } 118 | 119 | // Server Component 120 | import { getI18n, getScopedI18n } from '../../locales/server' 121 | 122 | export default async function Page() { 123 | const t = await getI18n() 124 | const scopedT = await getScopedI18n('hello') 125 | 126 | return ( 127 |
128 |

{t('hello')}

129 | 130 | {/* Both are equivalent: */} 131 |

{t('hello.world')}

132 |

{scopedT('world')}

133 | 134 |

{t('welcome', { name: 'John' })}

135 |

{t('welcome', { name: John })}

136 |
137 | ) 138 | } 139 | ``` 140 | 141 |
142 | 143 | ## What's next? 144 | 145 | Learn how to add [plurals](/docs/app-plurals), use [scoped translations](/docs/app-scoped-translations), setup [Static Rendering](/docs/app-static-rendering), and tweak the [middleware configuration](/docs/app-middleware-configuration). 146 | -------------------------------------------------------------------------------- /docs/pages/docs/app-static-rendering.md: -------------------------------------------------------------------------------- 1 | # Static Rendering 2 | 3 | Next.js App Router supports [Static Rendering](https://nextjs.org/docs/app/building-your-application/rendering/server-components#static-rendering-default), meaning your pages will be rendered at build time and can then be served statically from CDNs, resulting in faster TTFB. 4 | 5 | ## Static Rendering 6 | 7 | Export `getStaticParams` from `createI18nServer`: 8 | 9 | ```ts {3} 10 | // locales/server.ts 11 | export const { 12 | getStaticParams, 13 | ... 14 | } = createI18nServer({ 15 | ... 16 | }) 17 | ``` 18 | 19 | Inside all pages that you want to be statically rendered, call this `setStaticParamsLocale` function by giving it the `locale` page param: 20 | 21 | ```tsx {2,4-5} 22 | // app/[locale]/page.tsx and any other page 23 | import { setStaticParamsLocale } from 'next-international/server' 24 | 25 | // If you are using Next.js < 15, you don't need to await `params`: 26 | // export default function Page({ params: { locale } }: { params: { locale: string } }) { 27 | export default function Page({ params }: { params: Promise<{ locale: string }> }) { 28 | const { locale } = await params 29 | setStaticParamsLocale(locale) 30 | 31 | return ( 32 | ... 33 | ) 34 | } 35 | ``` 36 | 37 | And export a new `generateStaticParams` function. If all your pages should be rendered statically, you can also move this to the root layout: 38 | 39 | ```ts {2,4-6} 40 | // app/[locale]/page.tsx and any other page, or in the root layout 41 | import { getStaticParams } from '../../locales/server' 42 | 43 | export function generateStaticParams() { 44 | return getStaticParams() 45 | } 46 | ``` 47 | 48 | 49 | ## Static Export with `output: 'export'` 50 | 51 | You can also export your Next.js application to be completely static using [`output: 'export'`](https://nextjs.org/docs/app/building-your-application/deploying/static-exports) inside your `next.config.js`. Note that this will [disable many features](https://nextjs.org/docs/app/building-your-application/deploying/static-exports#unsupported-features) of Next.js 52 | -------------------------------------------------------------------------------- /docs/pages/docs/examples.md: -------------------------------------------------------------------------------- 1 | # Examples 2 | 3 | You can find complete examples inside the [examples/next-pages](https://github.com/QuiiBz/next-international/tree/main/examples/next-pages/) and [examples/next-app](https://github.com/QuiiBz/next-international/tree/main/examples/next-app/) directories of the GitHub repository. 4 | 5 | We also have a CodeSandbox template for the App Router that you can browse live here: 6 | 7 | [![Open with CodeSandbox](https://assets.codesandbox.io/github/button-edit-lime.svg)](https://codesandbox.io/p/sandbox/jovial-paper-skkprk?file=%2Fapp%2F%5Blocale%5D%2Fpage.tsx%3A1%2C1) 8 | 9 | -------------------------------------------------------------------------------- /docs/pages/docs/index.mdx: -------------------------------------------------------------------------------- 1 | import { Tabs, Tab, Cards, Card } from 'nextra/components' 2 | import { AppIcon, PagesIcon } from '@components/icons' 3 | 4 | # Get Started 5 | 6 | ## Features 7 | 8 | - **100% Type-safe**: Locales in TS or JSON, type-safe `t()` & `scopedT()`, type-safe params, type-safe plurals, type-safe `changeLocale()`... 9 | - **Small**: No dependencies, lazy-loaded 10 | - **Simple**: No Webpack configuration, no CLI, no code generation, just pure TypeScript 11 | - **Server and Client, Static Rendering**: Lazy-load server and client-side, support for Static Rendering 12 | - App or Pages Router: With support for React Server Components 13 | 14 | Try it live on CodeSandbox: 15 | 16 | [![Open with CodeSandbox](https://assets.codesandbox.io/github/button-edit-lime.svg)](https://codesandbox.io/p/sandbox/jovial-paper-skkprk?file=%2Fapp%2F%5Blocale%5D%2Fpage.tsx%3A1%2C1) 17 | 18 | ## Installation 19 | 20 | 21 | 22 | ```bash 23 | pnpm install next-international 24 | ``` 25 | 26 | 27 | ```bash 28 | npm install next-international 29 | ``` 30 | 31 | 32 | ```bash 33 | yarn add next-international 34 | ``` 35 | 36 | 37 | ```bash 38 | bun add next-international 39 | ``` 40 | 41 | 42 | 43 | ## TypeScript config 44 | 45 | Make sure that `strict` is set to `true` in your `tsconfig.json`. This will allow next-international to provide the best in-class developer experience. 46 | 47 | ## Setup 48 | 49 | next-international supports both the App Router and Pages Router of Next.js. The APIs are very similar, but each have small specificities. Follow the one you're using: 50 | 51 | 52 | } title="App Router" href="/docs/app-setup" /> 53 | } title="Pages Router" href="/docs/pages-setup" /> 54 | -------------------------------------------------------------------------------- /docs/pages/docs/pages-get-change-locale.md: -------------------------------------------------------------------------------- 1 | # Get and change the locale 2 | 3 | Export `useChangeLocale` and `useCurrentLocale` from `createI18n`: 4 | 5 | ```ts {3,4} 6 | // locales/index.ts 7 | export const { 8 | useChangeLocale, 9 | useCurrentLocale, 10 | ... 11 | } = createI18n({ 12 | ... 13 | }) 14 | ``` 15 | 16 | Then use it as a hook: 17 | 18 | ```tsx {4,5,9-11} 19 | import { useChangeLocale, useCurrentLocale } from '../locales' 20 | 21 | export default function Page() { 22 | const changeLocale = useChangeLocale() 23 | const locale = useCurrentLocale() 24 | 25 | return ( 26 | <> 27 |

Current locale: {locale}

28 | 29 | 30 | 31 | ) 32 | } 33 | ``` 34 | 35 | -------------------------------------------------------------------------------- /docs/pages/docs/pages-plurals.md: -------------------------------------------------------------------------------- 1 | # Plurals 2 | 3 | Plural translations work out of the box without any external dependencies, using the [`Intl.PluralRules`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/PluralRules) API, which is supported in all browsers and Node.js. 4 | 5 | To declare plural translations, append `#` followed by `zero`, `one`, `two`, `few`, `many` or `other`: 6 | 7 | ```ts {3-4} 8 | // locales/en.ts 9 | export default { 10 | 'cows#zero': 'No cows', 11 | 'cows#one': 'A cow', 12 | 'cows#other': '{count} cows' 13 | } as const 14 | ``` 15 | 16 | The correct translation will then be determined automatically using a mandatory `count` parameter. The value of `count` is determined by the union of all suffixes, enabling type safety: 17 | 18 | - `zero` allows 0 19 | - `one` autocompletes 1, 21, 31, 41... but allows any number 20 | - `two` autocompletes 2, 22, 32, 42... but allows any number 21 | - `few`, `many` and `other` allow any number 22 | 23 | This also works with [scoped translations](/docs/pages-scoped-translations): 24 | 25 | ```tsx {7,9} 26 | export default function Page() { 27 | const t = useI18n() 28 | 29 | return ( 30 |
31 | {/* Output: No cows */} 32 |

{t('cows', { count: 0 })}

33 | {/* Output: A cow */} 34 |

{t('cows', { count: 1 })}

35 | {/* Output: 3 cows */} 36 |

{t('cows', { count: 3 })}

37 |
38 | ) 39 | } 40 | ``` 41 | 42 | -------------------------------------------------------------------------------- /docs/pages/docs/pages-scoped-translations.md: -------------------------------------------------------------------------------- 1 | # Scoped translations 2 | 3 | When you have a lot of keys, you may notice in a file that you always use and duplicate the same scope: 4 | 5 | ```ts 6 | // We always repeat `pages.settings` 7 | t('pages.settings.title') 8 | t('pages.settings.description', { identifier }) 9 | t('pages.settings.cta') 10 | ``` 11 | 12 | We can avoid this using the `useScopedI18n` hook / `getScopedI18n` method. And of course, the scoped key, subsequent keys and params will still be 100% type-safe. 13 | 14 | Export `useScopedI18n` from `createI18n`: 15 | 16 | ```ts {3} 17 | // locales/index.ts 18 | export const { 19 | useScopedI18n, 20 | ... 21 | } = createI18n({ 22 | ... 23 | }) 24 | ``` 25 | 26 | Then use it in your component: 27 | 28 | ```tsx {4,8-10} 29 | import { useScopedI18n } from '../locales' 30 | 31 | export default function Page() { 32 | const t = useScopedI18n('pages.settings') 33 | 34 | return ( 35 |
36 |

{t('title')}

37 |

{t('description', { identifier })}

38 |

{t('cta')}

39 |
40 | ) 41 | } 42 | ``` 43 | 44 | -------------------------------------------------------------------------------- /docs/pages/docs/pages-setup.mdx: -------------------------------------------------------------------------------- 1 | import { Callout, Steps } from 'nextra/components' 2 | 3 | # Setup 4 | 5 | 6 | Make sure you've followed the [Get Started](/docs) documentation before continuing! 7 | 8 | 9 | 10 | 11 | ### Create locale filesMake 12 | 13 | Make sure that you've set up correctly the [`i18n` key inside `next.config.js`](https://nextjs.org/docs/pages/building-your-application/routing/internationalization), then create `locales/index.ts` with your locales: 14 | 15 | ```ts 16 | // locales/index.ts 17 | import { createI18n } from 'next-international' 18 | 19 | export const { useI18n, useScopedI18n, I18nProvider, getLocaleProps } = createI18n({ 20 | en: () => import('./en'), 21 | fr: () => import('./fr') 22 | }) 23 | ``` 24 | 25 | Each locale file should export a default object (don't forget `as const`): 26 | 27 | ```ts 28 | // locales/en.ts 29 | export default { 30 | 'hello': 'Hello', 31 | 'hello.world': 'Hello world!', 32 | 'welcome': 'Hello {name}!' 33 | } as const 34 | ``` 35 | 36 | ### Setup provider 37 | 38 | Wrap your whole app with `I18nProvider` inside `_app.tsx`: 39 | 40 | ```tsx 41 | // pages/_app.tsx 42 | import { I18nProvider } from '../locales' 43 | import { AppProps } from 'next/app' 44 | 45 | export default function App({ Component, pageProps }: AppProps) { 46 | return ( 47 | 48 | 49 | 50 | ) 51 | } 52 | ``` 53 | 54 | You can also provide a `fallback` component prop to show while waiting for the locale to load, and a `fallbackLocale` prop to specify a locale to fallback on if a key has not been translated. 55 | 56 | ### Translate 57 | 58 | Use `useI18n` and `useScopedI18n()`: 59 | 60 | ```tsx {2,8-9} 61 | // pages/index.ts 62 | import { useI18n, useScopedI18n } from '../locales' 63 | 64 | // export const getStaticProps = ... 65 | // export const getServerSideProps = ... 66 | 67 | export default function Page() { 68 | const t = useI18n() 69 | const scopedT = useScopedI18n('hello') 70 | 71 | return ( 72 |
73 |

{t('hello')}

74 | 75 | {/* Both are equivalent: */} 76 |

{t('hello.world')}

77 |

{scopedT('world')}

78 | 79 |

{t('welcome', { name: 'John' })}

80 |

{t('welcome', { name: John })}

81 |
82 | ) 83 | } 84 | ``` 85 | 86 |
87 | 88 | ## What's next? 89 | 90 | Learn how to add [plurals](/docs/pages-plurals), use [scoped translations](/docs/pages-scoped-translations), and setup [Static Site Generation](/docs/pages-static-site-generation). 91 | -------------------------------------------------------------------------------- /docs/pages/docs/pages-static-site-generation.md: -------------------------------------------------------------------------------- 1 | # Static Site Generation 2 | 3 | Next.js allows to render pages statically with `output: 'export'` inside `next.config.js`. Export `getLocaleProps` from `createI18n`: 4 | 5 | ```ts 6 | // locales/index.ts 7 | export const { 8 | getLocaleProps, 9 | ... 10 | } = createI18n({ 11 | ... 12 | }) 13 | ``` 14 | 15 | Then, export a `getStaticProps` variable from your pages, or wrap your existing `getStaticProps`: 16 | 17 | ```ts 18 | // pages/index.tsx 19 | export const getStaticProps = getLocaleProps() 20 | 21 | // or with an existing `getStaticProps` function: 22 | export const getStaticProps = getLocaleProps(ctx => { 23 | // your existing code 24 | return { 25 | ... 26 | } 27 | }) 28 | ``` 29 | 30 | ## Static Site Rendering 31 | 32 | If you already have a `getServerSideProps` on a page, you can't use `getStaticProps`. In this case, you can still use `getLocaleProps` the same way: 33 | 34 | ```ts 35 | // pages/index.tsx 36 | export const getServerSideProps = getLocaleProps(ctx => { 37 | // your existing code 38 | return { 39 | ... 40 | } 41 | }) 42 | ``` 43 | -------------------------------------------------------------------------------- /docs/pages/docs/rtl-support.mdx: -------------------------------------------------------------------------------- 1 | import { Tabs, Tab } from 'nextra/components' 2 | 3 | # RTL support 4 | 5 | If you want to support the `dir` attribute in your `` tag, you'll need to add some logic: 6 | 7 | ## From the root layout 8 | 9 | ```tsx {3,6} 10 | // app/[locale]/layout.tsx 11 | 12 | // If you are using Next.js < 15, you don't need to await `params`: 13 | // export default function Layout({ children, params: { locale } }: { children: ReactElement, params: { locale: string } }) { 14 | export default function Layout({ children, params }: { children: ReactElement, params: Promise<{ locale: string }> }) { 15 | const { locale } = await params 16 | const dir = new Intl.Locale(locale).getTextInfo().direction 17 | 18 | return ( 19 | 20 | 21 | {children} 22 | 23 | 24 | ) 25 | } 26 | ``` 27 | 28 | ### Caveat 29 | 30 | Note that this won't work in Firefox if your root layout is a Client Component. This is because the `Intl.Locale.prototype.getTextInfo` API is [not yet supported in Firefox](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/Locale/getTextInfo#browser_compatibility). 31 | 32 | To prevent this, you can add a polyfill to guarantee compatibility with all browsers, until this standard is fully adopted. First, install the `intl-locale-textinfo-polyfill` package: 33 | 34 | 35 | 36 | ```bash 37 | pnpm install intl-locale-textinfo-polyfill 38 | ``` 39 | 40 | 41 | ```bash 42 | npm install intl-locale-textinfo-polyfill 43 | ``` 44 | 45 | 46 | ```bash 47 | yarn add intl-locale-textinfo-polyfill 48 | ``` 49 | 50 | 51 | ```bash 52 | bun add intl-locale-textinfo-polyfill 53 | ``` 54 | 55 | 56 | 57 | Then, use it instead of the native `Intl.Locale.prototype.getTextInfo` API: 58 | 59 | ```tsx {2,5} 60 | // app/[locale]/layout.tsx 61 | import Locale from 'intl-locale-textinfo-polyfill' 62 | 63 | // If you are using Next.js < 15, you don't need to await `params`: 64 | // export default function Layout({ children, params: { locale } }: { children: ReactElement, params: { locale: string } }) { 65 | export default function Layout({ children, params }: { children: ReactElement, params: Promise<{ locale: string }> }) { 66 | const { locale } = await params 67 | const { direction: dir } = new Locale(locale).textInfo 68 | 69 | return ( 70 | 71 | 72 | {children} 73 | 74 | 75 | ) 76 | } 77 | ``` 78 | 79 | ## With a useEffect call 80 | 81 | You may implement your RTL support with a `useEffect` as well. For instance, if you have a language switcher component on all your pages, you could also choose to write the language direction detection logic in it: 82 | 83 | ```tsx {11-13} 84 | // LanguageSwitcher.tsx 85 | 'use client' 86 | 87 | import { useChangeLocale, useCurrentLocale } from '../../locales/client' 88 | import { FunctionComponent, useEffect } from 'react' 89 | 90 | export default function LanguageSwitcher() { 91 | const currentLocale = useCurrentLocale() 92 | const changeLocale = useChangeLocale() 93 | 94 | useEffect(() => { 95 | document.documentElement.dir = isLocaleRTL(currentLocale) ? 'rtl' : 'ltr' 96 | }, [currentLocale]) 97 | 98 | return ( 99 |
100 | 101 | 102 |
103 | ) 104 | } 105 | ``` 106 | 107 | Where `isLocaleRTL` could be a call to a librarie like [rtl-detect](https://github.com/shadiabuhilal/rtl-detect), or the implementation mentioned just above, based on `Intl.Locale.prototype.getTextInfo`, including the polyfill currently required to ensure compatibility with Firefox-based browsers. 108 | 109 | ### Caveat 110 | 111 | Note that this choice of implementation will cause an UI flickering when your user loads your web pages for the first time: the page will first be mounted with the default `dir` attribute value of your HTML node, then updates when the component including the `useEffect` is mounted. This will introduce CLS (Cumulative Layout Shift) issues. 112 | -------------------------------------------------------------------------------- /docs/pages/docs/testing.md: -------------------------------------------------------------------------------- 1 | # Testing 2 | 3 | In case you want to make tests with next-international, you will need to create a custom render. The following example uses `@testing-library` and `Vitest`, but should work with `Jest` too. 4 | 5 | ```tsx 6 | // customRender.tsx 7 | import { ReactElement } from 'react' 8 | import { cleanup, render } from '@testing-library/react' 9 | import { afterEach } from 'vitest' 10 | 11 | afterEach(() => { 12 | cleanup() 13 | }) 14 | 15 | const customRender = (ui: ReactElement, options = {}) => 16 | render(ui, { 17 | // wrap provider(s) here if needed 18 | wrapper: ({ children }) => children, 19 | ...options, 20 | }) 21 | 22 | export * from '@testing-library/react' 23 | export { default as userEvent } from '@testing-library/user-event' 24 | export { customRender as render } 25 | ``` 26 | 27 | You will also need your locales files, or one for testing purposes. 28 | 29 | ```ts 30 | // en.ts 31 | export default { 32 | hello: 'Hello', 33 | } as const 34 | ``` 35 | 36 | Then, you can later use it in your tests: 37 | 38 | ```tsx 39 | // *.test.tsx 40 | import { describe, vi } from 'vitest' 41 | import { createI18n } from 'next-international' 42 | import { render, screen, waitFor } from './customRender' // Our custom render function. 43 | import en from './en' // Your locales. 44 | 45 | // Don't forget to mock the "next/router", not doing this may lead to some console errors. 46 | beforeEach(() => { 47 | vi.mock('next/router', () => ({ 48 | useRouter: vi.fn().mockImplementation(() => ({ 49 | locale: 'en', 50 | defaultLocale: 'en', 51 | locales: ['en', 'fr'], 52 | })), 53 | })) 54 | }) 55 | 56 | afterEach(() => { 57 | vi.clearAllMocks() 58 | }) 59 | 60 | describe('Example test', () => { 61 | it('just an example', async () => { 62 | const { useI18n, I18nProvider } = createI18n({ 63 | en: () => import('./en'), 64 | }) 65 | 66 | function App() { 67 | const t = useI18n() 68 | 69 | return

{t('hello')}

70 | } 71 | 72 | render( 73 | 74 | 75 | 76 | ) 77 | 78 | expect(screen.queryByText('Hello')).not.toBeInTheDocument() 79 | 80 | await waitFor(() => { 81 | expect(screen.getByText('Hello')).toBeInTheDocument() 82 | }) 83 | }) 84 | }) 85 | ``` 86 | 87 | -------------------------------------------------------------------------------- /docs/pages/docs/writing-locales.md: -------------------------------------------------------------------------------- 1 | # Writing locales 2 | 3 | Locales files can be written in TypeScript or JSON, but writing them in TypeScript will provide proper type-safety for params. 4 | 5 | ## Dot notation 6 | 7 | The default notation for writing locales looks like: 8 | 9 | ```ts 10 | // locales/en.ts 11 | export default { 12 | 'hello.world': 'Hello {param}!', 13 | 'hello.nested.translations': 'Translations' 14 | } as const 15 | ``` 16 | 17 | ## Object notation 18 | 19 | You can also write locales using nested objects instead of the default dot notation. 20 | 21 | ```ts 22 | // locales/en.ts 23 | export default { 24 | hello: { 25 | world: 'Hello {param}!', 26 | nested: { 27 | translations: 'Translations' 28 | } 29 | } 30 | } as const 31 | ``` 32 | 33 | ## JSON 34 | 35 | We're working on a tool to convert JSON translations to TypeScript. In the meantime, you will need to declare manually any parameter: 36 | 37 | ```ts 38 | const locales = { 39 | en: () => import('./en.json') 40 | } 41 | 42 | type Locale = { 43 | 'hello.world': '{param}', 44 | 'hello.nested.translations': string 45 | }; 46 | 47 | export const { ... } = createI18nServer< 48 | typeof locales, 49 | { en: Locale } 50 | >(locales); 51 | ``` 52 | 53 | -------------------------------------------------------------------------------- /docs/pages/index.mdx: -------------------------------------------------------------------------------- 1 | --- 2 | title: Home 3 | --- 4 | 5 | import { Cards, Card } from 'nextra/components' 6 | import Logo from '@components/logo' 7 | import { AppIcon, PagesIcon } from '@components/icons' 8 | 9 |

10 | 11 |
12 | Type-safe internationalization (i18n) for Next.js 13 |

14 | 15 | ## Features 16 | 17 | - **100% Type-safe**: Locales in TS or JSON, type-safe `t()` & `scopedT()`, type-safe params, type-safe plurals, type-safe `changeLocale()`... 18 | - **Small**: No dependencies, lazy-loaded 19 | - **Simple**: No Webpack configuration, no CLI, no code generation, just pure TypeScript 20 | - **Server and Client, Static Rendering**: Lazy-load server and client-side, support for Static Rendering 21 | - App or Pages Router: With support for React Server Components 22 | 23 | Try it live on CodeSandbox: 24 | 25 | [![Open with CodeSandbox](https://assets.codesandbox.io/github/button-edit-lime.svg)](https://codesandbox.io/p/sandbox/jovial-paper-skkprk?file=%2Fapp%2F%5Blocale%5D%2Fpage.tsx%3A1%2C1) 26 | 27 | ## Documentation 28 | 29 | 30 | 31 | 32 | 33 | ## Contributing 34 | 35 | [See the contributing guide](./CONTRIBUTING.md). 36 | 37 | ## Sponsors 38 | 39 | ![Sponsors](https://github.com/QuiiBz/dotfiles/blob/main/sponsors.png?raw=true) 40 | 41 | -------------------------------------------------------------------------------- /docs/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/QuiiBz/next-international/4e873d10a366ed1e9502c470d1765168f493faf6/docs/public/favicon.ico -------------------------------------------------------------------------------- /docs/public/logo-black.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/QuiiBz/next-international/4e873d10a366ed1e9502c470d1765168f493faf6/docs/public/logo-black.png -------------------------------------------------------------------------------- /docs/public/logo-white.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/QuiiBz/next-international/4e873d10a366ed1e9502c470d1765168f493faf6/docs/public/logo-white.png -------------------------------------------------------------------------------- /docs/public/og.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/QuiiBz/next-international/4e873d10a366ed1e9502c470d1765168f493faf6/docs/public/og.jpg -------------------------------------------------------------------------------- /docs/theme.config.jsx: -------------------------------------------------------------------------------- 1 | import { useConfig } from 'nextra-theme-docs'; 2 | import Image from 'next/image'; 3 | 4 | export default { 5 | logo: ( 6 | <> 7 | next-international logo 8 | next-international 9 | 10 | ), 11 | head: () => { 12 | const { title } = useConfig(); 13 | const socialCard = 14 | process.env.NODE_ENV === 'development' 15 | ? 'http://localhost:3000/og.jpg' 16 | : 'https://next-international.vercel.app/og.jpg'; 17 | 18 | return ( 19 | <> 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | ); 32 | }, 33 | project: { 34 | link: 'https://github.com/QuiiBz/next-international', 35 | }, 36 | docsRepositoryBase: 'https://github.com/QuiiBz/next-international/blob/main/docs', 37 | useNextSeoProps() { 38 | return { 39 | titleTemplate: '%s – next-international', 40 | }; 41 | }, 42 | footer: { 43 | text: MIT {new Date().getFullYear()} © next-international contributors., 44 | }, 45 | nextThemes: { 46 | defaultTheme: 'light', 47 | }, 48 | }; 49 | -------------------------------------------------------------------------------- /docs/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "lib": ["dom", "dom.iterable", "esnext"], 5 | "allowJs": true, 6 | "skipLibCheck": true, 7 | "strict": true, 8 | "forceConsistentCasingInFileNames": true, 9 | "noEmit": true, 10 | "esModuleInterop": true, 11 | "module": "esnext", 12 | "moduleResolution": "bundler", 13 | "resolveJsonModule": true, 14 | "isolatedModules": true, 15 | "jsx": "preserve", 16 | "incremental": true, 17 | "paths": { 18 | "@/*": ["./*"], 19 | "@components/*": ["./components/*"] 20 | } 21 | }, 22 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"], 23 | "exclude": ["node_modules"] 24 | } 25 | -------------------------------------------------------------------------------- /examples/next-app/.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "next/core-web-vitals" 3 | } 4 | -------------------------------------------------------------------------------- /examples/next-app/.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 | 30 | # vercel 31 | .vercel 32 | 33 | # typescript 34 | *.tsbuildinfo 35 | next-env.d.ts 36 | -------------------------------------------------------------------------------- /examples/next-app/README.md: -------------------------------------------------------------------------------- 1 | This is a [Next.js](https://nextjs.org/) project bootstrapped with [`create-next-app`](https://github.com/vercel/next.js/tree/canary/packages/create-next-app). 2 | 3 | ## Getting Started 4 | 5 | First, run the development server: 6 | 7 | ```bash 8 | npm run dev 9 | # or 10 | yarn dev 11 | # or 12 | pnpm dev 13 | ``` 14 | 15 | Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. 16 | 17 | You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file. 18 | 19 | [http://localhost:3000/api/hello](http://localhost:3000/api/hello) is an endpoint that uses [Route Handlers](https://beta.nextjs.org/docs/routing/route-handlers). This endpoint can be edited in `app/api/hello/route.ts`. 20 | 21 | This project uses [`next/font`](https://nextjs.org/docs/basic-features/font-optimization) to automatically optimize and load Inter, a custom Google Font. 22 | 23 | ## Learn More 24 | 25 | To learn more about Next.js, take a look at the following resources: 26 | 27 | - [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API. 28 | - [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial. 29 | 30 | You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js/) - your feedback and contributions are welcome! 31 | 32 | ## Deploy on Vercel 33 | 34 | The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js. 35 | 36 | Check out our [Next.js deployment documentation](https://nextjs.org/docs/deployment) for more details. 37 | -------------------------------------------------------------------------------- /examples/next-app/app/[locale]/client.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { useI18n } from '../../locales/client'; 4 | 5 | export default function Client() { 6 | const t = useI18n(); 7 | 8 | return

From client: {t('hello')}

; 9 | } 10 | -------------------------------------------------------------------------------- /examples/next-app/app/[locale]/client/layout.tsx: -------------------------------------------------------------------------------- 1 | import type { ReactNode } from 'react'; 2 | import { Provider } from '../provider'; 3 | 4 | export default async function Layout({ 5 | params, 6 | children, 7 | }: { 8 | params: Promise<{ locale: string }>; 9 | children: ReactNode; 10 | }) { 11 | const { locale } = await params; 12 | 13 | return {children}; 14 | } 15 | -------------------------------------------------------------------------------- /examples/next-app/app/[locale]/client/page.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { useI18n, useScopedI18n, useChangeLocale, useCurrentLocale } from '../../../locales/client'; 4 | 5 | export default function Client() { 6 | const t = useI18n(); 7 | const changeLocale = useChangeLocale(); 8 | const t2 = useScopedI18n('scope.more'); 9 | const locale = useCurrentLocale(); 10 | 11 | return ( 12 |
13 |

CSR

14 |

15 | Current locale: 16 | {locale} 17 |

18 |

Hello: {t('hello')}

19 |

20 | Hello:{' '} 21 | {t('welcome', { 22 | name: 'John', 23 | })} 24 |

25 |

26 | Hello (with React components):{' '} 27 | {t('welcome', { 28 | name: John, 29 | })} 30 |

31 |

32 | Hello:{' '} 33 | {t('about.you', { 34 | age: '23', 35 | name: 'Doe', 36 | })} 37 |

38 |

39 | Hello (with React components):{' '} 40 | {t('about.you', { 41 | age: 23, 42 | name: 'Doe', 43 | })} 44 |

45 |

{t2('test')}

46 |

47 | {t2('param', { 48 | param: 'test', 49 | })} 50 |

51 |

52 | {t2('param', { 53 | param: test, 54 | })} 55 |

56 |

{t2('and.more.test')}

57 |

{t('missing.translation.in.fr')}

58 |

59 | {t('cows', { 60 | count: 1, 61 | })} 62 |

63 |

64 | {t('cows', { 65 | count: 2, 66 | })} 67 |

68 |

69 | {t2('stars', { 70 | count: 1, 71 | })} 72 |

73 |

74 | {t2('stars', { 75 | count: 2, 76 | })} 77 |

78 | 81 | 84 |
85 | ); 86 | } 87 | -------------------------------------------------------------------------------- /examples/next-app/app/[locale]/globals.css: -------------------------------------------------------------------------------- 1 | :root { 2 | --max-width: 1100px; 3 | --border-radius: 12px; 4 | --font-mono: ui-monospace, Menlo, Monaco, 'Cascadia Mono', 'Segoe UI Mono', 5 | 'Roboto Mono', 'Oxygen Mono', 'Ubuntu Monospace', 'Source Code Pro', 6 | 'Fira Mono', 'Droid Sans Mono', 'Courier New', monospace; 7 | 8 | --foreground-rgb: 0, 0, 0; 9 | --background-start-rgb: 214, 219, 220; 10 | --background-end-rgb: 255, 255, 255; 11 | 12 | --primary-glow: conic-gradient( 13 | from 180deg at 50% 50%, 14 | #16abff33 0deg, 15 | #0885ff33 55deg, 16 | #54d6ff33 120deg, 17 | #0071ff33 160deg, 18 | transparent 360deg 19 | ); 20 | --secondary-glow: radial-gradient( 21 | rgba(255, 255, 255, 1), 22 | rgba(255, 255, 255, 0) 23 | ); 24 | 25 | --tile-start-rgb: 239, 245, 249; 26 | --tile-end-rgb: 228, 232, 233; 27 | --tile-border: conic-gradient( 28 | #00000080, 29 | #00000040, 30 | #00000030, 31 | #00000020, 32 | #00000010, 33 | #00000010, 34 | #00000080 35 | ); 36 | 37 | --callout-rgb: 238, 240, 241; 38 | --callout-border-rgb: 172, 175, 176; 39 | --card-rgb: 180, 185, 188; 40 | --card-border-rgb: 131, 134, 135; 41 | } 42 | 43 | @media (prefers-color-scheme: dark) { 44 | :root { 45 | --foreground-rgb: 255, 255, 255; 46 | --background-start-rgb: 0, 0, 0; 47 | --background-end-rgb: 0, 0, 0; 48 | 49 | --primary-glow: radial-gradient(rgba(1, 65, 255, 0.4), rgba(1, 65, 255, 0)); 50 | --secondary-glow: linear-gradient( 51 | to bottom right, 52 | rgba(1, 65, 255, 0), 53 | rgba(1, 65, 255, 0), 54 | rgba(1, 65, 255, 0.3) 55 | ); 56 | 57 | --tile-start-rgb: 2, 13, 46; 58 | --tile-end-rgb: 2, 5, 19; 59 | --tile-border: conic-gradient( 60 | #ffffff80, 61 | #ffffff40, 62 | #ffffff30, 63 | #ffffff20, 64 | #ffffff10, 65 | #ffffff10, 66 | #ffffff80 67 | ); 68 | 69 | --callout-rgb: 20, 20, 20; 70 | --callout-border-rgb: 108, 108, 108; 71 | --card-rgb: 100, 100, 100; 72 | --card-border-rgb: 200, 200, 200; 73 | } 74 | } 75 | 76 | * { 77 | box-sizing: border-box; 78 | padding: 0; 79 | margin: 0; 80 | } 81 | 82 | html, 83 | body { 84 | max-width: 100vw; 85 | overflow-x: hidden; 86 | } 87 | 88 | body { 89 | color: rgb(var(--foreground-rgb)); 90 | background: linear-gradient( 91 | to bottom, 92 | transparent, 93 | rgb(var(--background-end-rgb)) 94 | ) 95 | rgb(var(--background-start-rgb)); 96 | } 97 | 98 | a { 99 | color: inherit; 100 | text-decoration: none; 101 | } 102 | 103 | @media (prefers-color-scheme: dark) { 104 | html { 105 | color-scheme: dark; 106 | } 107 | } 108 | -------------------------------------------------------------------------------- /examples/next-app/app/[locale]/layout.tsx: -------------------------------------------------------------------------------- 1 | import type { ReactElement } from 'react'; 2 | import './globals.css'; 3 | import { Switch } from './switch'; 4 | import Link from 'next/link'; 5 | // import { getStaticParams } from '../../locales/server'; 6 | 7 | export const metadata = { 8 | title: 'Create Next App', 9 | description: 'Generated by create next app', 10 | }; 11 | 12 | // Uncomment to test Static Generation for all pages 13 | // export function generateStaticParams() { 14 | // return getStaticParams(); 15 | // } 16 | 17 | export default function RootLayout({ children }: { children: ReactElement }) { 18 | return ( 19 | 20 | 21 | {/* Uncomment the suspense boundary if using `preserveSearchParams` in `useChangeLocale()` */} 22 | {/* */} 23 | 24 | {/* */} 25 |
    26 |
  • 27 | Go to / 28 |
  • 29 |
  • 30 | Go to /subpage 31 |
  • 32 |
  • 33 | Go to /client 34 |
  • 35 |
36 | {children} 37 | 38 | 39 | ); 40 | } 41 | -------------------------------------------------------------------------------- /examples/next-app/app/[locale]/page.module.css: -------------------------------------------------------------------------------- 1 | .main { 2 | display: flex; 3 | flex-direction: column; 4 | justify-content: space-between; 5 | align-items: center; 6 | padding: 6rem; 7 | min-height: 100vh; 8 | } 9 | 10 | .description { 11 | display: inherit; 12 | justify-content: inherit; 13 | align-items: inherit; 14 | font-size: 0.85rem; 15 | max-width: var(--max-width); 16 | width: 100%; 17 | z-index: 2; 18 | font-family: var(--font-mono); 19 | } 20 | 21 | .description a { 22 | display: flex; 23 | justify-content: center; 24 | align-items: center; 25 | gap: 0.5rem; 26 | } 27 | 28 | .description p { 29 | position: relative; 30 | margin: 0; 31 | padding: 1rem; 32 | background-color: rgba(var(--callout-rgb), 0.5); 33 | border: 1px solid rgba(var(--callout-border-rgb), 0.3); 34 | border-radius: var(--border-radius); 35 | } 36 | 37 | .code { 38 | font-weight: 700; 39 | font-family: var(--font-mono); 40 | } 41 | 42 | .grid { 43 | display: grid; 44 | grid-template-columns: repeat(4, minmax(25%, auto)); 45 | width: var(--max-width); 46 | max-width: 100%; 47 | } 48 | 49 | .card { 50 | padding: 1rem 1.2rem; 51 | border-radius: var(--border-radius); 52 | background: rgba(var(--card-rgb), 0); 53 | border: 1px solid rgba(var(--card-border-rgb), 0); 54 | transition: background 200ms, border 200ms; 55 | } 56 | 57 | .card span { 58 | display: inline-block; 59 | transition: transform 200ms; 60 | } 61 | 62 | .card h2 { 63 | font-weight: 600; 64 | margin-bottom: 0.7rem; 65 | } 66 | 67 | .card p { 68 | margin: 0; 69 | opacity: 0.6; 70 | font-size: 0.9rem; 71 | line-height: 1.5; 72 | max-width: 30ch; 73 | } 74 | 75 | .center { 76 | display: flex; 77 | justify-content: center; 78 | align-items: center; 79 | position: relative; 80 | padding: 4rem 0; 81 | } 82 | 83 | .center::before { 84 | background: var(--secondary-glow); 85 | border-radius: 50%; 86 | width: 480px; 87 | height: 360px; 88 | margin-left: -400px; 89 | } 90 | 91 | .center::after { 92 | background: var(--primary-glow); 93 | width: 240px; 94 | height: 180px; 95 | z-index: -1; 96 | } 97 | 98 | .center::before, 99 | .center::after { 100 | content: ''; 101 | left: 50%; 102 | position: absolute; 103 | filter: blur(45px); 104 | transform: translateZ(0); 105 | } 106 | 107 | .logo { 108 | position: relative; 109 | } 110 | /* Enable hover only on non-touch devices */ 111 | @media (hover: hover) and (pointer: fine) { 112 | .card:hover { 113 | background: rgba(var(--card-rgb), 0.1); 114 | border: 1px solid rgba(var(--card-border-rgb), 0.15); 115 | } 116 | 117 | .card:hover span { 118 | transform: translateX(4px); 119 | } 120 | } 121 | 122 | @media (prefers-reduced-motion) { 123 | .card:hover span { 124 | transform: none; 125 | } 126 | } 127 | 128 | /* Mobile */ 129 | @media (max-width: 700px) { 130 | .content { 131 | padding: 4rem; 132 | } 133 | 134 | .grid { 135 | grid-template-columns: 1fr; 136 | margin-bottom: 120px; 137 | max-width: 320px; 138 | text-align: center; 139 | } 140 | 141 | .card { 142 | padding: 1rem 2.5rem; 143 | } 144 | 145 | .card h2 { 146 | margin-bottom: 0.5rem; 147 | } 148 | 149 | .center { 150 | padding: 8rem 0 6rem; 151 | } 152 | 153 | .center::before { 154 | transform: none; 155 | height: 300px; 156 | } 157 | 158 | .description { 159 | font-size: 0.8rem; 160 | } 161 | 162 | .description a { 163 | padding: 1rem; 164 | } 165 | 166 | .description p, 167 | .description div { 168 | display: flex; 169 | justify-content: center; 170 | position: fixed; 171 | width: 100%; 172 | } 173 | 174 | .description p { 175 | align-items: center; 176 | inset: 0 0 auto; 177 | padding: 2rem 1rem 1.4rem; 178 | border-radius: 0; 179 | border: none; 180 | border-bottom: 1px solid rgba(var(--callout-border-rgb), 0.25); 181 | background: linear-gradient( 182 | to bottom, 183 | rgba(var(--background-start-rgb), 1), 184 | rgba(var(--callout-rgb), 0.5) 185 | ); 186 | background-clip: padding-box; 187 | backdrop-filter: blur(24px); 188 | } 189 | 190 | .description div { 191 | align-items: flex-end; 192 | pointer-events: none; 193 | inset: auto 0 0; 194 | padding: 2rem; 195 | height: 200px; 196 | background: linear-gradient( 197 | to bottom, 198 | transparent 0%, 199 | rgb(var(--background-end-rgb)) 40% 200 | ); 201 | z-index: 1; 202 | } 203 | } 204 | 205 | /* Tablet and Smaller Desktop */ 206 | @media (min-width: 701px) and (max-width: 1120px) { 207 | .grid { 208 | grid-template-columns: repeat(2, 50%); 209 | } 210 | } 211 | 212 | @media (prefers-color-scheme: dark) { 213 | .vercelLogo { 214 | filter: invert(1); 215 | } 216 | 217 | .logo { 218 | filter: invert(1) drop-shadow(0 0 0.3rem #ffffff70); 219 | } 220 | } 221 | 222 | @keyframes rotate { 223 | from { 224 | transform: rotate(360deg); 225 | } 226 | to { 227 | transform: rotate(0deg); 228 | } 229 | } 230 | -------------------------------------------------------------------------------- /examples/next-app/app/[locale]/page.tsx: -------------------------------------------------------------------------------- 1 | // import { setStaticParamsLocale } from 'next-international/server'; 2 | import { getI18n, getScopedI18n, getCurrentLocale } from '../../locales/server'; 3 | import Client from './client'; 4 | import { Provider } from './provider'; 5 | 6 | // Uncomment to test Static Generation on this page only 7 | // export function generateStaticParams() { 8 | // return getStaticParams(); 9 | // } 10 | 11 | // eslint-disable-next-line @typescript-eslint/no-unused-vars 12 | export default async function Home({ params }: { params: Promise<{ locale: string }> }) { 13 | const { locale } = await params; 14 | 15 | // Uncomment to test Static Generation 16 | // setStaticParamsLocale(locale); 17 | 18 | const t = await getI18n(); 19 | const t2 = await getScopedI18n('scope.more'); 20 | const currentLocale = getCurrentLocale(); 21 | 22 | return ( 23 |
24 | 25 | 26 | 27 |

SSR / SSG

28 |

29 | Current locale: 30 | {currentLocale} 31 |

32 |

Hello: {t('hello')}

33 |

34 | Hello:{' '} 35 | {t('welcome', { 36 | name: 'John', 37 | })} 38 |

39 |

40 | Hello (with React components):{' '} 41 | {t('welcome', { 42 | name: John, 43 | })} 44 |

45 |

46 | Hello:{' '} 47 | {t('about.you', { 48 | age: '23', 49 | name: 'Doe', 50 | })} 51 |

52 |

53 | Hello (with React components):{' '} 54 | {t('about.you', { 55 | age: 23, 56 | name: 'Doe', 57 | })} 58 |

59 |

{t2('test')}

60 |

61 | {t2('param', { 62 | param: 'test', 63 | })} 64 |

65 |

66 | {t2('param', { 67 | param: test, 68 | })} 69 |

70 |

{t2('and.more.test')}

71 |

{t('missing.translation.in.fr')}

72 |

73 | {t('cows', { 74 | count: 1, 75 | })} 76 |

77 |

78 | {t('cows', { 79 | count: 2, 80 | })} 81 |

82 |

83 | {t2('stars', { 84 | count: 1, 85 | })} 86 |

87 |

88 | {t2('stars', { 89 | count: 2, 90 | })} 91 |

92 |
93 | ); 94 | } 95 | -------------------------------------------------------------------------------- /examples/next-app/app/[locale]/provider.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import type { ReactNode } from 'react'; 4 | import { I18nProviderClient } from '../../locales/client'; 5 | 6 | type ProviderProps = { 7 | locale: string; 8 | children: ReactNode; 9 | }; 10 | 11 | export function Provider({ locale, children }: ProviderProps) { 12 | return ( 13 | Loading...

}> 14 | {children} 15 |
16 | ); 17 | } 18 | -------------------------------------------------------------------------------- /examples/next-app/app/[locale]/subpage/page.tsx: -------------------------------------------------------------------------------- 1 | // import { setStaticParamsLocale } from 'next-international/server'; 2 | import { getI18n } from '../../../locales/server'; 3 | 4 | // Uncomment to test Static Generation on this page only 5 | // export function generateStaticParams() { 6 | // return getStaticParams(); 7 | // } 8 | 9 | // eslint-disable-next-line @typescript-eslint/no-unused-vars 10 | export default async function Subpage({ params }: { params: Promise<{ locale: string }> }) { 11 | // Uncomment to test Static Generation 12 | // const { locale } = await params; 13 | // setStaticParamsLocale(locale); 14 | 15 | const t = await getI18n(); 16 | 17 | return ( 18 |
19 |

SSR / SSG

20 |

Hello: {t('hello')}

21 |
22 | ); 23 | } 24 | -------------------------------------------------------------------------------- /examples/next-app/app/[locale]/switch.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { useChangeLocale } from '../../locales/client'; 4 | 5 | export function Switch() { 6 | // Uncomment to preserve the search params. Don't forget to also uncomment the Suspense in the layout 7 | const changeLocale = useChangeLocale(/* { preserveSearchParams: true } */); 8 | 9 | return ( 10 | <> 11 | 14 | 17 | 18 | ); 19 | } 20 | -------------------------------------------------------------------------------- /examples/next-app/app/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/QuiiBz/next-international/4e873d10a366ed1e9502c470d1765168f493faf6/examples/next-app/app/favicon.ico -------------------------------------------------------------------------------- /examples/next-app/locales/client.ts: -------------------------------------------------------------------------------- 1 | import { createI18nClient } from 'next-international/client'; 2 | // import en from './en'; 3 | 4 | export const { useI18n, useScopedI18n, I18nProviderClient, useChangeLocale, defineLocale, useCurrentLocale } = 5 | createI18nClient( 6 | { 7 | en: async () => { 8 | await new Promise(resolve => setTimeout(resolve, 100)); 9 | return import('./en'); 10 | }, 11 | fr: async () => { 12 | await new Promise(resolve => setTimeout(resolve, 100)); 13 | return import('./fr'); 14 | }, 15 | }, 16 | { 17 | // Uncomment to set base path 18 | // basePath: '/base', 19 | // Uncomment to use custom segment name 20 | // segmentName: 'locale', 21 | // Uncomment to set fallback locale 22 | // fallbackLocale: en, 23 | }, 24 | ); 25 | -------------------------------------------------------------------------------- /examples/next-app/locales/en.ts: -------------------------------------------------------------------------------- 1 | console.log('Loaded EN'); 2 | 3 | export default { 4 | hello: 'Hello', 5 | welcome: 'Hello {name}!', 6 | 'about.you': 'Hello {name}! You have {age} yo', 7 | 'scope.test': 'A scope', 8 | 'scope.more.test': 'A scope', 9 | 'scope.more.param': 'A scope with {param}', 10 | 'scope.more.and.more.test': 'A scope', 11 | 'scope.more.stars#one': '1 star on GitHub', 12 | 'scope.more.stars#other': '{count} stars on GitHub', 13 | 'missing.translation.in.fr': 'This should work', 14 | 'cows#one': 'A cow', 15 | 'cows#other': '{count} cows', 16 | } as const; 17 | 18 | // We can also write locales using nested objects 19 | // export default { 20 | // hello: 'Hello', 21 | // welcome: 'Hello {name}!', 22 | // about: { 23 | // you: 'Hello {name}! You have {age} yo', 24 | // }, 25 | // scope: { 26 | // test: 'A scope', 27 | // more: { 28 | // test: 'A scope', 29 | // param: 'A scope with {param}', 30 | // and: { 31 | // more: { 32 | // test: 'A scope', 33 | // }, 34 | // }, 35 | // 'stars#one': '1 star on GitHub', 36 | // 'stars#other': '{count} stars on GitHub', 37 | // }, 38 | // }, 39 | // missing: { 40 | // translation: { 41 | // in: { 42 | // fr: 'This should work', 43 | // }, 44 | // }, 45 | // }, 46 | // 'cows#one': 'A cow', 47 | // 'cows#other': '{count} cows', 48 | // } as const; 49 | -------------------------------------------------------------------------------- /examples/next-app/locales/fr.ts: -------------------------------------------------------------------------------- 1 | // import { defineLocale } from '.'; 2 | 3 | console.log('Loaded FR'); 4 | 5 | export default { 6 | hello: 'Bonjour', 7 | welcome: 'Bonjour {name}!', 8 | 'about.you': 'Bonjour {name}! Vous avez {age} ans', 9 | 'scope.test': 'Un scope', 10 | 'scope.more.test': 'Un scope', 11 | 'scope.more.param': 'Un scope avec un {param}', 12 | 'scope.more.and.more.test': 'Un scope', 13 | 'scope.more.stars#one': '1 étoile sur GitHub', 14 | 'scope.more.stars#other': '{count} étoiles sur GitHub', 15 | // 'missing.translation.in.fr': '', 16 | 'cows#one': 'Une vache', 17 | 'cows#other': '{count} vaches', 18 | } as const; 19 | -------------------------------------------------------------------------------- /examples/next-app/locales/server.ts: -------------------------------------------------------------------------------- 1 | import { createI18nServer } from 'next-international/server'; 2 | // import en from './en'; 3 | 4 | export const { getI18n, getScopedI18n, getCurrentLocale, getStaticParams } = createI18nServer( 5 | { 6 | en: () => import('./en'), 7 | fr: () => import('./fr'), 8 | }, 9 | { 10 | // Uncomment to use custom segment name 11 | // segmentName: 'locale', 12 | // Uncomment to set fallback locale 13 | // fallbackLocale: en, 14 | }, 15 | ); 16 | -------------------------------------------------------------------------------- /examples/next-app/middleware.ts: -------------------------------------------------------------------------------- 1 | import { createI18nMiddleware } from 'next-international/middleware'; 2 | import type { NextRequest } from 'next/server'; 3 | 4 | const I18nMiddleware = createI18nMiddleware({ 5 | locales: ['en', 'fr'], 6 | defaultLocale: 'en', 7 | }); 8 | 9 | export function middleware(request: NextRequest) { 10 | return I18nMiddleware(request); 11 | } 12 | 13 | export const config = { 14 | matcher: ['/((?!api|static|.*\\..*|_next|favicon.ico|robots.txt).*)'], 15 | }; 16 | -------------------------------------------------------------------------------- /examples/next-app/next.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('next').NextConfig} */ 2 | const nextConfig = { 3 | transpilePackages: ['next-international', 'international-types'], 4 | eslint: { 5 | ignoreDuringBuilds: true, 6 | }, 7 | typescript: { 8 | ignoreBuildErrors: true, 9 | }, 10 | // Uncomment to set base path 11 | // basePath: '/base', 12 | // Uncomment to use Static Export 13 | // output: 'export', 14 | }; 15 | 16 | module.exports = nextConfig; 17 | -------------------------------------------------------------------------------- /examples/next-app/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "example-next-app", 3 | "version": "0.0.1", 4 | "private": true, 5 | "scripts": { 6 | "dev": "next dev", 7 | "build": "next build", 8 | "start": "next start", 9 | "lint": "next lint" 10 | }, 11 | "dependencies": { 12 | "next": "^15.0.0", 13 | "next-international": "workspace:*", 14 | "react": "^18.2.0", 15 | "react-dom": "^18.2.0" 16 | }, 17 | "devDependencies": { 18 | "@types/node": "^20.8.7", 19 | "@types/react": "^18.2.45", 20 | "eslint": "^8.52.0", 21 | "eslint-config-next": "^13.5.6", 22 | "typescript": "^5.2.2" 23 | } 24 | } -------------------------------------------------------------------------------- /examples/next-app/public/next.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /examples/next-app/public/vercel.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /examples/next-app/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "lib": [ 5 | "dom", 6 | "dom.iterable", 7 | "esnext" 8 | ], 9 | "allowJs": true, 10 | "skipLibCheck": true, 11 | "strict": true, 12 | "forceConsistentCasingInFileNames": true, 13 | "noEmit": true, 14 | "esModuleInterop": true, 15 | "module": "esnext", 16 | "moduleResolution": "node", 17 | "resolveJsonModule": true, 18 | "isolatedModules": true, 19 | "jsx": "preserve", 20 | "incremental": true, 21 | "plugins": [ 22 | { 23 | "name": "next" 24 | } 25 | ] 26 | }, 27 | "include": [ 28 | "next-env.d.ts", 29 | "**/*.ts", 30 | "**/*.tsx", 31 | ".next/types/**/*.ts" 32 | ], 33 | "exclude": [ 34 | "node_modules" 35 | ] 36 | } 37 | -------------------------------------------------------------------------------- /examples/next-pages/.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "next/core-web-vitals" 3 | } 4 | -------------------------------------------------------------------------------- /examples/next-pages/locales/en.ts: -------------------------------------------------------------------------------- 1 | console.log('Loaded EN'); 2 | 3 | export default { 4 | hello: 'Hello', 5 | welcome: 'Hello {name}!', 6 | 'about.you': 'Hello {name}! You have {age} yo', 7 | 'scope.test': 'A scope', 8 | 'scope.more.test': 'A scope', 9 | 'scope.more.param': 'A scope with {param}', 10 | 'scope.more.and.more.test': 'A scope', 11 | 'scope.more.stars#one': '1 star on GitHub', 12 | 'scope.more.stars#other': '{count} stars on GitHub', 13 | 'missing.translation.in.fr': 'This should work', 14 | 'cows#one': 'A cow', 15 | 'cows#other': '{count} cows', 16 | } as const; 17 | 18 | // We can also write locales using nested objects 19 | // export default { 20 | // hello: 'Hello', 21 | // welcome: 'Hello {name}!', 22 | // about: { 23 | // you: 'Hello {name}! You have {age} yo', 24 | // }, 25 | // scope: { 26 | // test: 'A scope', 27 | // more: { 28 | // test: 'A scope', 29 | // param: 'A scope with {param}', 30 | // and: { 31 | // more: { 32 | // test: 'A scope', 33 | // }, 34 | // }, 35 | // 'stars#one': '1 star on GitHub', 36 | // 'stars#other': '{count} stars on GitHub', 37 | // }, 38 | // }, 39 | // missing: { 40 | // translation: { 41 | // in: { 42 | // fr: 'This should work', 43 | // }, 44 | // }, 45 | // }, 46 | // 'cows#one': 'A cow', 47 | // 'cows#other': '{count} cows', 48 | // } as const; 49 | -------------------------------------------------------------------------------- /examples/next-pages/locales/fr.ts: -------------------------------------------------------------------------------- 1 | // import { defineLocale } from '.'; 2 | 3 | console.log('Loaded FR'); 4 | 5 | export default { 6 | hello: 'Bonjour', 7 | welcome: 'Bonjour {name}!', 8 | 'about.you': 'Bonjour {name}! Vous avez {age} ans', 9 | 'scope.test': 'Un scope', 10 | 'scope.more.test': 'Un scope', 11 | 'scope.more.param': 'Un scope avec un {param}', 12 | 'scope.more.and.more.test': 'Un scope', 13 | 'scope.more.stars#one': '1 étoile sur GitHub', 14 | 'scope.more.stars#other': '{count} étoiles sur GitHub', 15 | // 'missing.translation.in.fr': '', 16 | 'cows#one': 'Une vache', 17 | 'cows#other': '{count} vaches', 18 | } as const; 19 | -------------------------------------------------------------------------------- /examples/next-pages/locales/index.ts: -------------------------------------------------------------------------------- 1 | import { createI18n } from 'next-international'; 2 | 3 | export const { useI18n, useScopedI18n, I18nProvider, useChangeLocale, defineLocale, getLocaleProps, useCurrentLocale } = 4 | createI18n({ 5 | en: () => import('./en'), 6 | fr: () => import('./fr'), 7 | }); 8 | -------------------------------------------------------------------------------- /examples/next-pages/next-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | /// 3 | 4 | // NOTE: This file should not be edited 5 | // see https://nextjs.org/docs/basic-features/typescript for more information. 6 | -------------------------------------------------------------------------------- /examples/next-pages/next.config.js: -------------------------------------------------------------------------------- 1 | // const withTM = require('next-transpile-modules')(['next-international']); 2 | 3 | /** @type {import('next').NextConfig} */ 4 | const nextConfig = { 5 | reactStrictMode: false, 6 | swcMinify: true, 7 | i18n: { 8 | locales: ['en', 'fr'], 9 | defaultLocale: 'en', 10 | }, 11 | eslint: { 12 | ignoreDuringBuilds: true, 13 | }, 14 | typescript: { 15 | ignoreBuildErrors: true, 16 | }, 17 | }; 18 | 19 | module.exports = nextConfig; 20 | -------------------------------------------------------------------------------- /examples/next-pages/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "example-next-pages", 3 | "version": "0.0.1", 4 | "private": true, 5 | "scripts": { 6 | "dev": "next dev", 7 | "build": "next build", 8 | "start": "next start", 9 | "lint": "next lint" 10 | }, 11 | "dependencies": { 12 | "next": "^15.0.0", 13 | "next-international": "workspace:*", 14 | "react": "^18.2.0", 15 | "react-dom": "^18.2.0" 16 | }, 17 | "devDependencies": { 18 | "@types/node": "^20.8.7", 19 | "@types/react": "^18.2.45", 20 | "eslint": "^8.52.0", 21 | "eslint-config-next": "^13.5.6", 22 | "typescript": "^5.2.2" 23 | } 24 | } -------------------------------------------------------------------------------- /examples/next-pages/pages/_app.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import type { AppProps } from 'next/app'; 3 | import { I18nProvider } from '../locales'; 4 | import en from '../locales/en'; 5 | 6 | const App = ({ Component, pageProps }: AppProps) => { 7 | return ( 8 | Loading initial locale client-side

} fallbackLocale={en}> 9 | 10 |
11 | ); 12 | }; 13 | 14 | export default App; 15 | -------------------------------------------------------------------------------- /examples/next-pages/pages/index.tsx: -------------------------------------------------------------------------------- 1 | import { useChangeLocale, useCurrentLocale, useI18n, useScopedI18n } from '../locales'; 2 | 3 | export default function Home() { 4 | const t = useI18n(); 5 | const changeLocale = useChangeLocale(); 6 | const t2 = useScopedI18n('scope.more'); 7 | const locale = useCurrentLocale(); 8 | 9 | return ( 10 |
11 |

CSR

12 |

13 | Current locale: {locale} 14 |

15 |

Hello: {t('hello')}

16 |

17 | Hello:{' '} 18 | {t('welcome', { 19 | name: 'John', 20 | })} 21 |

22 |

23 | Hello (with React components):{' '} 24 | {t('welcome', { 25 | name: John, 26 | })} 27 |

28 |

29 | Hello:{' '} 30 | {t('about.you', { 31 | age: '23', 32 | name: 'Doe', 33 | })} 34 |

35 |

36 | Hello (with React components):{' '} 37 | {t('about.you', { 38 | age: 23, 39 | name: 'Doe', 40 | })} 41 |

42 |

{t2('test')}

43 |

44 | {t2('param', { 45 | param: 'test', 46 | })} 47 |

48 |

49 | {t2('param', { 50 | param: test, 51 | })} 52 |

53 |

{t2('and.more.test')}

54 |

{t('missing.translation.in.fr')}

55 |

56 | {t('cows', { 57 | count: 1, 58 | })} 59 |

60 |

61 | {t('cows', { 62 | count: 2, 63 | })} 64 |

65 |

66 | {t2('stars', { 67 | count: 1, 68 | })} 69 |

70 |

71 | {t2('stars', { 72 | count: 2, 73 | })} 74 |

75 | 78 | 81 |
82 | ); 83 | } 84 | -------------------------------------------------------------------------------- /examples/next-pages/pages/ssr-ssg.tsx: -------------------------------------------------------------------------------- 1 | import type { GetServerSideProps } from 'next'; 2 | import { getLocaleProps, useChangeLocale, useCurrentLocale, useI18n, useScopedI18n } from '../locales'; 3 | 4 | export const getServerSideProps: GetServerSideProps = getLocaleProps(); 5 | // export const getStaticProps: GetStaticProps = getLocaleProps(); 6 | 7 | export default function SSR() { 8 | const t = useI18n(); 9 | const changeLocale = useChangeLocale(); 10 | const t2 = useScopedI18n('scope.more'); 11 | const locale = useCurrentLocale(); 12 | 13 | return ( 14 |
15 |

SSR / SSG

16 |

17 | Current locale: {locale} 18 |

19 |

Hello: {t('hello')}

20 |

21 | Hello:{' '} 22 | {t('welcome', { 23 | name: 'John', 24 | })} 25 |

26 |

27 | Hello (with React components):{' '} 28 | {t('welcome', { 29 | name: John, 30 | })} 31 |

32 |

33 | Hello:{' '} 34 | {t('about.you', { 35 | age: '23', 36 | name: 'Doe', 37 | })} 38 |

39 |

{t2('test')}

40 |

41 | {t2('param', { 42 | param: 'test', 43 | })} 44 |

45 |

{t2('and.more.test')}

46 |

{t('missing.translation.in.fr')}

47 |

48 | {t('cows', { 49 | count: 1, 50 | })} 51 |

52 |

53 | {t('cows', { 54 | count: 2, 55 | })} 56 |

57 |

58 | {t2('stars', { 59 | count: 1, 60 | })} 61 |

62 |

63 | {t2('stars', { 64 | count: 2, 65 | })} 66 |

67 | 70 | 73 |
74 | ); 75 | } 76 | -------------------------------------------------------------------------------- /examples/next-pages/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/QuiiBz/next-international/4e873d10a366ed1e9502c470d1765168f493faf6/examples/next-pages/public/favicon.ico -------------------------------------------------------------------------------- /examples/next-pages/public/vercel.svg: -------------------------------------------------------------------------------- 1 | 3 | 4 | -------------------------------------------------------------------------------- /examples/next-pages/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "lib": [ 5 | "dom", 6 | "dom.iterable", 7 | "esnext" 8 | ], 9 | "allowJs": true, 10 | "skipLibCheck": true, 11 | "strict": true, 12 | "forceConsistentCasingInFileNames": true, 13 | "noEmit": true, 14 | "incremental": true, 15 | "esModuleInterop": true, 16 | "module": "esnext", 17 | "resolveJsonModule": true, 18 | "moduleResolution": "node", 19 | "isolatedModules": true, 20 | "jsx": "preserve" 21 | }, 22 | "include": [ 23 | "next-env.d.ts", 24 | "**/*.ts", 25 | "**/*.tsx" 26 | ], 27 | "exclude": [ 28 | "node_modules" 29 | ] 30 | } 31 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "next-international", 3 | "private": true, 4 | "description": "Type-safe internationalization (i18n) for Next.js", 5 | "scripts": { 6 | "test": "vitest --run --coverage", 7 | "lint": "eslint --cache .", 8 | "typecheck": "tsc --noEmit", 9 | "prepare": "husky install" 10 | }, 11 | "lint-staged": { 12 | "*.{ts,tsx}": "eslint --fix" 13 | }, 14 | "engines": { 15 | "pnpm": ">=8" 16 | }, 17 | "packageManager": "pnpm@8.9.2", 18 | "devDependencies": { 19 | "@testing-library/dom": "^9.3.3", 20 | "@testing-library/jest-dom": "^6.1.4", 21 | "@testing-library/react": "^14.0.0", 22 | "@testing-library/user-event": "^14.5.1", 23 | "@types/node": "^20.8.7", 24 | "@types/react": "^18.2.31", 25 | "@typescript-eslint/eslint-plugin": "^6.8.0", 26 | "@typescript-eslint/parser": "^6.8.0", 27 | "@vitejs/plugin-react": "^4.1.0", 28 | "@vitest/coverage-v8": "^0.34.6", 29 | "eslint": "^8.52.0", 30 | "eslint-config-prettier": "^9.0.0", 31 | "eslint-plugin-import": "^2.28.1", 32 | "eslint-plugin-prettier": "^5.0.1", 33 | "eslint-plugin-react": "^7.33.2", 34 | "eslint-plugin-react-hooks": "5.0.0-canary-7118f5dd7-20230705", 35 | "husky": "^8.0.3", 36 | "jsdom": "^22.1.0", 37 | "lint-staged": "^15.0.2", 38 | "prettier": "^3.0.3", 39 | "react": "^18.2.0", 40 | "react-dom": "^18.2.0", 41 | "typescript": "^5.2.2", 42 | "vite": "^4.5.0", 43 | "vitest": "^0.34.6" 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /packages/international-types/README.md: -------------------------------------------------------------------------------- 1 |

2 | 3 | 4 | 5 | 6 | 7 |
8 | Type-safe internationalization (i18n) utility types 9 |

10 | 11 | --- 12 | 13 | - [Features](#features) 14 | - [Usage](#usage) 15 | - [Type-safe keys](#type-safe-keys) 16 | - [License](#license) 17 | 18 | ## Features 19 | 20 | - **Autocompletion**: For locale keys, scopes and params! 21 | - **Extensible**: Designed to be used with any library 22 | 23 | > **Note**: Using Next.js? Check out [next-international](https://github.com/QuiiBz/next-international)! 24 | 25 | ## Usage 26 | 27 | ```bash 28 | pnpm install international-types 29 | ``` 30 | 31 | ### Type-safe keys 32 | 33 | ```ts 34 | import type { LocaleKeys } from 'international-types' 35 | 36 | type Locale = { 37 | hello: 'Hello' 38 | 'hello.world': 'Hello World!' 39 | } 40 | 41 | function t>(key: Key) { 42 | // ... 43 | } 44 | 45 | t('') 46 | // hello | hello.world 47 | ``` 48 | 49 | ### Type-safe scopes with keys 50 | 51 | ```ts 52 | import type { LocaleKeys, Scopes } from 'international-types' 53 | 54 | type Locale = { 55 | hello: 'Hello' 56 | 'scope.nested.demo': 'Nested scope' 57 | 'scope.nested.another.demo': 'Another nested scope' 58 | } 59 | 60 | function scopedT>(scope: Scope) { 61 | return function t>(key: Key) { 62 | // ... 63 | } 64 | } 65 | 66 | const t = scopedT('') 67 | // scope | scope.nested 68 | 69 | t('') 70 | // For scope: nested.demo | nested.another.demo 71 | // For scope.nested: demo | another.demo 72 | ``` 73 | 74 | ### Type-safe params 75 | 76 | ```ts 77 | import type { LocaleKeys, BaseLocale, Scopes, ScopedValue, CreateParams, ParamsObject } from 'international-types' 78 | 79 | type Locale = { 80 | param: 'This is a {value}' 81 | 'hello.people': 'Hello {name}! You are {age} years old.' 82 | } 83 | 84 | function scopedT | undefined>(scope?: Scope) { 85 | return function t, Value extends ScopedValue>( 86 | key: Key, 87 | ...params: CreateParams, Locale, Scope, Key, Value> 88 | ) { 89 | // ... 90 | } 91 | } 92 | 93 | const t = scopedT(); 94 | 95 | t('param', { 96 | value: '' 97 | // value is required 98 | }) 99 | 100 | t('hello.people', { 101 | name: '', 102 | age: '' 103 | // name and age are required 104 | }) 105 | ``` 106 | 107 | ## License 108 | 109 | [MIT](./LICENSE) 110 | -------------------------------------------------------------------------------- /packages/international-types/__tests__/keys.test.ts: -------------------------------------------------------------------------------- 1 | import { assertType } from 'vitest'; 2 | import type { LocaleKeys } from '../'; 3 | 4 | describe('keys', () => { 5 | it('should return the keys', () => { 6 | const value = {} as LocaleKeys< 7 | { 8 | hello: 'Hello'; 9 | 'hello.world': 'Hello World'; 10 | }, 11 | undefined 12 | >; 13 | 14 | assertType<'hello' | 'hello.world'>(value); 15 | }); 16 | 17 | it('should return the keys with scope', () => { 18 | const value = {} as LocaleKeys< 19 | { 20 | hello: 'Hello'; 21 | 'scope.demo': 'Nested scope'; 22 | 'scope.another.demo': 'Another nested scope'; 23 | }, 24 | 'scope' 25 | >; 26 | 27 | assertType<'demo' | 'another.demo'>(value); 28 | }); 29 | 30 | it('should return the keys with nested scope', () => { 31 | const value = {} as LocaleKeys< 32 | { 33 | hello: 'Hello'; 34 | 'scope.nested.demo': 'Nested scope'; 35 | 'scope.nested.another.demo': 'Another nested scope'; 36 | }, 37 | 'scope.nested' 38 | >; 39 | 40 | assertType<'demo' | 'another.demo'>(value); 41 | }); 42 | }); 43 | -------------------------------------------------------------------------------- /packages/international-types/__tests__/params.test.ts: -------------------------------------------------------------------------------- 1 | import type { LocaleValue, ParamsObject } from '../index'; 2 | import { assertType } from 'vitest'; 3 | 4 | describe('param', () => { 5 | it('should extract param', () => { 6 | const value = {} as ParamsObject<'Hello {world}'>; 7 | 8 | assertType<{ 9 | world: LocaleValue; 10 | }>(value); 11 | }); 12 | 13 | it('should extract multiple params', () => { 14 | const value = {} as ParamsObject<'{username}: {age}'>; 15 | 16 | assertType<{ 17 | username: LocaleValue; 18 | age: LocaleValue; 19 | }>(value); 20 | }); 21 | 22 | it('should extract params from plural', () => { 23 | const value = {} as ParamsObject<'{value, plural, =1 {1 item left} other {Many items left}}'>; 24 | 25 | assertType<{ 26 | value: LocaleValue; 27 | }>(value); 28 | }); 29 | 30 | it('should extract two params from plural', () => { 31 | const value = {} as ParamsObject<'{value, plural, =1 {{items} item left} other {{items} items left}}'>; 32 | 33 | assertType<{ 34 | value: LocaleValue; 35 | items: LocaleValue; 36 | }>(value); 37 | }); 38 | 39 | it('should extract multiple params from plural', () => { 40 | const value = 41 | {} as ParamsObject<'{value, plural, =0 {{items} left} =1 {{items} item left} other {{items} items left}}'>; 42 | 43 | assertType<{ 44 | value: LocaleValue; 45 | items: LocaleValue; 46 | }>(value); 47 | }); 48 | 49 | it('should extract multiple params from plural with different params', () => { 50 | const value = 51 | {} as ParamsObject<'{value, plural, =0 {{items} left} =1 {{items} item left} other {You have too many {custom}}}'>; 52 | 53 | assertType<{ 54 | value: LocaleValue; 55 | items: LocaleValue; 56 | custom: LocaleValue; 57 | }>(value); 58 | }); 59 | 60 | it('should extract multiple params from plural with multiple params inside', () => { 61 | const value = 62 | {} as ParamsObject<'{count, plural, =1 {The {serverName} server could not be delivered because the {serverModel} is out of stock. You will not be billed for this order} other {The {serverName} servers could not be delivered because the {serverModel} are out of stock. You will not be billed for this order}}'>; 63 | 64 | assertType<{ 65 | count: LocaleValue; 66 | serverName: LocaleValue; 67 | serverModel: LocaleValue; 68 | }>(value); 69 | }); 70 | }); 71 | -------------------------------------------------------------------------------- /packages/international-types/__tests__/scopes.test.ts: -------------------------------------------------------------------------------- 1 | import { assertType } from 'vitest'; 2 | 3 | import type { Scopes } from '../'; 4 | 5 | describe('scopes', () => { 6 | it('should return the scopes', () => { 7 | const value = {} as Scopes<{ 8 | hello: 'Hello'; 9 | 'hello.world': 'Hello World'; 10 | }>; 11 | 12 | assertType<'hello'>(value); 13 | }); 14 | 15 | it('should return the nested scopes', () => { 16 | const value = {} as Scopes<{ 17 | hello: 'Hello'; 18 | 'hello.world': 'Hello World'; 19 | 'scope.demo': 'Nested scope'; 20 | 'scope.another.demo': 'Another nested scope'; 21 | }>; 22 | 23 | assertType<'hello' | 'scope' | 'scope.another'>(value); 24 | }); 25 | }); 26 | -------------------------------------------------------------------------------- /packages/international-types/index.ts: -------------------------------------------------------------------------------- 1 | export type LocaleValue = string | number | boolean | null | undefined | Date; 2 | export type BaseLocale = Record; 3 | export type ImportedLocales = Record Promise>; 4 | export type ExplicitLocales = Record; 5 | 6 | export type LocaleKeys< 7 | Locale extends BaseLocale, 8 | Scope extends Scopes | undefined, 9 | Key extends string = Extract, 10 | > = Scope extends undefined ? RemovePlural : Key extends `${Scope}.${infer Test}` ? RemovePlural : never; 11 | 12 | type Delimiter = `=${number}` | 'other'; 13 | 14 | type ExtractParams = Value extends '' 15 | ? [] 16 | : Value extends `${string}{${infer Param}}${infer Tail}` 17 | ? [Param, ...ExtractParams] 18 | : []; 19 | 20 | export type Params = Value extends '' 21 | ? [] 22 | : // Plural with 3 cases 23 | Value extends `{${infer Param}, plural, ${Delimiter} {${infer Content}} ${Delimiter} {${infer Content2}} ${Delimiter} {${infer Content3}}}` 24 | ? [Param, ...ExtractParams, ...ExtractParams, ...ExtractParams] 25 | : // Plural with 2 cases 26 | Value extends `{${infer Param}, plural, ${Delimiter} {${infer Content}} ${Delimiter} {${infer Content2}}}` 27 | ? [Param, ...ExtractParams, ...ExtractParams] 28 | : // Simple cases (e.g `This is a {param}`) 29 | Value extends `${string}{${infer Param}}${infer Tail}` 30 | ? [Param, ...Params] 31 | : []; 32 | 33 | export type GetParams = Value extends '' 34 | ? [] 35 | : Value extends `${string}{${infer Param}}${infer Tail}` 36 | ? [Param, ...Params] 37 | : []; 38 | 39 | export type ParamsObject = Record[number], LocaleValue>; 40 | 41 | type ExtractScopes< 42 | Value extends string, 43 | Prev extends string | undefined = undefined, 44 | > = Value extends `${infer Head}.${infer Tail}` 45 | ? [ 46 | Prev extends string ? `${Prev}.${Head}` : Head, 47 | ...ExtractScopes, 48 | ] 49 | : []; 50 | 51 | export type Scopes = ExtractScopes>[number]; 52 | 53 | export type ScopedValue< 54 | Locale extends BaseLocale, 55 | Scope extends Scopes | undefined, 56 | Key extends LocaleKeys, 57 | > = Scope extends undefined 58 | ? IsPlural extends true 59 | ? Locale[`${Key}#${PluralSuffix}`] 60 | : Locale[Key] 61 | : IsPlural extends true 62 | ? Locale[`${Scope}.${Key}#${PluralSuffix}`] 63 | : Locale[`${Scope}.${Key}`]; 64 | 65 | // From https://github.com/microsoft/TypeScript/issues/13298#issuecomment-885980381 66 | type UnionToIntersection = (U extends never ? never : (arg: U) => never) extends (arg: infer I) => void ? I : never; 67 | 68 | type UnionToTuple = UnionToIntersection T> extends (_: never) => infer W 69 | ? [...UnionToTuple>, W] 70 | : []; 71 | 72 | // Given a object type with string keys, return the "first" key. 73 | // Because the key ordering is not guaranteed, this type should be used 74 | // only when the key order is not important. 75 | type SomeKey> = UnionToTuple[0] extends string 76 | ? UnionToTuple[0] 77 | : never; 78 | 79 | // Gets a single locale type from an object of the shape of BaseLocales. 80 | export type GetLocaleType = Locales extends ImportedLocales 81 | ? Awaited]>>['default'] 82 | : Locales[SomeKey]; 83 | 84 | type Join = K extends string | number 85 | ? P extends string | number 86 | ? `${K}${'' extends P ? '' : '.'}${P}` 87 | : never 88 | : never; 89 | 90 | type Prev = [never, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, ...0[]]; 91 | 92 | type Leaves = [D] extends [never] 93 | ? never 94 | : T extends object 95 | ? { [K in keyof T]-?: Join> }[keyof T] 96 | : ''; 97 | 98 | type FollowPath = P extends `${infer U}.${infer R}` 99 | ? U extends keyof T 100 | ? FollowPath 101 | : P extends keyof T 102 | ? T[P] 103 | : never 104 | : P extends keyof T 105 | ? T[P] 106 | : never; 107 | 108 | export type FlattenLocale> = { 109 | [K in Leaves]: FollowPath; 110 | }; 111 | 112 | type PluralSuffix = 'zero' | 'one' | 'two' | 'few' | 'many' | 'other'; 113 | 114 | type RemovePlural = Key extends `${infer Head}#${PluralSuffix}` ? Head : Key; 115 | 116 | type GetPlural< 117 | Key extends string, 118 | Scope extends Scopes | undefined, 119 | Locale extends BaseLocale, 120 | > = Scope extends undefined 121 | ? `${Key}#${PluralSuffix}` & keyof Locale extends infer PluralKey 122 | ? PluralKey extends `${string}#${infer Plural extends PluralSuffix}` 123 | ? Plural 124 | : never 125 | : never 126 | : `${Scope}.${Key}#${PluralSuffix}` & keyof Locale extends infer PluralKey 127 | ? PluralKey extends `${string}#${infer Plural extends PluralSuffix}` 128 | ? Plural 129 | : never 130 | : never; 131 | 132 | type IsPlural< 133 | Key extends string, 134 | Scope extends Scopes | undefined, 135 | Locale extends BaseLocale, 136 | > = Scope extends undefined 137 | ? `${Key}#${PluralSuffix}` & keyof Locale extends never 138 | ? false 139 | : true 140 | : `${Scope}.${Key}#${PluralSuffix}` & keyof Locale extends never 141 | ? false 142 | : true; 143 | 144 | type GetCountUnion< 145 | Key extends string, 146 | Scope extends Scopes | undefined, 147 | Locale extends BaseLocale, 148 | Plural extends PluralSuffix = GetPlural, 149 | > = Plural extends 'zero' 150 | ? 0 151 | : Plural extends 'one' 152 | ? // eslint-disable-next-line @typescript-eslint/ban-types 153 | 1 | 21 | 31 | 41 | 51 | 61 | 71 | 81 | 91 | 101 | (number & {}) 154 | : Plural extends 'two' 155 | ? // eslint-disable-next-line @typescript-eslint/ban-types 156 | 2 | 22 | 32 | 42 | 52 | 62 | 72 | 82 | 92 | 102 | (number & {}) 157 | : number; 158 | 159 | type AddCount | undefined, Locale extends BaseLocale> = T extends [] 160 | ? [ 161 | { 162 | /** 163 | * The `count` depends on the plural tags defined in your locale, 164 | * and the current locale rules. 165 | * 166 | * - `zero` allows 0 167 | * - `one` autocompletes 1, 21, 31, 41... but allows any number 168 | * - `two` autocompletes 2, 22, 32, 42... but allows any number 169 | * - `few`, `many` and `other` allow any number 170 | * 171 | * @see https://www.unicode.org/cldr/charts/43/supplemental/language_plural_rules.html 172 | */ 173 | count: GetCountUnion; 174 | }, 175 | ] 176 | : T extends [infer R] 177 | ? [ 178 | { 179 | /** 180 | * The `count` depends on the plural tags defined in your locale, 181 | * and the current locale rules. 182 | * 183 | * - `zero` allows 0 184 | * - `one` autocompletes 1, 21, 31, 41... but allows any number 185 | * - `two` autocompletes 2, 22, 32, 42... but allows any number 186 | * - `few`, `many` and `other` allow any number 187 | * 188 | * @see https://www.unicode.org/cldr/charts/43/supplemental/language_plural_rules.html 189 | */ 190 | count: GetCountUnion; 191 | } & R, 192 | ] 193 | : never; 194 | 195 | export type CreateParams< 196 | T, 197 | Locale extends BaseLocale, 198 | Scope extends Scopes | undefined, 199 | Key extends LocaleKeys, 200 | Value extends LocaleValue = ScopedValue, 201 | > = IsPlural extends true 202 | ? AddCount['length'] extends 0 ? [] : [T], Key, Scope, Locale> 203 | : GetParams['length'] extends 0 204 | ? [] 205 | : [T]; 206 | -------------------------------------------------------------------------------- /packages/international-types/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "international-types", 3 | "version": "0.8.1", 4 | "description": "Type-safe internationalization (i18n) utility types", 5 | "types": "dist/index.d.ts", 6 | "keywords": [ 7 | "i18n", 8 | "types", 9 | "typescript", 10 | "translate", 11 | "internationalization" 12 | ], 13 | "files": [ 14 | "dist" 15 | ], 16 | "repository": { 17 | "type": "git", 18 | "url": "git+https://github.com/QuiiBz/next-international.git" 19 | }, 20 | "bugs": { 21 | "url": "https://github.com/QuiiBz/next-international/issues" 22 | }, 23 | "homepage": "https://next-international.vercel.app", 24 | "license": "MIT", 25 | "scripts": { 26 | "build": "tsc --declaration --emitDeclarationOnly --outDir dist index.ts" 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /packages/next-international/README.md: -------------------------------------------------------------------------------- 1 |

2 | 3 | 4 | 5 | 6 | 7 |
8 | Type-safe internationalization (i18n) for Next.js 9 |

10 | 11 | --- 12 | 13 | - [Features](#features) 14 | - [Documentation](#documentation) 15 | - [Sponsors](#sponsors) 16 | - [License](#license) 17 | 18 | ## Features 19 | 20 | - **100% Type-safe**: Locales in TS or JSON, type-safe `t()` & `scopedT()`, type-safe params, type-safe plurals, type-safe `changeLocale()`... 21 | - **Small**: No dependencies, lazy-loaded 22 | - **Simple**: No Webpack configuration, no CLI, no code generation, just pure TypeScript 23 | - **Server and Client, Static Rendering**: Lazy-load server and client-side, support for Static Rendering 24 | - **App or Pages Router**: With support for React Server Components 25 | 26 | > **Note**: You can now build on top of the types used by next-international using [international-types](https://github.com/QuiiBz/next-international/tree/main/packages/international-types)! 27 | 28 | Try it live on CodeSandbox: 29 | 30 | [![Open with CodeSandbox](https://assets.codesandbox.io/github/button-edit-lime.svg)](https://codesandbox.io/p/sandbox/jovial-paper-skkprk?file=%2Fapp%2F%5Blocale%5D%2Fpage.tsx%3A1%2C1) 31 | 32 | ## Documentation 33 | 34 | Check out the documentation at [https://next-international.vercel.app](https://next-international.vercel.app). 35 | 36 | ## Contributing 37 | 38 | [See the contributing guide](./CONTRIBUTING.md). 39 | 40 | ## Sponsors 41 | 42 | ![Sponsors](https://github.com/QuiiBz/dotfiles/blob/main/sponsors.png?raw=true) 43 | 44 | ## License 45 | 46 | [MIT](./LICENSE) 47 | -------------------------------------------------------------------------------- /packages/next-international/__tests__/create-i18n.test.tsx: -------------------------------------------------------------------------------- 1 | import { assertType, describe, expect, it, vi } from 'vitest'; 2 | import { createI18n } from '../src'; 3 | import React from 'react'; 4 | import { render } from './utils'; 5 | import en from './utils/en'; 6 | 7 | const push = vi.fn(); 8 | 9 | beforeEach(() => { 10 | vi.mock('next/router', () => ({ 11 | useRouter: vi.fn().mockImplementation(() => ({ 12 | push, 13 | asPath: '', 14 | locale: 'en', 15 | defaultLocale: 'en', 16 | locales: ['en', 'fr'], 17 | })), 18 | })); 19 | }); 20 | 21 | afterEach(() => { 22 | vi.clearAllMocks(); 23 | push.mockReset(); 24 | }); 25 | 26 | describe('createI18n', () => { 27 | it('should create i18n', () => { 28 | const result = createI18n({}); 29 | 30 | expect(result.I18nProvider).toBeDefined(); 31 | 32 | expect(result.getLocaleProps).toBeDefined(); 33 | expect(result.getLocaleProps).toBeInstanceOf(Function); 34 | 35 | expect(result.useChangeLocale).toBeDefined(); 36 | expect(result.useChangeLocale).toBeInstanceOf(Function); 37 | 38 | expect(result.useCurrentLocale).toBeDefined(); 39 | expect(result.useCurrentLocale).toBeInstanceOf(Function); 40 | 41 | expect(result.useI18n).toBeDefined(); 42 | expect(result.useI18n).toBeInstanceOf(Function); 43 | }); 44 | 45 | it('createI18n can infer types from imported .ts locales', () => { 46 | const { useI18n, I18nProvider, useChangeLocale, useCurrentLocale } = createI18n({ 47 | en: () => import('./utils/en'), 48 | fr: () => import('./utils/fr'), 49 | }); 50 | 51 | function App() { 52 | const changeLocale = useChangeLocale(); 53 | const t = useI18n(); 54 | 55 | assertType<() => 'en' | 'fr'>(useCurrentLocale); 56 | assertType<(newLocale: 'en' | 'fr') => void>(useChangeLocale()); 57 | 58 | assertType(useI18n()('hello')); 59 | 60 | //@ts-expect-error invalid key should give error 61 | useI18n()('asdfasdf'); 62 | 63 | return ( 64 |
65 | 68 |

{t('hello')}

69 |
70 | ); 71 | } 72 | 73 | render( 74 | 75 | 76 | , 77 | ); 78 | }); 79 | 80 | it('createI18n can infer types from explicitly typed locales', () => { 81 | type Locale = { 82 | hello: string; 83 | welcome: string; 84 | }; 85 | 86 | type Locales = { 87 | en: Locale; 88 | fr: Locale; 89 | }; 90 | 91 | const { useChangeLocale, useCurrentLocale, useI18n, I18nProvider } = createI18n({ 92 | en: () => import('./utils/en'), 93 | fr: () => import('./utils/fr'), 94 | }); 95 | 96 | function App() { 97 | const changeLocale = useChangeLocale(); 98 | const t = useI18n(); 99 | 100 | assertType<'en' | 'fr'>(useCurrentLocale()); 101 | assertType<(newLocale: 'en' | 'fr') => void>(useChangeLocale()); 102 | 103 | assertType(useI18n()('welcome')); 104 | 105 | //@ts-expect-error invalid key should give error 106 | useI18n()('asdfasdf'); 107 | 108 | return ( 109 |
110 | 113 |

{t('hello')}

114 |
115 | ); 116 | } 117 | 118 | render( 119 | 120 | 121 | , 122 | ); 123 | }); 124 | }); 125 | -------------------------------------------------------------------------------- /packages/next-international/__tests__/fallback-locale.test.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; 3 | import { createI18n } from '../src'; 4 | import { render, screen } from './utils'; 5 | import en from './utils/en'; 6 | import fr from './utils/fr'; 7 | 8 | beforeEach(() => { 9 | vi.mock('next/router', () => ({ 10 | useRouter: vi.fn().mockImplementation(() => ({ 11 | locale: 'fr', 12 | defaultLocale: 'fr', 13 | locales: ['en', 'fr'], 14 | })), 15 | })); 16 | }); 17 | 18 | afterEach(() => { 19 | vi.clearAllMocks(); 20 | }); 21 | 22 | describe('fallbackLocale', () => { 23 | it('should output the key when no fallback locale is configured', async () => { 24 | const { useI18n, I18nProvider } = createI18n({ 25 | en: () => import('./utils/en'), 26 | fr: () => import('./utils/fr'), 27 | }); 28 | 29 | function App() { 30 | const t = useI18n(); 31 | 32 | return

{t('only.exists.in.en')}

; 33 | } 34 | 35 | render( 36 | // @ts-expect-error missing key 37 | 38 | 39 | , 40 | ); 41 | 42 | expect(screen.getByText('only.exists.in.en')).toBeInTheDocument(); 43 | }); 44 | 45 | it.skip('should output the key when no fallback locale is configured', async () => { 46 | const { useI18n, I18nProvider } = createI18n({ 47 | en: () => import('./utils/en'), 48 | fr: () => import('./utils/fr'), 49 | }); 50 | 51 | function App() { 52 | const t = useI18n(); 53 | 54 | return

{t('only.exists.in.en')}

; 55 | } 56 | 57 | render( 58 | // @ts-expect-error missing key 59 | 60 | 61 | , 62 | ); 63 | 64 | expect(screen.getByText('FR ')).toBeInTheDocument(); 65 | }); 66 | }); 67 | -------------------------------------------------------------------------------- /packages/next-international/__tests__/get-locale-props.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it, vi } from 'vitest'; 2 | import { createI18n } from '../src'; 3 | import en from './utils/en'; 4 | 5 | describe('getLocaleProps', () => { 6 | it('should error if locale is not defined', async () => { 7 | const spy = vi.spyOn(console, 'error').mockImplementation(() => null); 8 | const { getLocaleProps } = createI18n({ 9 | en: () => import('./utils/en'), 10 | fr: () => import('./utils/fr'), 11 | }); 12 | 13 | const props = await getLocaleProps()({ 14 | locales: ['en', 'fr'], 15 | }); 16 | 17 | expect(props).toEqual({ 18 | props: {}, 19 | }); 20 | expect(console.error).toHaveBeenCalledWith( 21 | "[next-international] 'i18n.defaultLocale' not defined in 'next.config.js'", 22 | ); 23 | spy.mockReset(); 24 | }); 25 | 26 | it('should return default locale', async () => { 27 | const { getLocaleProps } = createI18n({ 28 | en: () => import('./utils/en'), 29 | fr: () => import('./utils/fr'), 30 | }); 31 | 32 | const props = await getLocaleProps()({ 33 | locale: 'en', 34 | defaultLocale: 'en', 35 | locales: ['en', 'fr'], 36 | }); 37 | 38 | expect(props).toEqual({ 39 | props: { 40 | locale: en, 41 | }, 42 | }); 43 | }); 44 | 45 | it('should return default locale with existing getStaticProps', async () => { 46 | const { getLocaleProps } = createI18n({ 47 | en: () => import('./utils/en'), 48 | fr: () => import('./utils/fr'), 49 | }); 50 | 51 | const props = await getLocaleProps(() => ({ 52 | props: { 53 | hello: 'world', 54 | }, 55 | }))({ 56 | locale: 'en', 57 | defaultLocale: 'en', 58 | locales: ['en', 'fr'], 59 | }); 60 | 61 | expect(props).toEqual({ 62 | props: { 63 | hello: 'world', 64 | locale: en, 65 | }, 66 | }); 67 | }); 68 | }); 69 | -------------------------------------------------------------------------------- /packages/next-international/__tests__/i18n-provider.test.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { describe, vi } from 'vitest'; 3 | import { createI18n } from '../src'; 4 | import { render, screen, waitFor } from './utils'; 5 | import en from './utils/en'; 6 | 7 | beforeEach(() => { 8 | vi.mock('next/router', () => ({ 9 | useRouter: vi.fn().mockImplementation(() => ({ 10 | locale: 'en', 11 | defaultLocale: 'en', 12 | locales: ['en', 'fr'], 13 | })), 14 | })); 15 | }); 16 | 17 | afterEach(() => { 18 | vi.clearAllMocks(); 19 | }); 20 | 21 | describe('I18nProvider', () => { 22 | describe('fallback', () => { 23 | it('should return the fallback while loading the locale', async () => { 24 | const { useI18n, I18nProvider } = createI18n({ 25 | en: () => import('./utils/en'), 26 | fr: () => import('./utils/fr'), 27 | }); 28 | 29 | function App() { 30 | const t = useI18n(); 31 | 32 | return

{t('hello')}

; 33 | } 34 | 35 | render( 36 | // @ts-expect-error we don't provide `locale` to test the fallback 37 | Loading...

}> 38 | 39 |
, 40 | ); 41 | 42 | expect(screen.getByText('Loading...')).toBeInTheDocument(); 43 | 44 | await waitFor(() => { 45 | expect(screen.getByText('Hello')).toBeInTheDocument(); 46 | }); 47 | }); 48 | 49 | it('should return null if no fallback provided', async () => { 50 | const { useI18n, I18nProvider } = createI18n({ 51 | en: () => import('./utils/en'), 52 | fr: () => import('./utils/fr'), 53 | }); 54 | 55 | function App() { 56 | const t = useI18n(); 57 | 58 | return

{t('hello')}

; 59 | } 60 | 61 | render( 62 | // @ts-expect-error we don't provide `locale` to test the fallback 63 | 64 | 65 | , 66 | ); 67 | 68 | expect(screen.queryByText('Hello')).not.toBeInTheDocument(); 69 | 70 | await waitFor(() => { 71 | expect(screen.getByText('Hello')).toBeInTheDocument(); 72 | }); 73 | }); 74 | }); 75 | 76 | describe('config', () => { 77 | it('should warn about mismatching locales in createI18n', () => { 78 | const spy = vi.spyOn(console, 'warn').mockImplementation(() => null); 79 | 80 | const { useI18n, I18nProvider } = createI18n({ 81 | en: () => import('./utils/en'), 82 | fr: () => import('./utils/fr'), 83 | es: () => import('./utils/en'), 84 | }); 85 | 86 | function App() { 87 | const t = useI18n(); 88 | 89 | return

{t('hello')}

; 90 | } 91 | 92 | render( 93 | 94 | 95 | , 96 | ); 97 | 98 | expect(console.warn).toHaveBeenCalledWith( 99 | "[next-international] The following locales are defined in 'createI18n' but not in 'next.config.js': es", 100 | ); 101 | spy.mockClear(); 102 | }); 103 | 104 | it('should warn about mismatching locales in next.config.js', () => { 105 | const spy = vi.spyOn(console, 'warn').mockImplementation(() => null); 106 | 107 | const { useI18n, I18nProvider } = createI18n({ 108 | en: () => import('./utils/en'), 109 | }); 110 | 111 | function App() { 112 | const t = useI18n(); 113 | 114 | return

{t('hello')}

; 115 | } 116 | 117 | render( 118 | 119 | 120 | , 121 | ); 122 | 123 | expect(console.warn).toHaveBeenCalledWith( 124 | "[next-international] The following locales are defined in 'next.config.js' but not in 'createI18n': fr", 125 | ); 126 | spy.mockClear(); 127 | }); 128 | }); 129 | }); 130 | -------------------------------------------------------------------------------- /packages/next-international/__tests__/log.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it, vi } from 'vitest'; 2 | import { error, warn } from '../src/helpers/log'; 3 | 4 | describe('log', () => { 5 | it('should log warn', () => { 6 | const spy = vi.spyOn(console, 'warn').mockImplementation(() => null); 7 | 8 | warn('This is a warn'); 9 | 10 | expect(console.warn).toHaveBeenCalledWith('[next-international] This is a warn'); 11 | spy.mockClear(); 12 | }); 13 | 14 | it('should log error', () => { 15 | const spy = vi.spyOn(console, 'error').mockImplementation(() => null); 16 | 17 | error('This is an error'); 18 | 19 | expect(console.error).toHaveBeenCalledWith('[next-international] This is an error'); 20 | spy.mockClear(); 21 | }); 22 | }); 23 | -------------------------------------------------------------------------------- /packages/next-international/__tests__/use-change-locale.test.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; 3 | import { createI18n } from '../src'; 4 | import { render, userEvent, screen } from './utils'; 5 | import en from './utils/en'; 6 | 7 | const push = vi.fn(); 8 | 9 | beforeEach(() => { 10 | vi.mock('next/router', () => ({ 11 | useRouter: vi.fn().mockImplementation(() => ({ 12 | push, 13 | asPath: '', 14 | locale: 'en', 15 | defaultLocale: 'en', 16 | locales: ['en', 'fr'], 17 | })), 18 | })); 19 | }); 20 | 21 | afterEach(() => { 22 | vi.clearAllMocks(); 23 | push.mockReset(); 24 | }); 25 | 26 | describe('useChangeLocale', () => { 27 | it('should change locale', async () => { 28 | const { useI18n, I18nProvider, useChangeLocale } = createI18n({ 29 | en: () => import('./utils/en'), 30 | fr: () => import('./utils/fr'), 31 | }); 32 | 33 | function App() { 34 | const changeLocale = useChangeLocale(); 35 | const t = useI18n(); 36 | 37 | return ( 38 |
39 | 42 |

{t('hello')}

43 |
44 | ); 45 | } 46 | 47 | render( 48 | 49 | 50 | , 51 | ); 52 | 53 | await userEvent.click(screen.getByText('Change locale')); 54 | expect(push).toHaveBeenCalledOnce(); 55 | }); 56 | }); 57 | -------------------------------------------------------------------------------- /packages/next-international/__tests__/use-i18n.test.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { renderHook } from '@testing-library/react'; 3 | import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; 4 | import { createI18n } from '../src'; 5 | import { render, screen } from './utils'; 6 | import en from './utils/en'; 7 | 8 | beforeEach(() => { 9 | vi.mock('next/router', () => ({ 10 | useRouter: vi 11 | .fn() 12 | .mockImplementationOnce(() => ({ 13 | locales: ['en', 'fr'], 14 | })) 15 | .mockImplementationOnce(() => ({ 16 | locale: 'en', 17 | defaultLocale: 'en', 18 | })) 19 | .mockImplementation(() => ({ 20 | locale: 'en', 21 | defaultLocale: 'en', 22 | locales: ['en', 'fr'], 23 | })), 24 | })); 25 | }); 26 | 27 | afterEach(() => { 28 | vi.clearAllMocks(); 29 | }); 30 | 31 | describe('useI18n', () => { 32 | it('should log error if locale not set in next.config.js', () => { 33 | const spy = vi.spyOn(console, 'error').mockImplementation(() => null); 34 | const { useI18n, I18nProvider } = createI18n({ 35 | en: () => import('./utils/en'), 36 | fr: () => import('./utils/fr'), 37 | }); 38 | 39 | function App() { 40 | const t = useI18n(); 41 | 42 | return

{t('hello')}

; 43 | } 44 | 45 | render( 46 | 47 | 48 | , 49 | ); 50 | expect(console.error).toHaveBeenCalledWith( 51 | "[next-international] 'i18n.defaultLocale' not defined in 'next.config.js'", 52 | ); 53 | 54 | spy.mockReset(); 55 | }); 56 | 57 | it('should log error if locales not set in next.config.js', () => { 58 | const spy = vi.spyOn(console, 'error').mockImplementation(() => null); 59 | const { useI18n, I18nProvider } = createI18n({ 60 | en: () => import('./utils/en'), 61 | fr: () => import('./utils/fr'), 62 | }); 63 | 64 | function App() { 65 | const t = useI18n(); 66 | 67 | return

{t('hello')}

; 68 | } 69 | 70 | render( 71 | 72 | 73 | , 74 | ); 75 | expect(console.error).toHaveBeenCalledWith("[next-international] 'i18n.locales' not defined in 'next.config.js'"); 76 | 77 | spy.mockReset(); 78 | }); 79 | 80 | it('should throw if not used inside I18nProvider', () => { 81 | const { useI18n } = createI18n({ 82 | en: () => import('./utils/en'), 83 | fr: () => import('./utils/fr'), 84 | }); 85 | 86 | function App() { 87 | const t = useI18n(); 88 | 89 | return

{t('hello')}

; 90 | } 91 | 92 | expect(() => render()).toThrowError('`useI18n` must be used inside `I18nProvider`'); 93 | }); 94 | 95 | it('should translate', async () => { 96 | const { useI18n, I18nProvider } = createI18n({ 97 | en: () => import('./utils/en'), 98 | fr: () => import('./utils/fr'), 99 | }); 100 | 101 | function App() { 102 | const t = useI18n(); 103 | 104 | return

{t('hello')}

; 105 | } 106 | 107 | render( 108 | 109 | 110 | , 111 | ); 112 | 113 | expect(screen.getByText('Hello')).toBeInTheDocument(); 114 | }); 115 | 116 | it('should translate multiple keys', async () => { 117 | const { useI18n, I18nProvider } = createI18n({ 118 | en: () => import('./utils/en'), 119 | fr: () => import('./utils/fr'), 120 | }); 121 | 122 | function App() { 123 | const t = useI18n(); 124 | 125 | return

{t('hello.world')}

; 126 | } 127 | 128 | render( 129 | 130 | 131 | , 132 | ); 133 | 134 | expect(screen.getByText('Hello World!')).toBeInTheDocument(); 135 | }); 136 | 137 | it('should translate with param', async () => { 138 | const { useI18n, I18nProvider } = createI18n({ 139 | en: () => import('./utils/en'), 140 | fr: () => import('./utils/fr'), 141 | }); 142 | 143 | function App() { 144 | const t = useI18n(); 145 | 146 | return ( 147 |

148 | {t('weather', { 149 | weather: 'sunny', 150 | })} 151 |

152 | ); 153 | } 154 | 155 | render( 156 | 157 | 158 | , 159 | ); 160 | 161 | expect(screen.getByText("Today's weather is sunny")).toBeInTheDocument(); 162 | }); 163 | 164 | it('should translate with param (with react component)', async () => { 165 | const { useI18n, I18nProvider } = createI18n({ 166 | en: () => import('./utils/en'), 167 | fr: () => import('./utils/fr'), 168 | }); 169 | 170 | function App() { 171 | const t = useI18n(); 172 | 173 | return ( 174 |

175 | {t('weather', { 176 | weather: sunny, 177 | })} 178 |

179 | ); 180 | } 181 | 182 | render( 183 | 184 | 185 | , 186 | ); 187 | expect(screen.getByTestId('test')).toHaveTextContent("Today's weather is sunny"); 188 | }); 189 | 190 | it('should translate with the same param used twice', async () => { 191 | const { useI18n, I18nProvider } = createI18n({ 192 | en: () => import('./utils/en'), 193 | fr: () => import('./utils/fr'), 194 | }); 195 | 196 | function App() { 197 | const t = useI18n(); 198 | 199 | return ( 200 |

201 | {t('double.param', { 202 | param: '', 203 | })} 204 |

205 | ); 206 | } 207 | 208 | render( 209 | 210 | 211 | , 212 | ); 213 | expect(screen.getByText('This is used twice ()')).toBeInTheDocument(); 214 | }); 215 | 216 | it('should translate with multiple params', async () => { 217 | const { useI18n, I18nProvider } = createI18n({ 218 | en: () => import('./utils/en'), 219 | fr: () => import('./utils/fr'), 220 | }); 221 | 222 | function App() { 223 | const t = useI18n(); 224 | 225 | return ( 226 |

227 | {t('user.description', { 228 | name: 'John', 229 | years: '30', 230 | })} 231 |

232 | ); 233 | } 234 | 235 | render( 236 | 237 | 238 | , 239 | ); 240 | 241 | expect(screen.getByText('John is 30 years old')).toBeInTheDocument(); 242 | }); 243 | 244 | it('return a string if no properties are passed', async () => { 245 | const { useI18n, I18nProvider } = createI18n({ 246 | en: () => import('./utils/en'), 247 | fr: () => import('./utils/fr'), 248 | }); 249 | 250 | const App = ({ children }: { children: React.ReactNode }) => { 251 | return {children}; 252 | }; 253 | 254 | const { result } = renderHook( 255 | () => { 256 | const t = useI18n(); 257 | return t('hello'); 258 | }, 259 | { 260 | wrapper: App, 261 | }, 262 | ); 263 | 264 | expect(typeof result.current).toBe('string'); 265 | }); 266 | 267 | it('return a string if all properties are strings', async () => { 268 | const { useI18n, I18nProvider } = createI18n({ 269 | en: () => import('./utils/en'), 270 | fr: () => import('./utils/fr'), 271 | }); 272 | 273 | const App = ({ children }: { children: React.ReactNode }) => { 274 | return {children}; 275 | }; 276 | 277 | const { result } = renderHook( 278 | () => { 279 | const t = useI18n(); 280 | return t('user.description', { 281 | name: 'John', 282 | years: '30', 283 | }); 284 | }, 285 | { 286 | wrapper: App, 287 | }, 288 | ); 289 | 290 | expect(typeof result.current).toBe('string'); 291 | }); 292 | 293 | it('return an array if some property is a react node', async () => { 294 | const { useI18n, I18nProvider } = createI18n({ 295 | en: () => import('./utils/en'), 296 | fr: () => import('./utils/fr'), 297 | }); 298 | 299 | const App = ({ children }: { children: React.ReactNode }) => { 300 | return {children}; 301 | }; 302 | 303 | const { result } = renderHook( 304 | () => { 305 | const t = useI18n(); 306 | return t('user.description', { 307 | name: John, 308 | years: '30', 309 | }); 310 | }, 311 | { 312 | wrapper: App, 313 | }, 314 | ); 315 | 316 | expect(Array.isArray(result.current)).toBe(true); 317 | }); 318 | 319 | const pluralTestCases = [ 320 | { 321 | count: 0, 322 | expected: 'No cows (#zero)', 323 | }, 324 | { 325 | count: 1, 326 | expected: 'One cow (#one)', 327 | }, 328 | { 329 | count: 2, 330 | expected: '2 cows (#other)', 331 | }, 332 | ...[...new Array(30).fill(0)].map((_, i) => ({ count: i + 3, expected: `${i + 3} cows (#other)` })), 333 | ]; 334 | 335 | it.each(pluralTestCases)('should return correct plural: $count ($expected)', async ({ count, expected }) => { 336 | const { useI18n, I18nProvider } = createI18n({ 337 | en: () => import('./utils/en'), 338 | fr: () => import('./utils/fr'), 339 | }); 340 | 341 | const App = ({ children }: { children: React.ReactNode }) => { 342 | return {children}; 343 | }; 344 | 345 | const { result } = renderHook( 346 | () => { 347 | const t = useI18n(); 348 | return t('cow', { 349 | count, 350 | }); 351 | }, 352 | { 353 | wrapper: App, 354 | }, 355 | ); 356 | 357 | expect(result.current).toBe(expected); 358 | }); 359 | it('should fallback on #other if #zero is not defined', async () => { 360 | const { useI18n, I18nProvider } = createI18n({ 361 | en: () => import('./utils/en'), 362 | fr: () => import('./utils/fr'), 363 | }); 364 | 365 | const App = ({ children }: { children: React.ReactNode }) => { 366 | return {children}; 367 | }; 368 | 369 | const { result } = renderHook( 370 | () => { 371 | const t = useI18n(); 372 | return t('horse', { 373 | count: 0, 374 | }); 375 | }, 376 | { 377 | wrapper: App, 378 | }, 379 | ); 380 | 381 | expect(result.current).toBe('0 horses (#other)'); 382 | }); 383 | }); 384 | -------------------------------------------------------------------------------- /packages/next-international/__tests__/use-scoped-i18n.test.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; 3 | import { createI18n } from '../src'; 4 | import { render, screen } from './utils'; 5 | import en from './utils/en'; 6 | 7 | beforeEach(() => { 8 | vi.mock('next/router', () => ({ 9 | useRouter: vi 10 | .fn() 11 | .mockImplementationOnce(() => ({ 12 | locales: ['en', 'fr'], 13 | })) 14 | .mockImplementationOnce(() => ({ 15 | locale: 'en', 16 | defaultLocale: 'en', 17 | })) 18 | .mockImplementation(() => ({ 19 | locale: 'en', 20 | defaultLocale: 'en', 21 | locales: ['en', 'fr'], 22 | })), 23 | })); 24 | }); 25 | 26 | afterEach(() => { 27 | vi.clearAllMocks(); 28 | }); 29 | 30 | describe('useScopedI18n', () => { 31 | it('should log error if locale not set in next.config.js', () => { 32 | const spy = vi.spyOn(console, 'error').mockImplementation(() => null); 33 | const { useScopedI18n, I18nProvider } = createI18n({ 34 | en: () => import('./utils/en'), 35 | fr: () => import('./utils/fr'), 36 | }); 37 | 38 | function App() { 39 | const t = useScopedI18n('namespace.subnamespace'); 40 | 41 | return

{t('hello')}

; 42 | } 43 | 44 | render( 45 | 46 | 47 | , 48 | ); 49 | expect(console.error).toHaveBeenCalledWith( 50 | "[next-international] 'i18n.defaultLocale' not defined in 'next.config.js'", 51 | ); 52 | 53 | spy.mockReset(); 54 | }); 55 | 56 | it('should log error if locales not set in next.config.js', () => { 57 | const spy = vi.spyOn(console, 'error').mockImplementation(() => null); 58 | const { useScopedI18n, I18nProvider } = createI18n({ 59 | en: () => import('./utils/en'), 60 | fr: () => import('./utils/fr'), 61 | }); 62 | 63 | function App() { 64 | const t = useScopedI18n('namespace.subnamespace'); 65 | 66 | return

{t('hello')}

; 67 | } 68 | 69 | render( 70 | 71 | 72 | , 73 | ); 74 | expect(console.error).toHaveBeenCalledWith("[next-international] 'i18n.locales' not defined in 'next.config.js'"); 75 | 76 | spy.mockReset(); 77 | }); 78 | 79 | it('should throw if not used inside I18nProvider', () => { 80 | const { useScopedI18n } = createI18n({ 81 | en: () => import('./utils/en'), 82 | fr: () => import('./utils/fr'), 83 | }); 84 | 85 | function App() { 86 | const t = useScopedI18n('namespace.subnamespace'); 87 | 88 | return

{t('hello')}

; 89 | } 90 | 91 | expect(() => render()).toThrowError('`useI18n` must be used inside `I18nProvider`'); 92 | }); 93 | 94 | it('should translate', async () => { 95 | const { useScopedI18n, I18nProvider } = createI18n({ 96 | en: () => import('./utils/en'), 97 | fr: () => import('./utils/fr'), 98 | }); 99 | 100 | function App() { 101 | const t = useScopedI18n('namespace'); 102 | 103 | return

{t('hello')}

; 104 | } 105 | 106 | render( 107 | 108 | 109 | , 110 | ); 111 | 112 | expect(screen.getByText('Hello')).toBeInTheDocument(); 113 | }); 114 | 115 | it('should translate multiple keys', async () => { 116 | const { useScopedI18n, I18nProvider } = createI18n({ 117 | en: () => import('./utils/en'), 118 | fr: () => import('./utils/fr'), 119 | }); 120 | 121 | function App() { 122 | const t = useScopedI18n('namespace.subnamespace'); 123 | 124 | return ( 125 | <> 126 |

{t('hello')}

127 |

{t('hello.world')}

128 | 129 | ); 130 | } 131 | 132 | render( 133 | 134 | 135 | , 136 | ); 137 | 138 | expect(screen.getByText('Hello')).toBeInTheDocument(); 139 | expect(screen.getByText('Hello World!')).toBeInTheDocument(); 140 | }); 141 | 142 | it('should translate with param', async () => { 143 | const { useScopedI18n, I18nProvider } = createI18n({ 144 | en: () => import('./utils/en'), 145 | fr: () => import('./utils/fr'), 146 | }); 147 | 148 | function App() { 149 | const t = useScopedI18n('namespace.subnamespace'); 150 | 151 | return ( 152 |

153 | {t('weather', { 154 | weather: 'sunny', 155 | })} 156 |

157 | ); 158 | } 159 | 160 | render( 161 | 162 | 163 | , 164 | ); 165 | 166 | expect(screen.getByText("Today's weather is sunny")).toBeInTheDocument(); 167 | }); 168 | 169 | it('should translate with multiple params', async () => { 170 | const { useScopedI18n, I18nProvider } = createI18n({ 171 | en: () => import('./utils/en'), 172 | fr: () => import('./utils/fr'), 173 | }); 174 | 175 | function App() { 176 | const t = useScopedI18n('namespace.subnamespace'); 177 | 178 | return ( 179 |

180 | {t('user.description', { 181 | name: 'John', 182 | years: '30', 183 | })} 184 |

185 | ); 186 | } 187 | 188 | render( 189 | 190 | 191 | , 192 | ); 193 | 194 | expect(screen.getByText('John is 30 years old')).toBeInTheDocument(); 195 | }); 196 | 197 | it('should translate with multiple params (using react component)', async () => { 198 | const { useScopedI18n, I18nProvider } = createI18n({ 199 | en: () => import('./utils/en'), 200 | fr: () => import('./utils/fr'), 201 | }); 202 | 203 | function App() { 204 | const t = useScopedI18n('namespace.subnamespace'); 205 | 206 | return ( 207 |

208 | {t('user.description', { 209 | name: John, 210 | years: '30', 211 | })} 212 |

213 | ); 214 | } 215 | 216 | render( 217 | 218 | 219 | , 220 | ); 221 | 222 | expect(screen.getByTestId('test')).toHaveTextContent('John is 30 years old'); 223 | }); 224 | }); 225 | -------------------------------------------------------------------------------- /packages/next-international/__tests__/utils/en.ts: -------------------------------------------------------------------------------- 1 | export default { 2 | hello: 'Hello', 3 | 'hello.world': 'Hello World!', 4 | weather: "Today's weather is {weather}", 5 | 'user.description': '{name} is {years} years old', 6 | 'namespace.hello': 'Hello', 7 | 'namespace.subnamespace.hello': 'Hello', 8 | 'namespace.subnamespace.hello.world': 'Hello World!', 9 | 'namespace.subnamespace.weather': "Today's weather is {weather}", 10 | 'namespace.subnamespace.user.description': '{name} is {years} years old', 11 | 'only.exists.in.en': 'EN locale', 12 | 'double.param': 'This {param} is used twice ({param})', 13 | 'cow#zero': 'No cows (#zero)', 14 | 'cow#one': 'One cow (#one)', 15 | 'cow#other': '{count} cows (#other)', 16 | 'horse#one': 'One horse (#one)', 17 | 'horse#other': '{count} horses (#other)', 18 | } as const; 19 | -------------------------------------------------------------------------------- /packages/next-international/__tests__/utils/fr.ts: -------------------------------------------------------------------------------- 1 | export default { 2 | hello: 'Bonjour', 3 | 'hello.world': 'Bonjour le monde !', 4 | weather: "La météo d'aujourd'hui est {weather}", 5 | 'user.description': '{name} a {years} ans', 6 | 'namespace.hello': 'Bonjour', 7 | } as const; 8 | -------------------------------------------------------------------------------- /packages/next-international/__tests__/utils/index.tsx: -------------------------------------------------------------------------------- 1 | import { cleanup, render } from '@testing-library/react'; 2 | import { afterEach } from 'vitest'; 3 | 4 | afterEach(() => { 5 | cleanup(); 6 | }); 7 | 8 | const customRender = (ui: React.ReactElement, options = {}) => 9 | render(ui, { 10 | // wrap provider(s) here if needed 11 | wrapper: ({ children }) => children, 12 | ...options, 13 | }); 14 | 15 | export * from '@testing-library/react'; 16 | export { default as userEvent } from '@testing-library/user-event'; 17 | // override render export 18 | export { customRender as render }; 19 | -------------------------------------------------------------------------------- /packages/next-international/client.d.ts: -------------------------------------------------------------------------------- 1 | export * from './dist/app/client'; 2 | -------------------------------------------------------------------------------- /packages/next-international/middleware.d.ts: -------------------------------------------------------------------------------- 1 | export * from './dist/app/middleware'; 2 | -------------------------------------------------------------------------------- /packages/next-international/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "next-international", 3 | "version": "1.3.1", 4 | "description": "Type-safe internationalization (i18n) for Next.js", 5 | "main": "dist/index.js", 6 | "types": "dist/index.d.ts", 7 | "exports": { 8 | ".": { 9 | "types": "./dist/index.d.ts", 10 | "default": "./dist/index.js" 11 | }, 12 | "./package.json": "./package.json", 13 | "./client": { 14 | "types": "./dist/app/client/index.d.ts", 15 | "default": "./dist/app/client/index.js" 16 | }, 17 | "./server": { 18 | "types": "./dist/app/server/index.d.ts", 19 | "default": "./dist/app/server/index.js" 20 | }, 21 | "./middleware": { 22 | "types": "./dist/app/middleware/index.d.ts", 23 | "default": "./dist/app/middleware/index.js" 24 | } 25 | }, 26 | "keywords": [ 27 | "next", 28 | "i18n", 29 | "translate", 30 | "internationalization" 31 | ], 32 | "files": [ 33 | "dist", 34 | "client.d.ts", 35 | "server.d.ts", 36 | "middleware.d.ts" 37 | ], 38 | "repository": { 39 | "type": "git", 40 | "url": "git+https://github.com/QuiiBz/next-international.git" 41 | }, 42 | "bugs": { 43 | "url": "https://github.com/QuiiBz/next-international/issues" 44 | }, 45 | "homepage": "https://next-international.vercel.app", 46 | "license": "MIT", 47 | "scripts": { 48 | "build": "tsup src/index.ts src/app/client/index.ts src/app/server/index.ts src/app/middleware/index.ts --external next --external react --dts", 49 | "watch": "pnpm build --watch" 50 | }, 51 | "devDependencies": { 52 | "@types/react": "^18.2.45", 53 | "next": "^15.0.0", 54 | "react": "^18.2.0", 55 | "tsup": "^8.0.1" 56 | }, 57 | "dependencies": { 58 | "client-only": "^0.0.1", 59 | "international-types": "^0.8.1", 60 | "server-only": "^0.0.1" 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /packages/next-international/server.d.ts: -------------------------------------------------------------------------------- 1 | export * from './dist/app/server'; 2 | -------------------------------------------------------------------------------- /packages/next-international/src/app/client/create-i18n-provider-client.tsx: -------------------------------------------------------------------------------- 1 | import type { BaseLocale, ImportedLocales } from 'international-types'; 2 | import { notFound } from 'next/navigation'; 3 | import type { Context, ReactNode } from 'react'; 4 | import React, { Suspense, use, useMemo } from 'react'; 5 | import { flattenLocale } from '../../common/flatten-locale'; 6 | import { error } from '../../helpers/log'; 7 | import type { LocaleContext } from '../../types'; 8 | 9 | type I18nProviderProps = Omit & { 10 | importLocale: Promise>; 11 | }; 12 | 13 | type I18nProviderWrapperProps = { 14 | locale: string; 15 | fallback?: ReactNode; 16 | children: ReactNode; 17 | }; 18 | 19 | export const localesCache = new Map>(); 20 | 21 | export function createI18nProviderClient( 22 | I18nClientContext: Context | null>, 23 | locales: ImportedLocales, 24 | fallbackLocale?: Record, 25 | ) { 26 | function I18nProvider({ locale, importLocale, children }: I18nProviderProps) { 27 | const clientLocale = (localesCache.get(locale) ?? use(importLocale).default) as Record; 28 | 29 | if (!localesCache.has(locale)) { 30 | localesCache.set(locale, clientLocale); 31 | } 32 | 33 | const value = useMemo( 34 | () => ({ 35 | localeContent: flattenLocale(clientLocale), 36 | fallbackLocale: fallbackLocale ? flattenLocale(fallbackLocale) : undefined, 37 | locale: locale as string, 38 | }), 39 | [clientLocale, locale], 40 | ); 41 | 42 | return {children}; 43 | } 44 | 45 | return function I18nProviderWrapper({ locale, fallback, children }: I18nProviderWrapperProps) { 46 | const importFnLocale = locales[locale as keyof typeof locales]; 47 | 48 | if (!importFnLocale) { 49 | error(`The locale '${locale}' is not supported. Defined locales are: [${Object.keys(locales).join(', ')}].`); 50 | notFound(); 51 | } 52 | 53 | return ( 54 | 55 | 56 | {children} 57 | 58 | 59 | ); 60 | }; 61 | } 62 | -------------------------------------------------------------------------------- /packages/next-international/src/app/client/create-use-change-locale.ts: -------------------------------------------------------------------------------- 1 | import { warn } from '../../helpers/log'; 2 | import type { ImportedLocales } from 'international-types'; 3 | import { usePathname, useRouter, useSearchParams } from 'next/navigation'; 4 | import type { I18nChangeLocaleConfig, I18nClientConfig } from '../../types'; 5 | import { localesCache } from './create-i18n-provider-client'; 6 | 7 | export function createUseChangeLocale( 8 | useCurrentLocale: () => LocalesKeys, 9 | locales: ImportedLocales, 10 | config: I18nClientConfig, 11 | ) { 12 | return function useChangeLocale(changeLocaleConfig?: I18nChangeLocaleConfig) { 13 | const { push, refresh } = useRouter(); 14 | const currentLocale = useCurrentLocale(); 15 | const path = usePathname(); 16 | // We call the hook conditionally to avoid always opting out of Static Rendering. 17 | // eslint-disable-next-line react-hooks/rules-of-hooks 18 | const searchParams = changeLocaleConfig?.preserveSearchParams ? useSearchParams().toString() : undefined; 19 | const finalSearchParams = searchParams ? `?${searchParams}` : ''; 20 | 21 | let pathWithoutLocale = path; 22 | 23 | if (config.basePath) { 24 | pathWithoutLocale = pathWithoutLocale.replace(config.basePath, ''); 25 | } 26 | 27 | if (pathWithoutLocale.startsWith(`/${currentLocale}/`)) { 28 | pathWithoutLocale = pathWithoutLocale.replace(`/${currentLocale}/`, '/'); 29 | } else if (pathWithoutLocale === `/${currentLocale}`) { 30 | pathWithoutLocale = '/'; 31 | } 32 | 33 | return function changeLocale(newLocale: LocalesKeys) { 34 | if (newLocale === currentLocale) return; 35 | 36 | const importFnLocale = locales[newLocale as keyof typeof locales]; 37 | 38 | if (!importFnLocale) { 39 | warn(`The locale '${newLocale}' is not supported. Defined locales are: [${Object.keys(locales).join(', ')}].`); 40 | return; 41 | } 42 | 43 | importFnLocale().then(module => { 44 | localesCache.set(newLocale as string, module.default); 45 | 46 | push(`/${newLocale}${pathWithoutLocale}${finalSearchParams}`); 47 | refresh(); 48 | }); 49 | }; 50 | }; 51 | } 52 | -------------------------------------------------------------------------------- /packages/next-international/src/app/client/create-use-current-locale.ts: -------------------------------------------------------------------------------- 1 | import { notFound, useParams } from 'next/navigation'; 2 | import { useMemo } from 'react'; 3 | import { DEFAULT_SEGMENT_NAME } from '../../common/constants'; 4 | import type { I18nClientConfig } from '../../types'; 5 | import { error } from '../../helpers/log'; 6 | 7 | export function createUseCurrentLocale(locales: LocalesKeys[], config: I18nClientConfig) { 8 | return function useCurrentLocale() { 9 | const params = useParams(); 10 | const segment = params[config.segmentName ?? DEFAULT_SEGMENT_NAME]; 11 | 12 | return useMemo(() => { 13 | for (const locale of locales) { 14 | if (segment === locale) { 15 | return locale; 16 | } 17 | } 18 | 19 | error(`Locale "${segment}" not found in locales (${locales.join(', ')}), returning "notFound()"`); 20 | notFound(); 21 | }, [segment]); 22 | }; 23 | } 24 | -------------------------------------------------------------------------------- /packages/next-international/src/app/client/index.ts: -------------------------------------------------------------------------------- 1 | import 'client-only'; 2 | import type { ExplicitLocales, FlattenLocale, GetLocaleType, ImportedLocales } from 'international-types'; 3 | import type { I18nClientConfig, LocaleContext } from '../../types'; 4 | import { createI18nProviderClient } from './create-i18n-provider-client'; 5 | import { createContext } from 'react'; 6 | import { createUsei18n } from '../../common/create-use-i18n'; 7 | import { createScopedUsei18n } from '../../common/create-use-scoped-i18n'; 8 | import { createUseChangeLocale } from './create-use-change-locale'; 9 | import { createDefineLocale } from '../../common/create-define-locale'; 10 | import { createUseCurrentLocale } from './create-use-current-locale'; 11 | 12 | export function createI18nClient( 13 | locales: Locales, 14 | config: I18nClientConfig = {}, 15 | ) { 16 | type TempLocale = OtherLocales extends ExplicitLocales ? GetLocaleType : GetLocaleType; 17 | type Locale = TempLocale extends Record ? TempLocale : FlattenLocale; 18 | 19 | type LocalesKeys = OtherLocales extends ExplicitLocales ? keyof OtherLocales : keyof Locales; 20 | 21 | const localesKeys = Object.keys(locales) as LocalesKeys[]; 22 | 23 | // @ts-expect-error deep type 24 | const I18nClientContext = createContext | null>(null); 25 | 26 | const useCurrentLocale = createUseCurrentLocale(localesKeys, config); 27 | const I18nProviderClient = createI18nProviderClient(I18nClientContext, locales, config.fallbackLocale); 28 | const useI18n = createUsei18n(I18nClientContext); 29 | const useScopedI18n = createScopedUsei18n(I18nClientContext); 30 | const useChangeLocale = createUseChangeLocale(useCurrentLocale, locales, config); 31 | const defineLocale = createDefineLocale(); 32 | 33 | return { 34 | useI18n, 35 | useScopedI18n, 36 | I18nProviderClient, 37 | I18nClientContext, 38 | useChangeLocale, 39 | defineLocale, 40 | useCurrentLocale, 41 | }; 42 | } 43 | -------------------------------------------------------------------------------- /packages/next-international/src/app/middleware/index.ts: -------------------------------------------------------------------------------- 1 | import type { NextRequest } from 'next/server'; 2 | import { NextResponse } from 'next/server'; 3 | 4 | import { LOCALE_COOKIE, LOCALE_HEADER } from '../../common/constants'; 5 | import { warn } from '../../helpers/log'; 6 | import type { I18nMiddlewareConfig } from '../../types'; 7 | 8 | const DEFAULT_STRATEGY: NonNullable['urlMappingStrategy']> = 'redirect'; 9 | 10 | export function createI18nMiddleware(config: I18nMiddlewareConfig) { 11 | return function I18nMiddleware(request: NextRequest) { 12 | const locale = localeFromRequest(config.locales, request, config.resolveLocaleFromRequest) ?? config.defaultLocale; 13 | const nextUrl = request.nextUrl; 14 | 15 | // If the locale from the request is not an handled locale, then redirect to the same URL with the default locale 16 | if (noLocalePrefix(config.locales, nextUrl.pathname)) { 17 | nextUrl.pathname = `/${locale}${nextUrl.pathname}`; 18 | 19 | const strategy = config.urlMappingStrategy ?? DEFAULT_STRATEGY; 20 | if (strategy === 'rewrite' || (strategy === 'rewriteDefault' && locale === config.defaultLocale)) { 21 | const response = NextResponse.rewrite(nextUrl); 22 | return addLocaleToResponse(request, response, locale); 23 | } else { 24 | if (!['redirect', 'rewriteDefault'].includes(strategy)) { 25 | warn(`Invalid urlMappingStrategy: ${strategy}. Defaulting to redirect.`); 26 | } 27 | 28 | const response = NextResponse.redirect(nextUrl); 29 | return addLocaleToResponse(request, response, locale); 30 | } 31 | } 32 | 33 | let response = NextResponse.next(); 34 | const pathnameLocale = nextUrl.pathname.split('/', 2)?.[1]; 35 | 36 | if (!pathnameLocale || config.locales.includes(pathnameLocale)) { 37 | // If the URL mapping strategy is set to 'rewrite' and the locale from the request doesn't match the locale in the pathname, 38 | // or if the URL mapping strategy is set to 'rewriteDefault' and the locale from the request doesn't match the locale in the pathname 39 | // or is the same as the default locale, then proceed with the following logic 40 | if ( 41 | (config.urlMappingStrategy === 'rewrite' && pathnameLocale !== locale) || 42 | (config.urlMappingStrategy === 'rewriteDefault' && 43 | (pathnameLocale !== locale || pathnameLocale === config.defaultLocale)) 44 | ) { 45 | // Remove the locale from the pathname 46 | const pathnameWithoutLocale = nextUrl.pathname.slice(pathnameLocale.length + 1); 47 | 48 | // Create a new URL without the locale in the pathname 49 | const newUrl = new URL(pathnameWithoutLocale || '/', request.url); 50 | 51 | // Preserve the original search parameters 52 | newUrl.search = nextUrl.search; 53 | response = NextResponse.redirect(newUrl); 54 | } 55 | 56 | return addLocaleToResponse(request, response, pathnameLocale ?? config.defaultLocale); 57 | } 58 | 59 | return response; 60 | }; 61 | } 62 | 63 | /** 64 | * Retrieve `Next-Locale` header from request 65 | * and check if it is an handled locale. 66 | */ 67 | function localeFromRequest( 68 | locales: Locales, 69 | request: NextRequest, 70 | resolveLocaleFromRequest: NonNullable< 71 | I18nMiddlewareConfig['resolveLocaleFromRequest'] 72 | > = defaultResolveLocaleFromRequest, 73 | ) { 74 | const locale = request.cookies.get(LOCALE_COOKIE)?.value ?? resolveLocaleFromRequest(request); 75 | 76 | if (!locale || !locales.includes(locale)) { 77 | return null; 78 | } 79 | 80 | return locale; 81 | } 82 | 83 | /** 84 | * Default implementation of the `resolveLocaleFromRequest` function for the I18nMiddlewareConfig. 85 | * This function extracts the locale from the 'Accept-Language' header of the request. 86 | */ 87 | const defaultResolveLocaleFromRequest: NonNullable['resolveLocaleFromRequest']> = request => { 88 | const header = request.headers.get('Accept-Language'); 89 | const locale = header?.split(',', 1)?.[0]?.split('-', 1)?.[0]; 90 | return locale ?? null; 91 | }; 92 | 93 | /** 94 | * Returns `true` if the pathname does not start with an handled locale 95 | */ 96 | function noLocalePrefix(locales: readonly string[], pathname: string) { 97 | return locales.every(locale => { 98 | return !(pathname === `/${locale}` || pathname.startsWith(`/${locale}/`)); 99 | }); 100 | } 101 | 102 | /** 103 | * Add `X-Next-Locale` header and `Next-Locale` cookie to response 104 | * 105 | * **NOTE:** The cookie is only set if the locale is different from the one in the cookie 106 | */ 107 | function addLocaleToResponse(request: NextRequest, response: NextResponse, locale: string) { 108 | response.headers.set(LOCALE_HEADER, locale); 109 | 110 | if (request.cookies.get(LOCALE_COOKIE)?.value !== locale) { 111 | response.cookies.set(LOCALE_COOKIE, locale, { sameSite: 'strict' }); 112 | } 113 | return response; 114 | } 115 | -------------------------------------------------------------------------------- /packages/next-international/src/app/server/create-get-current-locale.ts: -------------------------------------------------------------------------------- 1 | import { getLocaleCache } from './get-locale-cache'; 2 | 3 | export function createGetCurrentLocale(): () => Promise { 4 | return function getCurrentLocale() { 5 | return getLocaleCache() as Promise; 6 | }; 7 | } 8 | -------------------------------------------------------------------------------- /packages/next-international/src/app/server/create-get-i18n.ts: -------------------------------------------------------------------------------- 1 | import type { BaseLocale, ImportedLocales } from 'international-types'; 2 | import { createT } from '../../common/create-t'; 3 | import { flattenLocale } from '../../common/flatten-locale'; 4 | import type { I18nServerConfig, LocaleContext } from '../../types'; 5 | import { getLocaleCache } from './get-locale-cache'; 6 | 7 | export function createGetI18n( 8 | locales: Locales, 9 | config: I18nServerConfig, 10 | ) { 11 | const localeCache = new Map>>>(); 12 | 13 | return async function getI18n() { 14 | const locale = await getLocaleCache(); 15 | const cached = localeCache.get(locale); 16 | 17 | if (cached) { 18 | return await cached; 19 | } 20 | 21 | const localeFnPromise = (async () => { 22 | const localeModule = await locales[locale](); 23 | return createT( 24 | { 25 | localeContent: flattenLocale(localeModule.default), 26 | fallbackLocale: config.fallbackLocale ? flattenLocale(config.fallbackLocale) : undefined, 27 | locale, 28 | } as LocaleContext, 29 | undefined, 30 | ); 31 | })(); 32 | 33 | localeCache.set(locale, localeFnPromise); 34 | 35 | return await localeFnPromise; 36 | }; 37 | } 38 | -------------------------------------------------------------------------------- /packages/next-international/src/app/server/create-get-scoped-i18n.ts: -------------------------------------------------------------------------------- 1 | import type { BaseLocale, ImportedLocales, Scopes } from 'international-types'; 2 | import { createT } from '../../common/create-t'; 3 | import type { I18nServerConfig, LocaleContext } from '../../types'; 4 | import { getLocaleCache } from './get-locale-cache'; 5 | import { flattenLocale } from '../../common/flatten-locale'; 6 | 7 | export function createGetScopedI18n( 8 | locales: Locales, 9 | config: I18nServerConfig, 10 | ) { 11 | const localeCache = new Map>>>(); 12 | 13 | return async function getScopedI18n>( 14 | scope: Scope, 15 | ): Promise>> { 16 | const locale = await getLocaleCache(); 17 | const cacheKey = `${locale}-${scope}`; 18 | const cached = localeCache.get(cacheKey); 19 | 20 | if (cached) { 21 | return (await cached) as ReturnType>; 22 | } 23 | 24 | const localeFnPromise = (async () => { 25 | const localeModule = await locales[locale](); 26 | return createT( 27 | { 28 | localeContent: flattenLocale(localeModule.default), 29 | fallbackLocale: config.fallbackLocale ? flattenLocale(config.fallbackLocale) : undefined, 30 | locale, 31 | } as LocaleContext, 32 | scope, 33 | ); 34 | })(); 35 | 36 | localeCache.set(cacheKey, localeFnPromise); 37 | 38 | return await localeFnPromise; 39 | }; 40 | } 41 | -------------------------------------------------------------------------------- /packages/next-international/src/app/server/create-get-static-params.ts: -------------------------------------------------------------------------------- 1 | import type { ImportedLocales } from 'international-types'; 2 | import type { I18nServerConfig } from '../../types'; 3 | import { DEFAULT_SEGMENT_NAME } from '../../common/constants'; 4 | 5 | export function createGetStaticParams(locales: Locales, config: I18nServerConfig) { 6 | return function getStaticParams() { 7 | return Object.keys(locales).map(locale => ({ 8 | [config.segmentName ?? DEFAULT_SEGMENT_NAME]: locale, 9 | })); 10 | }; 11 | } 12 | -------------------------------------------------------------------------------- /packages/next-international/src/app/server/get-locale-cache.tsx: -------------------------------------------------------------------------------- 1 | import { cookies, headers } from 'next/headers'; 2 | // @ts-expect-error - no types 3 | import { cache } from 'react'; 4 | import { LOCALE_COOKIE, LOCALE_HEADER } from '../../common/constants'; 5 | import { notFound } from 'next/navigation'; 6 | import { error } from '../../helpers/log'; 7 | 8 | const getLocale = cache<() => { current: string | undefined }>(() => ({ current: undefined })); 9 | const getStaticParamsLocale = () => getLocale().current; 10 | 11 | export const setStaticParamsLocale = (value: string) => { 12 | getLocale().current = value; 13 | }; 14 | 15 | export const getLocaleCache = cache(async () => { 16 | let locale: string | undefined | null; 17 | 18 | locale = getStaticParamsLocale(); 19 | 20 | if (!locale) { 21 | try { 22 | locale = (await headers()).get(LOCALE_HEADER); 23 | 24 | if (!locale) { 25 | locale = (await cookies()).get(LOCALE_COOKIE)?.value; 26 | } 27 | } catch (e) { 28 | throw new Error( 29 | 'Could not find locale while pre-rendering page, make sure you called `setStaticParamsLocale` at the top of your pages', 30 | ); 31 | } 32 | } 33 | 34 | if (!locale) { 35 | error(`Locale not found in headers or cookies, returning "notFound()"`); 36 | notFound(); 37 | } 38 | 39 | return locale; 40 | }); 41 | -------------------------------------------------------------------------------- /packages/next-international/src/app/server/index.ts: -------------------------------------------------------------------------------- 1 | import 'server-only'; 2 | 3 | import type { ExplicitLocales, FlattenLocale, GetLocaleType, ImportedLocales } from 'international-types'; 4 | import type { I18nServerConfig } from '../../types'; 5 | import { createGetCurrentLocale } from './create-get-current-locale'; 6 | import { createGetI18n } from './create-get-i18n'; 7 | import { createGetScopedI18n } from './create-get-scoped-i18n'; 8 | import { createGetStaticParams } from './create-get-static-params'; 9 | 10 | export { setStaticParamsLocale } from './get-locale-cache'; 11 | 12 | export function createI18nServer( 13 | locales: Locales, 14 | config: I18nServerConfig = {}, 15 | ) { 16 | type TempLocale = OtherLocales extends ExplicitLocales ? GetLocaleType : GetLocaleType; 17 | type Locale = TempLocale extends Record ? TempLocale : FlattenLocale; 18 | 19 | type LocalesKeys = OtherLocales extends ExplicitLocales ? keyof OtherLocales : keyof Locales; 20 | 21 | // @ts-expect-error deep type 22 | const getI18n = createGetI18n(locales, config); 23 | const getScopedI18n = createGetScopedI18n(locales, config); 24 | const getCurrentLocale = createGetCurrentLocale(); 25 | const getStaticParams = createGetStaticParams(locales, config); 26 | 27 | return { 28 | getI18n, 29 | getScopedI18n, 30 | getCurrentLocale, 31 | getStaticParams, 32 | }; 33 | } 34 | -------------------------------------------------------------------------------- /packages/next-international/src/common/constants.ts: -------------------------------------------------------------------------------- 1 | export const LOCALE_HEADER = 'X-Next-Locale'; 2 | export const LOCALE_COOKIE = 'Next-Locale'; 3 | export const DEFAULT_SEGMENT_NAME = 'locale'; 4 | -------------------------------------------------------------------------------- /packages/next-international/src/common/create-define-locale.ts: -------------------------------------------------------------------------------- 1 | import type { BaseLocale, LocaleValue } from 'international-types'; 2 | 3 | export function createDefineLocale() { 4 | return function defineLocale(locale: { [key in keyof Locale]: LocaleValue }) { 5 | return locale; 6 | }; 7 | } 8 | -------------------------------------------------------------------------------- /packages/next-international/src/common/create-t.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | BaseLocale, 3 | CreateParams, 4 | LocaleKeys, 5 | LocaleValue, 6 | ParamsObject, 7 | ScopedValue, 8 | Scopes, 9 | } from 'international-types'; 10 | import type { ReactNode } from 'react'; 11 | import { cloneElement, isValidElement } from 'react'; 12 | import type { LocaleContext, LocaleMap, ReactParamsObject } from '../types'; 13 | 14 | export function createT | undefined>( 15 | context: LocaleContext, 16 | scope: Scope | undefined, 17 | ) { 18 | const { localeContent, fallbackLocale } = context; 19 | // If there is no localeContent (e.g. on initial render on the client-side), we use the fallback locale 20 | // otherwise, we use the fallback locale as a fallback for missing keys in the current locale 21 | const content = 22 | fallbackLocale && typeof localeContent === 'string' 23 | ? fallbackLocale 24 | : Object.assign(fallbackLocale ?? {}, localeContent); 25 | 26 | const pluralKeys = new Set( 27 | Object.keys(content) 28 | .filter(key => key.includes('#')) 29 | .map(key => key.split('#', 1)[0]), 30 | ); 31 | 32 | const pluralRules = new Intl.PluralRules(context.locale); 33 | 34 | function getPluralKey(count: number) { 35 | if (count === 0) return 'zero'; 36 | return pluralRules.select(count); 37 | } 38 | 39 | function t, Value extends LocaleValue = ScopedValue>( 40 | key: Key, 41 | ...params: CreateParams, Locale, Scope, Key, Value> 42 | ): string; 43 | function t, Value extends LocaleValue = ScopedValue>( 44 | key: Key, 45 | ...params: CreateParams, Locale, Scope, Key, Value> 46 | ): React.ReactNode; 47 | function t, Value extends LocaleValue = ScopedValue>( 48 | key: Key, 49 | ...params: CreateParams | ReactParamsObject, Locale, Scope, Key, Value> 50 | ) { 51 | const paramObject = params[0]; 52 | let isPlural = false; 53 | 54 | if (paramObject && 'count' in paramObject) { 55 | const isPluralKey = scope ? pluralKeys.has(`${scope}.${key}`) : pluralKeys.has(key); 56 | 57 | if (isPluralKey) { 58 | key = `${key}#${getPluralKey(paramObject.count)}` as Key; 59 | isPlural = true; 60 | } 61 | } 62 | 63 | let value = scope ? content[`${scope}.${key}`] : content[key]; 64 | 65 | if (!value && isPlural) { 66 | const baseKey = key.split('#', 1)[0] as Key; 67 | value = (content[`${baseKey}#other`] || key)?.toString(); 68 | } else { 69 | value = (value || key)?.toString(); 70 | } 71 | 72 | if (!paramObject) { 73 | return value; 74 | } 75 | 76 | let isString = true; 77 | 78 | const result = value?.split(/({[^}]*})/).map((part, index) => { 79 | const match = part.match(/{(.*)}/); 80 | 81 | if (match) { 82 | const param = match[1] as keyof Locale; 83 | const paramValue = (paramObject as LocaleMap)[param]; 84 | 85 | if (isValidElement(paramValue)) { 86 | isString = false; 87 | return cloneElement(paramValue, { key: `${String(param)}-${index}` }); 88 | } 89 | 90 | return paramValue as ReactNode; 91 | } 92 | 93 | // if there's no match - it's not a variable and just a normal string 94 | return part; 95 | }); 96 | 97 | return isString ? result?.join('') : result; 98 | } 99 | 100 | return t; 101 | } 102 | -------------------------------------------------------------------------------- /packages/next-international/src/common/create-use-i18n.ts: -------------------------------------------------------------------------------- 1 | import type { Context } from 'react'; 2 | import { useContext, useMemo } from 'react'; 3 | import type { BaseLocale } from 'international-types'; 4 | import type { LocaleContext } from '../types'; 5 | import { createT } from './create-t'; 6 | 7 | export function createUsei18n(I18nClientContext: Context | null>) { 8 | return function useI18n() { 9 | const context = useContext(I18nClientContext); 10 | 11 | if (!context) { 12 | throw new Error('`useI18n` must be used inside `I18nProvider`'); 13 | } 14 | 15 | return useMemo(() => createT(context, undefined), [context]); 16 | }; 17 | } 18 | -------------------------------------------------------------------------------- /packages/next-international/src/common/create-use-scoped-i18n.ts: -------------------------------------------------------------------------------- 1 | import type { Context } from 'react'; 2 | import { useContext, useMemo } from 'react'; 3 | import type { BaseLocale, Scopes } from 'international-types'; 4 | import type { LocaleContext } from '../types'; 5 | import { createT } from '../common/create-t'; 6 | 7 | export function createScopedUsei18n( 8 | I18nClientContext: Context | null>, 9 | ) { 10 | return function useScopedI18n>(scope: Scope): ReturnType> { 11 | const context = useContext(I18nClientContext); 12 | 13 | if (!context) { 14 | throw new Error('`useI18n` must be used inside `I18nProvider`'); 15 | } 16 | 17 | return useMemo(() => createT(context, scope), [context, scope]); 18 | }; 19 | } 20 | -------------------------------------------------------------------------------- /packages/next-international/src/common/flatten-locale.ts: -------------------------------------------------------------------------------- 1 | import type { BaseLocale } from 'international-types'; 2 | 3 | export const flattenLocale = (locale: Record, prefix = ''): Locale => 4 | Object.entries(locale).reduce( 5 | (prev, [name, value]) => ({ 6 | ...prev, 7 | ...(typeof value === 'string' 8 | ? { [prefix + name]: value } 9 | : flattenLocale(value as unknown as Locale, `${prefix}${name}.`)), 10 | }), 11 | {} as Locale, 12 | ); 13 | -------------------------------------------------------------------------------- /packages/next-international/src/helpers/log.ts: -------------------------------------------------------------------------------- 1 | function log(type: keyof Pick, message: string) { 2 | if (process.env.NODE_ENV !== 'production') { 3 | console[type](`[next-international] ${message}`); 4 | } 5 | 6 | return null; 7 | } 8 | 9 | export const warn = (message: string) => log('warn', message); 10 | export const error = (message: string) => log('error', message); 11 | -------------------------------------------------------------------------------- /packages/next-international/src/index.ts: -------------------------------------------------------------------------------- 1 | export { createI18n } from './pages'; 2 | export { createT } from './common/create-t'; 3 | export { flattenLocale } from './common/flatten-locale'; 4 | export type { ReactParamsObject } from './types'; 5 | export type { BaseLocale, LocaleValue } from 'international-types'; 6 | -------------------------------------------------------------------------------- /packages/next-international/src/pages/create-get-locale-props.ts: -------------------------------------------------------------------------------- 1 | import type { ImportedLocales } from 'international-types'; 2 | import type { GetStaticProps, GetServerSideProps } from 'next'; 3 | import { error } from '../helpers/log'; 4 | import { flattenLocale } from '../common/flatten-locale'; 5 | 6 | export function createGetLocaleProps(locales: ImportedLocales) { 7 | return function getLocaleProps< 8 | T extends { [key: string]: any }, 9 | GetProps extends GetStaticProps | GetServerSideProps, 10 | >(initialGetProps?: GetProps) { 11 | return async (context: any) => { 12 | const initialResult = await initialGetProps?.(context); 13 | 14 | // No current locales means that `defaultLocale` does not exists 15 | if (!context.locale) { 16 | error(`'i18n.defaultLocale' not defined in 'next.config.js'`); 17 | return initialResult || { props: {} }; 18 | } 19 | 20 | const load = locales[context.locale]; 21 | 22 | return { 23 | ...initialResult, 24 | props: { 25 | // @ts-expect-error Next `GetStaticPropsResult` doesn't have `props` 26 | ...initialResult?.props, 27 | locale: flattenLocale((await load()).default), 28 | }, 29 | }; 30 | }; 31 | }; 32 | } 33 | -------------------------------------------------------------------------------- /packages/next-international/src/pages/create-i18n-provider.tsx: -------------------------------------------------------------------------------- 1 | import type { BaseLocale, ImportedLocales } from 'international-types'; 2 | import { useRouter } from 'next/router'; 3 | import type { Context, ReactElement, ReactNode } from 'react'; 4 | import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; 5 | import { flattenLocale } from '../common/flatten-locale'; 6 | import { error, warn } from '../helpers/log'; 7 | import type { LocaleContext } from '../types'; 8 | 9 | type I18nProviderProps = { 10 | locale: Locale; 11 | fallback?: ReactElement | null; 12 | fallbackLocale?: Record; 13 | children: ReactNode; 14 | }; 15 | 16 | export function createI18nProvider( 17 | I18nContext: Context | null>, 18 | locales: ImportedLocales, 19 | ) { 20 | return function I18nProvider({ 21 | locale: baseLocale, 22 | fallback = null, 23 | fallbackLocale, 24 | children, 25 | }: I18nProviderProps) { 26 | const { locale, defaultLocale, locales: nextLocales } = useRouter(); 27 | const [clientLocale, setClientLocale] = useState(); 28 | const initialLoadRef = useRef(true); 29 | 30 | useEffect(() => { 31 | function checkConfigMatch([first, second]: [[string, string[]], [string, string[]]]) { 32 | const notDefined = first[1].filter(locale => !second[1].includes(locale)); 33 | 34 | if (notDefined.length > 0) { 35 | warn( 36 | `The following locales are defined in '${first[0]}' but not in '${second[0]}': ${notDefined.join(', ')}`, 37 | ); 38 | } 39 | } 40 | 41 | const createI18n = ['createI18n', Object.keys(locales)] as [string, string[]]; 42 | const nextConfig = ['next.config.js', nextLocales || []] as [string, string[]]; 43 | 44 | checkConfigMatch([createI18n, nextConfig]); 45 | checkConfigMatch([nextConfig, createI18n]); 46 | }, [nextLocales]); 47 | 48 | const loadLocale = useCallback((locale: string) => { 49 | locales[locale]().then(content => { 50 | setClientLocale(flattenLocale(content.default)); 51 | }); 52 | }, []); 53 | 54 | useEffect(() => { 55 | // Initial page load 56 | // Load locale if no baseLocale provided from getLocaleProps 57 | if (!baseLocale && locale && initialLoadRef.current) { 58 | loadLocale(locale); 59 | } 60 | 61 | // // Subsequent locale change 62 | if (locale && !initialLoadRef.current) { 63 | loadLocale(locale); 64 | } 65 | 66 | initialLoadRef.current = false; 67 | }, [baseLocale, loadLocale, locale]); 68 | 69 | const value = useMemo( 70 | () => ({ 71 | localeContent: (clientLocale || baseLocale) as Locale, 72 | fallbackLocale: fallbackLocale ? flattenLocale(fallbackLocale) : undefined, 73 | locale: locale ?? defaultLocale ?? '', 74 | }), 75 | [clientLocale, baseLocale, fallbackLocale, locale, defaultLocale], 76 | ); 77 | 78 | if (!locale || !defaultLocale) { 79 | return error(`'i18n.defaultLocale' not defined in 'next.config.js'`); 80 | } 81 | 82 | if (!nextLocales) { 83 | return error(`'i18n.locales' not defined in 'next.config.js'`); 84 | } 85 | 86 | if (!clientLocale && !baseLocale) { 87 | return fallback; 88 | } 89 | 90 | return {children}; 91 | }; 92 | } 93 | -------------------------------------------------------------------------------- /packages/next-international/src/pages/create-use-change-locale.ts: -------------------------------------------------------------------------------- 1 | import { useRouter } from 'next/router'; 2 | 3 | export function createUseChangeLocale() { 4 | return function useChangeLocale() { 5 | const { push, asPath } = useRouter(); 6 | 7 | return function changeLocale(newLocale: LocalesKeys) { 8 | push(asPath, undefined, { locale: newLocale as unknown as string, shallow: true }); 9 | }; 10 | }; 11 | } 12 | -------------------------------------------------------------------------------- /packages/next-international/src/pages/create-use-current-locale.ts: -------------------------------------------------------------------------------- 1 | import { useRouter } from 'next/router'; 2 | 3 | export function createUseCurrentLocale(): () => LocalesKeys { 4 | return function useCurrentLocale() { 5 | const { locale } = useRouter(); 6 | 7 | return locale as unknown as LocalesKeys; 8 | }; 9 | } 10 | -------------------------------------------------------------------------------- /packages/next-international/src/pages/index.ts: -------------------------------------------------------------------------------- 1 | import { createContext } from 'react'; 2 | import type { ImportedLocales, ExplicitLocales, GetLocaleType, FlattenLocale } from 'international-types'; 3 | import type { LocaleContext } from '../types'; 4 | import { createDefineLocale } from '../common/create-define-locale'; 5 | import { createGetLocaleProps } from './create-get-locale-props'; 6 | import { createI18nProvider } from './create-i18n-provider'; 7 | import { createUseChangeLocale } from './create-use-change-locale'; 8 | import { createUsei18n } from '../common/create-use-i18n'; 9 | import { createScopedUsei18n } from '../common/create-use-scoped-i18n'; 10 | import { createUseCurrentLocale } from './create-use-current-locale'; 11 | 12 | export function createI18n( 13 | locales: Locales, 14 | ) { 15 | type TempLocale = OtherLocales extends ExplicitLocales ? GetLocaleType : GetLocaleType; 16 | type Locale = TempLocale extends Record ? TempLocale : FlattenLocale; 17 | 18 | type LocalesKeys = OtherLocales extends ExplicitLocales ? keyof OtherLocales : keyof Locales; 19 | 20 | // @ts-expect-error deep type 21 | const I18nContext = createContext | null>(null); 22 | const I18nProvider = createI18nProvider(I18nContext, locales); 23 | const useI18n = createUsei18n(I18nContext); 24 | const useScopedI18n = createScopedUsei18n(I18nContext); 25 | const useChangeLocale = createUseChangeLocale(); 26 | const defineLocale = createDefineLocale(); 27 | const getLocaleProps = createGetLocaleProps(locales); 28 | const useCurrentLocale = createUseCurrentLocale(); 29 | 30 | return { 31 | useI18n, 32 | useScopedI18n, 33 | I18nProvider, 34 | useChangeLocale, 35 | defineLocale, 36 | getLocaleProps, 37 | useCurrentLocale, 38 | }; 39 | } 40 | -------------------------------------------------------------------------------- /packages/next-international/src/types.ts: -------------------------------------------------------------------------------- 1 | import type { BaseLocale, LocaleValue, Params } from 'international-types'; 2 | import type { NextRequest } from 'next/server'; 3 | 4 | export type LocaleContext = { 5 | locale: string; 6 | localeContent: Locale; 7 | fallbackLocale?: Locale; 8 | }; 9 | 10 | export type LocaleMap = Record; 11 | 12 | export type ReactParamsObject = Record[number], React.ReactNode>; 13 | 14 | export type I18nClientConfig = { 15 | /** 16 | * The name of the Next.js layout segment param that will be used to determine the locale in a client component. 17 | * 18 | * An app directory folder hierarchy that looks like `app/[locale]/products/[category]/[subCategory]/page.tsx` would be `locale`. 19 | * 20 | * @default locale 21 | */ 22 | segmentName?: string; 23 | /** 24 | * If you are using a custom basePath inside `next.config.js`, you must also specify it here. 25 | * 26 | * @see https://nextjs.org/docs/app/api-reference/next-config-js/basePath 27 | */ 28 | basePath?: string; 29 | /** 30 | * A locale to use if some keys aren't translated, to fallback to this locale instead of showing the translation key. 31 | */ 32 | fallbackLocale?: Record; 33 | }; 34 | 35 | export type I18nServerConfig = { 36 | /** 37 | * The name of the Next.js layout segment param that will be used to determine the locale in a client component. 38 | * 39 | * An app directory folder hierarchy that looks like `app/[locale]/products/[category]/[subCategory]/page.tsx` would be `locale`. 40 | * 41 | * @default locale 42 | */ 43 | segmentName?: string; 44 | /** 45 | * A locale to use if some keys aren't translated, to fallback to this locale instead of showing the translation key. 46 | */ 47 | fallbackLocale?: Record; 48 | }; 49 | 50 | export type I18nMiddlewareConfig = { 51 | locales: Locales; 52 | defaultLocale: Locales[number]; 53 | /** 54 | * When a url is not prefixed with a locale, this setting determines whether the middleware should perform a *redirect* or *rewrite* to the default locale. 55 | * 56 | * **redirect**: `https://example.com/products` -> *redirect* to `https://example.com/en/products` -> client sees the locale in the url 57 | * 58 | * **rewrite**: `https://example.com/products` -> *rewrite* to `https://example.com/en/products` -> client doesn't see the locale in the url 59 | * 60 | * **rewriteDefault**: `https://example.com/products` -> use *rewrite* for the default locale, *redirect* for all other locales 61 | * 62 | * @default redirect 63 | */ 64 | urlMappingStrategy?: 'redirect' | 'rewrite' | 'rewriteDefault'; 65 | 66 | /** 67 | * Override the resolution of a locale from a `Request`, which by default will try to extract it from the `Accept-Language` header. This can be useful to force the use of a specific locale regardless of the `Accept-Language` header. 68 | * 69 | * @description This function will only be called if the user doesn't already have a `Next-Locale` cookie. 70 | */ 71 | resolveLocaleFromRequest?: (request: NextRequest) => Locales[number] | null; 72 | }; 73 | 74 | export type I18nChangeLocaleConfig = { 75 | /** 76 | * If `true`, the search params will be preserved when changing the locale. 77 | * Don't forget to **wrap the component in a `Suspense` boundary to avoid opting out the page from Static Rendering**. 78 | * 79 | * @see https://nextjs.org/docs/app/api-reference/functions/use-search-params#static-rendering 80 | * @default false 81 | */ 82 | preserveSearchParams?: boolean; 83 | }; 84 | -------------------------------------------------------------------------------- /packages/next-international/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.json", 3 | "compilerOptions": { 4 | "jsx": "react" 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /pnpm-workspace.yaml: -------------------------------------------------------------------------------- 1 | packages: 2 | - 'packages/*' 3 | - 'examples/*' 4 | - 'docs' 5 | -------------------------------------------------------------------------------- /tests/setup.ts: -------------------------------------------------------------------------------- 1 | import '@testing-library/jest-dom/vitest'; 2 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | /* Visit https://aka.ms/tsconfig.json to read more about this file */ 4 | /* Projects */ 5 | // "incremental": true, /* Enable incremental compilation */ 6 | // "composite": true, /* Enable constraints that allow a TypeScript project to be used with project references. */ 7 | // "tsBuildInfoFile": "./", /* Specify the folder for .tsbuildinfo incremental compilation files. */ 8 | // "disableSourceOfProjectReferenceRedirect": true, /* Disable preferring source files instead of declaration files when referencing composite projects */ 9 | // "disableSolutionSearching": true, /* Opt a project out of multi-project reference checking when editing. */ 10 | // "disableReferencedProjectLoad": true, /* Reduce the number of projects loaded automatically by TypeScript. */ 11 | /* Language and Environment */ 12 | "target": "es2016", /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */ 13 | "lib": [ 14 | "DOM", 15 | "DOM.Iterable", 16 | "ESNext" 17 | ], /* Specify a set of bundled library declaration files that describe the target runtime environment. */ 18 | "jsx": "preserve", /* Specify what JSX code is generated. */ 19 | // "experimentalDecorators": true, /* Enable experimental support for TC39 stage 2 draft decorators. */ 20 | // "emitDecoratorMetadata": true, /* Emit design-type metadata for decorated declarations in source files. */ 21 | // "jsxFactory": "", /* Specify the JSX factory function used when targeting React JSX emit, e.g. 'React.createElement' or 'h' */ 22 | // "jsxFragmentFactory": "", /* Specify the JSX Fragment reference used for fragments when targeting React JSX emit e.g. 'React.Fragment' or 'Fragment'. */ 23 | // "jsxImportSource": "", /* Specify module specifier used to import the JSX factory functions when using `jsx: react-jsx*`.` */ 24 | // "reactNamespace": "", /* Specify the object invoked for `createElement`. This only applies when targeting `react` JSX emit. */ 25 | // "noLib": true, /* Disable including any library files, including the default lib.d.ts. */ 26 | // "useDefineForClassFields": true, /* Emit ECMAScript-standard-compliant class fields. */ 27 | /* Modules */ 28 | "module": "commonjs", /* Specify what module code is generated. */ 29 | // "rootDir": "./", /* Specify the root folder within your source files. */ 30 | "moduleResolution": "node", /* Specify how TypeScript looks up a file from a given module specifier. */ 31 | // "baseUrl": "./", /* Specify the base directory to resolve non-relative module names. */ 32 | // "paths": {}, /* Specify a set of entries that re-map imports to additional lookup locations. */ 33 | // "rootDirs": [], /* Allow multiple folders to be treated as one when resolving modules. */ 34 | // "typeRoots": [], /* Specify multiple folders that act like `./node_modules/@types`. */ 35 | // "types": [], /* Specify type package names to be included without being referenced in a source file. */ 36 | // "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */ 37 | // "resolveJsonModule": true, /* Enable importing .json files */ 38 | // "noResolve": true, /* Disallow `import`s, `require`s or ``s from expanding the number of files TypeScript should add to a project. */ 39 | /* JavaScript Support */ 40 | // "allowJs": true, /* Allow JavaScript files to be a part of your program. Use the `checkJS` option to get errors from these files. */ 41 | // "checkJs": true, /* Enable error reporting in type-checked JavaScript files. */ 42 | // "maxNodeModuleJsDepth": 1, /* Specify the maximum folder depth used for checking JavaScript files from `node_modules`. Only applicable with `allowJs`. */ 43 | /* Emit */ 44 | // "declaration": true, /* Generate .d.ts files from TypeScript and JavaScript files in your project. */ 45 | // "declarationMap": true, /* Create sourcemaps for d.ts files. */ 46 | // "emitDeclarationOnly": true, /* Only output d.ts files and not JavaScript files. */ 47 | // "sourceMap": true, /* Create source map files for emitted JavaScript files. */ 48 | // "outFile": "./", /* Specify a file that bundles all outputs into one JavaScript file. If `declaration` is true, also designates a file that bundles all .d.ts output. */ 49 | // "outDir": "./", /* Specify an output folder for all emitted files. */ 50 | // "removeComments": true, /* Disable emitting comments. */ 51 | // "noEmit": true, /* Disable emitting files from a compilation. */ 52 | // "importHelpers": true, /* Allow importing helper functions from tslib once per project, instead of including them per-file. */ 53 | // "importsNotUsedAsValues": "remove", /* Specify emit/checking behavior for imports that are only used for types */ 54 | // "downlevelIteration": true, /* Emit more compliant, but verbose and less performant JavaScript for iteration. */ 55 | // "sourceRoot": "", /* Specify the root path for debuggers to find the reference source code. */ 56 | // "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */ 57 | // "inlineSourceMap": true, /* Include sourcemap files inside the emitted JavaScript. */ 58 | // "inlineSources": true, /* Include source code in the sourcemaps inside the emitted JavaScript. */ 59 | // "emitBOM": true, /* Emit a UTF-8 Byte Order Mark (BOM) in the beginning of output files. */ 60 | // "newLine": "crlf", /* Set the newline character for emitting files. */ 61 | // "stripInternal": true, /* Disable emitting declarations that have `@internal` in their JSDoc comments. */ 62 | // "noEmitHelpers": true, /* Disable generating custom helper functions like `__extends` in compiled output. */ 63 | // "noEmitOnError": true, /* Disable emitting files if any type checking errors are reported. */ 64 | // "preserveConstEnums": true, /* Disable erasing `const enum` declarations in generated code. */ 65 | // "declarationDir": "./", /* Specify the output directory for generated declaration files. */ 66 | // "preserveValueImports": true, /* Preserve unused imported values in the JavaScript output that would otherwise be removed. */ 67 | /* Interop Constraints */ 68 | // "isolatedModules": true, /* Ensure that each file can be safely transpiled without relying on other imports. */ 69 | // "allowSyntheticDefaultImports": true, /* Allow 'import x from y' when a module doesn't have a default export. */ 70 | "esModuleInterop": true, /* Emit additional JavaScript to ease support for importing CommonJS modules. This enables `allowSyntheticDefaultImports` for type compatibility. */ 71 | // "preserveSymlinks": true, /* Disable resolving symlinks to their realpath. This correlates to the same flag in node. */ 72 | "forceConsistentCasingInFileNames": true, /* Ensure that casing is correct in imports. */ 73 | /* Type Checking */ 74 | "strict": true, /* Enable all strict type-checking options. */ 75 | // "noImplicitAny": true, /* Enable error reporting for expressions and declarations with an implied `any` type.. */ 76 | // "strictNullChecks": true, /* When type checking, take into account `null` and `undefined`. */ 77 | // "strictFunctionTypes": true, /* When assigning functions, check to ensure parameters and the return values are subtype-compatible. */ 78 | // "strictBindCallApply": true, /* Check that the arguments for `bind`, `call`, and `apply` methods match the original function. */ 79 | // "strictPropertyInitialization": true, /* Check for class properties that are declared but not set in the constructor. */ 80 | // "noImplicitThis": true, /* Enable error reporting when `this` is given the type `any`. */ 81 | // "useUnknownInCatchVariables": true, /* Type catch clause variables as 'unknown' instead of 'any'. */ 82 | // "alwaysStrict": true, /* Ensure 'use strict' is always emitted. */ 83 | // "noUnusedLocals": true, /* Enable error reporting when a local variables aren't read. */ 84 | // "noUnusedParameters": true, /* Raise an error when a function parameter isn't read */ 85 | // "exactOptionalPropertyTypes": true, /* Interpret optional property types as written, rather than adding 'undefined'. */ 86 | // "noImplicitReturns": true, /* Enable error reporting for codepaths that do not explicitly return in a function. */ 87 | // "noFallthroughCasesInSwitch": true, /* Enable error reporting for fallthrough cases in switch statements. */ 88 | // "noUncheckedIndexedAccess": true, /* Include 'undefined' in index signature results */ 89 | // "noImplicitOverride": true, /* Ensure overriding members in derived classes are marked with an override modifier. */ 90 | // "noPropertyAccessFromIndexSignature": true, /* Enforces using indexed accessors for keys declared using an indexed type */ 91 | // "allowUnusedLabels": true, /* Disable error reporting for unused labels. */ 92 | // "allowUnreachableCode": true, /* Disable error reporting for unreachable code. */ 93 | /* Completeness */ 94 | // "skipDefaultLibCheck": true, /* Skip type checking .d.ts files that are included with TypeScript. */ 95 | "skipLibCheck": true /* Skip type checking all .d.ts files. */ 96 | }, 97 | "include": [ 98 | "./tests/setup.ts" 99 | ] 100 | } 101 | -------------------------------------------------------------------------------- /vitest.config.ts: -------------------------------------------------------------------------------- 1 | /// 2 | /// 3 | 4 | import react from '@vitejs/plugin-react'; 5 | import { defineConfig } from 'vite'; 6 | 7 | // https://vitejs.dev/config/ 8 | export default defineConfig({ 9 | plugins: [react()], 10 | test: { 11 | globals: true, 12 | environment: 'jsdom', 13 | coverage: { 14 | reporter: ['html', 'text'], 15 | }, 16 | setupFiles: './tests/setup.ts', 17 | include: ['**/?(*.){test,spec}.?(c|m)[jt]s?(x)'], 18 | }, 19 | }); 20 | --------------------------------------------------------------------------------