├── .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 ;
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 | changeLocale('en')}>English
39 | changeLocale('fr')}>French
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 | [](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 | [](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 | changeLocale('en')}>English
29 | changeLocale('fr')}>French
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 | changeLocale('en')}>EN
101 | changeLocale('fr')}>FR
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 | [](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 | 
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 |
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 |
changeLocale('en')}>
79 | EN
80 |
81 |
changeLocale('fr')}>
82 | FR
83 |
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 | changeLocale('en')}>
12 | EN
13 |
14 | changeLocale('fr')}>
15 | FR
16 |
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 |
changeLocale('en')}>
76 | EN
77 |
78 |
changeLocale('fr')}>
79 | FR
80 |
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 |
changeLocale('en')}>
68 | EN
69 |
70 |
changeLocale('fr')}>
71 | FR
72 |
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 | [](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 | 
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 |
changeLocale('fr')}>
66 | Change locale
67 |
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 |
changeLocale('fr')}>
111 | Change locale
112 |
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 |
changeLocale('fr')}>
40 | Change locale
41 |
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 |
--------------------------------------------------------------------------------