Skip to content

Latest commit

 

History

History
803 lines (681 loc) · 28.1 KB

04-i18n.md

File metadata and controls

803 lines (681 loc) · 28.1 KB

« previous | next »

4. Internationalization

Within this chapter we are going to build the base for internationalization.

Localization refers to the adaptation of a product, application or document content to meet the language, cultural and other requirements of a specific target market (a locale).

Internationalization is the design and development of a product, application or document content that enables easy localization for target audiences that vary in culture, region, or language.

https://www.w3.org/International/questions/qa-i18n.en, 2022-05-24

This definition makes it clear: Internationalization (i18n) is not only about translations, but also about localization like time or date formats. Let's keep that in mind.

We could handle i18n stuff with a fancy library like i18next, but this library is definitely an overkill for our plans in this tutorial. To improve our learning process, we are going to write a custom solution with service implementations, which easily can be replaced by any other solution in the market.

4.1 Create the i18n base

First, we need a global state which describes the language and the region and is available over the whole app. As a reference, the browser's window.navigator goes with RFC 5646: Tags for Identifying Languages (also known as BCP 47) for such information. So the current language and localization is written as a single string, in the following format [language id]-[COUNTRY CODE] (e.g. en-US or de-CH). Let's call this one "language code" and use this approach by creating our i18n package in the packages/core folder like so:

// src/packages/core/i18n/i18n.ts

import { createContext, useContext } from 'react';

export type LanguageCode = 'en-US' | 'en-GB' | 'de-CH';

function getLanguageId(languageCode: LanguageCode): string {
    const separator = '-';
    const languageCodeParts = languageCode.split(separator);
    languageCodeParts.pop();
    return languageCodeParts.join(separator);
}

export type I18n = {
    ampm: boolean;
    languageCode: LanguageCode;
};

export function createI18n(languageCode: LanguageCode = 'en-US'): I18n {
    return {
        ampm: getLanguageId(languageCode) === 'en',
        languageCode: languageCode,
    };
}

const context = createContext<null | I18n>(null);

export const I18nProvider = context.Provider;

export function useI18n(): I18n {
    const state = useContext(context);
    if (!state) {
        throw new Error('no i18n was provided');
    }
    return state;
}

Did you notice the ampm flag? As we learned above, i18n is not only about translations. So whenever we need to render a date in a user-friendly manner, we can call the useI18n() hook and use the ampm flag to decide whether the date should be shown in 12 or 24 hours format.

With the code we are going to write, it should also be no problem to implement an adapter for the i18next translator without refactoring the whole code base at that moment. So add the following files to your codebase:

// src/packages/core/i18n/translator.ts

import { createContext, useContext } from 'react';
import { useI18n } from './i18n';

export type TranslationPlaceholders = {
    [key: string]: string;
};

export type Translation = {
    id: string;
    placeholders?: TranslationPlaceholders;
};

export type Translator = {
    t: (translationId: string, placeholders?: TranslationPlaceholders) => string;
};

const translatorContext = createContext<null | Translator>(null);

export const TranslatorProvider = translatorContext.Provider;

export function useTranslator(): Translator {
    useI18n(); // make sure translations are rendered whenever the i18n state does change
    const translator = useContext(translatorContext);
    if (!translator) {
        throw new Error('No Translator was provided');
    }
    return translator;
}

and don't forget the export:

// src/packages/core/i18n/index.ts

export * from './i18n';
export * from './translator';

4.2 Translate our contents

Let's replace our existing static texts with our translator's t function. To achieve this let's replace the following file contents:

In src/components/page-layout/NavBarPage.tsx:

  • Add import { useTranslator } from '@packages/core/i18n'; in the top of the file
  • Use the translator by adding const { t } = useTranslator(); in the top of the <LoggedInUserMenu> component.
  • Replace My settings with {t('core.nav.mySettings')}
  • Replace Logout with {t('core.nav.logout')}
  • Use the translator by adding const { t } = useTranslator(); in the top of the <Nav> component.
  • Replace Login with {t('core.nav.login')}
  • Replace Sign up with {t('core.nav.signUp')}

