diff --git a/docs/Translation.md b/docs/Translation.md
index d832d571990..ca214e71cbb 100644
--- a/docs/Translation.md
+++ b/docs/Translation.md
@@ -130,6 +130,70 @@ const App = () => (
Check [the translation setup documentation](./TranslationSetup.md) for details about `ra-i18n-polyglot` and how to configure it.
+## `ra-i18n-i18next`
+
+React-admin also provides a package called `ra-i18n-i18next` that leverages [the i18next library](https://www.i18next.com/) to build an `i18nProvider` based on a dictionary of translations.
+
+You might prefer this package over `ra-i18n-polyglot` when:
+- you already use i18next services such as [locize](https://locize.com/)
+- you want more control on how you organize translations, leveraging [multiple files and namespaces](https://www.i18next.com/principles/namespaces)
+- you want more control on how you [load translations](https://www.i18next.com/how-to/add-or-load-translations)
+- you want to use features not available in Polyglot such as:
+ - [advanced formatting](https://www.i18next.com/translation-function/formatting);
+ - [nested translations](https://www.i18next.com/translation-function/nesting)
+ - [context](https://www.i18next.com/translation-function/context)
+
+```tsx
+// in src/i18nProvider.js
+import i18n from 'i18next';
+import { useI18nextProvider, convertRaTranslationsToI18next } from 'ra-i18n-i18next';
+import en from 'ra-language-english';
+import fr from 'ra-language-french';
+
+const i18nInstance = i18n.use(
+ resourcesToBackend(language => {
+ if (language === 'fr') {
+ return import(
+ `ra-language-french`
+ ).then(({ default: messages }) =>
+ convertRaTranslationsToI18next(messages)
+ );
+ }
+ return import(`ra-language-english`).then(({ default: messages }) =>
+ convertRaTranslationsToI18next(messages)
+ );
+ })
+);
+
+export const useMyI18nProvider = () => useI18nextProvider({
+ i18nInstance,
+ availableLocales: [
+ { locale: 'en', name: 'English' },
+ { locale: 'fr', name: 'French' },
+ ],
+});
+
+// in src/App.tsx
+import { Admin } from 'react-admin';
+import { useMyI18nProvider } from './i18nProvider';
+
+const App = () => {
+ const i18nProvider = useMyI18nProvider();
+ if (!i18nProvider) return null;
+
+ return (
+
+ ...
+
+ );
+};
+```
+
+Check [the ra-i18n-i18next documentation](https://github.com/marmelab/react-admin/tree/master/packages/ra-i18n-i18next) for details.
+
## Translation Files
`ra-i18n-polyglot` relies on JSON objects for translations. This means that the only thing required to add support for a new language is a JSON file.
diff --git a/packages/ra-i18n-i18next/README.md b/packages/ra-i18n-i18next/README.md
new file mode 100644
index 00000000000..81af7c7fcb7
--- /dev/null
+++ b/packages/ra-i18n-i18next/README.md
@@ -0,0 +1,228 @@
+# i18next i18n provider for react-admin
+
+i18next i18n provider for [react-admin](https://github.com/marmelab/react-admin), the frontend framework for building admin applications on top of REST/GraphQL services. It relies on [i18next](https://www.i18next.com/).
+
+You might prefer this package over `ra-i18n-polyglot` when:
+- you already use i18next services such as [locize](https://locize.com/)
+- you want more control on how you organize translations, leveraging [multiple files and namespaces](https://www.i18next.com/principles/namespaces)
+- you want more control on how you [load translations](https://www.i18next.com/how-to/add-or-load-translations)
+- you want to use features not available in Polyglot such as:
+ - [advanced formatting](https://www.i18next.com/translation-function/formatting);
+ - [nested translations](https://www.i18next.com/translation-function/nesting)
+ - [context](https://www.i18next.com/translation-function/context)
+
+## Installation
+
+```sh
+npm install --save ra-i18n-i18next
+```
+
+## Usage
+
+```tsx
+import { Admin } from 'react-admin';
+import { useI18nextProvider, convertRaMessagesToI18next } from 'ra-i18n-i18next';
+import englishMessages from 'ra-language-english';
+
+const App = () => {
+ const i18nProvider = useI18nextProvider({
+ options: {
+ resources: {
+ translations: convertRaMessagesToI18next(englishMessages)
+ }
+ }
+ });
+ if (!i18nProvider) return (
Loading...
);
+
+ return (
+
+ ...
+
+ );
+};
+```
+
+## API
+
+### `useI18nextProvider` hook
+
+A hook that returns an i18nProvider for react-admin applications, based on i18next.
+
+You can provide your own i18next instance but don't initialize it, the hook will do it for you with the options you may provide. Besides, this hook already adds the `initReactI18next` plugin to i18next.
+
+#### Usage
+
+```tsx
+import { Admin } from 'react-admin';
+import { useI18nextProvider, convertRaMessagesToI18next } from 'ra-i18n-i18next';
+import englishMessages from 'ra-language-english';
+
+const App = () => {
+ const i18nProvider = useI18nextProvider({
+ options: {
+ resources: {
+ translations: convertRaMessagesToI18next(englishMessages)
+ }
+ }
+ });
+ if (!i18nProvider) return (Loading...
);
+
+ return (
+
+ ...
+
+ );
+};
+```
+
+#### Parameters
+
+| Parameter | Required | Type | Default | Description |
+| -------------------- | -------- | ----------- | ------- | ---------------------------------------------------------------- |
+| `i18nextInstance` | Optional | I18n | | Your own i18next instance. If not provided, one will be created. |
+| `options` | Optional | InitOptions | | The options passed to the i18next init function |
+| `availableLocales` | Optional | Locale[] | | An array describing the available locales. Used to automatically include the locale selector menu in the default react-admin AppBar |
+
+##### `i18nextInstance`
+
+This parameter lets you pass your own instance of i18next, allowing you to customize its plugins such as the backends.
+
+```tsx
+import { Admin } from 'react-admin';
+import { useI18nextProvider } from 'ra-i18n-i18next';
+import i18n from 'i18next';
+import Backend from 'i18next-http-backend';
+import LanguageDetector from 'i18next-browser-languagedetector';
+
+const App = () => {
+ const i18nextInstance = i18n
+ .use(Backend)
+ .use(LanguageDetector);
+
+ const i18nProvider = useI18nextProvider({
+ i18nextInstance
+ });
+
+ if (!i18nProvider) return (Loading...
);
+
+ return (
+
+ ...
+
+ );
+};
+```
+
+##### `options`
+
+This parameter lets you pass your own options for the i18n `init` function.
+
+Please refer to [the i18next documentation](https://www.i18next.com/overview/configuration-options) for details.
+
+```tsx
+import { Admin } from 'react-admin';
+import { useI18nextProvider } from 'ra-i18n-i18next';
+import i18n from 'i18next';
+
+const App = () => {
+ const i18nProvider = useI18nextProvider({
+ options: {
+ debug: true,
+ }
+ });
+
+ if (!i18nProvider) return (Loading...
);
+
+ return (
+
+ ...
+
+ );
+};
+```
+
+#### `availableLocales`
+
+This parameter lets you provide the list of available locales for your application. This is used by the default react-admin AppBar to detect whether to display a locale selector.
+
+```tsx
+import { Admin } from 'react-admin';
+import { useI18nextProvider, convertRaTranslationsToI18next } from 'ra-i18n-i18next';
+import i18n from 'i18next';
+import resourcesToBackend from 'i18next-resources-to-backend';
+
+const App = () => {
+ const i18nextInstance = i18n.use(
+ // Here we use a Backend provided by i18next that allows us to load
+ // the translations however we want.
+ // See https://www.i18next.com/how-to/add-or-load-translations#lazy-load-in-memory-translations
+ resourcesToBackend(language => {
+ if (language === 'fr') {
+ // Load the ra-language-french package and convert its translations in i18next format
+ return import(
+ `ra-language-french`
+ ).then(({ default: messages }) =>
+ convertRaTranslationsToI18next(messages)
+ );
+ }
+ // Load the ra-language-english package and convert its translations in i18next format
+ return import(`ra-language-english`).then(({ default: messages }) =>
+ convertRaTranslationsToI18next(messages)
+ );
+ })
+ );
+
+ const i18nProvider = useI18nextProvider({
+ i18nextInstance,
+ availableLocales: [
+ { locale: 'en', name: 'English' },
+ { locale: 'fr', name: 'French' },
+ ],
+ });
+
+ if (!i18nProvider) return (Loading...
);
+
+ return (
+
+ ...
+
+ );
+};
+```
+
+### `convertRaMessagesToI18next` function
+
+A function that takes translations from a standard react-admin language package and converts them to i18next format.
+It transforms the following:
+- interpolations wrappers from `%{foo}` to `{{foo}}` unless a prefix and/or a suffix are provided
+- pluralization messages from a single key containing text like `"key": "foo |||| bar"` to multiple keys `"foo_one": "foo"` and `"foo_other": "bar"`
+
+#### Usage
+
+```ts
+import englishMessages from 'ra-language-english';
+import { convertRaMessagesToI18next } from 'ra-i18n-18next';
+
+const messages = convertRaMessagesToI18next(englishMessages);
+```
+
+#### Parameters
+
+| Parameter | Required | Type | Default | Description |
+| -------------------- | -------- | ----------- | ------- | ---------------------------------------------------------------- |
+| `raMessages` | Required | object | | An object containing standard react-admin translations such as provided by ra-language-english |
+| `options` | Optional | object | | An object providing custom interpolation suffix and/or suffix |
+
+##### `options`
+
+If you provided interpolation options to your i18next instance, you should provide them when calling this function:
+
+```ts
+import englishMessages from 'ra-language-english';
+import { convertRaMessagesToI18next } from 'ra-i18n-18next';
+
+const messages = convertRaMessagesToI18next(englishMessages, {
+ prefix: '#{',
+ suffix: '}#',
+});
+```
diff --git a/packages/ra-i18n-i18next/package.json b/packages/ra-i18n-i18next/package.json
new file mode 100644
index 00000000000..6935e9f421d
--- /dev/null
+++ b/packages/ra-i18n-i18next/package.json
@@ -0,0 +1,39 @@
+{
+ "name": "ra-i18n-i18next",
+ "version": "4.13.4",
+ "description": "i18next i18n provider for react-admin",
+ "main": "dist/cjs/index.js",
+ "module": "dist/esm/index.js",
+ "types": "dist/cjs/index.d.ts",
+ "sideEffects": false,
+ "files": [
+ "*.md",
+ "dist",
+ "src"
+ ],
+ "authors": [
+ "François Zaninotto"
+ ],
+ "repository": "marmelab/react-admin",
+ "homepage": "https://github.com/marmelab/react-admin#readme",
+ "bugs": "https://github.com/marmelab/react-admin/issues",
+ "license": "MIT",
+ "scripts": {
+ "build": "yarn run build-cjs && yarn run build-esm",
+ "build-cjs": "rimraf ./dist/cjs && tsc --outDir dist/cjs",
+ "build-esm": "rimraf ./dist/esm && tsc --outDir dist/esm --module es2015",
+ "watch": "tsc --outDir dist/esm --module es2015 --watch"
+ },
+ "dependencies": {
+ "i18next": "^23.5.1",
+ "ra-core": "^4.13.4",
+ "react-i18next": "^13.2.2"
+ },
+ "devDependencies": {
+ "cross-env": "^5.2.0",
+ "i18next-resources-to-backend": "^1.1.4",
+ "rimraf": "^3.0.2",
+ "typescript": "^5.1.3"
+ },
+ "gitHead": "e936ff2c3f887d2e98ef136cf3b3f3d254725fc4"
+}
diff --git a/packages/ra-i18n-i18next/src/convertRaTranslationsToI18next.spec.tsx b/packages/ra-i18n-i18next/src/convertRaTranslationsToI18next.spec.tsx
new file mode 100644
index 00000000000..dbb243b2606
--- /dev/null
+++ b/packages/ra-i18n-i18next/src/convertRaTranslationsToI18next.spec.tsx
@@ -0,0 +1,74 @@
+import { convertRaTranslationsToI18next } from './convertRaTranslationsToI18next';
+
+describe('i18next i18nProvider', () => {
+ describe('convertRaTranslationsToI18next', () => {
+ test('should convert react-admin default messages to i18next format', () => {
+ expect(
+ convertRaTranslationsToI18next(
+ {
+ simple: 'simple',
+ interpolation: 'interpolation %{variable}',
+ pluralization: 'singular |||| plural',
+ nested: {
+ deep: {
+ simple: 'simple',
+ interpolation: 'interpolation %{variable}',
+ pluralization: 'singular |||| plural',
+ },
+ },
+ },
+ {}
+ )
+ ).toEqual({
+ simple: 'simple',
+ interpolation: 'interpolation {{variable}}',
+ pluralization_one: 'singular',
+ pluralization_other: 'plural',
+ nested: {
+ deep: {
+ simple: 'simple',
+ interpolation: 'interpolation {{variable}}',
+ pluralization_one: 'singular',
+ pluralization_other: 'plural',
+ },
+ },
+ });
+ });
+
+ test('should convert react-admin default messages to i18next format with custom prefix/suffix', () => {
+ expect(
+ convertRaTranslationsToI18next(
+ {
+ simple: 'simple',
+ interpolation: 'interpolation %{variable}',
+ pluralization: 'singular |||| plural',
+ nested: {
+ deep: {
+ simple: 'simple',
+ interpolation: 'interpolation %{variable}',
+ pluralization: 'singular |||| plural',
+ },
+ },
+ },
+ {
+ prefix: '#{',
+ suffix: '}#',
+ }
+ )
+ ).toEqual({
+ simple: 'simple',
+ interpolation: 'interpolation #{variable}#',
+ pluralization_one: 'singular',
+ pluralization_other: 'plural',
+ nested: {
+ deep: {
+ simple: 'simple',
+ interpolation: 'interpolation #{variable}#',
+ pluralization_one: 'singular',
+ pluralization_other: 'plural',
+ },
+ },
+ });
+ });
+ });
+});
diff --git a/packages/ra-i18n-i18next/src/convertRaTranslationsToI18next.ts b/packages/ra-i18n-i18next/src/convertRaTranslationsToI18next.ts
new file mode 100644
index 00000000000..06fc648523b
--- /dev/null
+++ b/packages/ra-i18n-i18next/src/convertRaTranslationsToI18next.ts
@@ -0,0 +1,117 @@
+import clone from 'lodash/clone';
+
+/**
+ * A function that takes translations from a standard react-admin language package and converts them to i18next format.
+ * It transforms the following:
+ * - interpolations wrappers from `%{foo}` to `{{foo}}` unless a prefix and/or a suffix are provided
+ * - pluralization messages from a single key containing text like `"key": "foo |||| bar"` to multiple keys `"foo_one": "foo"` and `"foo_other": "bar"`
+ * @param raMessages The translations to convert. This is an object as found in packages such as ra-language-english.
+ * @param options Options to customize the conversion.
+ * @param options.prefix The prefix to use for interpolation variables. Defaults to `{{`.
+ * @param options.suffix The suffix to use for interpolation variables. Defaults to `}}`.
+ * @returns The converted translations as an object.
+ *
+ * @example Convert the english translations from ra-language-english to i18next format
+ * import englishMessages from 'ra-language-english';
+ * import { convertRaMessagesToI18next } from 'ra-i18n-18next';
+ *
+ * const messages = convertRaMessagesToI18next(englishMessages);
+ *
+ * @example Convert the english translations from ra-language-english to i18next format with custom interpolation wrappers
+ * import englishMessages from 'ra-language-english';
+ * import { convertRaMessagesToI18next } from 'ra-i18n-18next';
+ *
+ * const messages = convertRaMessagesToI18next(englishMessages, {
+ * prefix: '#{',
+ * suffix: '}#',
+ * });
+ */
+export const convertRaTranslationsToI18next = (
+ raMessages: object,
+ { prefix = '{{', suffix = '}}' } = {}
+) => {
+ return Object.keys(raMessages).reduce((acc, key) => {
+ if (typeof acc[key] === 'object') {
+ acc[key] = convertRaTranslationsToI18next(acc[key], {
+ prefix,
+ suffix,
+ });
+ return acc;
+ }
+
+ const message = acc[key] as string;
+
+ if (message.indexOf(' |||| ') > -1) {
+ const pluralVariants = message.split(' |||| ');
+
+ if (
+ pluralVariants.length > 2 &&
+ process.env.NODE_ENV === 'development'
+ ) {
+ console.warn(
+ 'A message contains more than two plural forms so we can not convert it to i18next format automatically. You should provide your own translations for this language.'
+ );
+ }
+ acc[`${key}_one`] = convertRaTranslationToI18next(
+ pluralVariants[0],
+ {
+ prefix,
+ suffix,
+ }
+ );
+ acc[`${key}_other`] = convertRaTranslationToI18next(
+ pluralVariants[1],
+ {
+ prefix,
+ suffix,
+ }
+ );
+ delete acc[key];
+ } else {
+ acc[key] = convertRaTranslationToI18next(message, {
+ prefix,
+ suffix,
+ });
+ }
+
+ return acc;
+ }, clone(raMessages));
+};
+
+/**
+ * A function that takes a single translation text from a standard react-admin language package and converts it to i18next format.
+ * It transforms the interpolations wrappers from `%{foo}` to `{{foo}}` unless a prefix and/or a suffix are provided
+ *
+ * @param translationText The translation text to convert.
+ * @param options Options to customize the conversion.
+ * @param options.prefix The prefix to use for interpolation variables. Defaults to `{{`.
+ * @param options.suffix The suffix to use for interpolation variables. Defaults to `}}`.
+ * @returns The converted translation text.
+ *
+ * @example Convert a single message to i18next format
+ * import { convertRaTranslationToI18next } from 'ra-i18n-18next';
+ *
+ * const messages = convertRaTranslationToI18next("Hello %{name}!");
+ * // "Hello {{name}}!"
+ *
+ * @example Convert the english translations from ra-language-english to i18next format with custom interpolation wrappers
+ * import englishMessages from 'ra-language-english';
+ * import { convertRaTranslationToI18next } from 'ra-i18n-18next';
+ *
+ * const messages = convertRaTranslationToI18next("Hello %{name}!", {
+ * prefix: '#{',
+ * suffix: '}#',
+ * });
+ * // "Hello #{name}#!"
+ */
+export const convertRaTranslationToI18next = (
+ translationText: string,
+ { prefix = '{{', suffix = '}}' } = {}
+) => {
+ const result = translationText.replace(
+ /%\{([a-zA-Z0-9-_]*)\}/g,
+ (match, p1) => `${prefix}${p1}${suffix}`
+ );
+
+ return result;
+};
diff --git a/packages/ra-i18n-i18next/src/index.spec.tsx b/packages/ra-i18n-i18next/src/index.spec.tsx
new file mode 100644
index 00000000000..8b95e40faf6
--- /dev/null
+++ b/packages/ra-i18n-i18next/src/index.spec.tsx
@@ -0,0 +1,66 @@
+import * as React from 'react';
+import { fireEvent, render, screen } from '@testing-library/react';
+import {
+ Basic,
+ WithCustomTranslations,
+ WithCustomOptions,
+ WithLazyLoadedLanguages,
+} from './index.stories';
+
+describe('i18next i18nProvider', () => {
+ test('should work with no configuration except the messages', async () => {
+ render();
+
+ await screen.findByText('Comments');
+ await screen.findByText('Export');
+ expect(await screen.findAllByText('Posts')).toHaveLength(2);
+
+ // Check interpolation
+ await screen.findByText('1-1 of 1');
+ fireEvent.click(await screen.findByText('Lorem Ipsum'));
+ // Check singularization
+ await screen.findByText('Post #1');
+ });
+
+ test('should work with multiple languages', async () => {
+ render();
+
+ await screen.findByText('Export');
+
+ // Check interpolation
+ await screen.findByText('1-1 of 1');
+ fireEvent.click(
+ await screen.findByText('English', { selector: 'button' })
+ );
+ fireEvent.click(await screen.findByText('French'));
+ await screen.findByText('Exporter');
+ });
+
+ test('should work with custom translations', async () => {
+ render();
+
+ await screen.findByText('Comments');
+ await screen.findByText('Export');
+ expect(await screen.findAllByText('Blog posts')).toHaveLength(2);
+
+ // Check interpolation
+ await screen.findByText('1-1 of 1');
+ fireEvent.click(await screen.findByText('Lorem Ipsum'));
+ // Check singularization
+ await screen.findByText('Blog post #1');
+ });
+
+ test('should work with custom interpolation options', async () => {
+ render();
+
+ await screen.findByText('Comments');
+ await screen.findByText('Export');
+ expect(await screen.findAllByText('Posts')).toHaveLength(2);
+
+ // Check interpolation
+ await screen.findByText('1-1 of 1');
+ fireEvent.click(await screen.findByText('Lorem Ipsum'));
+ // Check singularization
+ await screen.findByText('Post #1');
+ });
+});
diff --git a/packages/ra-i18n-i18next/src/index.stories.tsx b/packages/ra-i18n-i18next/src/index.stories.tsx
new file mode 100644
index 00000000000..02c255afe7b
--- /dev/null
+++ b/packages/ra-i18n-i18next/src/index.stories.tsx
@@ -0,0 +1,175 @@
+import * as React from 'react';
+import { Admin, EditGuesser, ListGuesser, Resource } from 'react-admin';
+import i18n from 'i18next';
+import resourcesToBackend from 'i18next-resources-to-backend';
+import englishMessages from 'ra-language-english';
+import fakeRestDataProvider from 'ra-data-fakerest';
+import { MemoryRouter } from 'react-router-dom';
+import { useI18nextProvider } from './index';
+import { convertRaTranslationsToI18next } from './convertRaTranslationsToI18next';
+
+export default {
+ title: 'ra-i18n-i18next',
+};
+
+export const Basic = () => {
+ const i18nProvider = useI18nextProvider({
+ options: {
+ resources: {
+ en: {
+ translation: convertRaTranslationsToI18next(
+ englishMessages
+ ),
+ },
+ },
+ },
+ });
+
+ if (!i18nProvider) return null;
+
+ return (
+
+
+ }
+ edit={}
+ />
+ }
+ edit={}
+ />
+
+
+ );
+};
+
+export const WithLazyLoadedLanguages = () => {
+ const i18nextInstance = i18n.use(
+ resourcesToBackend(language => {
+ if (language === 'fr') {
+ return import(`./stories-fr`).then(({ default: messages }) =>
+ convertRaTranslationsToI18next(messages)
+ );
+ }
+ return import(`./stories-en`).then(({ default: messages }) =>
+ convertRaTranslationsToI18next(messages)
+ );
+ })
+ );
+
+ const i18nProvider = useI18nextProvider({
+ i18nextInstance,
+ availableLocales: [
+ { locale: 'en', name: 'English' },
+ { locale: 'fr', name: 'French' },
+ ],
+ });
+
+ if (!i18nProvider) return null;
+
+ return (
+
+
+ }
+ edit={}
+ />
+ }
+ edit={}
+ />
+
+
+ );
+};
+
+export const WithCustomTranslations = () => {
+ const i18nProvider = useI18nextProvider({
+ options: {
+ resources: {
+ en: {
+ translation: {
+ ...convertRaTranslationsToI18next(englishMessages),
+ resources: {
+ posts: {
+ name_one: 'Blog post',
+ name_other: 'Blog posts',
+ fields: {
+ title: 'Title',
+ },
+ },
+ },
+ },
+ },
+ },
+ },
+ });
+
+ if (!i18nProvider) return null;
+
+ return (
+
+
+ }
+ edit={}
+ />
+ }
+ edit={}
+ />
+
+
+ );
+};
+
+export const WithCustomOptions = () => {
+ const defaultMessages = convertRaTranslationsToI18next(englishMessages, {
+ prefix: '#{',
+ suffix: '}#',
+ });
+
+ const i18nProvider = useI18nextProvider({
+ options: {
+ interpolation: {
+ prefix: '#{',
+ suffix: '}#',
+ },
+ resources: {
+ en: {
+ translation: defaultMessages,
+ },
+ },
+ },
+ });
+
+ if (!i18nProvider) return null;
+
+ return (
+
+
+ }
+ edit={}
+ />
+ }
+ edit={}
+ />
+
+
+ );
+};
+
+const dataProvider = fakeRestDataProvider({
+ posts: [{ id: 1, title: 'Lorem Ipsum' }],
+ comments: [{ id: 1, body: 'Sic dolor amet...' }],
+});
diff --git a/packages/ra-i18n-i18next/src/index.ts b/packages/ra-i18n-i18next/src/index.ts
new file mode 100644
index 00000000000..1059f73135e
--- /dev/null
+++ b/packages/ra-i18n-i18next/src/index.ts
@@ -0,0 +1,2 @@
+export * from './useI18nextProvider';
+export * from './convertRaTranslationsToI18next';
diff --git a/packages/ra-i18n-i18next/src/stories-en.ts b/packages/ra-i18n-i18next/src/stories-en.ts
new file mode 100644
index 00000000000..9dcdb278742
--- /dev/null
+++ b/packages/ra-i18n-i18next/src/stories-en.ts
@@ -0,0 +1,24 @@
+import raMessages from 'ra-language-english';
+import { convertRaTranslationsToI18next } from './convertRaTranslationsToI18next';
+
+export default {
+ ...convertRaTranslationsToI18next(raMessages),
+ resources: {
+ posts: {
+ name_one: 'Post',
+ name_other: 'Posts',
+ fields: {
+ id: 'Id',
+ title: 'Title',
+ },
+ },
+ comments: {
+ name_one: 'Comment',
+ name_other: 'Comments',
+ fields: {
+ id: 'Id',
+ body: 'Message',
+ },
+ },
+ },
+};
diff --git a/packages/ra-i18n-i18next/src/stories-fr.ts b/packages/ra-i18n-i18next/src/stories-fr.ts
new file mode 100644
index 00000000000..8e577a8b747
--- /dev/null
+++ b/packages/ra-i18n-i18next/src/stories-fr.ts
@@ -0,0 +1,24 @@
+import raMessages from 'ra-language-french';
+import { convertRaTranslationsToI18next } from './convertRaTranslationsToI18next';
+
+export default {
+ ...convertRaTranslationsToI18next(raMessages),
+ resources: {
+ posts: {
+ name_one: 'Article',
+ name_other: 'Articles',
+ fields: {
+ id: 'Id',
+ title: 'Titre',
+ },
+ },
+ comments: {
+ name_one: 'Commentaire',
+ name_other: 'Commentaires',
+ fields: {
+ id: 'Id',
+ body: 'Message',
+ },
+ },
+ },
+};
diff --git a/packages/ra-i18n-i18next/src/useI18nextProvider.ts b/packages/ra-i18n-i18next/src/useI18nextProvider.ts
new file mode 100644
index 00000000000..64a507315e8
--- /dev/null
+++ b/packages/ra-i18n-i18next/src/useI18nextProvider.ts
@@ -0,0 +1,127 @@
+import { useEffect, useRef, useState } from 'react';
+import { createInstance, InitOptions, i18n as I18n, TFunction } from 'i18next';
+import { initReactI18next } from 'react-i18next';
+import { I18nProvider, Locale } from 'ra-core';
+
+/**
+ * A hook that returns an i18nProvider for react-admin applications, based on i18next.
+ * You can provide your own i18next instance but don't initialize it, the hook will do it for you with the options you may provide.
+ * Besides, this hook already adds the `initReactI18next` plugin to i18next.
+ *
+ * @example Basic usage
+ * import { Admin } from 'react-admin';
+ * import { useI18nextProvider } from 'ra-i18n-i18next';
+ *
+ * const App = () => {
+ * const i18nProvider = useI18nextProvider();
+ * if (!i18nProvider) return (Loading...
);
+ *
+ * return (
+ *
+ * ...
+ *
+ * );
+ * };
+ *
+ * @example With a custom i18next instance and options
+ * import { Admin } from 'react-admin';
+ * import { useI18nextProvider } from 'ra-i18n-i18next';
+ * import i18n from 'i18next';
+ * import Backend from 'i18next-http-backend';
+ * import LanguageDetector from 'i18next-browser-languagedetector';
+ *
+ * const App = () => {
+ * const i18nextInstance = i18n
+ * .use(Backend)
+ * .use(LanguageDetector);
+ *
+ * const i18nProvider = useI18nextProvider({
+ * i18nInstance: i18nextInstance,
+ * options: {
+ * fallbackLng: 'en',
+ * debug: true,
+ * interpolation: {
+ * escapeValue: false, // not needed for react!!
+ * },
+ * }
+ * });
+ *
+ * if (!i18nProvider) return (Loading...
);
+ *
+ * return (
+ *
+ * ...
+ *
+ * );
+ * };
+ */
+export const useI18nextProvider = ({
+ i18nextInstance = createInstance(),
+ options = {},
+ availableLocales = [{ locale: 'en', name: 'English' }],
+}: {
+ i18nextInstance?: I18n;
+ options?: InitOptions;
+ availableLocales?: Locale[];
+} = {}) => {
+ const [i18nProvider, setI18nProvider] = useState(null);
+ const initializationPromise = useRef>(null);
+
+ useEffect(() => {
+ if (initializationPromise.current) {
+ return;
+ }
+
+ initializationPromise.current = getI18nProvider(
+ i18nextInstance,
+ options,
+ availableLocales
+ ).then(provider => {
+ setI18nProvider(provider);
+ return provider;
+ });
+ }, [availableLocales, i18nextInstance, options]);
+
+ return i18nProvider;
+};
+
+export const getI18nProvider = async (
+ i18nextInstance: I18n,
+ options?: InitOptions,
+ availableLocales: Locale[] = [{ locale: 'en', name: 'English' }]
+): Promise => {
+ let translate: TFunction;
+
+ await i18nextInstance
+ .use(initReactI18next)
+ .init({
+ lng: 'en',
+ fallbackLng: 'en',
+ react: { useSuspense: false },
+ ...options,
+ })
+ .then(t => {
+ translate = t;
+ });
+
+ return {
+ translate: (key: string, options: any = {}) => {
+ const { _: defaultValue, smart_count: count, ...otherOptions } =
+ options || {};
+ return translate(key, {
+ defaultValue,
+ count,
+ ...otherOptions,
+ }).toString();
+ },
+ changeLocale: async (newLocale: string) => {
+ await i18nextInstance.loadLanguages(newLocale);
+ const t = await i18nextInstance.changeLanguage(newLocale);
+ translate = t;
+ },
+ getLocale: () => i18nextInstance.language,
+ getLocales: () => {
+ return availableLocales;
+ },
+ };
+};
diff --git a/packages/ra-i18n-i18next/tsconfig.json b/packages/ra-i18n-i18next/tsconfig.json
new file mode 100644
index 00000000000..a18665f388c
--- /dev/null
+++ b/packages/ra-i18n-i18next/tsconfig.json
@@ -0,0 +1,10 @@
+{
+ "extends": "../../tsconfig.json",
+ "compilerOptions": {
+ "outDir": "lib",
+ "rootDir": "src",
+ "allowJs": false
+ },
+ "exclude": ["**/*.spec.ts", "**/*.spec.tsx", "**/*.spec.js"],
+ "include": ["src"]
+}
diff --git a/yarn.lock b/yarn.lock
index 2fdc52a7b76..14ea3a9bff6 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -1816,6 +1816,15 @@ __metadata:
languageName: node
linkType: hard
+"@babel/runtime@npm:^7.21.5":
+ version: 7.23.1
+ resolution: "@babel/runtime@npm:7.23.1"
+ dependencies:
+ regenerator-runtime: ^0.14.0
+ checksum: e57ab1436d4845efe67c3f76d578508bb584173690ecfeac105bc4e09d64b2aa6a53c1e03bca3c97cc238e5390a804e5a4ded211e6350243b735905ca45a4822
+ languageName: node
+ linkType: hard
+
"@babel/template@npm:^7.16.7, @babel/template@npm:^7.22.5":
version: 7.22.5
resolution: "@babel/template@npm:7.22.5"
@@ -12832,6 +12841,15 @@ __metadata:
languageName: node
linkType: hard
+"html-parse-stringify@npm:^3.0.1":
+ version: 3.0.1
+ resolution: "html-parse-stringify@npm:3.0.1"
+ dependencies:
+ void-elements: 3.1.0
+ checksum: 159292753d48b84d216d61121054ae5a33466b3db5b446e2ffc093ac077a411a99ce6cbe0d18e55b87cf25fa3c5a86c4d8b130b9719ec9b66623259000c72c15
+ languageName: node
+ linkType: hard
+
"html-tags@npm:^3.1.0":
version: 3.3.1
resolution: "html-tags@npm:3.3.1"
@@ -13036,6 +13054,24 @@ __metadata:
languageName: node
linkType: hard
+"i18next-resources-to-backend@npm:^1.1.4":
+ version: 1.1.4
+ resolution: "i18next-resources-to-backend@npm:1.1.4"
+ dependencies:
+ "@babel/runtime": ^7.21.5
+ checksum: 221a22d08eccdd946c12c11de1910c70d8bf61b9834f17b72ddad24ea304264a12ea50953a740e5fa1f56d32a2290dcef6f7eb699fd30984f7e8676944e41aed
+ languageName: node
+ linkType: hard
+
+"i18next@npm:^23.5.1":
+ version: 23.5.1
+ resolution: "i18next@npm:23.5.1"
+ dependencies:
+ "@babel/runtime": ^7.22.5
+ checksum: af49c399a90505ae26c1a022d06c4a11c4adcde6524b31c315dcaa43443c85892adef6de934b2af737abbdd2ffa66449d2854f135af8691223a8bb4ffaf6e1af
+ languageName: node
+ linkType: hard
+
"iconv-lite@npm:0.4.24, iconv-lite@npm:^0.4.24":
version: 0.4.24
resolution: "iconv-lite@npm:0.4.24"
@@ -18361,6 +18397,20 @@ __metadata:
languageName: unknown
linkType: soft
+"ra-i18n-i18next@workspace:packages/ra-i18n-i18next":
+ version: 0.0.0-use.local
+ resolution: "ra-i18n-i18next@workspace:packages/ra-i18n-i18next"
+ dependencies:
+ cross-env: ^5.2.0
+ i18next: ^23.5.1
+ i18next-resources-to-backend: ^1.1.4
+ ra-core: ^4.13.4
+ react-i18next: ^13.2.2
+ rimraf: ^3.0.2
+ typescript: ^5.1.3
+ languageName: unknown
+ linkType: soft
+
"ra-i18n-polyglot@^4.12.0, ra-i18n-polyglot@^4.13.4, ra-i18n-polyglot@workspace:packages/ra-i18n-polyglot":
version: 0.0.0-use.local
resolution: "ra-i18n-polyglot@workspace:packages/ra-i18n-polyglot"
@@ -18852,6 +18902,24 @@ __metadata:
languageName: node
linkType: hard
+"react-i18next@npm:^13.2.2":
+ version: 13.2.2
+ resolution: "react-i18next@npm:13.2.2"
+ dependencies:
+ "@babel/runtime": ^7.22.5
+ html-parse-stringify: ^3.0.1
+ peerDependencies:
+ i18next: ">= 23.2.3"
+ react: ">= 16.8.0"
+ peerDependenciesMeta:
+ react-dom:
+ optional: true
+ react-native:
+ optional: true
+ checksum: 846e73130414989304c395c247cec8371ec25b5a0284588c913542741ce0cc33b4681d573980c64320138601d08a2ddec5042dc6f841e48a575e3f11ab83a368
+ languageName: node
+ linkType: hard
+
"react-inspector@npm:^6.0.0":
version: 6.0.1
resolution: "react-inspector@npm:6.0.1"
@@ -19426,6 +19494,13 @@ __metadata:
languageName: node
linkType: hard
+"regenerator-runtime@npm:^0.14.0":
+ version: 0.14.0
+ resolution: "regenerator-runtime@npm:0.14.0"
+ checksum: e25f062c1a183f81c99681691a342760e65c55e8d3a4d4fe347ebe72433b123754b942b70b622959894e11f8a9131dc549bd3c9a5234677db06a4af42add8d12
+ languageName: node
+ linkType: hard
+
"regenerator-transform@npm:^0.15.1":
version: 0.15.1
resolution: "regenerator-transform@npm:0.15.1"
@@ -22109,6 +22184,13 @@ __metadata:
languageName: node
linkType: hard
+"void-elements@npm:3.1.0":
+ version: 3.1.0
+ resolution: "void-elements@npm:3.1.0"
+ checksum: 0b8686f9f9aa44012e9bd5eabf287ae0cde409b9a2854c5a2335cb83920c957668ac5876e3f0d158dd424744ac411a7270e64128556b451ed3bec875ef18534d
+ languageName: node
+ linkType: hard
+
"w3c-keyname@npm:^2.2.0":
version: 2.2.4
resolution: "w3c-keyname@npm:2.2.4"