Skip to main content

Handle Internationalization (i18n)

A localized app adapts to a specific culture by displaying text in the correct language and formatting data to expectations. With western languages, it often boils down to providing the app with the translated text and a mechanism to choose the correct translation. Things like currency, measurement units, dates, etc., are formatted on the fly.

The Rhapsody platform, build pipeline, dev toolchain, and CI are being updated to provide a solid foundation for localization. Once the main work here is complete, we will provide guidelines on how to approach internationalization in other places.

Glossaryโ€‹

ICU - International Components for Unicode

Internationalization (i18n) - making software capable of supporting multiple locales

Localization (l10n) - making an internationalized software adapt to a specific locale

Message - a string that supports a special templating syntax for localization

Locale - a string consisting of a set of parameters that represent language, region, and preferences. en-US or en-UK.UTF-8

๐Ÿ’ก Although internationalization (I18n) and localization (l10n) mean different things, they are used interchangeably here.

How does this affect us?โ€‹

The most important thing โ€” developers are responsible for making user-facing text go through an i18n library.

The library, or rather a set of packages, of our choosing is FormatJS.

We use three packages to cover our needs:

  • @formatjs/intl - The core FormatJS library that provides functions to take ICU strings and translate them.
  • react-intl - React bindings that make using @formatjs/intl in components easier
  • @rhapsody/localization - Rhapsody platform package to interface with @formatjs/intl

Basic Usageโ€‹

JavaScript / TypeScriptโ€‹

The intl object provides methods such as formatMessage to localize data. Usually, this object is provided by @formatjs/intl, but the @rhapsody/localization platform package provides a pre-configured instance of intl. Always use the intl export from @rhapsody/localization.

import { intl } from '@rhapsody/localization'

const eventsMessage = intl.formatMessage({
defaultMessage: 'Future events to this domain will be recorded',
description:
'Message displayed when the system tells the user that future events to this domain will be recorded',
})

๐Ÿ’ก intl has methods to format dates, numbers, lists, and more. Format.JS intl docs.

Reactโ€‹

In addition to the preconfigured intl from @rhapsody/localization, which can be used directly, there are also React bindings. They can be imported from react-intl package.

Importing intl directly from @rhapsody/localizationโ€‹

import { intl } from '@rhapsody/localization';

const PostDate = ({ date }) => {
return (
<span title={intl.formatDate(date)}>
{intl.formatMessage({ defaultMessage: 'Yesterday', description: 'Date text' })}
</span>
)
});

Using useIntl hook from react-intlโ€‹

intl can be obtained from a hook, given that we have I18nProvider somewhere above the component tree.

import { render } from 'react-dom'
import { I18nProvider } from '@rhapsody/localization'
import { FormattedRelative, useIntl } from 'react-intl';

const PostDate = ({ date }) => {
const intl = useIntl()
return (
<span title={intl.formatDate(date)}>
<FormattedRelativeTime value={date} unit="day" />
</span>
)
});

render(
<I18nProvider>
<Component />
</I18nProvider>,
document.getElementById('#mount')
)

Using FormattedMessage component from react-intlโ€‹

FormattedMessage is another way to handle translations in React. Why components?

import { render } from 'react-dom'
import { FormattedMessage } from 'react-intl'
import { I18nProvider } from '@rhapsody/localization'

function Component() {
return (
<FormattedMessage
defaultMessage="Getting Started with Salesloft"
description="Onboarding title"
/>
)
}

render(
<I18nProvider>
<Component />
</I18nProvider>,
document.getElementById('#mount')
)

NOTE: the above example assumes we have added the I18nProvider component provided by @rhapsody/localization higher up in the React component tree.

๐Ÿ’ก there are more components to format dates, numbers, lists, etc. Format.JS React bindings docs.

Angularโ€‹

Import intl instance normally and use it as in regular JS.

import { intl } from '@rhapsody/localization'

angular.module('companies', ['shared']).config(function ($stateProvider) {
$stateProvider.state('companies', {
abstract: true,
url: '/company',
templateUrl: 'abstract.html',
resolve: {
$title: function () {
return intl.formatMessage({
defaultMessage: 'Accounts',
description: 'Page title of /company',
})
},
},
})
})

โŒ๏ธ Do not use intl in Angular templates.

example of code that is not supported
<li ng-click="ctrl.handleBulkEnable(true)">
<a><span>{{ intl.formatMessage({ defaultMessage: 'Enable' }) }}</span></a>
</li>

Advanced Examplesโ€‹

Dynamic Stringsโ€‹

To make localized text dynamic, we must use templating capabilities of ICU messages:

import { intl } from '@rhapsody/localization'

const greeting = intl.formatMessage(
{
defaultMessage: 'Hello, {name}!',
description: 'Greeting to welcome the user to the app',
},
{
name: 'Lisa',
}
)

Rich Text Formattingโ€‹

Pseudo tags can be used in defaultMessage in combination with formatting functions.

import { intl } from '@rhapsody/localization'

