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"