4.3 Create a JSON file for the translations

Having the translations in the codebase is less error-prone than loading the translations from an external source. I suggest going with json files in your codebase which hold our translations. With this practise you are able to make sure that only these translations are in the codebase which are required for the current git branch. No worries about outdated or missing translations in an external system. No worries about missing or unused translations in an external system, which holds all translations for multiple minor or even multiple patch releases. It's better to keep things encapsulated whenever possible.

However, with JSON files you can store nested translations and store keys under the same property when they belong to the same UI element. I propose having one file per language code.

So let's add the enUS.json file in our components folder. You could have different translations for different apps. This is the reason we don't define our translation files in the packages folder:

// src/components/translations/enUS.json

{
    "core": {
        "currentUser": {
            "guestDisplayName": "Guest"
        },
        "nav": {
            "logout": "Logout",
            "login": "Login",
            "signUp": "Sign Up",
            "mySettings": "Settings"
        }
    }
}

Well, now that we have the i18nState and the Translator interface available, we should create our first translator implementation. By doing this, let's pay attention to the requirements mentioned before:

// src/packages/core/i18n/dictionaryTranslator.ts

import { TranslationPlaceholders, Translator } from './translator';

export type Dictionary = {
    [key: string]: string | Dictionary;
};

function findNestedProp(dictionary: Dictionary, translationId: string): null | string {
    const translationIdParts = translationId.split('.');
    if (translationIdParts.length === 1) {
        const key = translationIdParts[0];
        if (dictionary[key] === undefined) {
            return null;
        }
        const translationContent = dictionary[key];
        if (typeof translationContent === 'string') {
            return translationContent;
        }
        console.error(`Invalid translationId "${translationId}": Translation is not a string`);
        return null;
    }
    const key = translationIdParts[0];
    if (dictionary[key] === undefined) {
        return null;
    }
    const subDictionary = dictionary[key];
    if (typeof subDictionary !== 'object') {
        return null;
    }
    const subTranslationId = translationIdParts.slice(1).join('.');
    return findNestedProp(subDictionary, subTranslationId);
}

export class DictionaryTranslator implements Translator {
    private dictionary: Dictionary;

    constructor(dictionary: Dictionary) {
        this.dictionary = dictionary;
        this.t = this.t.bind(this);
    }

    setDictionary(dictionary: Dictionary) {
        this.dictionary = dictionary;
    }

    t(translationId: string, placeholders?: TranslationPlaceholders): string {
        let translation = findNestedProp(this.dictionary, translationId);
        if (translation === null) {
            return translationId;
        }
        if (!placeholders) {
            return translation;
        }
        Object.keys(placeholders).forEach((placeholderKey) => {
            const placeholderValue = placeholders[placeholderKey];
            if (translation === null) {
                throw new Error(`this case should have been handled by an early return beforehand`);
            }
            translation = translation.replaceAll('{{' + placeholderKey + '}}', placeholderValue);
        });
        return translation;
    }
}

Don't forget to export this class in the index.ts file as well.

With the setDictionary method, we are able to load the contents of a different language file into the DictionaryTranslator. This could happen e.g. after an asynchronous fetch of another language file. To keep it simple and stupid, we are going to import all the different language files directly in the ServiceProvider.

Add the following changes to the ServiceProvider:

// src/ServiceProvider.tsx

// Add these import statements
import { createI18n, DictionaryTranslator, I18n, I18nProvider, TranslatorProvider } from '@packages/core/i18n';
import enUS from '@components/translations/enUS.json';

// Add the following lines in the top of the ServiceProvider component:
const [i18nState] = useState<I18n>(createI18n('en-US'));
const translatorRef = useRef<DictionaryTranslator>(new DictionaryTranslator(enUS));

// Make the return statement look like so:
return (
    <MuiThemeProvider theme={theme}>
        <ScThemeProvider theme={theme}>
            <BrowserRouter>
                <ConfigProvider value={configRef.current}>
                    <I18nProvider value={i18nState}>
                        <TranslatorProvider value={translatorRef.current}>
                            <CurrentUserRepositoryProvider value={browserCurrentUserRepositoryRef.current}>
                                <CurrentUserProvider value={currentUserState}>{props.children}</CurrentUserProvider>
                            </CurrentUserRepositoryProvider>
                        </TranslatorProvider>
                    </I18nProvider>
                </ConfigProvider>
            </BrowserRouter>
        </ScThemeProvider>
    </MuiThemeProvider>
);

We shouldn't forget the <TestServiceProvider>. Make the file look like so:

// src/TestServiceProvider.tsx

import {
    anonymousAuthUser,
    AuthUser,
    CurrentUserProvider,
    CurrentUserRepository,
    CurrentUserRepositoryProvider,
} from '@packages/core/auth';
import React, { FC, PropsWithChildren, useRef, useState } from 'react';
import { Config, ConfigProvider } from '@packages/core/config';
import { MemoryRouter } from 'react-router-dom';
import {
    createI18n,
    I18n,
    I18nProvider,
    TranslationPlaceholders,
    Translator,
    TranslatorProvider,
} from '@packages/core/i18n';
import { ThemeProvider as MuiThemeProvider } from '@mui/material';
import { ThemeProvider as ScThemeProvider } from 'styled-components';
import { theme } from '@components/theme';

class StubCurrentUserRepository implements CurrentUserRepository {
    setCurrentUser(currentUser: AuthUser) {}
    init() {}
}

class StubTranslator implements Translator {
    t(translationId: string, _?: TranslationPlaceholders): string {
        return translationId;
    }
}

export const TestServiceProvider: FC<PropsWithChildren<{}>> = (props) => {
    const stubCurrentUserRepositoryRef = useRef(new StubCurrentUserRepository());
    const [i18nState] = useState<I18n>(createI18n('en-US'));
    const translatorRef = useRef<Translator>(new StubTranslator());
    const configRef = useRef<Config>({
        companyName: 'ACME',
    });
    return (
        <MuiThemeProvider theme={theme}>
            <ScThemeProvider theme={theme}>
                <MemoryRouter>
                    <ConfigProvider value={configRef.current}>
                        <I18nProvider value={i18nState}>
                            <TranslatorProvider value={translatorRef.current}>
                                <CurrentUserRepositoryProvider value={stubCurrentUserRepositoryRef.current}>
                                    <CurrentUserProvider value={anonymousAuthUser}>
                                        {props.children}
                                    </CurrentUserProvider>
                                </CurrentUserRepositoryProvider>
                            </TranslatorProvider>
                        </I18nProvider>
                    </ConfigProvider>
                </MemoryRouter>
            </ScThemeProvider>
        </MuiThemeProvider>
    );
};

Let's start our application and check it at localhost:3000. The navigation now should take its translated texts from enUS.json. Change some translations and refresh your browser to verify. So far so good. We have a pretty good preparation to implement another language and a language switch. Let's dig into it after we are able to use ReactNodes as translation placeholders.

4.4 Support ReactNode placeholders

Let's imagine that you have a translation "Hi {{username}}!" at the IndexPage. You've implemented the translations like we have done it before, using {t('path.to.translation', { username: 'Linus' })}. Everything looks fine, you feel good about it!

Days go by and suddenly your boss asks you to highlight the username with bold letters! Easy as that: We just need to wrap the username with the <strong> tag. As you start to implement it, you realize: The current solution only supports string placeholders 😨 !!

So let's write a React component to solve this problem:

// src/packages/core/i18n/T.tsx

import { FC, Fragment, ReactNode } from 'react';
import { Translation, useTranslator } from './translator';

type Placeholders = {
    [key: string]: string | ReactNode;
};

type TextWithPlaceholdersProps = {
    text: string;
    placeholders: Placeholders;
};