const greeting = intl.formatMessage(
{
defaultMessage: 'Hello, <bold>{name}</bold>!',
description: 'Greeting to welcome the user to the app',
},
{
name: 'Eric',
bold: (str) => <b>{str}</b>,
}
)

Conditionally render parts of textโ€‹

In this example, we make use of the select and plural ICU argments.

import React, { useState } from 'react'
import { FormattedMessage } from 'react-intl'

export const HiddenFields = ({ count, isExpanded }) => {
return (
<FormattedMessage
defaultMessage="{isExpanded, select, true {Hide} other {View}} {count} Blank {count, plural, one {Field} other {Fields}}"
values={{ isExpanded: String(isExpanded), count }}
description="A button that opens a list of extra fields"
/>
)
}

select is equivalent to switch in JS. We can make it work with a boolean value by converting the value to a string first. other in select is the same as the default clause in switch, but other must alway be present.

plural is similar to select but lets us match numbers to select a proper plural category. For example, there's one/two in English but one/few/many/other in Ukrainian.

More guides and API reference here https://formatjs.io/

Externalizing Stringsโ€‹

When externalizing strings, the following standards should be adhered to:

  • Translations MAY be colocated in the same file in which they are used if there are only a few ("a few" is left to the discretion of the individual developer)
  • Translations MAY be separated into a different file from where they are used. In doing so, one must adhere the following conventions:
    • Translation files MUST be named <adjacentfile>.messages.(js|ts) if scoped to a single file
    • Translation files MUST be named messages.(js|ts) if scoped to the current directory
    • Translation files scoped to the current directory (messages.(js|ts)) MAY include messages for code in child directories
    • Translation files MUST NEVER be placed in child directories, e.g. constants, utils, messages
    • Translation files MUST export a single messages named export that is an object of translated messages

Pseudolocalizationโ€‹

Pseudolocalization helps us see how a product's UI will look after translation, without creating a real translation.

We currently support two types of it:

  • en-XA: replacing text with accented characters (Cadence -> แธˆรขแธ‹รจล„ฤ‡รจ)
  • xx-LS: appending a bunch of "S" characters (Cadence -> CadenceSSSSSSSSSSSSSSSSSSSSSSSSS)

Generating dataโ€‹

Pseudolocalization data is static and must be updated semi-manually after localized code changes. It's not different from the regular translation files, so we store them together in the lang/ directory.

  1. run pnpm intl and give it a few moments so it can:
    • scan the codebase
    • extract necessary strings
    • compile our default (en-US.json) and fake messages (en-XA.json, xx-LS.json)
  2. verify changes in lang/
  3. check in and commit it with the rest of your code

Switching browser's localeโ€‹

  1. Open Chrome devtools
  2. Click "โ‹ฎ" menu within devtools โ†’ More tools โ†’ Sensors
  3. In "Location" select "Other..."
  4. Type the pseudolocale code (en-XA, xx-LS)

โš ๏ธ To avoid typing locale every time you can save it as a custom location: "Manage" โ†’ "Add Location..."

โš ๏ธ "Sensors" tab can be moved to the main part of devtools for easier access

Requirementsโ€‹

  1. defaultMessage must be a static string literal. To make it dynamic we have to use templating capabilities of ICU format.
// โŒ wrong
const text = intl.formatMessage(`Hello ${user.name}`)
const rendered = <FormmattedMessage defaultMessage={`Hello ${user.name}!`} />

// โœ… correct
const text = intl.formatMessage('Hello {name}!', { name: user.name })
const rendered = (
<FormmattedMessage
defaultMessage="Hello {name}!"
values={{ name: user.name }}
/>
)
  1. defaultMessage and description must not come from a variable.
// โŒ wrong - will cause errors
const message = 'My test message'

const invalid = intl.formatMessage({
defaultMessage: message,
description: 'A message for testing localization.',
})

// โœ… correct
const valid = intl.formatMessage({
defaultMessage: 'My test message',
description: 'A message for testing localization.',
})

What should we know?โ€‹

Localeโ€‹

A locale is a set of parameters that defines the user's language, region, and any special variant preferences. In its simplest form it's just a string that includes a language tag (e.g. en, uk, es), often in combination with a region after "-" (e.g. en-US, uk-UA, es-MX). In browsers the locale is stored in window.navigator.language, which inherits its value from the OS.

https://en.wikipedia.org/wiki/Locale_(computer_software)

https://developer.mozilla.org/en-US/docs/Web/API/Navigator/language

https://formatjs.io/docs/core-concepts/basic-internationalization-principles

ICUโ€‹

The ICU is an open-source project for Unicode support and software internationalization. Initially written for C/C++ and Java in 1999 it has since been widely spread across many languages and platforms.

While we don't need to understand the full scope of the ICU libraries, we should be comfortable when working with ICU messages and know their formatting capabilities.

ICU Messages are user-visible strings, often with variable elements like names, numbers, and dates. Message strings are typically translated into the different languages of a UI, and translators move around the variable elements according to the grammar of the target language. (ICU documentation)

This is the reason why text is often referred to as "message" in internationalization libraries (e.g. defaultMessage).

Further reading: