diff --git a/README.md b/README.md index 0a5cbfc..6b3e61b 100644 --- a/README.md +++ b/README.md @@ -36,7 +36,7 @@ First let's create some translation files ```json { - "greeting": "Hello" + "greeting": "Hello" } ``` @@ -44,7 +44,7 @@ First let's create some translation files ```json { - "greeting": "Hola" + "greeting": "Hola" } ``` @@ -56,13 +56,13 @@ For this example, we will create `app/i18n.ts`: ```ts export default { - // This is the list of languages your application supports - supportedLngs: ["en", "es"], - // 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", + // This is the list of languages your application supports + supportedLngs: ["en", "es"], + // 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", }; ``` @@ -75,22 +75,22 @@ import { RemixI18Next } from "remix-i18next/server"; import i18n from "~/i18n"; // your i18n configuration file let i18next = new RemixI18Next({ - detection: { - supportedLanguages: i18n.supportedLngs, - fallbackLanguage: i18n.fallbackLng, - }, - // This is the configuration for i18next used - // 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], + detection: { + supportedLanguages: i18n.supportedLngs, + fallbackLanguage: i18n.fallbackLng, + }, + // This is the configuration for i18next used + // 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], }); export default i18next; @@ -112,44 +112,44 @@ import Backend from "i18next-http-backend"; import { getInitialNamespaces } from "remix-i18next/client"; async function hydrate() { - await i18next - .use(initReactI18next) // Tell i18next to use the react-i18next plugin - .use(LanguageDetector) // Setup a client-side language detector - .use(Backend) // 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" }, - detection: { - // Here only enable htmlTag detection, we'll detect the language only - // server-side with remix-i18next, by using the `` attribute - // we can communicate to the client the language detected server-side - order: ["htmlTag"], - // Because we only use htmlTag, there's no reason to cache the language - // on the browser, so we disable it - caches: [], - }, - }); - - startTransition(() => { - hydrateRoot( - document, - - - - - , - ); - }); + await i18next + .use(initReactI18next) // Tell i18next to use the react-i18next plugin + .use(LanguageDetector) // Setup a client-side language detector + .use(Backend) // 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" }, + detection: { + // Here only enable htmlTag detection, we'll detect the language only + // server-side with remix-i18next, by using the `` attribute + // we can communicate to the client the language detected server-side + order: ["htmlTag"], + // Because we only use htmlTag, there's no reason to cache the language + // on the browser, so we disable it + caches: [], + }, + }); + + startTransition(() => { + hydrateRoot( + document, + + + + + + ); + }); } if (window.requestIdleCallback) { - window.requestIdleCallback(hydrate); + window.requestIdleCallback(hydrate); } else { - // Safari doesn't support requestIdleCallback - // https://caniuse.com/requestidlecallback - window.setTimeout(hydrate, 1); + // Safari doesn't support requestIdleCallback + // https://caniuse.com/requestidlecallback + window.setTimeout(hydrate, 1); } ``` @@ -160,8 +160,8 @@ And in your `entry.server.tsx` replace the code with this: ```tsx import { PassThrough } from "stream"; import { - createReadableStreamFromReadable, - type EntryContext, + createReadableStreamFromReadable, + type EntryContext, } from "@remix-run/node"; import { RemixServer } from "@remix-run/react"; import { isbot } from "isbot"; @@ -176,64 +176,64 @@ import { resolve } from "node:path"; const ABORT_DELAY = 5000; export default async function handleRequest( - request: Request, - responseStatusCode: number, - responseHeaders: Headers, - remixContext: EntryContext, + request: Request, + responseStatusCode: number, + responseHeaders: Headers, + remixContext: EntryContext ) { - let callbackName = isbot(request.headers.get("user-agent")) - ? "onAllReady" - : "onShellReady"; - - let instance = createInstance(); - let lng = await i18next.getLocale(request); - let ns = i18next.getRouteNamespaces(remixContext); - - await instance - .use(initReactI18next) // Tell our instance to use react-i18next - .use(Backend) // Setup our backend - .init({ - ...i18n, // spread the configuration - lng, // The locale we detected above - ns, // The namespaces the routes about to render wants to use - backend: { loadPath: resolve("./public/locales/{{lng}}/{{ns}}.json") }, - }); - - return new Promise((resolve, reject) => { - let didError = false; - - let { pipe, abort } = renderToPipeableStream( - - - , - { - [callbackName]: () => { - let body = new PassThrough(); - const stream = createReadableStreamFromReadable(body); - responseHeaders.set("Content-Type", "text/html"); - - resolve( - new Response(stream, { - headers: responseHeaders, - status: didError ? 500 : responseStatusCode, - }), - ); - - pipe(body); - }, - onShellError(error: unknown) { - reject(error); - }, - onError(error: unknown) { - didError = true; - - console.error(error); - }, - }, - ); - - setTimeout(abort, ABORT_DELAY); - }); + let callbackName = isbot(request.headers.get("user-agent")) + ? "onAllReady" + : "onShellReady"; + + let instance = createInstance(); + let lng = await i18next.getLocale(request); + let ns = i18next.getRouteNamespaces(remixContext); + + await instance + .use(initReactI18next) // Tell our instance to use react-i18next + .use(Backend) // Setup our backend + .init({ + ...i18n, // spread the configuration + lng, // The locale we detected above + ns, // The namespaces the routes about to render wants to use + backend: { loadPath: resolve("./public/locales/{{lng}}/{{ns}}.json") }, + }); + + return new Promise((resolve, reject) => { + let didError = false; + + let { pipe, abort } = renderToPipeableStream( + + + , + { + [callbackName]: () => { + let body = new PassThrough(); + const stream = createReadableStreamFromReadable(body); + responseHeaders.set("Content-Type", "text/html"); + + resolve( + new Response(stream, { + headers: responseHeaders, + status: didError ? 500 : responseStatusCode, + }) + ); + + pipe(body); + }, + onShellError(error: unknown) { + reject(error); + }, + onError(error: unknown) { + didError = true; + + console.error(error); + }, + } + ); + + setTimeout(abort, ABORT_DELAY); + }); } ``` @@ -247,44 +247,44 @@ import { useTranslation } from "react-i18next"; import i18next from "~/i18next.server"; export async function loader({ request }: LoaderArgs) { - let locale = await i18next.getLocale(request); - return json({ locale }); + let locale = await i18next.getLocale(request); + return json({ locale }); } export let handle = { - // In the handle export, we can add a i18n key with namespaces our route - // 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", + // In the handle export, we can add a i18n key with namespaces our route + // 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", }; export default function Root() { - // Get the locale from the loader - let { locale } = useLoaderData(); - - let { i18n } = useTranslation(); - - // This hook will change the i18n instance language to the current locale - // detected by the loader, this way, when we do something to change the - // language, this locale will change and i18next will load the correct - // translation files - useChangeLanguage(locale); - - return ( - - - - - - - - - - - - - ); + // Get the locale from the loader + let { locale } = useLoaderData(); + + let { i18n } = useTranslation(); + + // This hook will change the i18n instance language to the current locale + // detected by the loader, this way, when we do something to change the + // language, this locale will change and i18next will load the correct + // translation files + useChangeLanguage(locale); + + return ( + + + + + + + + + + + + + ); } ``` @@ -294,8 +294,8 @@ Finally, in any route you want to translate, you can use the `t()` function, as import { useTranslation } from "react-i18next"; export default function Component() { - let { t } = useTranslation(); - return