const TextWithPlaceholders: FC<TextWithPlaceholdersProps> = (props) => {
    const placeholderKeys = Object.keys(props.placeholders);
    if (!placeholderKeys.length) {
        return <>{props.text}</>;
    }
    const placeholderKey = placeholderKeys[0];
    const placeholderValue = props.placeholders[placeholderKey];
    let nextPlaceholders: Placeholders = { ...props.placeholders };
    delete nextPlaceholders[placeholderKey];
    const textParts = props.text.split(`{{${placeholderKey}}}`);
    const textPartsWithPlaceholders: ReactNode[] = textParts.map((textPart) => (
        <TextWithPlaceholders text={textPart} placeholders={nextPlaceholders} />
    ));
    return (
        <>
            {textPartsWithPlaceholders.reduce((prev, curr, index) => {
                return [
                    <Fragment key={'prev' + index}>{prev}</Fragment>,
                    <Fragment key={'placeholder' + index}>{placeholderValue}</Fragment>,
                    <Fragment key={'curr' + index}>{curr}</Fragment>,
                ];
            })}
        </>
    );
};

export type TProps = Omit<Translation, 'placeholders'> & {
    placeholders?: Placeholders;
};

export const T: FC<TProps> = (props) => {
    const { t } = useTranslator();
    const text = t(props.id);
    if (!props.placeholders) {
        return <>{text}</>;
    }
    return <TextWithPlaceholders text={text} placeholders={props.placeholders} />;
};

Also add this one to the index.ts file export. Before we check the index page, we should add the greeting translation. Make sure you add the following section without overwriting the existing content of the file:

// src/components/translations/enUS.json

"pages": {
    "indexPage": {
        "greeting": "Hi {{username}}!"
    }
}

Now let's use the <T> component in our IndexPage. The file should look like so:

// src/pages/IndexPage.tsx

import { FC } from 'react';
import { NavBarPage } from '@components/page-layout';
import { T, useTranslator } from '@packages/core/i18n';
import { useCurrentUser } from '@packages/core/auth';

export const IndexPage: FC = () => {
    const { t } = useTranslator();
    const currentUser = useCurrentUser();
    const username =
        currentUser.type === 'authenticated' ? currentUser.data.username : t('core.currentUser.guestDisplayName');
    return (
        <NavBarPage title="Home">
            <T id="pages.indexPage.greeting" placeholders={{ username: <strong>{username}</strong> }} />
        </NavBarPage>
    );
};

Cool! Let's check your browser at localhost:3000.

💡 If you need to change state or adapt props on runtime, make sure you achieve this by pure functions. Otherwise your code can lead to unexpected behaviour, which can be really hard to debug.

As of this, keep in mind: Avoid cloning objects in the render logic because this leads to performance issues, especially in the case of big objects like ReactNodes.

💾 branch 04-i18n-1

4.5 Clean up the body's css with MUI instead

Before we go ahead, let's fine tune our CSS a bit. A good practise to get a proper css baseline in combination with MUI, is to use its <CssBaseline> component. So let's replace the styled-components' GlobalStyles with the MUI's <Baseline> component:

// src/components/page-layout/BlankPage.tsx

import React, { FC, ReactNode, useEffect } from 'react';
import { useConfig } from '@packages/core/config';
import { CssBaseline } from '@mui/material';

export type BlankPageProps = {
    title: string;
    children?: ReactNode;
};

export const BlankPage: FC<BlankPageProps> = (props) => {
    const { companyName } = useConfig();
    const titleParts: string[] = [];
    if (props.title) {
        titleParts.push(props.title);
    }
    if (companyName) {
        titleParts.push(companyName);
    }
    useEffect(() => {
        if (document) {
            document.title = titleParts.join(' :: ');
        }
    });
    return (
        <>
            <CssBaseline />
            {props.children}
        </>
    );
};

4.6 Time to add another language

If we had no other language this chapter would definitely be obsolete, and we could have left the static texts in our components. So let's add another translation file for the german language in Switzerland:

// src/components/translations/deCH.json

{
  "core": {
    "currentUser": {
      "guestDisplayName": "Gast"
    },
    "nav": {
      "logout": "Ausloggen",
      "login": "Anmelden",
      "signUp": "Registrieren",
      "mySettings": "Einstellungen"
    },
    "languages": {
      "enUS": "English (US)",
      "deCH": "Deutsch (CH)"
    }
  },
  "pages": {
    "indexPage": {
      "greeting": "Hallo {{username}}!"
    }
  }
}

