├── .github ├── FUNDING.yml ├── ISSUE_TEMPLATE │ ├── bug_report.yml │ └── config.yml ├── dependabot.yml ├── release.yml └── workflows │ ├── bump.yml │ ├── ci.yml │ ├── dependabot.yml │ ├── docs.yml │ └── publish.yml ├── .gitignore ├── .vscode ├── extensions.json └── settings.json ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── biome.json ├── bun.lock ├── package.json ├── scripts └── exports.ts ├── src ├── client.ts ├── lib │ ├── format-language-string.ts │ ├── get-client-locales.test.ts │ ├── get-client-locales.ts │ ├── get-headers.ts │ ├── language-detector.ts │ ├── parser.test.ts │ ├── parser.ts │ └── test-helper.ts ├── middleware.test.ts ├── middleware.ts ├── react.tsx ├── server.test.ts └── server.ts ├── tsconfig.json └── typedoc.json /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: sergiodxa 2 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.yml: -------------------------------------------------------------------------------- 1 | name: '🐛 Bug report' 2 | description: Create a report to help us improve 3 | body: 4 | - type: markdown 5 | attributes: 6 | value: | 7 | Thank you for reporting an issue :pray:. 8 | 9 | This issue tracker is for reporting bugs found in Remix i18next (https://github.com/sergiodxa/remix-i18next). 10 | If you have a question about how to achieve something and are struggling, please post a question 11 | inside of Remix Auth's Discussions tab: https://github.com/sergiodxa/remix-i18next/discussions 12 | 13 | Before submitting a new bug/issue, please check the links below to see if there is a solution or question posted there already: 14 | - Remix Auth's Issues tab: https://github.com/sergiodxa/remix-i18next/issues?q=is%3Aissue+is%3Aopen+sort%3Aupdated-desc 15 | - Remix Auth's closed issues tab: https://github.com/sergiodxa/remix-i18next/issues?q=is%3Aissue+sort%3Aupdated-desc+is%3Aclosed 16 | - Remix Auth's Discussions tab: https://github.com/sergiodxa/remix-i18next/discussions 17 | 18 | The more information you fill in, the better the community can help you. 19 | - type: textarea 20 | id: description 21 | attributes: 22 | label: Describe the bug 23 | description: Provide a clear and concise description of the challenge you are running into. 24 | validations: 25 | required: true 26 | - type: input 27 | id: link 28 | attributes: 29 | label: Your Example Website or App 30 | description: | 31 | Which website or app were you using when the bug happened? 32 | Note: 33 | - Your bug will may get fixed much faster if we can run your code and it doesn't have dependencies other than the Remix Auth npm package. 34 | - To create a shareable code example you can use Stackblitz (https://stackblitz.com/). Please no localhost URLs. 35 | - Please read these tips for providing a minimal example: https://stackoverflow.com/help/mcve. 36 | placeholder: | 37 | e.g. https://stackblitz.com/edit/...... OR Github Repo 38 | validations: 39 | required: true 40 | - type: textarea 41 | id: steps 42 | attributes: 43 | label: Steps to Reproduce the Bug or Issue 44 | description: Describe the steps we have to take to reproduce the behavior. 45 | placeholder: | 46 | 1. Go to '...' 47 | 2. Click on '....' 48 | 3. Scroll down to '....' 49 | 4. See error 50 | validations: 51 | required: true 52 | - type: textarea 53 | id: expected 54 | attributes: 55 | label: Expected behavior 56 | description: Provide a clear and concise description of what you expected to happen. 57 | placeholder: | 58 | As a user, I expected ___ behavior but i am seeing ___ 59 | validations: 60 | required: true 61 | - type: textarea 62 | id: screenshots_or_videos 63 | attributes: 64 | label: Screenshots or Videos 65 | description: | 66 | If applicable, add screenshots or a video to help explain your problem. 67 | For more information on the supported file image/file types and the file size limits, please refer 68 | to the following link: https://docs.github.com/en/github/writing-on-github/working-with-advanced-formatting/attaching-files 69 | placeholder: | 70 | You can drag your video or image files inside of this editor ↓ 71 | - type: textarea 72 | id: platform 73 | attributes: 74 | label: Platform 75 | value: | 76 | - OS: [e.g. macOS, Windows, Linux] 77 | - Browser: [e.g. Chrome, Safari, Firefox] 78 | - Version: [e.g. 91.1] 79 | validations: 80 | required: true 81 | - type: textarea 82 | id: additional 83 | attributes: 84 | label: Additional context 85 | description: Add any other context about the problem here. -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/config.yml: -------------------------------------------------------------------------------- 1 | blank_issues_enabled: false 2 | contact_links: 3 | - name: 🤔 Feature Requests & Questions 4 | url: https://github.com/sergiodxa/remix-i18next/discussions 5 | about: Please ask and answer questions here. -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | enable-beta-ecosystems: true 3 | 4 | updates: 5 | - package-ecosystem: "github-actions" 6 | directory: "/" 7 | schedule: 8 | interval: "monthly" 9 | reviewers: 10 | - "sergiodxa" 11 | assignees: 12 | - "sergiodxa" 13 | 14 | - package-ecosystem: bun 15 | directory: / 16 | schedule: 17 | interval: "weekly" 18 | reviewers: 19 | - "sergiodxa" 20 | assignees: 21 | - "sergiodxa" 22 | -------------------------------------------------------------------------------- /.github/release.yml: -------------------------------------------------------------------------------- 1 | changelog: 2 | categories: 3 | - title: New Features 4 | labels: 5 | - enhancement 6 | - title: Documentation Changes 7 | labels: 8 | - documentation 9 | - title: Bug Fixes 10 | labels: 11 | - bug 12 | - title: Example 13 | labels: 14 | - example 15 | - title: Deprecations 16 | labels: 17 | - deprecated 18 | - title: Other Changes 19 | labels: 20 | - "*" 21 | -------------------------------------------------------------------------------- /.github/workflows/bump.yml: -------------------------------------------------------------------------------- 1 | name: Bump version 2 | 3 | on: 4 | workflow_dispatch: 5 | inputs: 6 | version: 7 | description: "Type of version to bump" 8 | required: true 9 | type: choice 10 | options: 11 | - major 12 | - minor 13 | - patch 14 | 15 | jobs: 16 | bump-version: 17 | name: Bump version 18 | runs-on: ubuntu-latest 19 | steps: 20 | - uses: actions/checkout@v4 21 | with: 22 | ssh-key: ${{ secrets.DEPLOY_KEY }} 23 | 24 | - uses: oven-sh/setup-bun@v2 25 | - run: bun install --frozen-lockfile 26 | 27 | - uses: actions/setup-node@v4 28 | with: 29 | node-version: "lts/*" 30 | 31 | - run: | 32 | git config user.name 'Sergio Xalambrí' 33 | git config user.email 'hello@sergiodxa.com' 34 | 35 | - run: npm version ${{ github.event.inputs.version }} 36 | - run: bun run quality:fix 37 | - run: git push origin main --follow-tags 38 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: [push] 4 | 5 | jobs: 6 | build: 7 | name: Build 8 | runs-on: ubuntu-latest 9 | steps: 10 | - uses: actions/checkout@v4 11 | - uses: oven-sh/setup-bun@v2 12 | - run: bun install --frozen-lockfile 13 | - run: bun run build 14 | 15 | typecheck: 16 | name: Typechecker 17 | runs-on: ubuntu-latest 18 | steps: 19 | - uses: actions/checkout@v4 20 | - uses: oven-sh/setup-bun@v2 21 | - run: bun install --frozen-lockfile 22 | - run: bun run typecheck 23 | 24 | quality: 25 | name: Code Quality 26 | runs-on: ubuntu-latest 27 | steps: 28 | - uses: actions/checkout@v4 29 | - uses: oven-sh/setup-bun@v2 30 | - run: bun install --frozen-lockfile 31 | - run: bun run quality 32 | 33 | test: 34 | name: Tests 35 | runs-on: ubuntu-latest 36 | steps: 37 | - uses: actions/checkout@v4 38 | - uses: oven-sh/setup-bun@v2 39 | - run: bun install --frozen-lockfile 40 | - run: bun test 41 | 42 | exports: 43 | name: Verify Exports 44 | runs-on: ubuntu-latest 45 | steps: 46 | - uses: actions/checkout@v4 47 | - uses: oven-sh/setup-bun@v2 48 | - run: bun install --frozen-lockfile 49 | - run: bun run build 50 | - run: bun run exports 51 | -------------------------------------------------------------------------------- /.github/workflows/dependabot.yml: -------------------------------------------------------------------------------- 1 | name: Enable auto-merge for Dependabot PRs 2 | 3 | on: 4 | pull_request: 5 | types: opened 6 | 7 | permissions: 8 | contents: write 9 | pull-requests: write 10 | 11 | jobs: 12 | dependabot: 13 | runs-on: ubuntu-latest 14 | if: ${{ github.actor == 'dependabot[bot]' }} 15 | steps: 16 | - id: metadata 17 | uses: dependabot/fetch-metadata@v2 18 | with: 19 | github-token: "${{ secrets.GITHUB_TOKEN }}" 20 | 21 | - run: gh pr merge --auto --squash "$PR_URL" 22 | env: 23 | PR_URL: ${{github.event.pull_request.html_url}} 24 | GITHUB_TOKEN: ${{secrets.GITHUB_TOKEN}} 25 | -------------------------------------------------------------------------------- /.github/workflows/docs.yml: -------------------------------------------------------------------------------- 1 | name: Deploy Documentation 2 | 3 | on: 4 | release: 5 | types: [published] 6 | workflow_dispatch: 7 | 8 | permissions: 9 | contents: read 10 | pages: write 11 | id-token: write 12 | 13 | concurrency: 14 | group: "docs" 15 | cancel-in-progress: false 16 | 17 | jobs: 18 | deploy: 19 | environment: 20 | name: github-pages 21 | url: ${{ steps.deployment.outputs.page_url }} 22 | runs-on: ubuntu-latest 23 | steps: 24 | - uses: actions/checkout@v4 25 | - uses: oven-sh/setup-bun@v2 26 | - run: bun install --frozen-lockfile 27 | - run: bunx typedoc 28 | - uses: actions/configure-pages@v5 29 | - uses: actions/upload-pages-artifact@v3 30 | with: 31 | path: "./docs" 32 | - uses: actions/deploy-pages@v4 33 | id: deployment 34 | -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | name: Publish 2 | 3 | on: 4 | release: 5 | types: [published] 6 | 7 | jobs: 8 | publish-npm: 9 | name: "Publish to npm" 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: actions/checkout@v4 13 | - uses: oven-sh/setup-bun@v2 14 | - run: bun install --frozen-lockfile 15 | - run: bun run build 16 | - run: bun run exports 17 | 18 | - uses: actions/setup-node@v4 19 | with: 20 | node-version: "lts/*" 21 | registry-url: https://registry.npmjs.org/ 22 | 23 | - run: npm publish --access public 24 | env: 25 | NODE_AUTH_TOKEN: ${{secrets.NPM_TOKEN}} 26 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /build 2 | /coverage 3 | /docs 4 | /node_modules 5 | *.log 6 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": ["biomejs.biome"] 3 | } 4 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "[javascript]": { 3 | "editor.defaultFormatter": "biomejs.biome" 4 | }, 5 | "[javascriptreact]": { 6 | "editor.defaultFormatter": "biomejs.biome" 7 | }, 8 | "[json]": { 9 | "editor.defaultFormatter": "biomejs.biome" 10 | }, 11 | "[jsonc]": { 12 | "editor.defaultFormatter": "biomejs.biome" 13 | }, 14 | "[typescript]": { 15 | "editor.defaultFormatter": "biomejs.biome" 16 | }, 17 | "[typescriptreact]": { 18 | "editor.defaultFormatter": "biomejs.biome" 19 | }, 20 | "editor.codeActionsOnSave": { 21 | "source.organizeImports.biome": "explicit", 22 | "quickfix.biome": "explicit" 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | We as members, contributors, and leaders pledge to make participation in our 6 | community a harassment-free experience for everyone, regardless of age, body 7 | size, visible or invisible disability, ethnicity, sex characteristics, gender 8 | identity and expression, level of experience, education, socio-economic status, 9 | nationality, personal appearance, race, religion, or sexual identity 10 | and orientation. 11 | 12 | We pledge to act and interact in ways that contribute to an open, welcoming, 13 | diverse, inclusive, and healthy community. 14 | 15 | ## Our Standards 16 | 17 | Examples of behavior that contributes to a positive environment for our 18 | community include: 19 | 20 | - Demonstrating empathy and kindness toward other people 21 | - Being respectful of differing opinions, viewpoints, and experiences 22 | - Giving and gracefully accepting constructive feedback 23 | - Accepting responsibility and apologizing to those affected by our mistakes, 24 | and learning from the experience 25 | - Focusing on what is best not just for us as individuals, but for the 26 | overall community 27 | 28 | Examples of unacceptable behavior include: 29 | 30 | - The use of sexualized language or imagery, and sexual attention or 31 | advances of any kind 32 | - Trolling, insulting or derogatory comments, and personal or political attacks 33 | - Public or private harassment 34 | - Publishing others' private information, such as a physical or email 35 | address, without their explicit permission 36 | - Other conduct which could reasonably be considered inappropriate in a 37 | professional setting 38 | 39 | ## Enforcement Responsibilities 40 | 41 | Community leaders are responsible for clarifying and enforcing our standards of 42 | acceptable behavior and will take appropriate and fair corrective action in 43 | response to any behavior that they deem inappropriate, threatening, offensive, 44 | or harmful. 45 | 46 | Community leaders have the right and responsibility to remove, edit, or reject 47 | comments, commits, code, wiki edits, issues, and other contributions that are 48 | not aligned to this Code of Conduct, and will communicate reasons for moderation 49 | decisions when appropriate. 50 | 51 | ## Scope 52 | 53 | This Code of Conduct applies within all community spaces, and also applies when 54 | an individual is officially representing the community in public spaces. 55 | Examples of representing our community include using an official e-mail address, 56 | posting via an official social media account, or acting as an appointed 57 | representative at an online or offline event. 58 | 59 | ## Enforcement 60 | 61 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 62 | reported to the community leaders responsible for enforcement at 63 | hello@sergiodxa.com. 64 | All complaints will be reviewed and investigated promptly and fairly. 65 | 66 | All community leaders are obligated to respect the privacy and security of the 67 | reporter of any incident. 68 | 69 | ## Enforcement Guidelines 70 | 71 | Community leaders will follow these Community Impact Guidelines in determining 72 | the consequences for any action they deem in violation of this Code of Conduct: 73 | 74 | ### 1. Correction 75 | 76 | **Community Impact**: Use of inappropriate language or other behavior deemed 77 | unprofessional or unwelcome in the community. 78 | 79 | **Consequence**: A private, written warning from community leaders, providing 80 | clarity around the nature of the violation and an explanation of why the 81 | behavior was inappropriate. A public apology may be requested. 82 | 83 | ### 2. Warning 84 | 85 | **Community Impact**: A violation through a single incident or series 86 | of actions. 87 | 88 | **Consequence**: A warning with consequences for continued behavior. No 89 | interaction with the people involved, including unsolicited interaction with 90 | those enforcing the Code of Conduct, for a specified period of time. This 91 | includes avoiding interactions in community spaces as well as external channels 92 | like social media. Violating these terms may lead to a temporary or 93 | permanent ban. 94 | 95 | ### 3. Temporary Ban 96 | 97 | **Community Impact**: A serious violation of community standards, including 98 | sustained inappropriate behavior. 99 | 100 | **Consequence**: A temporary ban from any sort of interaction or public 101 | communication with the community for a specified period of time. No public or 102 | private interaction with the people involved, including unsolicited interaction 103 | with those enforcing the Code of Conduct, is allowed during this period. 104 | Violating these terms may lead to a permanent ban. 105 | 106 | ### 4. Permanent Ban 107 | 108 | **Community Impact**: Demonstrating a pattern of violation of community 109 | standards, including sustained inappropriate behavior, harassment of an 110 | individual, or aggression toward or disparagement of classes of individuals. 111 | 112 | **Consequence**: A permanent ban from any sort of public interaction within 113 | the community. 114 | 115 | ## Attribution 116 | 117 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], 118 | version 2.0, available at 119 | https://www.contributor-covenant.org/version/2/0/code_of_conduct.html. 120 | 121 | Community Impact Guidelines were inspired by [Mozilla's code of conduct 122 | enforcement ladder](https://github.com/mozilla/diversity). 123 | 124 | [homepage]: https://www.contributor-covenant.org 125 | 126 | For answers to common questions about this code of conduct, see the FAQ at 127 | https://www.contributor-covenant.org/faq. Translations are available at 128 | https://www.contributor-covenant.org/translations. 129 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contribution 2 | 3 | ## Setup 4 | 5 | Run `bun install` to install the dependencies. 6 | 7 | Run the tests with `bun test`. 8 | 9 | Run the code quality checker with `bun run quality`. 10 | 11 | Run the typechecker with `bun run typecheck`. 12 | 13 | Run the exports checker with `bun run exports`. 14 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Sergio Xalambrí 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # remix-i18next 2 | 3 | **The easiest way to translate your React Router framework mode apps.** 4 | 5 | > [!IMPORTANT] 6 | > If you're still on Remix v2, keep using [remix-i18next v6](https://github.com/sergiodxa/remix-i18next/tree/v6.4.1) as the v7 is only compatible with React Router v7. 7 | 8 | ## Why remix-i18next? 9 | 10 | - Easy to set up, easy to use: setup only takes a few steps, and configuration is simple. 11 | - No other requirements: `remix-i18next` simplifies internationalisation for your React Router app without extra dependencies. 12 | - Production ready: `remix-i18next` supports passing translations and configuration options into routes from the loader. 13 | - Take the control: `remix-i18next` doesn't hide the configuration so you can add any plugin you want or configure as pleased. 14 | 15 | ## Setup 16 | 17 | > [!TIP] 18 | > Check https://github.com/sergiodxa/react-router-i18next-example for an example application, if you have an issue compare your setup with the example. 19 | 20 | ### Installation 21 | 22 | The first step is to install it in your project with 23 | 24 | ```sh 25 | npm install remix-i18next i18next react-i18next i18next-browser-languagedetector 26 | ``` 27 | 28 | You will need to configure an i18next backend and language detector, in that case you can install them too, for the rest of the setup guide we'll use the fetch backend. 29 | 30 | ```sh 31 | npm install i18next-fetch-backend 32 | ``` 33 | 34 | ### Localization files 35 | 36 | First let's create some translation files in `app/locales`: 37 | 38 | ```ts 39 | // app/locales/en.ts 40 | export default { 41 | title: "remix-i18next (en)", 42 | description: "A Remix + Vite + remix-i18next example", 43 | }; 44 | ``` 45 | 46 | ```ts 47 | // app/locales/es.ts 48 | import type en from "./en"; 49 | 50 | export default { 51 | title: "remix-i18next (es)", 52 | description: "Un ejemplo de Remix + Vite + remix-i18next", 53 | } satisfies typeof en; 54 | ``` 55 | 56 | The type import and the `satisfies` are optional, but it will help us ensure that if we add or remove a key from the `en` locale (our default one) we will get a type error in the `es` locale so we can keep them in sync. 57 | 58 | ### Setup the Middleware 59 | 60 | Create a file named `app/middleware/i18next.ts` with the following code: 61 | 62 | > [!CAUTION] 63 | > This depends on `react-router@7.3.0` or later, and it's considered unstable until React Router middleware feature itself is considered stable. Breaking changes may happen in minor versions. 64 | > Check older versions of the README for a guide on how to use RemixI18next class instead if you are using an older version of React Router or don't want to use the middleware. 65 | 66 | ```ts 67 | import { unstable_createI18nextMiddleware } from "remix-i18next/middleware"; 68 | import en from "~/locales/en"; 69 | import es from "~/locales/es"; 70 | 71 | export const [i18nextMiddleware, getLocale, getInstance] = 72 | unstable_createI18nextMiddleware({ 73 | detection: { 74 | supportedLanguages: ["en", "es"], 75 | fallbackLanguage: "en", 76 | }, 77 | i18next: { 78 | resources: { en: { translation: en }, es: { translation: es } }, 79 | // Other i18next options are available here 80 | }, 81 | }); 82 | ``` 83 | 84 | Then in your `app/root.tsx` setup the middleware: 85 | 86 | ```ts 87 | import { i18nextMiddleware } from "~/middleware/i18next"; 88 | 89 | export const unstable_middleware = [i18nextMiddleware]; 90 | ``` 91 | 92 | With this, on every request, the middleware will run, detect the language and set it in the router context. 93 | 94 | You can access the language in your loaders and actions using `getLocale(context)` function. 95 | 96 | If you need access to the underlying i18next instance, you can use `getInstance(context)`. This is useful if you want to call the `t` function or any other i18next method. 97 | 98 | ### Get the locale 99 | 100 | From this point, you can go to any loader and get the locale using the `getLocale` function. 101 | 102 | ```ts 103 | import { getLocale } from "~/middleware/i18next"; 104 | 105 | export async function loader({ context }: Route.LoaderArgs) { 106 | let locale = getLocale(context); 107 | let date = new Date().toLocaleDateString(locale, { 108 | year: "numeric", 109 | month: "2-digit", 110 | day: "2-digit", 111 | }); 112 | return { date }; 113 | } 114 | ``` 115 | 116 | ### Send translated text to the UI 117 | 118 | To send translated text to the UI, you can use the `t` function from i18next. You can get it from the context using `getInstance(context)`. 119 | 120 | ```ts 121 | import { getInstance } from "~/middleware/i18next"; 122 | 123 | export async function loader({ context }: Route.LoaderArgs) { 124 | let i18next = getInstance(context); 125 | return { title: i18next.t("title"), description: i18next.t("description") }; 126 | } 127 | ``` 128 | 129 | The `TFunction` accessible from the i18next instance is already configured with the locale detected by the middleware. 130 | 131 | If you want to use a different locale, you can use the `i18next.getFixedT` method. 132 | 133 | ```ts 134 | import { getInstance } from "~/middleware/i18next"; 135 | 136 | export async function loader({ context }: Route.LoaderArgs) { 137 | let i18next = getInstance(context); 138 | let t = i18next.getFixedT("es"); 139 | return { title: t("title"), description: t("description") }; 140 | } 141 | ``` 142 | 143 | This will return a new `TFunction` instance with the locale set to `es`. 144 | 145 | ### Usage with react-i18next 146 | 147 | So far this has configured the i18next instance to inside React Router loaders and actions, but in many cases we will need to use it directly in our React components. 148 | 149 | To do this, we need to setup react-i18next. 150 | 151 | Let's start by updating the `entry.client.tsx` and `entry.server.tsx` files to use the i18next instance created in the middleware. 152 | 153 | > [!TIP] 154 | > If you don't have these files, run `npx react-router reveal` to generate them. They are hidden by default. 155 | 156 | ### Update the root route 157 | 158 | First of all, we want to send the locale detected server-side by the middleware to the UI. To do this, we will return the locale from the `app/root.tsx` route. 159 | 160 | ```tsx 161 | import { 162 | data, 163 | Links, 164 | Meta, 165 | Outlet, 166 | Scripts, 167 | ScrollRestoration, 168 | } from "react-router"; 169 | import { useChangeLanguage } from "remix-i18next/react"; 170 | import type { Route } from "./+types/root"; 171 | import { 172 | getLocale, 173 | i18nextMiddleware, 174 | localeCookie, 175 | } from "./middleware/i18next"; 176 | import { useTranslation } from "react-i18next"; 177 | 178 | export const unstable_middleware = [i18nextMiddleware]; 179 | 180 | export async function loader({ context }: Route.LoaderArgs) { 181 | let locale = getLocale(context); 182 | return data( 183 | { locale }, 184 | { headers: { "Set-Cookie": await localeCookie.serialize(locale) } } 185 | ); 186 | } 187 | 188 | export function Layout({ children }: { children: React.ReactNode }) { 189 | let { i18n } = useTranslation(); 190 | 191 | return ( 192 | 193 | 194 | 195 | 196 | 197 | 198 | 199 | 200 | {children} 201 | 202 | 203 | 204 | 205 | ); 206 | } 207 | 208 | export default function App({ loaderData }: Route.ComponentProps) { 209 | useChangeLanguage(loaderData.locale); 210 | return ; 211 | } 212 | ``` 213 | 214 | We made a few changes here: 215 | 216 | 1. We added a `loader` that gets the locale from the context (set by the middleware) and returns it to the UI. 217 | 2. In the root Layout component, we use the `useTranslation` hook to get the i18n instance and set the `lang` attribute of the `` tag, along the `dir` attribute. 218 | 3. We added the `useChangeLanguage` hook to set the language in the i18next instance, this will keep the language in sync with the locale detected by the middleware after a refresh of the loader data. 219 | 220 | #### Client-side configuration 221 | 222 | Now in your `entry.client.tsx` replace the default code with this: 223 | 224 | ```tsx 225 | import Fetch from "i18next-fetch-backend"; 226 | import i18next from "i18next"; 227 | import { startTransition, StrictMode } from "react"; 228 | import { hydrateRoot } from "react-dom/client"; 229 | import { I18nextProvider, initReactI18next } from "react-i18next"; 230 | import { HydratedRouter } from "react-router/dom"; 231 | import I18nextBrowserLanguageDetector from "i18next-browser-languagedetector"; 232 | import { getInitialNamespaces } from "remix-i18next/client"; 233 | 234 | async function main() { 235 | await i18next 236 | .use(initReactI18next) 237 | .use(Fetch) 238 | .use(I18nextBrowserLanguageDetector) 239 | .init({ 240 | fallbackLng: "en", 241 | ns: getInitialNamespaces(), 242 | detection: { order: ["htmlTag"], caches: [] }, 243 | backend: { loadPath: "/api/locales/{{lng}}/{{ns}}" }, 244 | }); 245 | 246 | startTransition(() => { 247 | hydrateRoot( 248 | document, 249 | 250 | 251 | 252 | 253 | 254 | ); 255 | }); 256 | } 257 | 258 | main().catch((error) => console.error(error)); 259 | ``` 260 | 261 | The `getInitialNamespaces` function from `remix-i18next/client` will return the namespaces that were used in the server-side rendering. This way, we can load them on the client-side before rendering the app. 262 | 263 | We're also configuring `i18next-browser-languagedetector` to detect the language based on the `lang` attribute of the `` tag. This way, we can use the same language detected by the middleware server-side. 264 | 265 | ### API for locales 266 | 267 | The `app/entry.client.tsx` has the i18next backend configured to load the locales from the path `/api/locales/{{lng}}/{{ns}}`. Feel free to customize this but for this guide we will use that path. 268 | 269 | Now we need to create a route to serve the locales. So let's create a file `app/routes/locales.ts` and add the following code: 270 | 271 | ```ts 272 | import { data } from "react-router"; 273 | import { cacheHeader } from "pretty-cache-header"; 274 | import { z } from "zod"; 275 | import enTranslation from "~/locales/en"; 276 | import esTranslation from "~/locales/es"; 277 | import type { Route } from "./+types/locales"; 278 | 279 | const resources = { 280 | en: { translation: enTranslation }, 281 | es: { translation: esTranslation }, 282 | }; 283 | 284 | export async function loader({ params }: Route.LoaderArgs) { 285 | const lng = z 286 | .string() 287 | .refine((lng): lng is keyof typeof resources => 288 | Object.keys(resources).includes(lng) 289 | ) 290 | .safeParse(params.lng); 291 | 292 | if (lng.error) return data({ error: lng.error }, { status: 400 }); 293 | 294 | const namespaces = resources[lng.data]; 295 | 296 | const ns = z 297 | .string() 298 | .refine((ns): ns is keyof typeof namespaces => { 299 | return Object.keys(resources[lng.data]).includes(ns); 300 | }) 301 | .safeParse(params.ns); 302 | 303 | if (ns.error) return data({ error: ns.error }, { status: 400 }); 304 | 305 | const headers = new Headers(); 306 | 307 | // On production, we want to add cache headers to the response 308 | if (process.env.NODE_ENV === "production") { 309 | headers.set( 310 | "Cache-Control", 311 | cacheHeader({ 312 | maxAge: "5m", // Cache in the browser for 5 minutes 313 | sMaxage: "1d", // Cache in the CDN for 1 day 314 | // Serve stale content while revalidating for 7 days 315 | staleWhileRevalidate: "7d", 316 | // Serve stale content if there's an error for 7 days 317 | staleIfError: "7d", 318 | }) 319 | ); 320 | } 321 | 322 | return data(namespaces[ns.data], { headers }); 323 | } 324 | ``` 325 | 326 | This file introduces two dependencies 327 | 328 | 1. [Zod](https://zod.dev/) for validating the parameters passed to the route. 329 | 2. [pretty-cache-header](https://npm.im/pretty-cache-header) for generating cache headers. 330 | 331 | They are not hard requirements, but they are useful for our example, feel free to change them or remove them. 332 | 333 | ### Server-side configuration 334 | 335 | Now in your `entry.server.tsx` replace the default code with this: 336 | 337 | ```tsx 338 | import { PassThrough } from "node:stream"; 339 | 340 | import type { 341 | EntryContext, 342 | unstable_RouterContextProvider, 343 | } from "react-router"; 344 | import { createReadableStreamFromReadable } from "@react-router/node"; 345 | import { ServerRouter } from "react-router"; 346 | import { isbot } from "isbot"; 347 | import type { RenderToPipeableStreamOptions } from "react-dom/server"; 348 | import { renderToPipeableStream } from "react-dom/server"; 349 | import { I18nextProvider } from "react-i18next"; 350 | import { getInstance } from "./middleware/i18next"; 351 | 352 | export const streamTimeout = 5_000; 353 | 354 | export default function handleRequest( 355 | request: Request, 356 | responseStatusCode: number, 357 | responseHeaders: Headers, 358 | entryContext: EntryContext, 359 | routerContext: unstable_RouterContextProvider 360 | ) { 361 | return new Promise((resolve, reject) => { 362 | let shellRendered = false; 363 | let userAgent = request.headers.get("user-agent"); 364 | 365 | let readyOption: keyof RenderToPipeableStreamOptions = 366 | (userAgent && isbot(userAgent)) || entryContext.isSpaMode 367 | ? "onAllReady" 368 | : "onShellReady"; 369 | 370 | let { pipe, abort } = renderToPipeableStream( 371 | 372 | 373 | , 374 | { 375 | [readyOption]() { 376 | shellRendered = true; 377 | let body = new PassThrough(); 378 | let stream = createReadableStreamFromReadable(body); 379 | 380 | responseHeaders.set("Content-Type", "text/html"); 381 | 382 | resolve( 383 | new Response(stream, { 384 | headers: responseHeaders, 385 | status: responseStatusCode, 386 | }) 387 | ); 388 | 389 | pipe(body); 390 | }, 391 | onShellError(error: unknown) { 392 | reject(error); 393 | }, 394 | onError(error: unknown) { 395 | responseStatusCode = 500; 396 | if (shellRendered) console.error(error); 397 | }, 398 | } 399 | ); 400 | 401 | setTimeout(abort, streamTimeout + 1000); 402 | }); 403 | } 404 | ``` 405 | 406 | Here we are using the `getInstance` function from the middleware to get the i18next instance. 407 | 408 | This way, we can re-use the instance created in the middleware and avoid creating a new one. And since the instance is already configured with the language we detected, we can use it directly in the `I18nextProvider`. 409 | 410 | ## Finding the locale from the request URL pathname 411 | 412 | If you want to keep the user locale on the pathname, you have two possible options. 413 | 414 | First option is to ignore the locale detected by the middleware and manually grab the locale from the URL pathname. 415 | 416 | Second options is to pass a `findLocale` function to the detection options in the middleware. 417 | 418 | ```ts 419 | import { unstable_createI18nextMiddleware } from "remix-i18next/middleware"; 420 | 421 | export const [i18nextMiddleware, getLocale, getInstance] = 422 | unstable_createI18nextMiddleware({ 423 | detection: { 424 | supportedLanguages: ["es", "en"], 425 | fallbackLanguage: "en", 426 | findLocale(request) { 427 | let locale = new URL(request.url).pathname.split("/").at(1); 428 | return locale; 429 | }, 430 | }, 431 | i18next: { 432 | resources: { en: { translation: en }, es: { translation: es } }, 433 | }, 434 | }); 435 | ``` 436 | 437 | The locale returned by `findLocale` will be validated against the list of supported locales, in case it's not valid the fallback locale will be used. 438 | 439 | ## Querying the locale from the database 440 | 441 | If your application stores the user locale in the database, you can use `findLocale` function to query the database and return the locale. 442 | 443 | ```ts 444 | export let i18n = new RemixI18Next({ 445 | detection: { 446 | supportedLanguages: ["es", "en"], 447 | fallbackLanguage: "en", 448 | async findLocale(request) { 449 | let user = await db.getUser(request); 450 | return user.locale; 451 | }, 452 | }, 453 | }); 454 | ``` 455 | 456 | ## Store the locale in a cookie 457 | 458 | If you want to store the locale in a cookie, you can create a cookie using `createCookie` helper from React Router and pass the Cookie object to the middleware. 459 | 460 | ```ts 461 | import { createCookie } from "react-router"; 462 | 463 | export const localeCookie = createCookie("lng", { 464 | path: "/", 465 | sameSite: "lax", 466 | secure: process.env.NODE_ENV === "production", 467 | httpOnly: true, 468 | }); 469 | ``` 470 | 471 | Then you can pass the cookie to the middleware: 472 | 473 | ```ts 474 | import { unstable_createI18nextMiddleware } from "remix-i18next/middleware"; 475 | import { localeCookie } from "~/cookies"; 476 | 477 | export const [i18nextMiddleware, getLocale, getInstance] = 478 | unstable_createI18nextMiddleware({ 479 | detection: { 480 | supportedLanguages: ["es", "en"], 481 | fallbackLanguage: "en", 482 | cookie: localeCookie, 483 | }, 484 | i18next: { 485 | resources: { en: { translation: en }, es: { translation: es } }, 486 | }, 487 | }); 488 | ``` 489 | 490 | now the middleware will read the locale from the cookie, if it exists, and set it in the context. If the cookie doesn't exist, it will use the Accept-Language header or the fallback language. 491 | 492 | Then in your routes, you can use this cookie to save the user preference, a simple way is to navigate the user to the same URL with `?lng=es` (replacing `es` with the desired language) and then in the `app/root.tsx` route set the cookie with the new value. 493 | 494 | ```ts 495 | import { data } from "react-router"; 496 | import { localeCookie } from "~/cookies"; 497 | import { getLocale } from "~/middleware/i18next"; 498 | 499 | export async function loader({ context }: Route.LoaderArgs) { 500 | let locale = getLocale(context); 501 | return data( 502 | { locale }, 503 | { headers: { "Set-Cookie": await localeCookie.serialize(locale) } } 504 | ); 505 | } 506 | ``` 507 | 508 | ## Store the locale in the session 509 | 510 | Similarly to the cookie, you can store the locale in the session. To do this, you can create a session using `createSessionStorage` helpers from React Router and pass the SessionStorage object to the middleware. 511 | 512 | ```ts 513 | import { createCookieSessionStorage } from "react-router"; 514 | 515 | export const sessionStorage = createCookieSessionStorage({ 516 | cookie: { 517 | name: "session", 518 | path: "/", 519 | sameSite: "lax", 520 | secure: process.env.NODE_ENV === "production", 521 | httpOnly: true, 522 | }, 523 | }); 524 | ``` 525 | 526 | Then you can pass the session to the middleware: 527 | 528 | ```ts 529 | import { unstable_createI18nextMiddleware } from "remix-i18next/middleware"; 530 | import { sessionStorage } from "~/session"; 531 | 532 | export const [i18nextMiddleware, getLocale, getInstance] = 533 | unstable_createI18nextMiddleware({ 534 | detection: { 535 | supportedLanguages: ["es", "en"], 536 | fallbackLanguage: "en", 537 | sessionStorage, 538 | }, 539 | i18next: { 540 | resources: { en: { translation: en }, es: { translation: es } }, 541 | }, 542 | }); 543 | ``` 544 | 545 | Now the middleware will read the locale from the session, if it exists, and set it in the context. If the session doesn't exist, it will use the Accept-Language header or the fallback language. 546 | 547 | Then in your routes, you can use this session to save the user preference, a simple way is to navigate the user to the same URL with `?lng=es` (replacing `es` with the desired language) and then in the `app/root.tsx` route set the session with the new value. 548 | 549 | ```ts 550 | import { data } from "react-router"; 551 | import { sessionStorage } from "~/session"; 552 | import { getLocale } from "~/middleware/i18next"; 553 | 554 | export async function loader({ request, context }: Route.LoaderArgs) { 555 | let locale = getLocale(context); 556 | 557 | let session = await sessionStorage.getSession(request.headers.get("Cookie")); 558 | session.set("lng", locale); 559 | 560 | return data( 561 | { locale }, 562 | { headers: { "Set-Cookie": await sessionStorage.commitSession(session) } } 563 | ); 564 | } 565 | ``` 566 | -------------------------------------------------------------------------------- /biome.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://biomejs.dev/schemas/1.7.1/schema.json", 3 | "organizeImports": { 4 | "enabled": true 5 | }, 6 | "linter": { 7 | "enabled": true, 8 | "rules": { 9 | "recommended": true, 10 | "correctness": { 11 | "useHookAtTopLevel": "error" 12 | }, 13 | "performance": { 14 | "noBarrelFile": "error", 15 | "noReExportAll": "error" 16 | }, 17 | "style": { 18 | "noDefaultExport": "error", 19 | "noNegationElse": "error", 20 | "useConst": "off", 21 | "useExportType": "off", 22 | "useImportType": "off" 23 | }, 24 | "suspicious": { 25 | "noConsoleLog": "warn", 26 | "noEmptyBlockStatements": "warn", 27 | "noSkippedTests": "error" 28 | } 29 | } 30 | }, 31 | "formatter": { "enabled": true }, 32 | "vcs": { 33 | "enabled": true, 34 | "clientKind": "git", 35 | "defaultBranch": "main", 36 | "useIgnoreFile": true 37 | }, 38 | "overrides": [ 39 | { 40 | "include": ["**/*.md"], 41 | "formatter": { "indentStyle": "tab" } 42 | } 43 | ] 44 | } 45 | -------------------------------------------------------------------------------- /bun.lock: -------------------------------------------------------------------------------- 1 | { 2 | "lockfileVersion": 1, 3 | "workspaces": { 4 | "": { 5 | "name": "remix-i18next", 6 | "devDependencies": { 7 | "@arethetypeswrong/cli": "^0.18.1", 8 | "@biomejs/biome": "^1.9.2", 9 | "@total-typescript/tsconfig": "^1.0.4", 10 | "@types/accept-language-parser": "^1.5.6", 11 | "@types/bun": "^1.1.14", 12 | "@types/react": "^19.1.0", 13 | "consola": "^3.3.3", 14 | "i18next": "^25.0.1", 15 | "react": "^19.0.0", 16 | "react-i18next": "^15.4.0", 17 | "react-router": "^7.3.0", 18 | "typedoc": "^0.28.0", 19 | "typedoc-plugin-mdn-links": "^5.0.1", 20 | "typescript": "^5.7.2", 21 | }, 22 | "peerDependencies": { 23 | "i18next": "^24.0.0 || ^25.0.0", 24 | "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", 25 | "react-i18next": "^13.0.0 || ^14.0.0 || ^15.0.0", 26 | "react-router": "^7.0.0", 27 | }, 28 | }, 29 | }, 30 | "packages": { 31 | "@andrewbranch/untar.js": ["@andrewbranch/untar.js@1.0.3", "", {}, "sha512-Jh15/qVmrLGhkKJBdXlK1+9tY4lZruYjsgkDFj08ZmDiWVBLJcqkok7Z0/R0In+i1rScBpJlSvrTS2Lm41Pbnw=="], 32 | 33 | "@arethetypeswrong/cli": ["@arethetypeswrong/cli@0.18.1", "", { "dependencies": { "@arethetypeswrong/core": "0.18.1", "chalk": "^4.1.2", "cli-table3": "^0.6.3", "commander": "^10.0.1", "marked": "^9.1.2", "marked-terminal": "^7.1.0", "semver": "^7.5.4" }, "bin": { "attw": "dist/index.js" } }, "sha512-SS1Z5gRSvbP4tl98KlNygSUp3Yfenktt782MQKEbYm6GFPowztnnvdEUhQGm2uVDIH4YkU6av+n8Lm6OEOigqA=="], 34 | 35 | "@arethetypeswrong/core": ["@arethetypeswrong/core@0.18.1", "", { "dependencies": { "@andrewbranch/untar.js": "^1.0.3", "@loaderkit/resolve": "^1.0.2", "cjs-module-lexer": "^1.2.3", "fflate": "^0.8.2", "lru-cache": "^11.0.1", "semver": "^7.5.4", "typescript": "5.6.1-rc", "validate-npm-package-name": "^5.0.0" } }, "sha512-uUw47cLgB6zYOpAxFp94NG/J9ev0wcOC+UOmTCFEWtbDEn4vpR0ScoPxD7LCGcPczOd7bDJSJL/gMSz3BknYcw=="], 36 | 37 | "@babel/runtime": ["@babel/runtime@7.27.1", "", {}, "sha512-1x3D2xEk2fRo3PAhwQwu5UubzgiVWSXTBfWpVd2Mx2AzRqJuDJCsgaDVZ7HB5iGzDW1Hl1sWN2mFyKjmR9uAog=="], 38 | 39 | "@biomejs/biome": ["@biomejs/biome@1.9.4", "", { "optionalDependencies": { "@biomejs/cli-darwin-arm64": "1.9.4", "@biomejs/cli-darwin-x64": "1.9.4", "@biomejs/cli-linux-arm64": "1.9.4", "@biomejs/cli-linux-arm64-musl": "1.9.4", "@biomejs/cli-linux-x64": "1.9.4", "@biomejs/cli-linux-x64-musl": "1.9.4", "@biomejs/cli-win32-arm64": "1.9.4", "@biomejs/cli-win32-x64": "1.9.4" }, "bin": { "biome": "bin/biome" } }, "sha512-1rkd7G70+o9KkTn5KLmDYXihGoTaIGO9PIIN2ZB7UJxFrWw04CZHPYiMRjYsaDvVV7hP1dYNRLxSANLaBFGpog=="], 40 | 41 | "@biomejs/cli-darwin-arm64": ["@biomejs/cli-darwin-arm64@1.9.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-bFBsPWrNvkdKrNCYeAp+xo2HecOGPAy9WyNyB/jKnnedgzl4W4Hb9ZMzYNbf8dMCGmUdSavlYHiR01QaYR58cw=="], 42 | 43 | "@biomejs/cli-darwin-x64": ["@biomejs/cli-darwin-x64@1.9.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-ngYBh/+bEedqkSevPVhLP4QfVPCpb+4BBe2p7Xs32dBgs7rh9nY2AIYUL6BgLw1JVXV8GlpKmb/hNiuIxfPfZg=="], 44 | 45 | "@biomejs/cli-linux-arm64": ["@biomejs/cli-linux-arm64@1.9.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-fJIW0+LYujdjUgJJuwesP4EjIBl/N/TcOX3IvIHJQNsAqvV2CHIogsmA94BPG6jZATS4Hi+xv4SkBBQSt1N4/g=="], 46 | 47 | "@biomejs/cli-linux-arm64-musl": ["@biomejs/cli-linux-arm64-musl@1.9.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-v665Ct9WCRjGa8+kTr0CzApU0+XXtRgwmzIf1SeKSGAv+2scAlW6JR5PMFo6FzqqZ64Po79cKODKf3/AAmECqA=="], 48 | 49 | "@biomejs/cli-linux-x64": ["@biomejs/cli-linux-x64@1.9.4", "", { "os": "linux", "cpu": "x64" }, "sha512-lRCJv/Vi3Vlwmbd6K+oQ0KhLHMAysN8lXoCI7XeHlxaajk06u7G+UsFSO01NAs5iYuWKmVZjmiOzJ0OJmGsMwg=="], 50 | 51 | "@biomejs/cli-linux-x64-musl": ["@biomejs/cli-linux-x64-musl@1.9.4", "", { "os": "linux", "cpu": "x64" }, "sha512-gEhi/jSBhZ2m6wjV530Yy8+fNqG8PAinM3oV7CyO+6c3CEh16Eizm21uHVsyVBEB6RIM8JHIl6AGYCv6Q6Q9Tg=="], 52 | 53 | "@biomejs/cli-win32-arm64": ["@biomejs/cli-win32-arm64@1.9.4", "", { "os": "win32", "cpu": "arm64" }, "sha512-tlbhLk+WXZmgwoIKwHIHEBZUwxml7bRJgk0X2sPyNR3S93cdRq6XulAZRQJ17FYGGzWne0fgrXBKpl7l4M87Hg=="], 54 | 55 | "@biomejs/cli-win32-x64": ["@biomejs/cli-win32-x64@1.9.4", "", { "os": "win32", "cpu": "x64" }, "sha512-8Y5wMhVIPaWe6jw2H+KlEm4wP/f7EW3810ZLmDlrEEy5KvBsb9ECEfu/kMWD484ijfQ8+nIi0giMgu9g1UAuuA=="], 56 | 57 | "@braidai/lang": ["@braidai/lang@1.1.0", "", {}, "sha512-xyJYkiyNQtTyCLeHxZmOs7rnB94D+N1IjKNArQIh8+8lTBOY7TFgwEV+Ow5a1uaBi5j2w9fLbWcJFTWLDItl5g=="], 58 | 59 | "@colors/colors": ["@colors/colors@1.5.0", "", {}, "sha512-ooWCrlZP11i8GImSjTHYHLkvFDP48nS4+204nGb1RiX/WXYHmJA2III9/e2DWVabCESdW7hBAEzHRqUn9OUVvQ=="], 60 | 61 | "@gerrit0/mini-shiki": ["@gerrit0/mini-shiki@3.2.2", "", { "dependencies": { "@shikijs/engine-oniguruma": "^3.2.1", "@shikijs/langs": "^3.2.1", "@shikijs/themes": "^3.2.1", "@shikijs/types": "^3.2.1", "@shikijs/vscode-textmate": "^10.0.2" } }, "sha512-vaZNGhGLKMY14HbF53xxHNgFO9Wz+t5lTlGNpl2N9xFiKQ0I5oIe0vKjU9dh7Nb3Dw6lZ7wqUE0ri+zcdpnK+Q=="], 62 | 63 | "@loaderkit/resolve": ["@loaderkit/resolve@1.0.3", "", { "dependencies": { "@braidai/lang": "^1.0.0" } }, "sha512-oo51csrgEfeHO593bqoPOGwrX093QzDWrc/7y876b/ObDqp2Hbw+rl+3s26WRXIbnhty40T403nwU4UFX3KQCg=="], 64 | 65 | "@shikijs/engine-oniguruma": ["@shikijs/engine-oniguruma@3.2.1", "", { "dependencies": { "@shikijs/types": "3.2.1", "@shikijs/vscode-textmate": "^10.0.2" } }, "sha512-wZZAkayEn6qu2+YjenEoFqj0OyQI64EWsNR6/71d1EkG4sxEOFooowKivsWPpaWNBu3sxAG+zPz5kzBL/SsreQ=="], 66 | 67 | "@shikijs/langs": ["@shikijs/langs@3.2.1", "", { "dependencies": { "@shikijs/types": "3.2.1" } }, "sha512-If0iDHYRSGbihiA8+7uRsgb1er1Yj11pwpX1c6HLYnizDsKAw5iaT3JXj5ZpaimXSWky/IhxTm7C6nkiYVym+A=="], 68 | 69 | "@shikijs/themes": ["@shikijs/themes@3.2.1", "", { "dependencies": { "@shikijs/types": "3.2.1" } }, "sha512-k5DKJUT8IldBvAm8WcrDT5+7GA7se6lLksR+2E3SvyqGTyFMzU2F9Gb7rmD+t+Pga1MKrYFxDIeyWjMZWM6uBQ=="], 70 | 71 | "@shikijs/types": ["@shikijs/types@3.2.1", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-/NTWAk4KE2M8uac0RhOsIhYQf4pdU0OywQuYDGIGAJ6Mjunxl2cGiuLkvu4HLCMn+OTTLRWkjZITp+aYJv60yA=="], 72 | 73 | "@shikijs/vscode-textmate": ["@shikijs/vscode-textmate@10.0.2", "", {}, "sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg=="], 74 | 75 | "@sindresorhus/is": ["@sindresorhus/is@4.6.0", "", {}, "sha512-t09vSN3MdfsyCHoFcTRCH/iUtG7OJ0CsjzB8cjAmKc/va/kIgeDI/TxsigdncE/4be734m0cvIYwNaV4i2XqAw=="], 76 | 77 | "@total-typescript/tsconfig": ["@total-typescript/tsconfig@1.0.4", "", {}, "sha512-fO4ctMPGz1kOFOQ4RCPBRBfMy3gDn+pegUfrGyUFRMv/Rd0ZM3/SHH3hFCYG4u6bPLG8OlmOGcBLDexvyr3A5w=="], 78 | 79 | "@types/accept-language-parser": ["@types/accept-language-parser@1.5.8", "", {}, "sha512-6+dKdh9q/I8xDBnKQKddCBKaWBWLmJ97HTiSbAXVpL7LEgDfOkKF98UVCaZ5KJrtdN5Wa5ndXUiqD3XR9XGqWQ=="], 80 | 81 | "@types/bun": ["@types/bun@1.2.14", "", { "dependencies": { "bun-types": "1.2.14" } }, "sha512-VsFZKs8oKHzI7zwvECiAJ5oSorWndIWEVhfbYqZd4HI/45kzW7PN2Rr5biAzvGvRuNmYLSANY+H59ubHq8xw7Q=="], 82 | 83 | "@types/cookie": ["@types/cookie@0.6.0", "", {}, "sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA=="], 84 | 85 | "@types/hast": ["@types/hast@3.0.4", "", { "dependencies": { "@types/unist": "*" } }, "sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ=="], 86 | 87 | "@types/node": ["@types/node@22.13.10", "", { "dependencies": { "undici-types": "~6.20.0" } }, "sha512-I6LPUvlRH+O6VRUqYOcMudhaIdUVWfsjnZavnsraHvpBwaEyMN29ry+0UVJhImYL16xsscu0aske3yA+uPOWfw=="], 88 | 89 | "@types/react": ["@types/react@19.1.5", "", { "dependencies": { "csstype": "^3.0.2" } }, "sha512-piErsCVVbpMMT2r7wbawdZsq4xMvIAhQuac2gedQHysu1TZYEigE6pnFfgZT+/jQnrRuF5r+SHzuehFjfRjr4g=="], 90 | 91 | "@types/unist": ["@types/unist@3.0.3", "", {}, "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q=="], 92 | 93 | "ansi-escapes": ["ansi-escapes@7.0.0", "", { "dependencies": { "environment": "^1.0.0" } }, "sha512-GdYO7a61mR0fOlAsvC9/rIHf7L96sBc6dEWzeOu+KAea5bZyQRPIpojrVoI4AXGJS/ycu/fBTdLrUkA4ODrvjw=="], 94 | 95 | "ansi-regex": ["ansi-regex@6.1.0", "", {}, "sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA=="], 96 | 97 | "ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], 98 | 99 | "any-promise": ["any-promise@1.3.0", "", {}, "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A=="], 100 | 101 | "argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="], 102 | 103 | "balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], 104 | 105 | "brace-expansion": ["brace-expansion@2.0.1", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA=="], 106 | 107 | "bun-types": ["bun-types@1.2.14", "", { "dependencies": { "@types/node": "*" } }, "sha512-Kuh4Ub28ucMRWeiUUWMHsT9Wcbr4H3kLIO72RZZElSDxSu7vpetRvxIUDUaW6QtaIeixIpm7OXtNnZPf82EzwA=="], 108 | 109 | "chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], 110 | 111 | "char-regex": ["char-regex@1.0.2", "", {}, "sha512-kWWXztvZ5SBQV+eRgKFeh8q5sLuZY2+8WUIzlxWVTg+oGwY14qylx1KbKzHd8P6ZYkAg0xyIDU9JMHhyJMZ1jw=="], 112 | 113 | "cjs-module-lexer": ["cjs-module-lexer@1.4.3", "", {}, "sha512-9z8TZaGM1pfswYeXrUpzPrkx8UnWYdhJclsiYMm6x/w5+nN+8Tf/LnAgfLGQCm59qAOxU8WwHEq2vNwF6i4j+Q=="], 114 | 115 | "cli-highlight": ["cli-highlight@2.1.11", "", { "dependencies": { "chalk": "^4.0.0", "highlight.js": "^10.7.1", "mz": "^2.4.0", "parse5": "^5.1.1", "parse5-htmlparser2-tree-adapter": "^6.0.0", "yargs": "^16.0.0" }, "bin": { "highlight": "bin/highlight" } }, "sha512-9KDcoEVwyUXrjcJNvHD0NFc/hiwe/WPVYIleQh2O1N2Zro5gWJZ/K+3DGn8w8P/F6FxOgzyC5bxDyHIgCSPhGg=="], 116 | 117 | "cli-table3": ["cli-table3@0.6.5", "", { "dependencies": { "string-width": "^4.2.0" }, "optionalDependencies": { "@colors/colors": "1.5.0" } }, "sha512-+W/5efTR7y5HRD7gACw9yQjqMVvEMLBHmboM/kPWam+H+Hmyrgjh6YncVKK122YZkXrLudzTuAukUw9FnMf7IQ=="], 118 | 119 | "cliui": ["cliui@7.0.4", "", { "dependencies": { "string-width": "^4.2.0", "strip-ansi": "^6.0.0", "wrap-ansi": "^7.0.0" } }, "sha512-OcRE68cOsVMXp1Yvonl/fzkQOyjLSu/8bhPDfQt0e0/Eb283TKP20Fs2MqoPsr9SwA595rRCA+QMzYc9nBP+JQ=="], 120 | 121 | "color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="], 122 | 123 | "color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="], 124 | 125 | "commander": ["commander@10.0.1", "", {}, "sha512-y4Mg2tXshplEbSGzx7amzPwKKOCGuoSRP/CjEdwwk0FOGlUbq6lKuoyDZTNZkmxHdJtp54hdfY/JUrdL7Xfdug=="], 126 | 127 | "consola": ["consola@3.4.2", "", {}, "sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA=="], 128 | 129 | "cookie": ["cookie@1.0.2", "", {}, "sha512-9Kr/j4O16ISv8zBBhJoi4bXOYNTkFLOqSL3UDB0njXxCXNezjeyVrJyGOWtgfs/q2km1gwBcfH8q1yEGoMYunA=="], 130 | 131 | "csstype": ["csstype@3.1.3", "", {}, "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw=="], 132 | 133 | "emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], 134 | 135 | "emojilib": ["emojilib@2.4.0", "", {}, "sha512-5U0rVMU5Y2n2+ykNLQqMoqklN9ICBT/KsvC1Gz6vqHbz2AXXGkG+Pm5rMWk/8Vjrr/mY9985Hi8DYzn1F09Nyw=="], 136 | 137 | "entities": ["entities@4.5.0", "", {}, "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw=="], 138 | 139 | "environment": ["environment@1.1.0", "", {}, "sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q=="], 140 | 141 | "escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="], 142 | 143 | "fflate": ["fflate@0.8.2", "", {}, "sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A=="], 144 | 145 | "get-caller-file": ["get-caller-file@2.0.5", "", {}, "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg=="], 146 | 147 | "has-flag": ["has-flag@4.0.0", "", {}, "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="], 148 | 149 | "highlight.js": ["highlight.js@10.7.3", "", {}, "sha512-tzcUFauisWKNHaRkN4Wjl/ZA07gENAjFl3J/c480dprkGTg5EQstgaNFqBfUqCq54kZRIEcreTsAgF/m2quD7A=="], 150 | 151 | "html-parse-stringify": ["html-parse-stringify@3.0.1", "", { "dependencies": { "void-elements": "3.1.0" } }, "sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg=="], 152 | 153 | "i18next": ["i18next@25.0.2", "", { "dependencies": { "@babel/runtime": "^7.26.10" }, "peerDependencies": { "typescript": "^5" }, "optionalPeers": ["typescript"] }, "sha512-xWxgK8GAaPYkV9ia2tdgbtdM+qiC+ysVTBPvXhpCORU/+QkeQe3BSI7Crr+c4ZXULN1PfnXG/HY2n7HGx4KKBg=="], 154 | 155 | "is-fullwidth-code-point": ["is-fullwidth-code-point@3.0.0", "", {}, "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg=="], 156 | 157 | "linkify-it": ["linkify-it@5.0.0", "", { "dependencies": { "uc.micro": "^2.0.0" } }, "sha512-5aHCbzQRADcdP+ATqnDuhhJ/MRIqDkZX5pyjFHRRysS8vZ5AbqGEoFIb6pYHPZ+L/OC2Lc+xT8uHVVR5CAK/wQ=="], 158 | 159 | "lru-cache": ["lru-cache@11.1.0", "", {}, "sha512-QIXZUBJUx+2zHUdQujWejBkcD9+cs94tLn0+YL8UrCh+D5sCXZ4c7LaEH48pNwRY3MLDgqUFyhlCyjJPf1WP0A=="], 160 | 161 | "lunr": ["lunr@2.3.9", "", {}, "sha512-zTU3DaZaF3Rt9rhN3uBMGQD3dD2/vFQqnvZCDv4dl5iOzq2IZQqTxu90r4E5J+nP70J3ilqVCrbho2eWaeW8Ow=="], 162 | 163 | "markdown-it": ["markdown-it@14.1.0", "", { "dependencies": { "argparse": "^2.0.1", "entities": "^4.4.0", "linkify-it": "^5.0.0", "mdurl": "^2.0.0", "punycode.js": "^2.3.1", "uc.micro": "^2.1.0" }, "bin": { "markdown-it": "bin/markdown-it.mjs" } }, "sha512-a54IwgWPaeBCAAsv13YgmALOF1elABB08FxO9i+r4VFk5Vl4pKokRPeX8u5TCgSsPi6ec1otfLjdOpVcgbpshg=="], 164 | 165 | "marked": ["marked@9.1.6", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-jcByLnIFkd5gSXZmjNvS1TlmRhCXZjIzHYlaGkPlLIekG55JDR2Z4va9tZwCiP+/RDERiNhMOFu01xd6O5ct1Q=="], 166 | 167 | "marked-terminal": ["marked-terminal@7.3.0", "", { "dependencies": { "ansi-escapes": "^7.0.0", "ansi-regex": "^6.1.0", "chalk": "^5.4.1", "cli-highlight": "^2.1.11", "cli-table3": "^0.6.5", "node-emoji": "^2.2.0", "supports-hyperlinks": "^3.1.0" }, "peerDependencies": { "marked": ">=1 <16" } }, "sha512-t4rBvPsHc57uE/2nJOLmMbZCQ4tgAccAED3ngXQqW6g+TxA488JzJ+FK3lQkzBQOI1mRV/r/Kq+1ZlJ4D0owQw=="], 168 | 169 | "mdurl": ["mdurl@2.0.0", "", {}, "sha512-Lf+9+2r+Tdp5wXDXC4PcIBjTDtq4UKjCPMQhKIuzpJNW0b96kVqSwW0bT7FhRSfmAiFYgP+SCRvdrDozfh0U5w=="], 170 | 171 | "minimatch": ["minimatch@9.0.5", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow=="], 172 | 173 | "mz": ["mz@2.7.0", "", { "dependencies": { "any-promise": "^1.0.0", "object-assign": "^4.0.1", "thenify-all": "^1.0.0" } }, "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q=="], 174 | 175 | "node-emoji": ["node-emoji@2.2.0", "", { "dependencies": { "@sindresorhus/is": "^4.6.0", "char-regex": "^1.0.2", "emojilib": "^2.4.0", "skin-tone": "^2.0.0" } }, "sha512-Z3lTE9pLaJF47NyMhd4ww1yFTAP8YhYI8SleJiHzM46Fgpm5cnNzSl9XfzFNqbaz+VlJrIj3fXQ4DeN1Rjm6cw=="], 176 | 177 | "object-assign": ["object-assign@4.1.1", "", {}, "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="], 178 | 179 | "parse5": ["parse5@5.1.1", "", {}, "sha512-ugq4DFI0Ptb+WWjAdOK16+u/nHfiIrcE+sh8kZMaM0WllQKLI9rOUq6c2b7cwPkXdzfQESqvoqK6ug7U/Yyzug=="], 180 | 181 | "parse5-htmlparser2-tree-adapter": ["parse5-htmlparser2-tree-adapter@6.0.1", "", { "dependencies": { "parse5": "^6.0.1" } }, "sha512-qPuWvbLgvDGilKc5BoicRovlT4MtYT6JfJyBOMDsKoiT+GiuP5qyrPCnR9HcPECIJJmZh5jRndyNThnhhb/vlA=="], 182 | 183 | "punycode.js": ["punycode.js@2.3.1", "", {}, "sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA=="], 184 | 185 | "react": ["react@19.1.0", "", {}, "sha512-FS+XFBNvn3GTAWq26joslQgWNoFu08F4kl0J4CgdNKADkdSGXQyTCnKteIAJy96Br6YbpEU1LSzV5dYtjMkMDg=="], 186 | 187 | "react-i18next": ["react-i18next@15.4.1", "", { "dependencies": { "@babel/runtime": "^7.25.0", "html-parse-stringify": "^3.0.1" }, "peerDependencies": { "i18next": ">= 23.2.3", "react": ">= 16.8.0" } }, "sha512-ahGab+IaSgZmNPYXdV1n+OYky95TGpFwnKRflX/16dY04DsYYKHtVLjeny7sBSCREEcoMbAgSkFiGLF5g5Oofw=="], 188 | 189 | "react-router": ["react-router@7.3.0", "", { "dependencies": { "@types/cookie": "^0.6.0", "cookie": "^1.0.1", "set-cookie-parser": "^2.6.0", "turbo-stream": "2.4.0" }, "peerDependencies": { "react": ">=18", "react-dom": ">=18" }, "optionalPeers": ["react-dom"] }, "sha512-466f2W7HIWaNXTKM5nHTqNxLrHTyXybm7R0eBlVSt0k/u55tTCDO194OIx/NrYD4TS5SXKTNekXfT37kMKUjgw=="], 190 | 191 | "regenerator-runtime": ["regenerator-runtime@0.14.1", "", {}, "sha512-dYnhHh0nJoMfnkZs6GmmhFknAGRrLznOu5nc9ML+EJxGvrx6H7teuevqVqCuPcPK//3eDrrjQhehXVx9cnkGdw=="], 192 | 193 | "require-directory": ["require-directory@2.1.1", "", {}, "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q=="], 194 | 195 | "semver": ["semver@7.7.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-hlq8tAfn0m/61p4BVRcPzIGr6LKiMwo4VM6dGi6pt4qcRkmNzTcWq6eCEjEh+qXjkMDvPlOFFSGwQjoEa6gyMA=="], 196 | 197 | "set-cookie-parser": ["set-cookie-parser@2.7.1", "", {}, "sha512-IOc8uWeOZgnb3ptbCURJWNjWUPcO3ZnTTdzsurqERrP6nPyv+paC55vJM0LpOlT2ne+Ix+9+CRG1MNLlyZ4GjQ=="], 198 | 199 | "skin-tone": ["skin-tone@2.0.0", "", { "dependencies": { "unicode-emoji-modifier-base": "^1.0.0" } }, "sha512-kUMbT1oBJCpgrnKoSr0o6wPtvRWT9W9UKvGLwfJYO2WuahZRHOpEyL1ckyMGgMWh0UdpmaoFqKKD29WTomNEGA=="], 200 | 201 | "string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], 202 | 203 | "strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], 204 | 205 | "supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="], 206 | 207 | "supports-hyperlinks": ["supports-hyperlinks@3.2.0", "", { "dependencies": { "has-flag": "^4.0.0", "supports-color": "^7.0.0" } }, "sha512-zFObLMyZeEwzAoKCyu1B91U79K2t7ApXuQfo8OuxwXLDgcKxuwM+YvcbIhm6QWqz7mHUH1TVytR1PwVVjEuMig=="], 208 | 209 | "thenify": ["thenify@3.3.1", "", { "dependencies": { "any-promise": "^1.0.0" } }, "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw=="], 210 | 211 | "thenify-all": ["thenify-all@1.6.0", "", { "dependencies": { "thenify": ">= 3.1.0 < 4" } }, "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA=="], 212 | 213 | "turbo-stream": ["turbo-stream@2.4.0", "", {}, "sha512-FHncC10WpBd2eOmGwpmQsWLDoK4cqsA/UT/GqNoaKOQnT8uzhtCbg3EoUDMvqpOSAI0S26mr0rkjzbOO6S3v1g=="], 214 | 215 | "typedoc": ["typedoc@0.28.4", "", { "dependencies": { "@gerrit0/mini-shiki": "^3.2.2", "lunr": "^2.3.9", "markdown-it": "^14.1.0", "minimatch": "^9.0.5", "yaml": "^2.7.1" }, "peerDependencies": { "typescript": "5.0.x || 5.1.x || 5.2.x || 5.3.x || 5.4.x || 5.5.x || 5.6.x || 5.7.x || 5.8.x" }, "bin": { "typedoc": "bin/typedoc" } }, "sha512-xKvKpIywE1rnqqLgjkoq0F3wOqYaKO9nV6YkkSat6IxOWacUCc/7Es0hR3OPmkIqkPoEn7U3x+sYdG72rstZQA=="], 216 | 217 | "typedoc-plugin-mdn-links": ["typedoc-plugin-mdn-links@5.0.2", "", { "peerDependencies": { "typedoc": "0.27.x || 0.28.x" } }, "sha512-Bd3lsVWPSpDkn6NGZyPHpcK088PUvH4SRq4RD97OjA6l8PQA3yOnJhGACtjmIDdcenRTgWUosH+55ANZhx/wkw=="], 218 | 219 | "typescript": ["typescript@5.8.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ=="], 220 | 221 | "uc.micro": ["uc.micro@2.1.0", "", {}, "sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A=="], 222 | 223 | "undici-types": ["undici-types@6.20.0", "", {}, "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg=="], 224 | 225 | "unicode-emoji-modifier-base": ["unicode-emoji-modifier-base@1.0.0", "", {}, "sha512-yLSH4py7oFH3oG/9K+XWrz1pSi3dfUrWEnInbxMfArOfc1+33BlGPQtLsOYwvdMy11AwUBetYuaRxSPqgkq+8g=="], 226 | 227 | "validate-npm-package-name": ["validate-npm-package-name@5.0.1", "", {}, "sha512-OljLrQ9SQdOUqTaQxqL5dEfZWrXExyyWsozYlAWFawPVNuD83igl7uJD2RTkNMbniIYgt8l81eCJGIdQF7avLQ=="], 228 | 229 | "void-elements": ["void-elements@3.1.0", "", {}, "sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w=="], 230 | 231 | "wrap-ansi": ["wrap-ansi@7.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q=="], 232 | 233 | "y18n": ["y18n@5.0.8", "", {}, "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA=="], 234 | 235 | "yaml": ["yaml@2.7.1", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-10ULxpnOCQXxJvBgxsn9ptjq6uviG/htZKk9veJGhlqn3w/DxQ631zFF+nlQXLwmImeS5amR2dl2U8sg6U9jsQ=="], 236 | 237 | "yargs": ["yargs@16.2.0", "", { "dependencies": { "cliui": "^7.0.2", "escalade": "^3.1.1", "get-caller-file": "^2.0.5", "require-directory": "^2.1.1", "string-width": "^4.2.0", "y18n": "^5.0.5", "yargs-parser": "^20.2.2" } }, "sha512-D1mvvtDG0L5ft/jGWkLpG1+m0eQxOfaBvTNELraWj22wSVUMWxZUvYgJYcKh6jGGIkJFhH4IZPQhR4TKpc8mBw=="], 238 | 239 | "yargs-parser": ["yargs-parser@20.2.9", "", {}, "sha512-y11nGElTIV+CT3Zv9t7VKl+Q3hTQoT9a1Qzezhhl6Rp21gJ/IVTW7Z3y9EWXhuUBC2Shnf+DX0antecpAwSP8w=="], 240 | 241 | "@arethetypeswrong/core/typescript": ["typescript@5.6.1-rc", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-E3b2+1zEFu84jB0YQi9BORDjz9+jGbwwy1Zi3G0LUNw7a7cePUrHMRNy8aPh53nXpkFGVHSxIZo5vKTfYaFiBQ=="], 242 | 243 | "marked-terminal/chalk": ["chalk@5.4.1", "", {}, "sha512-zgVZuo2WcZgfUEmsn6eO3kINexW8RAE4maiQ8QNs8CtpPCSyMiYsULR3HQYkm3w8FIA3SberyMJMSldGsW+U3w=="], 244 | 245 | "parse5-htmlparser2-tree-adapter/parse5": ["parse5@6.0.1", "", {}, "sha512-Ofn/CTFzRGTTxwpNEs9PP93gXShHcTq255nzRYSKe8AkVpZY7e1fpmTfOyoIvjP5HG7Z2ZM7VS9PPhQGW2pOpw=="], 246 | 247 | "react-i18next/@babel/runtime": ["@babel/runtime@7.26.9", "", { "dependencies": { "regenerator-runtime": "^0.14.0" } }, "sha512-aA63XwOkcl4xxQa3HjPMqOP6LiK0ZDv3mUPYEFXkpHbaFjtGggE1A61FjFzJnB+p7/oy2gA8E+rcBNl/zC1tMg=="], 248 | 249 | "strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], 250 | } 251 | } 252 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "remix-i18next", 3 | "version": "7.2.1", 4 | "author": { 5 | "name": "Sergio Xalambrí", 6 | "url": "https://sergiodxa.com", 7 | "email": "hello@sergiodxa.com" 8 | }, 9 | "repository": { 10 | "type": "git", 11 | "url": "git+https://github.com/sergiodxa/remix-i18next.git" 12 | }, 13 | "devDependencies": { 14 | "@arethetypeswrong/cli": "^0.18.1", 15 | "@biomejs/biome": "^1.9.2", 16 | "@total-typescript/tsconfig": "^1.0.4", 17 | "@types/accept-language-parser": "^1.5.6", 18 | "@types/bun": "^1.1.14", 19 | "@types/react": "^19.1.0", 20 | "consola": "^3.3.3", 21 | "i18next": "^25.0.1", 22 | "react": "^19.0.0", 23 | "react-i18next": "^15.4.0", 24 | "react-router": "^7.3.0", 25 | "typedoc": "^0.28.0", 26 | "typedoc-plugin-mdn-links": "^5.0.1", 27 | "typescript": "^5.7.2" 28 | }, 29 | "peerDependencies": { 30 | "react-router": "^7.0.0", 31 | "i18next": "^24.0.0 || ^25.0.0", 32 | "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", 33 | "react-i18next": "^13.0.0 || ^14.0.0 || ^15.0.0" 34 | }, 35 | "exports": { 36 | "./package.json": "./package.json", 37 | "./client": "./build/client.js", 38 | "./server": "./build/server.js", 39 | "./react": "./build/react.js", 40 | "./middleware": "./build/middleware.js" 41 | }, 42 | "bugs": { 43 | "url": "https://github.com/sergiodxa/remix-i18next/issues" 44 | }, 45 | "description": "The easiest way to translate your Full Stack React Router apps", 46 | "engines": { 47 | "node": ">=20.0.0" 48 | }, 49 | "funding": "https://github.com/sponsors/sergiodxa", 50 | "homepage": "https://github.com/sergiodxa/remix-i18next#readme", 51 | "keywords": [ 52 | "remix", 53 | "i18n", 54 | "i18next", 55 | "ssr", 56 | "csr", 57 | "react-router" 58 | ], 59 | "license": "MIT", 60 | "scripts": { 61 | "build": "tsc", 62 | "typecheck": "tsc --noEmit", 63 | "quality": "biome check .", 64 | "quality:fix": "biome check . --write --unsafe", 65 | "exports": "bun run ./scripts/exports.ts" 66 | }, 67 | "sideEffects": false, 68 | "files": [ 69 | "build", 70 | "src", 71 | "package.json", 72 | "README.md" 73 | ], 74 | "type": "module" 75 | } 76 | -------------------------------------------------------------------------------- /scripts/exports.ts: -------------------------------------------------------------------------------- 1 | async function main() { 2 | let proc = Bun.spawn([ 3 | "bunx", 4 | "attw", 5 | "-f", 6 | "table-flipped", 7 | "--no-emoji", 8 | "--no-color", 9 | "--pack", 10 | ]); 11 | 12 | let text = await new Response(proc.stdout).text(); 13 | 14 | let entrypointLines = text 15 | .slice(text.indexOf('"remix-i18next/')) 16 | .split("\n") 17 | .filter(Boolean) 18 | .filter((line) => !line.includes("─")) 19 | .map((line) => 20 | line 21 | .replaceAll(/[^\d "()/A-Za-z│-]/g, "") 22 | .replaceAll("90m│39m", "│") 23 | .replaceAll(/^│/g, "") 24 | .replaceAll(/│$/g, ""), 25 | ); 26 | 27 | let pkg = await Bun.file("package.json").json(); 28 | let entrypoints = entrypointLines.map((entrypointLine) => { 29 | let [entrypoint, ...resolutionColumns] = entrypointLine.split("│"); 30 | if (!entrypoint) throw new Error("Entrypoint not found"); 31 | if (!resolutionColumns[2]) throw new Error("ESM resolution not found"); 32 | if (!resolutionColumns[3]) throw new Error("Bundler resolution not found"); 33 | return { 34 | entrypoint: entrypoint.replace(pkg.name, ".").trim(), 35 | esm: resolutionColumns[2].trim(), 36 | bundler: resolutionColumns[3].trim(), 37 | }; 38 | }); 39 | 40 | let entrypointsWithProblems = entrypoints.filter( 41 | (item) => item.esm.includes("fail") || item.bundler.includes("fail"), 42 | ); 43 | 44 | if (entrypointsWithProblems.length > 0) { 45 | console.error("Entrypoints with problems:", entrypointsWithProblems); 46 | process.exit(1); 47 | } 48 | } 49 | 50 | await main().catch((error) => { 51 | console.error(error); 52 | process.exit(1); 53 | }); 54 | 55 | export {}; 56 | -------------------------------------------------------------------------------- /src/client.ts: -------------------------------------------------------------------------------- 1 | import type { UNSAFE_RouteModules } from "react-router"; 2 | 3 | declare global { 4 | interface Window { 5 | __reactRouterRouteModules: UNSAFE_RouteModules; 6 | } 7 | } 8 | 9 | /** 10 | * Get the list of namespaces used by the application server-side so you could 11 | * set it on i18next init options. 12 | * @example 13 | * i18next.init({ 14 | * ns: getInitialNamespaces(), // this function 15 | * // ...more options 16 | * }) 17 | */ 18 | export function getInitialNamespaces(): string[] { 19 | let namespaces = Object.values(window.__reactRouterRouteModules).flatMap( 20 | (route) => { 21 | if (typeof route?.handle !== "object") return []; 22 | if (!route.handle) return []; 23 | if (!("i18n" in route.handle)) return []; 24 | if (typeof route.handle.i18n === "string") return [route.handle.i18n]; 25 | if ( 26 | Array.isArray(route.handle.i18n) && 27 | route.handle.i18n.every((value: unknown) => typeof value === "string") 28 | ) { 29 | return route.handle.i18n as string[]; 30 | } 31 | return []; 32 | }, 33 | ); 34 | 35 | return [...namespaces]; 36 | } 37 | -------------------------------------------------------------------------------- /src/lib/format-language-string.ts: -------------------------------------------------------------------------------- 1 | import type { Language } from "./parser.js"; 2 | 3 | export function formatLanguageString( 4 | language: Pick, 5 | ): string { 6 | let parts = [language.code]; 7 | if (language.script) parts.push(language.script); 8 | if (language.region) parts.push(language.region); 9 | return parts.join("-"); 10 | } 11 | -------------------------------------------------------------------------------- /src/lib/get-client-locales.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, test } from "bun:test"; 2 | import * as getClientLocales from "./get-client-locales.js"; 3 | 4 | describe(getClientLocales.getClientLocales.name, () => { 5 | test("should not throw on invalid locales", () => { 6 | let headers = new Headers(); 7 | headers.set( 8 | "Accept-Language", 9 | "cs-CZ,cs;q=0.9,true;q=0.8,en-US;q=0.7,en;q=0.6", 10 | ); 11 | expect(() => getClientLocales.getClientLocales(headers)).not.toThrowError( 12 | RangeError, 13 | ); 14 | }); 15 | }); 16 | -------------------------------------------------------------------------------- /src/lib/get-client-locales.ts: -------------------------------------------------------------------------------- 1 | import { formatLanguageString } from "./format-language-string.js"; 2 | import { parse, pick } from "./parser.js"; 3 | 4 | export type Locales = string | string[] | undefined; 5 | 6 | /** 7 | * Get the client's locales from the Accept-Language header. 8 | * If the header is not defined returns null. 9 | * If the header is defined return an array of locales, sorted by the quality 10 | * value. 11 | * 12 | * @example 13 | * export let loader: LoaderFunction = async ({ request }) => { 14 | * let locales = getClientLocales(request) 15 | * let date = new Date().toLocaleDateString(locales, { 16 | * "day": "numeric", 17 | * }); 18 | * return json({ date }) 19 | * } 20 | */ 21 | export function getClientLocales(headers: Headers): Locales; 22 | export function getClientLocales(request: Request): Locales; 23 | export function getClientLocales(requestOrHeaders: Request | Headers): Locales { 24 | let headers = getHeaders(requestOrHeaders); 25 | 26 | let acceptLanguage = headers.get("Accept-Language"); 27 | 28 | // if the header is not defined, return undefined 29 | if (!acceptLanguage) return undefined; 30 | 31 | let parsedLocales = parse(acceptLanguage) 32 | .filter((lang) => lang.code !== "*") 33 | .map(formatLanguageString); 34 | 35 | let validLocales: string[] = []; 36 | 37 | for (let locale of parsedLocales) { 38 | try { 39 | // This will throw on invalid locales 40 | new Intl.Locale(locale); 41 | 42 | // If we get here, the locale is valid 43 | validLocales.push(locale); 44 | } catch { 45 | // We want to ignore errors here 46 | } 47 | } 48 | 49 | let locale = pick( 50 | Intl.DateTimeFormat.supportedLocalesOf(validLocales), 51 | acceptLanguage, 52 | ); 53 | 54 | return locale ?? undefined; 55 | } 56 | 57 | /** 58 | * Receives a Request or Headers objects. 59 | * If it's a Request returns the request.headers 60 | * If it's a Headers returns the object directly. 61 | */ 62 | function getHeaders(requestOrHeaders: Request | Headers): Headers { 63 | if (requestOrHeaders instanceof Request) return requestOrHeaders.headers; 64 | return requestOrHeaders; 65 | } 66 | -------------------------------------------------------------------------------- /src/lib/get-headers.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Receives a Request or Headers objects. 3 | * If it's a Request returns the request.headers 4 | * If it's a Headers returns the object directly. 5 | */ 6 | export function getHeaders(requestOrHeaders: Request | Headers): Headers { 7 | if (requestOrHeaders instanceof Request) { 8 | return requestOrHeaders.headers; 9 | } 10 | 11 | return requestOrHeaders; 12 | } 13 | -------------------------------------------------------------------------------- /src/lib/language-detector.ts: -------------------------------------------------------------------------------- 1 | import type { Cookie, SessionStorage } from "react-router"; 2 | import { getClientLocales } from "./get-client-locales.js"; 3 | import { pick } from "./parser.js"; 4 | 5 | export interface LanguageDetectorOption { 6 | /** 7 | * Define the list of supported languages, this is used to determine if one of 8 | * the languages requested by the user is supported by the application. 9 | * This should be be same as the supportedLngs in the i18next options. 10 | */ 11 | supportedLanguages: string[]; 12 | /** 13 | * Define the fallback language that it's going to be used in the case user 14 | * expected language is not supported. 15 | * This should be be same as the fallbackLng in the i18next options. 16 | */ 17 | fallbackLanguage: string; 18 | /** 19 | * If you want to use a cookie to store the user preferred language, you can 20 | * pass the Cookie object here. 21 | */ 22 | cookie?: Cookie; 23 | /** 24 | * If you want to use a session to store the user preferred language, you can 25 | * pass the SessionStorage object here. 26 | * When this is not defined, getting the locale will ignore the session. 27 | */ 28 | sessionStorage?: SessionStorage; 29 | /** 30 | * If defined a sessionStorage and want to change the default key used to 31 | * store the user preferred language, you can pass the key here. 32 | * @default "lng" 33 | */ 34 | sessionKey?: string; 35 | /** 36 | * If you want to use search parameters for language detection and want to 37 | * change the default key used to for the parameter name, 38 | * you can pass the key here. 39 | * @default "lng" 40 | */ 41 | searchParamKey?: string; 42 | /** 43 | * The order the library will use to detect the user preferred language. 44 | * By default the order is 45 | * - searchParams 46 | * - cookie 47 | * - session 48 | * - header 49 | * If customized, a an extra `custom` option can be added to the order. 50 | * And finally the fallback language. 51 | */ 52 | order?: Array<"searchParams" | "cookie" | "session" | "header" | "custom">; 53 | /** 54 | * A function that can be used to find the locale based on the request object 55 | * using any custom logic you want. 56 | * This can be useful to get the locale from the URL pathname, or to query it 57 | * from the database or fetch it from an API. 58 | * @param request The request object received by the server. 59 | */ 60 | findLocale?(request: Request): Promise | null>; 61 | } 62 | 63 | /** 64 | * The LanguageDetector contains the logic to detect the user preferred language 65 | * fully server-side by using a SessionStorage, Cookie, URLSearchParams, or 66 | * Headers. 67 | */ 68 | export class LanguageDetector { 69 | constructor(private options: LanguageDetectorOption) { 70 | this.isSessionOnly(options); 71 | this.isCookieOnly(options); 72 | } 73 | 74 | private isSessionOnly(options: LanguageDetectorOption) { 75 | if ( 76 | options.order?.length === 1 && 77 | options.order[0] === "session" && 78 | !options.sessionStorage 79 | ) { 80 | throw new Error( 81 | "You need a sessionStorage if you want to only get the locale from the session", 82 | ); 83 | } 84 | } 85 | 86 | private isCookieOnly(options: LanguageDetectorOption) { 87 | if ( 88 | options.order?.length === 1 && 89 | options.order[0] === "cookie" && 90 | !options.cookie 91 | ) { 92 | throw new Error( 93 | "You need a cookie if you want to only get the locale from the cookie", 94 | ); 95 | } 96 | } 97 | 98 | public async detect(request: Request): Promise { 99 | let order = this.options.order ?? this.defaultOrder; 100 | 101 | for (let method of order) { 102 | let locale: string | null = null; 103 | 104 | if (method === "searchParams") { 105 | locale = this.fromSearchParams(request); 106 | } 107 | 108 | if (method === "cookie") { 109 | locale = await this.fromCookie(request); 110 | } 111 | 112 | if (method === "session") { 113 | locale = await this.fromSessionStorage(request); 114 | } 115 | 116 | if (method === "header") { 117 | locale = this.fromHeader(request); 118 | } 119 | 120 | if (method === "custom") { 121 | locale = await this.fromCustom(request); 122 | } 123 | 124 | if (locale) return locale; 125 | } 126 | 127 | return this.options.fallbackLanguage; 128 | } 129 | 130 | private get defaultOrder() { 131 | let order: Array< 132 | "searchParams" | "cookie" | "session" | "header" | "custom" 133 | > = ["searchParams", "cookie", "session", "header"]; 134 | if (this.options.findLocale) order.unshift("custom"); 135 | return order; 136 | } 137 | 138 | private fromSearchParams(request: Request): string | null { 139 | let url = new URL(request.url); 140 | if (!url.searchParams.has(this.options.searchParamKey ?? "lng")) { 141 | return null; 142 | } 143 | return this.fromSupported( 144 | url.searchParams.get(this.options.searchParamKey ?? "lng"), 145 | ); 146 | } 147 | 148 | private async fromCookie(request: Request): Promise { 149 | if (!this.options.cookie) return null; 150 | 151 | let cookie = this.options.cookie; 152 | let lng = await cookie.parse(request.headers.get("Cookie")); 153 | if (typeof lng !== "string" || !lng) return null; 154 | 155 | return this.fromSupported(lng); 156 | } 157 | 158 | private async fromSessionStorage(request: Request): Promise { 159 | if (!this.options.sessionStorage) return null; 160 | 161 | let session = await this.options.sessionStorage.getSession( 162 | request.headers.get("Cookie"), 163 | ); 164 | 165 | let lng = session.get(this.options.sessionKey ?? "lng"); 166 | 167 | if (!lng) return null; 168 | 169 | return this.fromSupported(lng); 170 | } 171 | 172 | private fromHeader(request: Request): string | null { 173 | let locales = getClientLocales(request); 174 | if (!locales) return null; 175 | if (Array.isArray(locales)) return this.fromSupported(locales.join(",")); 176 | return this.fromSupported(locales); 177 | } 178 | 179 | private async fromCustom(request: Request): Promise { 180 | if (!this.options.findLocale) { 181 | throw new ReferenceError( 182 | "You tried to find a locale using `findLocale` but it iss not defined. Change your order to not include `custom` or provide a findLocale functions.", 183 | ); 184 | } 185 | let locales = await this.options.findLocale(request); 186 | if (!locales) return null; 187 | if (Array.isArray(locales)) return this.fromSupported(locales.join(",")); 188 | return this.fromSupported(locales); 189 | } 190 | 191 | private fromSupported(language: string | null) { 192 | return ( 193 | pick( 194 | this.options.supportedLanguages, 195 | language ?? this.options.fallbackLanguage, 196 | { loose: false }, 197 | ) || 198 | pick( 199 | this.options.supportedLanguages, 200 | language ?? this.options.fallbackLanguage, 201 | { loose: true }, 202 | ) 203 | ); 204 | } 205 | } 206 | -------------------------------------------------------------------------------- /src/lib/parser.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, test } from "bun:test"; 2 | import * as parser from "./parser.js"; 3 | 4 | describe(parser.parse.name, () => { 5 | test("should correctly parse the language with quality", () => { 6 | let [result] = parser.parse("en-GB;q=0.8"); 7 | expect(result).toEqual({ 8 | code: "en", 9 | region: "GB", 10 | quality: 0.8, 11 | script: null, 12 | }); 13 | }); 14 | 15 | test("should correctly parse the language without quality (default 1)", () => { 16 | let [result] = parser.parse("en-GB"); 17 | expect(result).toEqual({ 18 | code: "en", 19 | region: "GB", 20 | quality: 1.0, 21 | script: null, 22 | }); 23 | }); 24 | 25 | test("should correctly parse the language without region", () => { 26 | let [result] = parser.parse("en;q=0.8"); 27 | expect(result).toEqual({ code: "en", quality: 0.8, script: null }); 28 | }); 29 | 30 | test("should ignore extra characters in the region code", () => { 31 | let [result] = parser.parse("az-AZ"); 32 | expect(result).toEqual({ 33 | code: "az", 34 | region: "AZ", 35 | quality: 1.0, 36 | script: null, 37 | }); 38 | }); 39 | 40 | test("should correctly parse a multi-language set", () => { 41 | let result = parser.parse("fr-CA,fr;q=0.8"); 42 | expect(result).toEqual([ 43 | { code: "fr", region: "CA", quality: 1.0, script: null }, 44 | { code: "fr", quality: 0.8, script: null }, 45 | ]); 46 | }); 47 | 48 | test("should correctly parse a wildcard", () => { 49 | let result = parser.parse("fr-CA,*;q=0.8"); 50 | expect(result).toEqual([ 51 | { code: "fr", region: "CA", quality: 1.0, script: null }, 52 | { code: "*", quality: 0.8, script: null }, 53 | ]); 54 | }); 55 | 56 | test("should correctly parse a region with numbers", () => { 57 | let [result] = parser.parse("fr-150"); 58 | expect(result).toEqual({ 59 | code: "fr", 60 | region: "150", 61 | quality: 1.0, 62 | script: null, 63 | }); 64 | }); 65 | 66 | test("should correctly parse complex set", () => { 67 | let result = parser.parse("fr-CA,fr;q=0.8,en-US;q=0.6,en;q=0.4,*;q=0.1"); 68 | expect(result).toEqual([ 69 | { code: "fr", region: "CA", quality: 1.0, script: null }, 70 | { code: "fr", quality: 0.8, script: null }, 71 | { code: "en", region: "US", quality: 0.6, script: null }, 72 | { code: "en", quality: 0.4, script: null }, 73 | { code: "*", quality: 0.1, script: null }, 74 | ]); 75 | }); 76 | 77 | test("should cope with random whitespace", () => { 78 | let result = parser.parse( 79 | "fr-CA, fr;q=0.8, en-US;q=0.6,en;q=0.4, *;q=0.1", 80 | ); 81 | expect(result).toEqual([ 82 | { code: "fr", region: "CA", quality: 1.0, script: null }, 83 | { code: "fr", quality: 0.8, script: null }, 84 | { code: "en", region: "US", quality: 0.6, script: null }, 85 | { code: "en", quality: 0.4, script: null }, 86 | { code: "*", quality: 0.1, script: null }, 87 | ]); 88 | 89 | let result2 = parser.parse("zh-CN, zh; q=0.9, en; q=0.8, ko; q=0.7"); 90 | 91 | expect(result2).toEqual([ 92 | { code: "zh", region: "CN", quality: 1.0, script: null }, 93 | { code: "zh", quality: 0.9, script: null }, 94 | { code: "en", quality: 0.8, script: null }, 95 | { code: "ko", quality: 0.7, script: null }, 96 | ]); 97 | }); 98 | 99 | test("should sort based on quality value", () => { 100 | let result = parser.parse("fr-CA,fr;q=0.2,en-US;q=0.6,en;q=0.4,*;q=0.5"); 101 | expect(result).toEqual([ 102 | { code: "fr", region: "CA", quality: 1.0, script: null }, 103 | { code: "en", region: "US", quality: 0.6, script: null }, 104 | { code: "*", quality: 0.5, script: null }, 105 | { code: "en", quality: 0.4, script: null }, 106 | { code: "fr", quality: 0.2, script: null }, 107 | ]); 108 | }); 109 | 110 | test("should correctly identify script", () => { 111 | let [result] = parser.parse("zh-Hant-cn"); 112 | expect(result).toEqual({ 113 | code: "zh", 114 | script: "Hant", 115 | region: "cn", 116 | quality: 1.0, 117 | }); 118 | }); 119 | 120 | test("should cope with script and a quality value", () => { 121 | let result = parser.parse("zh-Hant-cn;q=1, zh-cn;q=0.6, zh;q=0.4"); 122 | expect(result).toEqual([ 123 | { code: "zh", script: "Hant", region: "cn", quality: 1.0 }, 124 | { code: "zh", region: "cn", quality: 0.6, script: null }, 125 | { code: "zh", quality: 0.4, script: null }, 126 | ]); 127 | }); 128 | }); 129 | 130 | describe(parser.pick.name, () => { 131 | test("should pick a specific regional language", () => { 132 | let result = parser.pick( 133 | ["en-US", "fr-CA"], 134 | "fr-CA,fr;q=0.2,en-US;q=0.6,en;q=0.4,*;q=0.5", 135 | ); 136 | expect(result).toEqual("fr-CA"); 137 | }); 138 | 139 | test("should pick a specific regional language when accept-language is parsed", () => { 140 | let result = parser.pick( 141 | ["en-US", "fr-CA"], 142 | parser.parse("fr-CA,fr;q=0.2,en-US;q=0.6,en;q=0.4,*;q=0.5"), 143 | ); 144 | expect(result).toEqual("fr-CA"); 145 | }); 146 | 147 | test("should pick a specific script (if specified)", () => { 148 | let result = parser.pick( 149 | ["zh-Hant-cn", "zh-cn"], 150 | "zh-Hant-cn,zh-cn;q=0.6,zh;q=0.4", 151 | ); 152 | expect(result).toEqual("zh-Hant-cn"); 153 | }); 154 | 155 | test("should pick proper language regardless of casing", () => { 156 | let result = parser.pick( 157 | ["eN-Us", "Fr-cA"], 158 | "fR-Ca,fr;q=0.2,en-US;q=0.6,en;q=0.4,*;q=0.5", 159 | ); 160 | if (result === null) throw new Error("Result is null"); 161 | expect(result.toLowerCase()).toEqual("fr-ca"); 162 | }); 163 | 164 | test("should pick a specific language", () => { 165 | let result = parser.pick(["en", "fr-CA"], "ja-JP,ja;1=0.5,en;q=0.2"); 166 | expect(result).toEqual("en"); 167 | }); 168 | 169 | test("should pick a language when culture is not specified", () => { 170 | let result = parser.pick(["en-us", "it-IT"], "pl-PL,en"); 171 | expect(result).toEqual("en-us"); 172 | }); 173 | 174 | test("should return null if no matches are found", () => { 175 | let result = parser.pick( 176 | ["ko-KR"], 177 | "fr-CA,fr;q=0.8,en-US;q=0.6,en;q=0.4,*;q=0.1", 178 | ); 179 | expect(result).toEqual(null); 180 | }); 181 | 182 | test("should return null if support no languages", () => { 183 | let result = parser.pick([], "fr-CA,fr;q=0.8,en-US;q=0.6,en;q=0.4,*;q=0.1"); 184 | expect(result).toEqual(null); 185 | }); 186 | 187 | test("by default should be strict when selecting language", () => { 188 | let result = parser.pick(["en", "pl"], "en-US;q=0.6"); 189 | expect(result).toEqual(null); 190 | }); 191 | 192 | test("can select language loosely with an option", () => { 193 | let result = parser.pick(["en", "pl"], "en-US;q=0.6", { loose: true }); 194 | expect(result).toEqual("en"); 195 | }); 196 | 197 | test("selects most matching language in loose mode", () => { 198 | let result = parser.pick(["en-US", "en", "pl"], "en-US;q=0.6", { 199 | loose: true, 200 | }); 201 | expect(result).toEqual("en-US"); 202 | }); 203 | }); 204 | -------------------------------------------------------------------------------- /src/lib/parser.ts: -------------------------------------------------------------------------------- 1 | import { formatLanguageString } from "./format-language-string.js"; 2 | 3 | let REGEX = 4 | /[ ]*((([a-zA-Z]+(-[a-zA-Z0-9]+){0,2})|\*)(;[ ]*q=[0-1](\.[0-9]+)?[ ]*)?)*/g; 5 | 6 | export interface Language { 7 | code: string; 8 | script?: string | null | undefined; 9 | region?: string | undefined; 10 | quality: number; 11 | } 12 | 13 | export interface PickOptions { 14 | loose?: boolean | undefined; 15 | } 16 | 17 | function isString(value: unknown): value is string { 18 | return typeof value === "string"; 19 | } 20 | 21 | export function parse(acceptLanguage?: string): Language[] { 22 | let strings = (acceptLanguage || "").match(REGEX); 23 | if (!strings) throw new Error("Invalid Accept-Language header"); 24 | 25 | let languages: Language[] = []; 26 | 27 | for (let m of strings) { 28 | if (!m) continue; 29 | 30 | m = m.trim(); 31 | 32 | let bits = m.split(";"); 33 | let ietf = bits[0]?.split("-") ?? []; 34 | let hasScript = ietf.length === 3; 35 | 36 | languages.push({ 37 | // biome-ignore lint/style/noNonNullAssertion: We know this is not null 38 | code: ietf[0]!, 39 | script: hasScript ? ietf[1] : null, 40 | region: hasScript ? ietf[2] : ietf[1], 41 | quality: bits[1] 42 | ? // biome-ignore lint/style/noNonNullAssertion: We know this is not null 43 | (Number.parseFloat(bits[1]!.split("=")[1]!) ?? 1.0) 44 | : 1.0, 45 | }); 46 | } 47 | 48 | return languages.sort((a, b) => b.quality - a.quality); 49 | } 50 | 51 | export function pick( 52 | supportedLanguages: readonly T[], 53 | acceptLanguage: string | Language[], 54 | options: PickOptions = { loose: false }, 55 | ): T | null { 56 | if (!supportedLanguages || !supportedLanguages.length || !acceptLanguage) { 57 | return null; 58 | } 59 | 60 | let parsedAcceptLanguage = isString(acceptLanguage) 61 | ? parse(acceptLanguage) 62 | : acceptLanguage; 63 | 64 | let supported = supportedLanguages.map((support) => { 65 | let bits = support.split("-"); 66 | let hasScript = bits.length === 3; 67 | 68 | return { 69 | // biome-ignore lint/style/noNonNullAssertion: We know this is not null 70 | code: bits[0]!, 71 | script: hasScript ? bits[1] : null, 72 | region: (hasScript ? bits[2] : bits[1]) ?? undefined, 73 | }; 74 | }) satisfies Array>; 75 | 76 | for (let lang of parsedAcceptLanguage) { 77 | if (!lang) continue; 78 | let langCode = lang.code.toLowerCase(); 79 | let langRegion = lang.region ? lang.region.toLowerCase() : lang.region; 80 | let langScript = lang.script ? lang.script.toLowerCase() : lang.script; 81 | 82 | for (let supportedLanguage of supported) { 83 | let supportedCode = supportedLanguage.code?.toLowerCase() ?? ""; 84 | if (langCode !== supportedCode) continue; 85 | 86 | let supportedScript = supportedLanguage.script 87 | ? supportedLanguage.script.toLowerCase() 88 | : supportedLanguage.script; 89 | let supportedRegion = supportedLanguage.region 90 | ? supportedLanguage.region.toLowerCase() 91 | : supportedLanguage.region; 92 | 93 | if ( 94 | langCode === supportedCode && 95 | (options?.loose || !langScript || langScript === supportedScript) && 96 | (options?.loose || !langRegion || langRegion === supportedRegion) 97 | ) { 98 | return formatLanguageString(supportedLanguage) as T; 99 | } 100 | } 101 | } 102 | 103 | return null; 104 | } 105 | -------------------------------------------------------------------------------- /src/lib/test-helper.ts: -------------------------------------------------------------------------------- 1 | import { mock } from "bun:test"; 2 | import { 3 | type Params, 4 | type unstable_MiddlewareFunction, 5 | unstable_RouterContextProvider, 6 | } from "react-router"; 7 | 8 | const defaultNext = mock().mockImplementation(() => Response.json(null)); 9 | 10 | interface RunMiddlewareOptions { 11 | request?: Request; 12 | params?: Params; 13 | context?: unstable_RouterContextProvider; 14 | next?: () => T | Promise; 15 | } 16 | 17 | export async function runMiddleware( 18 | middleware: unstable_MiddlewareFunction, 19 | { 20 | request = new Request("https://remix.utils"), 21 | context = new unstable_RouterContextProvider(), 22 | params = {}, 23 | next = defaultNext, 24 | }: RunMiddlewareOptions = {}, 25 | ) { 26 | return await middleware({ request, params, context }, next); 27 | } 28 | 29 | export async function catchResponse(promise: Promise) { 30 | try { 31 | await promise; 32 | throw new Error("Expected promise to reject"); 33 | } catch (exception) { 34 | if (exception instanceof Response) return exception; 35 | throw exception; 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/middleware.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, test } from "bun:test"; 2 | 3 | import { unstable_RouterContextProvider } from "react-router"; 4 | import { runMiddleware } from "./lib/test-helper"; 5 | import { unstable_createI18nextMiddleware } from "./middleware"; 6 | 7 | describe(unstable_createI18nextMiddleware.name, () => { 8 | test("sets the locale in context", async () => { 9 | let [middleware, getLocale] = unstable_createI18nextMiddleware({ 10 | detection: { fallbackLanguage: "en", supportedLanguages: ["es", "en"] }, 11 | }); 12 | 13 | let context = new unstable_RouterContextProvider(); 14 | await runMiddleware(middleware, { context }); 15 | 16 | expect(getLocale(context)).toBe("en"); 17 | }); 18 | 19 | test("detects locale from request and saves it in context", async () => { 20 | let [middleware, getLocale] = unstable_createI18nextMiddleware({ 21 | detection: { fallbackLanguage: "en", supportedLanguages: ["es", "en"] }, 22 | }); 23 | 24 | let request = new Request("http://example.com", { 25 | headers: { "Accept-Language": "es" }, 26 | }); 27 | 28 | let context = new unstable_RouterContextProvider(); 29 | 30 | await runMiddleware(middleware, { request, context }); 31 | 32 | expect(getLocale(context)).toBe("es"); 33 | }); 34 | 35 | test("can access i18next instance", async () => { 36 | let [middleware, , getInstance] = unstable_createI18nextMiddleware({ 37 | detection: { fallbackLanguage: "en", supportedLanguages: ["es", "en"] }, 38 | }); 39 | 40 | let context = new unstable_RouterContextProvider(); 41 | await runMiddleware(middleware, { context }); 42 | 43 | let instance = getInstance(context); 44 | 45 | expect(instance).toBeDefined(); 46 | expect(instance.isInitialized).toBe(true); 47 | }); 48 | 49 | test("can access TFunction from instance", async () => { 50 | let [middleware, , getInstance] = unstable_createI18nextMiddleware({ 51 | detection: { fallbackLanguage: "en", supportedLanguages: ["es", "en"] }, 52 | i18next: { 53 | resources: { en: { translation: { key: "value" } } }, 54 | }, 55 | }); 56 | 57 | let context = new unstable_RouterContextProvider(); 58 | await runMiddleware(middleware, { context }); 59 | 60 | expect(getInstance(context).t("key")).toBe("value"); 61 | }); 62 | 63 | test("the instance has the detected locale configured", async () => { 64 | let [middleware, getLocale, getInstance] = unstable_createI18nextMiddleware( 65 | { 66 | detection: { 67 | fallbackLanguage: "en", 68 | supportedLanguages: ["es", "en"], 69 | }, 70 | }, 71 | ); 72 | 73 | let request = new Request("http://example.com", { 74 | headers: { "Accept-Language": "es" }, 75 | }); 76 | 77 | let context = new unstable_RouterContextProvider(); 78 | 79 | await runMiddleware(middleware, { request, context }); 80 | 81 | expect(getLocale(context)).toBe("es"); 82 | expect(getInstance(context).language).toBe("es"); 83 | }); 84 | }); 85 | -------------------------------------------------------------------------------- /src/middleware.ts: -------------------------------------------------------------------------------- 1 | import type { InitOptions, Module, NewableModule, i18n } from "i18next"; 2 | import { createInstance } from "i18next"; 3 | import type { 4 | unstable_MiddlewareFunction, 5 | unstable_RouterContextProvider, 6 | } from "react-router"; 7 | import { unstable_createContext } from "react-router"; 8 | import type { LanguageDetectorOption } from "./lib/language-detector.js"; 9 | import { LanguageDetector } from "./lib/language-detector.js"; 10 | 11 | export function unstable_createI18nextMiddleware({ 12 | detection, 13 | i18next = {}, 14 | plugins = [], 15 | }: unstable_createI18nextMiddleware.Options): unstable_createI18nextMiddleware.ReturnType { 16 | let localeContext = unstable_createContext(); 17 | let i18nextContext = unstable_createContext(); 18 | let languageDetector = new LanguageDetector(detection); 19 | 20 | return [ 21 | async function i18nextMiddleware({ request, context }, next) { 22 | let lng = await languageDetector.detect(request); 23 | context.set(localeContext, lng); 24 | 25 | let instance = createInstance(i18next); 26 | for (const plugin of plugins ?? []) instance.use(plugin); 27 | await instance.init({ lng }); 28 | context.set(i18nextContext, instance); 29 | 30 | return await next(); 31 | }, 32 | (context) => context.get(localeContext), 33 | (context) => context.get(i18nextContext), 34 | ]; 35 | } 36 | 37 | export namespace unstable_createI18nextMiddleware { 38 | export interface Options { 39 | /** 40 | * The i18next options used to initialize the internal i18next instance. 41 | */ 42 | i18next?: Omit; 43 | /** 44 | * The i18next plugins used to extend the internal i18next instance 45 | * when creating a new TFunction. 46 | */ 47 | plugins?: NewableModule[] | Module[]; 48 | detection: LanguageDetectorOption; 49 | } 50 | 51 | export type ReturnType = [ 52 | unstable_MiddlewareFunction, 53 | (context: unstable_RouterContextProvider) => string, 54 | (context: unstable_RouterContextProvider) => i18n, 55 | ]; 56 | } 57 | -------------------------------------------------------------------------------- /src/react.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { useTranslation } from "react-i18next"; 3 | import { useMatches } from "react-router"; 4 | 5 | /** 6 | * Get the locale returned by the root route loader under the `locale` key. 7 | * @example 8 | * let locale = useLocale() 9 | * let formattedDate = date.toLocaleDateString(locale); 10 | * @example 11 | * let locale = useLocale("language") 12 | * let formattedDate = date.toLocaleDateString(locale); 13 | */ 14 | export function useLocale(localeKey = "locale"): string { 15 | let matches = useMatches(); 16 | // biome-ignore lint/style/noNonNullAssertion: There's always a root match 17 | let rootMatch = matches[0]!; 18 | let { [localeKey]: locale } = 19 | (rootMatch.data as Record) ?? {}; 20 | if (!locale) throw new Error("Missing locale returned by the root loader."); 21 | if (typeof locale === "string") return locale; 22 | throw new Error("Invalid locale returned by the root loader."); 23 | } 24 | 25 | /** 26 | * Detect when the locale returned by the root route loader changes and call 27 | * `i18n.changeLanguage` with the new locale. 28 | * This will ensure translations are loaded automatically. 29 | */ 30 | export function useChangeLanguage(locale: string) { 31 | let { i18n } = useTranslation(); 32 | React.useEffect(() => { 33 | if (i18n.language !== locale) i18n.changeLanguage(locale); 34 | }, [locale, i18n]); 35 | } 36 | -------------------------------------------------------------------------------- /src/server.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, test } from "bun:test"; 2 | import type { BackendModule, FormatterModule } from "i18next"; 3 | import { createCookie, createMemorySessionStorage } from "react-router"; 4 | import { RemixI18Next } from "./server.js"; 5 | 6 | describe(RemixI18Next.name, () => { 7 | describe("getLocale", () => { 8 | test("should get the locale from the search param ?lng", async () => { 9 | let request = new Request("https://example.com/dashboard?lng=es"); 10 | 11 | let i18n = new RemixI18Next({ 12 | detection: { supportedLanguages: ["es", "en"], fallbackLanguage: "en" }, 13 | }); 14 | 15 | expect(await i18n.getLocale(request)).toBe("es"); 16 | }); 17 | 18 | test("should get the locale from the cookie", async () => { 19 | let cookie = createCookie("locale"); 20 | 21 | let request = new Request("https://example.com/dashboard", { 22 | headers: { Cookie: await cookie.serialize("es") }, 23 | }); 24 | 25 | let i18n = new RemixI18Next({ 26 | detection: { 27 | supportedLanguages: ["es", "en"], 28 | fallbackLanguage: "en", 29 | cookie, 30 | }, 31 | }); 32 | 33 | expect(await i18n.getLocale(request)).toBe("es"); 34 | }); 35 | 36 | test("should get the locale from the Session", async () => { 37 | let sessionStorage = createMemorySessionStorage({ 38 | cookie: { name: "session", secrets: ["s3cr3t"] }, 39 | }); 40 | 41 | let session = await sessionStorage.getSession(); 42 | session.set("lng", "es"); 43 | 44 | let request = new Request("https://example.com/dashboard", { 45 | headers: { Cookie: await sessionStorage.commitSession(session) }, 46 | }); 47 | 48 | let i18n = new RemixI18Next({ 49 | detection: { 50 | supportedLanguages: ["es", "en"], 51 | fallbackLanguage: "en", 52 | sessionStorage, 53 | }, 54 | }); 55 | 56 | expect(await i18n.getLocale(request)).toBe("es"); 57 | }); 58 | 59 | test("should get the locale from the Session using a different key", async () => { 60 | let sessionKey = "locale"; 61 | let sessionStorage = createMemorySessionStorage({ 62 | cookie: { name: "session", secrets: ["s3cr3t"] }, 63 | }); 64 | 65 | let session = await sessionStorage.getSession(); 66 | session.set(sessionKey, "es"); 67 | 68 | let request = new Request("https://example.com/dashboard", { 69 | headers: { Cookie: await sessionStorage.commitSession(session) }, 70 | }); 71 | 72 | let i18n = new RemixI18Next({ 73 | detection: { 74 | supportedLanguages: ["es", "en"], 75 | fallbackLanguage: "en", 76 | sessionStorage, 77 | sessionKey, 78 | }, 79 | }); 80 | 81 | expect(await i18n.getLocale(request)).toBe("es"); 82 | }); 83 | 84 | test("should get the locale from the request header", async () => { 85 | let request = new Request("https://example.com/dashboard", { 86 | headers: { 87 | "Accept-Language": "es-AR,es;q=0.2,en-US;q=0.6,en;q=0.4,*;q=0.5", 88 | }, 89 | }); 90 | 91 | let i18n = new RemixI18Next({ 92 | detection: { 93 | supportedLanguages: ["es", "en"], 94 | fallbackLanguage: "en", 95 | }, 96 | }); 97 | 98 | expect(await i18n.getLocale(request)).toBe("es"); 99 | }); 100 | 101 | test("should get the locale using the findLocale method", async () => { 102 | let request = new Request("https://example.com/dashboard", { 103 | headers: { 104 | "Accept-Language": "es-AR,es;q=0.2,en-US;q=0.6,en;q=0.4,*;q=0.5", 105 | }, 106 | }); 107 | 108 | let i18n = new RemixI18Next({ 109 | detection: { 110 | supportedLanguages: ["es", "en"], 111 | fallbackLanguage: "en", 112 | async findLocale(request) { 113 | return "es"; 114 | }, 115 | }, 116 | }); 117 | 118 | expect(await i18n.getLocale(request)).toBe("es"); 119 | }); 120 | 121 | test("should use the fallback language if search param, cookie and request headers are not there", async () => { 122 | let request = new Request("https://example.com/dashboard"); 123 | 124 | let i18n = new RemixI18Next({ 125 | detection: { 126 | supportedLanguages: ["es", "en"], 127 | fallbackLanguage: "en", 128 | }, 129 | }); 130 | 131 | expect(await i18n.getLocale(request)).toBe("en"); 132 | }); 133 | 134 | test("should use the fallback language if the expected one is not supported", async () => { 135 | let request = new Request("https://example.com/dashboard?lng=fr"); 136 | 137 | let i18n = new RemixI18Next({ 138 | detection: { 139 | supportedLanguages: ["es", "en"], 140 | fallbackLanguage: "en", 141 | }, 142 | }); 143 | 144 | expect(await i18n.getLocale(request)).toBe("en"); 145 | }); 146 | 147 | test("should prefer search params over cookie, session and header", async () => { 148 | let cookie = createCookie("locale"); 149 | 150 | let sessionStorage = createMemorySessionStorage({ 151 | cookie: { name: "session", secrets: ["s3cr3t"] }, 152 | }); 153 | 154 | let session = await sessionStorage.getSession(); 155 | session.set("lng", "en"); 156 | 157 | let headers = new Headers(); 158 | headers.set("Accept-Language", "fr"); 159 | headers.append("Cookie", await cookie.serialize("jp")); 160 | headers.append("Cookie", await sessionStorage.commitSession(session)); 161 | 162 | let request = new Request("https://example.com/dashboard?lng=es", { 163 | headers, 164 | }); 165 | 166 | let i18n = new RemixI18Next({ 167 | detection: { 168 | supportedLanguages: ["es", "fr", "jp", "en"], 169 | fallbackLanguage: "en", 170 | sessionStorage, 171 | cookie, 172 | }, 173 | }); 174 | 175 | expect(await i18n.getLocale(request)).toBe("es"); 176 | }); 177 | 178 | test("should prefer cookie over session and header", async () => { 179 | let cookie = createCookie("locale"); 180 | 181 | let request = new Request("https://example.com/dashboard", { 182 | headers: { 183 | "Accept-Language": "fr", 184 | Cookie: await cookie.serialize("jp"), 185 | }, 186 | }); 187 | 188 | let i18n = new RemixI18Next({ 189 | detection: { 190 | supportedLanguages: ["es", "fr", "jp", "en"], 191 | fallbackLanguage: "en", 192 | cookie, 193 | }, 194 | }); 195 | 196 | expect(await i18n.getLocale(request)).toBe("jp"); 197 | }); 198 | 199 | test("should prefer session over header", async () => { 200 | let sessionStorage = createMemorySessionStorage({ 201 | cookie: { name: "session", secrets: ["s3cr3t"] }, 202 | }); 203 | 204 | let session = await sessionStorage.getSession(); 205 | session.set("lng", "jp"); 206 | 207 | let request = new Request("https://example.com/dashboard", { 208 | headers: { 209 | "Accept-Language": "fr", 210 | Cookie: await sessionStorage.commitSession(session), 211 | }, 212 | }); 213 | 214 | let i18n = new RemixI18Next({ 215 | detection: { 216 | supportedLanguages: ["es", "fr", "jp", "en"], 217 | fallbackLanguage: "en", 218 | sessionStorage, 219 | }, 220 | }); 221 | 222 | expect(await i18n.getLocale(request)).toBe("jp"); 223 | }); 224 | 225 | test("allow changing the order", async () => { 226 | let cookie = createCookie("locale"); 227 | 228 | let sessionStorage = createMemorySessionStorage({ 229 | cookie: { name: "session", secrets: ["s3cr3t"] }, 230 | }); 231 | 232 | let session = await sessionStorage.getSession(); 233 | session.set("lng", "en"); 234 | 235 | let headers = new Headers(); 236 | headers.set("Accept-Language", "fr"); 237 | headers.append("Cookie", await sessionStorage.commitSession(session)); 238 | headers.append("Cookie", await cookie.serialize("jp")); 239 | 240 | let request = new Request("https://example.com/dashboard?lng=es", { 241 | headers, 242 | }); 243 | 244 | let i18n = new RemixI18Next({ 245 | detection: { 246 | supportedLanguages: ["es", "fr", "jp", "en"], 247 | fallbackLanguage: "en", 248 | sessionStorage, 249 | cookie, 250 | order: ["session", "cookie", "header", "searchParams"], 251 | }, 252 | }); 253 | 254 | expect(await i18n.getLocale(request)).toBe("en"); 255 | }); 256 | 257 | test("return the specific locale if there are multiple variants", async () => { 258 | let request = new Request("https://example.com/dashboard?lng=es-MX"); 259 | 260 | let i18n = new RemixI18Next({ 261 | detection: { 262 | supportedLanguages: ["es", "en", "es-MX"], 263 | fallbackLanguage: "en", 264 | }, 265 | }); 266 | 267 | expect(await i18n.getLocale(request)).toBe("es-MX"); 268 | }); 269 | }); 270 | 271 | describe("getFixedT", () => { 272 | let backendPlugin: BackendModule = { 273 | type: "backend", 274 | init: () => null, 275 | read(_language, _namespace, callback) { 276 | callback(null, { 277 | hello: "Hello {{name, uppercase}}", 278 | user: { age: "My age is {{number}}" }, 279 | }); 280 | }, 281 | }; 282 | 283 | let formatterPlugin: FormatterModule = { 284 | type: "formatter", 285 | init: () => null, 286 | add: () => null, 287 | addCached: () => null, 288 | format(value, format) { 289 | if (format === "uppercase") return value.toUpperCase(); 290 | }, 291 | }; 292 | 293 | test("get a fixed T function for server-side usage", async () => { 294 | let headers = new Headers(); 295 | headers.set("Accept-Language", "fr"); 296 | 297 | let request = new Request("https://example.com/dashboard?lng=1", { 298 | headers, 299 | }); 300 | 301 | let i18n = new RemixI18Next({ 302 | detection: { 303 | supportedLanguages: ["es", "fr", "jp", "en"], 304 | fallbackLanguage: "en", 305 | order: ["session", "cookie", "header", "searchParams"], 306 | }, 307 | i18next: { 308 | fallbackNS: "common", 309 | defaultNS: "common", 310 | resources: { 311 | fr: { 312 | common: { 313 | "Hello {{name}}": "Bonjour {{name}}", 314 | }, 315 | }, 316 | }, 317 | }, 318 | }); 319 | 320 | let t = await i18n.getFixedT(request, "common"); 321 | 322 | // @ts-expect-error - We're not using the typed resources here 323 | expect(t("Hello {{name}}", { name: "Remix" })).toBe("Bonjour Remix"); 324 | }); 325 | 326 | test("get a fixed T function set with `backend`", async () => { 327 | let request = new Request("https://example.com/dashboard?lng=1"); 328 | 329 | let i18n = new RemixI18Next({ 330 | backend: backendPlugin, 331 | detection: { 332 | supportedLanguages: ["en"], 333 | fallbackLanguage: "en", 334 | }, 335 | }); 336 | 337 | let t = await i18n.getFixedT(request, "common"); 338 | 339 | expect(t("hello", { name: "Remix" })).toBe("Hello Remix"); 340 | }); 341 | 342 | test("get a fixed T function set with single `plugins`", async () => { 343 | let request = new Request("https://example.com/dashboard?lng=1"); 344 | 345 | let i18n = new RemixI18Next({ 346 | plugins: [backendPlugin], 347 | detection: { 348 | supportedLanguages: ["en"], 349 | fallbackLanguage: "en", 350 | }, 351 | }); 352 | 353 | let t = await i18n.getFixedT(request, "common"); 354 | 355 | expect(t("hello", { name: "Remix" })).toBe("Hello Remix"); 356 | }); 357 | 358 | test("get a fixed T function set with multiple `plugins`", async () => { 359 | let request = new Request("https://example.com/dashboard?lng=1"); 360 | 361 | let i18n = new RemixI18Next({ 362 | plugins: [backendPlugin, formatterPlugin], 363 | detection: { 364 | supportedLanguages: ["en"], 365 | fallbackLanguage: "en", 366 | }, 367 | }); 368 | 369 | let t = await i18n.getFixedT(request, "common"); 370 | 371 | expect(t("hello", { name: "Remix" })).toBe("Hello REMIX"); 372 | }); 373 | 374 | test("get a fixed T function set with `backend` and `plugins`", async () => { 375 | let request = new Request("https://example.com/dashboard?lng=1"); 376 | 377 | let i18n = new RemixI18Next({ 378 | backend: backendPlugin, 379 | plugins: [formatterPlugin], 380 | detection: { 381 | supportedLanguages: ["en"], 382 | fallbackLanguage: "en", 383 | }, 384 | }); 385 | 386 | let t = await i18n.getFixedT(request, "common"); 387 | 388 | expect(t("hello", { name: "Remix" })).toBe("Hello REMIX"); 389 | }); 390 | 391 | test("get a fixed T with a key prefix", async () => { 392 | let request = new Request("https://example.com/dashboard?lng=1"); 393 | 394 | let i18n = new RemixI18Next({ 395 | backend: backendPlugin, 396 | plugins: [formatterPlugin], 397 | detection: { supportedLanguages: ["en"], fallbackLanguage: "en" }, 398 | }); 399 | 400 | let t = await i18n.getFixedT(request, "common", { keyPrefix: "user" }); 401 | 402 | expect(t("age", { number: 25 })).toBe("My age is 25"); 403 | }); 404 | 405 | test("get a fixed T that uses instance fallback language", async () => { 406 | let request = new Request("https://example.com/dashboard?lng=1"); 407 | 408 | let i18n = new RemixI18Next({ 409 | backend: backendPlugin, 410 | plugins: [formatterPlugin], 411 | detection: { supportedLanguages: ["es", "en"], fallbackLanguage: "en" }, 412 | }); 413 | 414 | let t = await i18n.getFixedT(request, "common"); 415 | 416 | expect(t("hello", { name: "Remix" })).toBe("Hello REMIX"); 417 | }); 418 | }); 419 | }); 420 | 421 | declare module "i18next" { 422 | interface CustomTypeOptions { 423 | resources: { 424 | common: { 425 | hello: string; 426 | user: { age: string }; 427 | }; 428 | }; 429 | } 430 | } 431 | -------------------------------------------------------------------------------- /src/server.ts: -------------------------------------------------------------------------------- 1 | import { 2 | type BackendModule, 3 | type DefaultNamespace, 4 | type FlatNamespace, 5 | type InitOptions, 6 | type KeyPrefix, 7 | type Module, 8 | type Namespace, 9 | type NewableModule, 10 | type TFunction, 11 | createInstance, 12 | } from "i18next"; 13 | import type { EntryContext } from "react-router"; 14 | import { 15 | LanguageDetector, 16 | type LanguageDetectorOption, 17 | } from "./lib/language-detector.js"; 18 | 19 | type FallbackNs = Ns extends undefined 20 | ? DefaultNamespace 21 | : Ns extends Namespace 22 | ? Ns 23 | : DefaultNamespace; 24 | 25 | export interface RemixI18NextOption { 26 | /** 27 | * The i18next options used to initialize the internal i18next instance. 28 | */ 29 | i18next?: Omit | null; 30 | /** 31 | * @deprecated Use `plugins` instead. 32 | * The i18next backend module used to load the translations when creating a 33 | * new TFunction. 34 | */ 35 | backend?: NewableModule> | BackendModule; 36 | /** 37 | * The i18next plugins used to extend the internal i18next instance 38 | * when creating a new TFunction. 39 | */ 40 | plugins?: NewableModule[] | Module[]; 41 | detection: LanguageDetectorOption; 42 | } 43 | 44 | export class RemixI18Next { 45 | private detector: LanguageDetector; 46 | 47 | constructor(private options: RemixI18NextOption) { 48 | this.detector = new LanguageDetector(this.options.detection); 49 | } 50 | 51 | /** 52 | * Detect the current locale by following the order defined in the 53 | * `detection.order` option. 54 | * By default the order is 55 | * - searchParams 56 | * - cookie 57 | * - session 58 | * - header 59 | * And finally the fallback language. 60 | */ 61 | public async getLocale(request: Request): Promise { 62 | return this.detector.detect(request); 63 | } 64 | 65 | /** 66 | * Get the namespaces required by the routes which are going to be rendered 67 | * when doing SSR. 68 | * 69 | * @param context The EntryContext object received by `handleRequest` in entry.server 70 | * 71 | * @example 72 | * await instance.init({ 73 | * ns: i18n.getRouteNamespaces(context), 74 | * // ...more options 75 | * }); 76 | */ 77 | public getRouteNamespaces(context: EntryContext): string[] { 78 | let namespaces = Object.values(context.routeModules).flatMap((route) => { 79 | if (typeof route?.handle !== "object") return []; 80 | if (!route.handle) return []; 81 | if (!("i18n" in route.handle)) return []; 82 | if (typeof route.handle.i18n === "string") return [route.handle.i18n]; 83 | if ( 84 | Array.isArray(route.handle.i18n) && 85 | route.handle.i18n.every((value) => typeof value === "string") 86 | ) { 87 | return route.handle.i18n as string[]; 88 | } 89 | return []; 90 | }); 91 | 92 | return [...new Set(namespaces)]; 93 | } 94 | 95 | /** 96 | * Return a TFunction that can be used to translate strings server-side. 97 | * This function is fixed to a specific namespace. 98 | * 99 | * @param requestOrLocale The request object or the locale string already detected 100 | * @param namespaces The namespaces to use for the T function. (Default: `translation`). 101 | * @param options The i18next init options and the key prefix to prepend to translation keys. 102 | */ 103 | async getFixedT< 104 | N extends 105 | | FlatNamespace 106 | | readonly [FlatNamespace, ...FlatNamespace[]] = DefaultNamespace, 107 | KPrefix extends KeyPrefix> = undefined, 108 | >( 109 | locale: string, 110 | namespaces?: N, 111 | options?: Omit & { keyPrefix?: KPrefix }, 112 | ): Promise, KPrefix>>; 113 | async getFixedT< 114 | N extends 115 | | FlatNamespace 116 | | readonly [FlatNamespace, ...FlatNamespace[]] = DefaultNamespace, 117 | KPrefix extends KeyPrefix> = undefined, 118 | >( 119 | request: Request, 120 | namespaces?: N, 121 | options?: Omit & { keyPrefix?: KPrefix }, 122 | ): Promise, KPrefix>>; 123 | async getFixedT< 124 | N extends 125 | | FlatNamespace 126 | | readonly [FlatNamespace, ...FlatNamespace[]] = DefaultNamespace, 127 | KPrefix extends KeyPrefix> = undefined, 128 | >( 129 | requestOrLocale: Request | string, 130 | namespaces?: N, 131 | options: Omit & { keyPrefix?: KPrefix } = {}, 132 | ): Promise, KPrefix>> { 133 | let [instance, locale] = await Promise.all([ 134 | this.createInstance({ ...this.options.i18next, ...options }), 135 | typeof requestOrLocale === "string" 136 | ? requestOrLocale 137 | : this.getLocale(requestOrLocale), 138 | ]); 139 | 140 | await instance.changeLanguage(locale); 141 | 142 | if (namespaces) await instance.loadNamespaces(namespaces); 143 | else if (instance.options.defaultNS) { 144 | await instance.loadNamespaces(instance.options.defaultNS); 145 | } else await instance.loadNamespaces("translation" as DefaultNamespace); 146 | 147 | return instance.getFixedT( 148 | locale, 149 | namespaces, 150 | options?.keyPrefix, 151 | ); 152 | } 153 | 154 | private async createInstance(options: Omit = {}) { 155 | let instance = createInstance(); 156 | let plugins = [ 157 | ...(this.options.backend ? [this.options.backend] : []), 158 | ...(this.options.plugins || []), 159 | ]; 160 | for (const plugin of plugins) instance.use(plugin); 161 | await instance.init(options); 162 | return instance; 163 | } 164 | } 165 | 166 | export { LanguageDetector }; 167 | export type { LanguageDetectorOption }; 168 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | /* Change to `@total-typescript/tsconfig/tsc/dom/library` for DOM usage */ 3 | "extends": "@total-typescript/tsconfig/tsc/dom/library", 4 | "include": [ 5 | "src/client.ts", 6 | "src/react.tsx", 7 | "src/server.ts", 8 | "src/middleware.ts", 9 | "src/lib/**/*.tsx" 10 | ], 11 | "compilerOptions": { 12 | "outDir": "./build", 13 | "jsx": "react" 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /typedoc.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://typedoc.org/schema.json", 3 | "includeVersion": true, 4 | "entryPoints": ["./src/client.ts", "./src/react.tsx", "./src/server.ts"], 5 | "out": "docs", 6 | "json": "docs/index.json", 7 | "cleanOutputDir": true, 8 | "plugin": ["typedoc-plugin-mdn-links"], 9 | "categorizeByGroup": false 10 | } 11 | --------------------------------------------------------------------------------