{t("greeting")}

; + let { t } = useTranslation(); + return

{t("greeting")}

; } ``` @@ -306,7 +306,7 @@ like: ```json { - "title": "remix-i18n is awesome" + "title": "remix-i18n is awesome" } ``` @@ -314,7 +314,7 @@ like: ```json { - "title": "remix-i18n es increĆ­ble" + "title": "remix-i18n es increĆ­ble" } ``` @@ -327,8 +327,8 @@ import { useTranslation } from "react-i18next"; export let handle = { i18n: "home" }; export default function Component() { - let { t } = useTranslation("home"); - return

{t("title")}

; + let { t } = useTranslation("home"); + return

{t("title")}

; } ``` @@ -340,13 +340,13 @@ If you need to get translated texts inside a loader or action function, for exam ```ts export async function loader({ request }: LoaderArgs) { - let t = await i18n.getFixedT(request); - let title = t("My page title"); - return json({ title }); + let t = await i18n.getFixedT(request); + let title = t("My page title"); + return json({ title }); } export let meta: MetaFunction = ({ data }) => { - return { title: data.title }; + return { title: data.title }; }; ``` @@ -363,13 +363,13 @@ If you always need to set the same i18next options, you can pass them to RemixI1 ```ts export let i18n = new RemixI18Next({ - detection: { supportedLanguages: ["es", "en"], fallbackLanguage: "en" }, - // The config here will be used for getFixedT - i18next: { - backend: { loadPath: resolve("./public/locales/{{lng}}/{{ns}}.json") }, - }, - // This backend will be used by getFixedT - backend: Backend, + detection: { supportedLanguages: ["es", "en"], fallbackLanguage: "en" }, + // The config here will be used for getFixedT + i18next: { + backend: { loadPath: resolve("./public/locales/{{lng}}/{{ns}}.json") }, + }, + // This backend will be used by getFixedT + backend: Backend, }); ``` @@ -391,3 +391,47 @@ export async function loader({ request }: LoaderArgs) { ``` This feature simplifies working with deeply nested translation keys and enhances the organization of your translation files. + +#### Finding the locale from the request URL pathname + +If you want to keep the user locale on the pathname, you have two possible options. + +First option is to pass the param from the loader/action params to `getFixedT`. This way you will stop using the language detection features of remix-i18next. + +Second options is to pass a `findLocale` function to the detection options in RemixI18Next. + +```ts +export let i18n = new RemixI18Next({ + detection: { + supportedLanguages: ["es", "en"], + fallbackLanguage: "en", + async findLocale(request) { + let locale = request.url.pathname.split("/").at(1); + return locale; + }, + }, +}); +``` + +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. + +#### Querying the locale from the database + +If your application stores the user locale in the database, you can use `findLocale` function to query the database and return the locale. + +```ts +export let i18n = new RemixI18Next({ + detection: { + supportedLanguages: ["es", "en"], + fallbackLanguage: "en", + async findLocale(request) { + let user = await db.getUser(request); + return user.locale; + }, + }, +}); +``` + +Note that every call to `getLocale` and `getFixedT` will call `findLocale` so it's important to keep it as fast as possible. + +If you need both the locale and the `t` function, you can call `getLocale`, and pass the result to `getFixedT`. diff --git a/package.json b/package.json index d7af31c..a1c0dfa 100644 --- a/package.json +++ b/package.json @@ -55,13 +55,7 @@ }, "funding": "https://github.com/sponsors/sergiodxa", "homepage": "https://github.com/sergiodxa/remix-i18next#readme", - "keywords": [ - "remix", - "i18n", - "i18next", - "ssr", - "csr" - ], + "keywords": ["remix", "i18n", "i18next", "ssr", "csr"], "license": "MIT", "peerDependenciesMeta": { "@remix-run/cloudflare": { diff --git a/src/server.test.ts b/src/server.test.ts index 1336600..72c550d 100644 --- a/src/server.test.ts +++ b/src/server.test.ts @@ -98,6 +98,26 @@ describe(RemixI18Next.name, () => { expect(await i18n.getLocale(request)).toBe("es"); }); + test("should get the locale using the findLocale method", async () => { + let request = new Request("https://example.com/dashboard", { + headers: { + "Accept-Language": "es-AR,es;q=0.2,en-US;q=0.6,en;q=0.4,*;q=0.5", + }, + }); + + let i18n = new RemixI18Next({ + detection: { + supportedLanguages: ["es", "en"], + fallbackLanguage: "en", + async findLocale(request) { + return "es"; + }, + }, + }); + + expect(await i18n.getLocale(request)).toBe("es"); + }); + test("should use the fallback language if search param, cookie and request headers are not there", async () => { let request = new Request("https://example.com/dashboard"); diff --git a/src/server.ts b/src/server.ts index 8edeabb..38c129e 100644 --- a/src/server.ts +++ b/src/server.ts @@ -68,9 +68,18 @@ export interface LanguageDetectorOption { * - cookie * - session * - header + * If customized, a an extra `custom` option can be added to the order. * And finally the fallback language. */ - order?: Array<"searchParams" | "cookie" | "session" | "header">; + order?: Array<"searchParams" | "cookie" | "session" | "header" | "custom">; + /** + * A function that can be used to find the locale based on the request object + * using any custom logic you want. + * This can be useful to get the locale from the URL pathname, or to query it + * from the database or fetch it from an API. + * @param request The request object received by the server. + */ + findLocale?(request: Request): Promise | null>; } export interface RemixI18NextOption { @@ -250,12 +259,7 @@ export class LanguageDetector { } public async detect(request: Request): Promise { - let order = this.options.order ?? [ - "searchParams", - "cookie", - "session", - "header", - ]; + let order = this.options.order ?? this.defaultOrder; for (let method of order) { let locale: string | null = null; @@ -276,12 +280,24 @@ export class LanguageDetector { locale = this.fromHeader(request); } + if (method === "custom") { + locale = await this.fromCustom(request); + } + if (locale) return locale; } return this.options.fallbackLanguage; } + private get defaultOrder() { + let order: Array< + "searchParams" | "cookie" | "session" | "header" | "custom" + > = ["searchParams", "cookie", "session", "header"]; + if (this.options.findLocale) order.unshift("custom"); + return order; + } + private fromSearchParams(request: Request): string | null { let url = new URL(request.url); if (!url.searchParams.has(this.options.searchParamKey ?? "lng")) { @@ -323,6 +339,18 @@ export class LanguageDetector { return this.fromSupported(locales); } + private async fromCustom(request: Request): Promise { + if (!this.options.findLocale) { + throw new ReferenceError( + "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.", + ); + } + let locales = await this.options.findLocale(request); + if (!locales) return null; + if (Array.isArray(locales)) return this.fromSupported(locales.join(",")); + return this.fromSupported(locales); + } + private fromSupported(language: string | null) { return ( pick(