Also add the core.languages section to the enUS.json file. We are going to use these translations later for our language switch.

💡 Never translate the displayed languages of the UI element which enables the user to choose another language. If the user does not understand the current language, he or she might have a problem to choose the right one.

4.7 Define an i18n manager service

To be able to switch the current language, we need a service for that. On the other hand, it would be really nice to detect the current users default language when the app initializes. So let's create the interface and the React context for this service.

// src/packages/core/i18n/i18nManager.ts

import { createContext, useContext } from 'react';
import { LanguageCode } from './i18n';

export type I18nManager = {
    setLanguage: (languageCode: LanguageCode) => void;
    init: () => void;
};

const i18nManagerContext = createContext<null | I18nManager>(null);

export const I18nManagerProvider = i18nManagerContext.Provider;

export function useI18nManager(): I18nManager {
    const repo = useContext(i18nManagerContext);
    if (!repo) {
        throw new Error('No I18nManager was provided');
    }
    return repo;
}

A browser implementation for this service could look like that:

// src/packages/core/i18n/browserI18nManager.ts

import { createI18n, I18n, LanguageCode } from '@packages/core/i18n/i18n';
import { I18nManager } from '@packages/core/i18n/i18nManager';

type I18nStateSetter = (i18nState: I18n) => void;

export class BrowserI18nManager implements I18nManager {
    private readonly setI18nState: I18nStateSetter;

    constructor(setI18nState: I18nStateSetter) {
        this.setI18nState = setI18nState;
        this.init = this.init.bind(this);
        this.setLanguage = this.setLanguage.bind(this);
    }

    setLanguage(languageCode: LanguageCode) {
        localStorage.setItem('currentLanguageCode', languageCode);
        this.setI18nState(createI18n(languageCode));
    }

    init() {
        const storedLanguageCode = localStorage.getItem('currentLanguageCode') as LanguageCode | null;
        if (storedLanguageCode) {
            this.setLanguage(storedLanguageCode);
            return;
        }
        // @ts-ignore
        const language = window.navigator.userLanguage || window.navigator.language;
        if (language === 'de-CH') {
            this.setLanguage(language);
            return;
        }
        this.setLanguage('en-US');
    }
}

By using the window.navigator.userLanguage it is possible to detect the users default language. By saving the current language code in the browsers local storage, we ensure that the chosen language of the user is also taken after a page refresh. With the call of setI18nState the whole app is re-rendered after a language switch.

As usual, don't forget to export these files in the packages/core/i18n/index.ts.

4.8 Provide and init the i18n manager service

To be able to switch the language between de-CH and en-US, we need to provide the i18n manager service we've created before. So let's add some required imports in the ServiceProvider.tsx:

// src/ServiceProvider.tsx

import {
    BrowserI18nManager,
    createI18n,
    DictionaryTranslator,
    I18n,
    I18nProvider,
    TranslatorProvider,
    I18nManagerProvider,
} from '@packages/core/i18n';
import deCH from '@components/translations/deCH.json';

To be able to fulfill the requirements of the BrowserI18nManager, we need to provide the setI18nState function. This one can be accessed by defining the second parameter in the return array of the useState<I18n> hook like so: const [i18nState, setI18nState] = useState<I18n>(createI18n('en-US'));.

Next, let's extend our <ServiceProvider> by adding the i18n manager reference in the top of the component.

// src/ServiceProvider.tsx

const i18nManagerRef = useRef<BrowserI18nManager>(
    new BrowserI18nManager((nextI18nState) => {
        switch (nextI18nState.languageCode) {
            case 'de-CH':
                translatorRef.current.setDictionary(deCH);
                break;
            default:
                translatorRef.current.setDictionary(enUS);
        }
        setI18nState(nextI18nState);
    })
);

It is up to you to define how the logic should look like when to choose which translation file. According to your requirements it could make more sense to choose an en.json file, no matter which country code is present in the nextI18nState.languageCode.

