diff --git a/README.md b/README.md index 3d7be75..680135f 100644 --- a/README.md +++ b/README.md @@ -79,3 +79,16 @@ Update 23/02/2024 noExternal: ['remix-i18next'], }, ``` + +Update 1/03/2024 + +- Following Remix vite 18next example by Sergio Xalambri + https://github.com/sergiodxa/remix-vite-i18next/tree/main, i refactored the + example. +- Updated to latest version of `remix-i18next`, we no longer need to modify the + `vite.config.ts` file. +- Exit `i18next-fs-backend` adn `i18next-http-backend`, we are now transforming + the json files to ts files that are managed by vite. +- This way, we can have HMR and a really nice developer UX. +- Locales are now located in `/app/locales`, `i18n` configuration is moved to + `/app/config` and load the translations. diff --git a/app/config/i18n.ts b/app/config/i18n.ts new file mode 100644 index 0000000..711ecbe --- /dev/null +++ b/app/config/i18n.ts @@ -0,0 +1,18 @@ +import en from '#/app/locales/en' +import fr from '#/app/locales/fr' + +// This is the list of languages your application supports +export const supportedLngs = ['fr', 'en'] + +// This is the language you want to use in case +// if the user language is not in the supportedLngs +export const fallbackLng = 'en' + +// The default namespace of i18next is "translation", but you can customize it +// here +export const defaultNS = 'common' + +export const resources = { + en: { common: en }, + fr: { common: fr }, +} diff --git a/app/entry.client.tsx b/app/entry.client.tsx index ee82b93..0ad9ca6 100644 --- a/app/entry.client.tsx +++ b/app/entry.client.tsx @@ -1,12 +1,11 @@ import { RemixBrowser } from '@remix-run/react' import i18next from 'i18next' import LanguageDetector from 'i18next-browser-languagedetector' -import HTTPBackend from 'i18next-http-backend' import { StrictMode, startTransition } from 'react' import { hydrateRoot } from 'react-dom/client' import { I18nextProvider, initReactI18next } from 'react-i18next' -import { getInitialNamespaces } from 'remix-i18next' -import { i18n } from './utils/i18n.ts' +import { getInitialNamespaces } from 'remix-i18next/client' +import * as i18n from '#app/config/i18n' if (ENV.MODE === 'production' && ENV.SENTRY_DSN) { import('./utils/monitoring.client.tsx').then(({ init }) => init()) @@ -16,23 +15,10 @@ async function hydrate() { await i18next .use(initReactI18next) // Tell i18next to use the react-i18next plugin .use(LanguageDetector) // Setup a client-side language detector - .use(HTTPBackend) // Setup your backend .init({ ...i18n, // spread the configuration // This function detects the namespaces your routes rendered while SSR use ns: getInitialNamespaces(), - backend: { - loadPath: '/locales/{{lng}}/{{ns}}.json', - // you can also use a query parameter in production env to force reloading the translations - // queryStringParams: { v: "version" }, - customHeaders: { - 'cache-control': - ENV.MODE === 'development' - ? 'no-cache, no-store, must-revalidate' - : undefined, - }, - }, - detection: { // Here only enable htmlTag detection, we'll detect the language only // server-side with remix-i18next, by using the `` attribute diff --git a/app/entry.server.tsx b/app/entry.server.tsx index bf641f1..c294a80 100644 --- a/app/entry.server.tsx +++ b/app/entry.server.tsx @@ -1,4 +1,3 @@ -import { resolve } from 'path' import { PassThrough } from 'stream' import { createReadableStreamFromReadable, @@ -9,13 +8,12 @@ import { import { RemixServer } from '@remix-run/react' import * as Sentry from '@sentry/remix' import { createInstance } from 'i18next' -import FSBackend from 'i18next-fs-backend' import { isbot } from 'isbot' import { renderToPipeableStream } from 'react-dom/server' import { I18nextProvider, initReactI18next } from 'react-i18next' +import * as i18n from '#app/config/i18n' import { getEnv, init } from './utils/env.server.ts' -import { i18n } from './utils/i18n.ts' -import { i18next } from './utils/i18next.server.ts' +import { i18next as i18nServer } from './utils/i18next.server.ts' import { getInstanceInfo } from './utils/litefs.server.ts' import { NonceProvider } from './utils/nonce-provider.ts' import { makeTimings } from './utils/timing.server.ts' @@ -53,21 +51,15 @@ export default async function handleRequest(...args: DocRequestArgs) { // completely unique instance and not share any state const i18nInstance = createInstance() // Then we could detect locale from the request - let lng = await i18next.getLocale(request) + let lng = await i18nServer.getLocale(request) // And here we detect what namespaces the routes about to render want to use - let ns = i18next.getRouteNamespaces(remixContext) + let ns = i18nServer.getRouteNamespaces(remixContext) - await i18nInstance - .use(initReactI18next) - .use(FSBackend) - .init({ - ...i18n, - lng, - ns, - backend: { - loadPath: resolve('./public/locales/{{lng}}/{{ns}}.json'), - }, - }) + await i18nInstance.use(initReactI18next).init({ + ...i18n, + lng, + ns, + }) const nonce = String(loadContext.cspNonce) ?? undefined return new Promise(async (resolve, reject) => { diff --git a/app/locales/en.ts b/app/locales/en.ts new file mode 100644 index 0000000..1ea84f3 --- /dev/null +++ b/app/locales/en.ts @@ -0,0 +1,15 @@ +export default { + 'auth.invalidUsernameOrPassword': 'Invalid username or password', + 'marketing.title.start': 'Check the', + 'marketing.title.middle': 'Getting Started', + 'marketing.title.end': + 'guide file for how to get your project off the ground!', + 'root.login': 'Log in', + 'root.language': 'Switch language', + 'root.french': 'French', + 'root.english': 'English', + 'root.profile': 'Profile', + 'root.logout': 'Logout', + 'root.notes': 'Notes', + search: 'Search', +} diff --git a/app/locales/fr.ts b/app/locales/fr.ts new file mode 100644 index 0000000..a8fc8ff --- /dev/null +++ b/app/locales/fr.ts @@ -0,0 +1,15 @@ +export default { + 'auth.invalidUsernameOrPassword': + "Nom d'utilisateur ou mot de passe incorrect", + 'marketing.title.start': 'Consulter le fichier', + 'marketing.title.middle': 'Getting Started', + 'marketing.title.end': 'pour savoir comment lancer votre projet !', + 'root.login': 'Se connecter', + 'root.language': 'Changer de langue', + 'root.french': 'Français', + 'root.english': 'Anglais', + 'root.profile': 'Profil', + 'root.logout': 'Se déconnecter', + 'root.notes': 'Notes', + search: 'Rechercher', +} diff --git a/app/root.tsx b/app/root.tsx index 1d75b06..d453e56 100644 --- a/app/root.tsx +++ b/app/root.tsx @@ -27,9 +27,10 @@ import { import { withSentry } from '@sentry/remix' import { useRef } from 'react' import { useTranslation } from 'react-i18next' -// import { useChangeLanguage } from 'remix-i18next' +import { useChangeLanguage } from 'remix-i18next/react' import { HoneypotProvider } from 'remix-utils/honeypot/react' import { z } from 'zod' +import * as i18n from '#app/config/i18n.ts' import { GeneralErrorBoundary } from './components/error-boundary.tsx' import { EpicProgress } from './components/progress-bar.tsx' import { SearchBar } from './components/search-bar.tsx' @@ -53,7 +54,6 @@ import { ClientHintCheck, getHints, useHints } from './utils/client-hints.tsx' import { prisma } from './utils/db.server.ts' import { getEnv } from './utils/env.server.ts' import { honeypot } from './utils/honeypot.server.ts' -import { i18n, useChangeLanguage } from './utils/i18n.ts' import { i18next } from './utils/i18next.server.ts' import { combineHeaders, getDomainUrl, getUserImgSrc } from './utils/misc.tsx' import { useNonce } from './utils/nonce-provider.ts' @@ -167,7 +167,7 @@ export const handle = { // will need to load. This key can be a single string or an array of strings. // TIP: In most cases, you should set this to your defaultNS from your i18n config // or if you did not set one, set it to the i18next default namespace "translation" - i18n: 'common', + i18n: ['common'], } export const headers: HeadersFunction = ({ loaderHeaders }) => { @@ -492,7 +492,6 @@ function LanguageDropDown() { export function ErrorBoundary() { // the nonce doesn't rely on the loader so we can access that const nonce = useNonce() - // const locale = useLocale() // NOTE: you cannot use useLoaderData in an ErrorBoundary because the loader // likely failed to run so we have to do the best we can. diff --git a/app/utils/i18n.ts b/app/utils/i18n.ts deleted file mode 100644 index 2a51c21..0000000 --- a/app/utils/i18n.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { useEffect } from 'react' -import { useTranslation } from 'react-i18next' - -export const i18n = { - // This is the list of languages your application supports - supportedLngs: ['en', 'fr'], - // This is the language you want to use in case - // if the user language is not in the supportedLngs - fallbackLng: 'en', - // The default namespace of i18next is "translation", but you can customize it here - defaultNS: 'common', - // Disabling suspense is recommended - react: { useSuspense: true }, -} - -export function useChangeLanguage(locale: string) { - const { i18n } = useTranslation() - useEffect(() => { - i18n.changeLanguage(locale) - }, [locale, i18n]) -} - -export const useLocale = () => { - const { i18n } = useTranslation() - return i18n.language -} diff --git a/app/utils/i18next.server.ts b/app/utils/i18next.server.ts index 4ba13b3..2bed610 100644 --- a/app/utils/i18next.server.ts +++ b/app/utils/i18next.server.ts @@ -1,12 +1,12 @@ -import { resolve } from 'node:path' import { createCookie } from '@remix-run/node' -import Backend from 'i18next-fs-backend' -import { RemixI18Next } from 'remix-i18next' -import { i18n } from './i18n.ts' // your i18n configuration file +import { RemixI18Next } from 'remix-i18next/server' +import * as i18n from '#/app/config/i18n' export const i18nCookie = createCookie('en_lang', { sameSite: 'lax', path: '/', + secure: process.env.NODE_ENV === 'production', + httpOnly: true, }) export const i18next = new RemixI18Next({ @@ -19,12 +19,5 @@ export const i18next = new RemixI18Next({ // when translating messages server-side only i18next: { ...i18n, - backend: { - loadPath: resolve('./public/locales/{{lng}}/{{ns}}.json'), - }, }, - // The i18next plugins you want RemixI18next to use for `i18n.getFixedT` inside loaders and actions. - // E.g. The Backend plugin for loading translations from the file system - // Tip: You could pass `resources` to the `i18next` configuration and avoid a backend here - plugins: [Backend], }) diff --git a/package-lock.json b/package-lock.json index af9323c..fc2ffb3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -50,10 +50,8 @@ "get-port": "^7.0.0", "glob": "^10.3.10", "helmet": "^7.1.0", - "i18next": "^23.8.2", + "i18next": "^23.10.0", "i18next-browser-languagedetector": "^7.2.0", - "i18next-fs-backend": "^2.3.1", - "i18next-http-backend": "^2.4.3", "intl-parse-accept-language": "^1.0.0", "isbot": "^4.4.0", "litefs-js": "^1.1.2", @@ -63,11 +61,11 @@ "qrcode": "^1.5.3", "react": "^18.2.0", "react-dom": "^18.2.0", - "react-i18next": "^14.0.1", + "react-i18next": "^14.0.5", "remix-auth": "^3.6.0", "remix-auth-form": "^1.4.0", "remix-auth-github": "^1.6.0", - "remix-i18next": "^5.5.0", + "remix-i18next": "^6.0.1", "remix-utils": "^7.5.0", "set-cookie-parser": "^2.6.0", "sonner": "^1.3.1", @@ -902,9 +900,9 @@ } }, "node_modules/@babel/runtime": { - "version": "7.23.8", - "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.23.8.tgz", - "integrity": "sha512-Y7KbAP984rn1VGMbGqKmBLio9V7y5Je9GvU4rQPCPinCyNfUcToxIXl06d59URp/F3LwinvODxab5N/G6qggkw==", + "version": "7.24.0", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.24.0.tgz", + "integrity": "sha512-Chk32uHMg6TnQdvw2e9IlqPpFX/6NLuK0Ys2PqLb7/gL5uFn9mXvK715FGLlOLQrcO4qIkNHkvPGktzzXexsFw==", "dependencies": { "regenerator-runtime": "^0.14.0" }, @@ -7982,14 +7980,6 @@ "yarn": ">=1" } }, - "node_modules/cross-fetch": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/cross-fetch/-/cross-fetch-4.0.0.tgz", - "integrity": "sha512-e4a5N8lVvuLgAWgnCrLr2PP0YyDOTHa9H/Rj54dirp61qXnNq46m82bRhNqIA5VccJtWBvPTFRV3TtvHUKPB1g==", - "dependencies": { - "node-fetch": "^2.6.12" - } - }, "node_modules/cross-spawn": { "version": "7.0.3", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz", @@ -10064,7 +10054,8 @@ "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", - "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==" + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true }, "node_modules/fast-glob": { "version": "3.3.2", @@ -10925,9 +10916,9 @@ } }, "node_modules/i18next": { - "version": "23.8.2", - "resolved": "https://registry.npmjs.org/i18next/-/i18next-23.8.2.tgz", - "integrity": "sha512-Z84zyEangrlERm0ZugVy4bIt485e/H8VecGUZkZWrH7BDePG6jT73QdL9EA1tRTTVVMpry/MgWIP1FjEn0DRXA==", + "version": "23.10.0", + "resolved": "https://registry.npmjs.org/i18next/-/i18next-23.10.0.tgz", + "integrity": "sha512-/TgHOqsa7/9abUKJjdPeydoyDc0oTi/7u9F8lMSj6ufg4cbC1Oj3f/Jja7zj7WRIhEQKB7Q4eN6y68I9RDxxGQ==", "funding": [ { "type": "individual", @@ -10954,19 +10945,6 @@ "@babel/runtime": "^7.23.2" } }, - "node_modules/i18next-fs-backend": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/i18next-fs-backend/-/i18next-fs-backend-2.3.1.tgz", - "integrity": "sha512-tvfXskmG/9o+TJ5Fxu54sSO5OkY6d+uMn+K6JiUGLJrwxAVfer+8V3nU8jq3ts9Pe5lXJv4b1N7foIjJ8Iy2Gg==" - }, - "node_modules/i18next-http-backend": { - "version": "2.4.3", - "resolved": "https://registry.npmjs.org/i18next-http-backend/-/i18next-http-backend-2.4.3.tgz", - "integrity": "sha512-jo2M03O6n1/DNb51WSQ8PsQ0xEELzLZRdYUTbf17mLw3rVwnJF9hwNgMXvEFSxxb+N8dT+o0vtigA6s5mGWyPA==", - "dependencies": { - "cross-fetch": "4.0.0" - } - }, "node_modules/iconv-lite": { "version": "0.4.24", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", @@ -15742,11 +15720,11 @@ } }, "node_modules/react-i18next": { - "version": "14.0.1", - "resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-14.0.1.tgz", - "integrity": "sha512-TMV8hFismBmpMdIehoFHin/okfvgjFhp723RYgIqB4XyhDobVMyukyM3Z8wtTRmajyFMZrBl/OaaXF2P6WjUAw==", + "version": "14.0.5", + "resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-14.0.5.tgz", + "integrity": "sha512-5+bQSeEtgJrMBABBL5lO7jPdSNAbeAZ+MlFWDw//7FnVacuVu3l9EeWFzBQvZsKy+cihkbThWOAThEdH8YjGEw==", "dependencies": { - "@babel/runtime": "^7.22.5", + "@babel/runtime": "^7.23.9", "html-parse-stringify": "^3.0.1" }, "peerDependencies": { @@ -16194,29 +16172,35 @@ } }, "node_modules/remix-i18next": { - "version": "5.5.0", - "resolved": "https://registry.npmjs.org/remix-i18next/-/remix-i18next-5.5.0.tgz", - "integrity": "sha512-QAHYlwb/0fmoSmH+t7AiJ3EhyG4SSQNQnAAPfqbkn/3UJfWO+NFFVxuggGx25FRJ3SOPexaspoiaCddcEayPAQ==", + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/remix-i18next/-/remix-i18next-6.0.1.tgz", + "integrity": "sha512-zO7sYe/Ui4s/ZGHMZoScFUNsuarykzQsQ9AB3KPswb7lLahwqIu+K09XFNZBUGBOPHOUl9txv0+2vOrIiGCSPA==", "dependencies": { "accept-language-parser": "^1.5.0", - "intl-parse-accept-language": "^1.0.0", - "lru-cache": "^7.14.1", - "use-consistent-value": "^1.0.0" + "intl-parse-accept-language": "^1.0.0" + }, + "engines": { + "node": ">=18.0.0" }, "peerDependencies": { - "@remix-run/react": "^1.0.0 || ^2.0.0", - "@remix-run/server-runtime": "^1.0.0 || ^2.0.0", + "@remix-run/cloudflare": "^2.0.0", + "@remix-run/deno": "^2.0.0", + "@remix-run/node": "^2.0.0", + "@remix-run/react": "^2.0.0", "i18next": "^23.1.0", "react": "^16.8.0 || ^17.0.0 || ^18.0.0", "react-i18next": "^13.0.0 || ^14.0.0" - } - }, - "node_modules/remix-i18next/node_modules/lru-cache": { - "version": "7.18.3", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-7.18.3.tgz", - "integrity": "sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA==", - "engines": { - "node": ">=12" + }, + "peerDependenciesMeta": { + "@remix-run/cloudflare": { + "optional": true + }, + "@remix-run/deno": { + "optional": true + }, + "@remix-run/node": { + "optional": true + } } }, "node_modules/remix-utils": { @@ -18457,20 +18441,6 @@ } } }, - "node_modules/use-consistent-value": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/use-consistent-value/-/use-consistent-value-1.0.0.tgz", - "integrity": "sha512-enIOysu0IwqjafsUXvhZPajB6UYjxwu8w38xWaHLfVs1onIyg2c0DwPgcknxqO0TpYpAHXdFDXOp7Cs9HbTIkw==", - "dependencies": { - "fast-deep-equal": "^3.1.3" - }, - "engines": { - "node": ">=10" - }, - "peerDependencies": { - "react": ">=16" - } - }, "node_modules/use-sidecar": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/use-sidecar/-/use-sidecar-1.1.2.tgz", diff --git a/package.json b/package.json index 4f69874..c343f14 100644 --- a/package.json +++ b/package.json @@ -81,10 +81,8 @@ "get-port": "^7.0.0", "glob": "^10.3.10", "helmet": "^7.1.0", - "i18next": "^23.8.2", + "i18next": "^23.10.0", "i18next-browser-languagedetector": "^7.2.0", - "i18next-fs-backend": "^2.3.1", - "i18next-http-backend": "^2.4.3", "intl-parse-accept-language": "^1.0.0", "isbot": "^4.4.0", "litefs-js": "^1.1.2", @@ -94,11 +92,11 @@ "qrcode": "^1.5.3", "react": "^18.2.0", "react-dom": "^18.2.0", - "react-i18next": "^14.0.1", + "react-i18next": "^14.0.5", "remix-auth": "^3.6.0", "remix-auth-form": "^1.4.0", "remix-auth-github": "^1.6.0", - "remix-i18next": "^5.5.0", + "remix-i18next": "^6.0.1", "remix-utils": "^7.5.0", "set-cookie-parser": "^2.6.0", "sonner": "^1.3.1", diff --git a/public/locales/en/common.json b/public/locales/en/common.json deleted file mode 100644 index 8030530..0000000 --- a/public/locales/en/common.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "auth.invalidUsernameOrPassword": "Invalid username or password", - "marketing.title.start": "Check the", - "marketing.title.middle": "Getting Started", - "marketing.title.end": "guide file for how to get your project off the ground!", - "root.login": "Log in", - "root.language": "Switch language", - "root.french": "French", - "root.english": "English", - "root.profile": "Profile", - "root.logout": "Logout", - "root.notes": "Notes", - "search": "Search" -} diff --git a/public/locales/fr/common.json b/public/locales/fr/common.json deleted file mode 100644 index 3ee3745..0000000 --- a/public/locales/fr/common.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "auth.invalidUsernameOrPassword": "Nom d'utilisateur ou mot de passe incorrect", - "marketing.title.start": "Consulter le fichier", - "marketing.title.middle": "Getting Started", - "marketing.title.end": "pour savoir comment lancer votre projet !", - "root.login": "Se connecter", - "root.language": "Changer de langue", - "root.french": "Français", - "root.english": "Anglais", - "root.profile": "Profil", - "root.logout": "Se déconnecter", - "root.notes": "Notes", - "search": "Rechercher" -} diff --git a/vite.config.ts b/vite.config.ts index 65a5719..b4e95c3 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -11,9 +11,6 @@ export default defineConfig({ external: [/node:.*/, 'stream', 'crypto', 'fsevents'], }, }, - ssr: { - noExternal: ['remix-i18next'], - }, plugins: [ remix({ ignoredRouteFiles: ['**/*'],