In the next step we should provide the BrowserI18nManager service with the <I18nManagerProvider> component in the <ServiceProvider>.

If you face some problems doing this, just have a look at the changes in the ServiceProvider.tsx.

Next let's add the following changes to our <TestServiceProvider> component:

// src/TestServiceProvider.tsx

// Add the following imports:
import {
    // let the existing import objects be there...
    I18nManager,
    LanguageCode,
    I18nManagerProvider,
} from '@packages/core/i18n';

// Add the following stub:
class StubI18nManager implements I18nManager {
    setLanguage(_: LanguageCode) {}
    init() {}
}

// Add a new i18nManagerRef to the top of the TestServiceProvider component:
const i18nManagerRef = useRef<I18nManager>(new StubI18nManager());

// Make the return statement look like so:
return (
    <MuiThemeProvider theme={theme}>
        <ScThemeProvider theme={theme}>
            <MemoryRouter>
                <ConfigProvider value={configRef.current}>
                    <I18nProvider value={i18nState}>
                        <I18nManagerProvider value={i18nManagerRef.current}>
                            <TranslatorProvider value={translatorRef.current}>
                                <CurrentUserRepositoryProvider value={stubCurrentUserRepositoryRef.current}>
                                    <CurrentUserProvider value={anonymousAuthUser}>
                                        {props.children}
                                    </CurrentUserProvider>
                                </CurrentUserRepositoryProvider>
                            </TranslatorProvider>
                        </I18nManagerProvider>
                    </I18nProvider>
                </ConfigProvider>
            </MemoryRouter>
        </ScThemeProvider>
    </MuiThemeProvider>
);

Now that the I18nManager is available for every child component, we can initialize the current language in the <App> component:

// src/App.tsx

// Add following import statement:
import { useI18nManager } from '@packages/core/i18n';

// Add following lines to the <App> component, before the return statement:
const i18nManager = useI18nManager();
useEffect(() => {
    i18nManager.init();
}, [i18nManager]);

This one detects the current user's language: Either the chosen one from the browser's local storage or the browser's default language. According to the logic in the ServiceProvider, the appropriate translations are then set in the DictionaryTranslator.

4.9 Implementing the language switch

I think the footer of a website is a good place to implement the component for a language switch.

So let's extend our <NavBar> component with it like so:

// src/components/page-layouts/NavBarPage.tsx

// Add following import statements:
import { LanguageCode, useI18n, useI18nManager, useTranslator } from '@packages/core/i18n';
import styled from 'styled-components';

// Define additional components for the footer
const StyledFooter = styled.footer`
    display: flex;
    justify-content: space-around;
    margin-top: 60px;
`;

const FooterLink = styled(FunctionalLink)`
    color: #ddd;
    text-decoration: none;
    margin: 0 5px;
    font-family: inherit;
    font-size: 12px;
    &.active {
        color: #bbb;
    }
`;

const Footer: FC = () => {
    const { t } = useTranslator();
    const { setLanguage } = useI18nManager();
    const { languageCode } = useI18n();
    function getLinkClassName(langCode: LanguageCode): undefined | string {
        if (languageCode === langCode) {
            return 'active';
        }
        return undefined;
    }
    return (
        <StyledFooter>
            <div>
                <FooterLink onClick={() => setLanguage('de-CH')} className={getLinkClassName('de-CH')}>
                    {t('core.languages.deCH')}
                </FooterLink>
                <FooterLink onClick={() => setLanguage('en-US')} className={getLinkClassName('en-US')}>
                    {t('core.languages.enUS')}
                </FooterLink>
            </div>
        </StyledFooter>
    );
};

// Extend the NavBarPage component with the Footer component, that is looks like so:
export const NavBarPage: FC<NavBarPageProps> = (props) => {
    return (
        <BlankPage title={props.title}>
            <Nav />
            <Container>
                {props.children}
                <Footer />
            </Container>
        </BlankPage>
    );
};

Open the app at localhost:3000 and check if the translations do change when you click on the links in the footer. I hope you got it working as well!

Uff.. finally, I think we are done with this chapter 😎

💾 branch 04-i18n-2

« previous | next »