Beginning JavaScript I18n with i18next and Moment.js

When it comes to I18n on the browser, we have several concerns when building a new JavaScript app. If we're working agile, we want to hit the ground running quickly, so our initial to-dos will be groundwork: locale support and determination, loading language files, and output in our HTML markup. We can use i18next and Moment.js as underlying frameworks for our custom JavaScript i18n / l10n library.

Let’s assume we’re building a new magazine web app for web developers, and we’re past initial prototyping phases. We’re ready to work on our alpha release. One of our core market differentiators is that we will provide quality content in Arabic, English, and French. So, we’ll need a good foundation for our browser and JavaScript i18n.

Our CEO is anxious to go live and start gathering real user feedback quickly because she believes in all this lean startup stuff. Our project manager has made sure mockups for this agile sprint are ready and that our designer has exported everything for the dev team.

The team is working with React for a view layer. However, our development lead wants us to write a framework-agnostic i18n / l10n library in ECMAScript 6. Our library should work with Angular, React, Vue,  you name it.

I18next

Ok, we’ve got our work cut out for us. To hit the ground running during this sprint, we’ll want a good JavaScript i18n third-party library as a layer underneath our own i18n API. i18next looks like a good choice: it’s feature-full and seems quite reliable.

Moment.js

I18next doesn’t cover date formatting. Instead, we’ll wire up i18next with the popular Moment.js library. Moment has good i18n and l10n date and time support built right into it.

Feature Laundry List

This isn’t our first rodeo, and we know any useable i18n library should provide the following:

  • Supported locales and validation
  • Locale output (for say, a language switcher)
  • Fallback to default locale (when translations are missing)
  • Loading translation files
  • Getting and setting the active locale
  • Determining the active locale from the user request
  • Getting the active locale’s directionality (right-to-left or left-to-right)
  • Translation retrieval by key e.g. t('author_name')
    • Simple plural handling e.g. t('article_count', {count: articles.length})
    • Multi-variant plural handling (for Arabic: few, many, etc.)
  • Date and time formatting and output

Luckily for us, i18next and Moment handle many of those things out of the box. Our basic architecture will look like this:
Architecture Diagram

Locale Support

Alright, scaffolding time. We can start on a little i18n library to serve as a foundation for our i18n and l10n work. Even though we will use i18next under the hood, we don’t want to tightly couple our i18n API to a third-party library. So we’ll build a little adapter around i18next to free us up for potential library swapping in the future.

First off, we were told that our magazine will serve Arabic, English, and French readers. Let’s get that configured, shall we?

/src/config/i18n.js

This can get us started on the beginnings for our i18n service library.

/src/services/i18n/lang.js

A simple singleton with a supported property and a couple of helpful methods for checking support and locale output can get us started.

A Language Switcher Component

We spike out a language switcher to make sure our feet are on the ground and we have some feature testing in place.

/src/components/LanguageSwitcher.js

The above will output a ul > li > a structure with a link for each supported locale looking like <a href="/?locale=ar">عربي</a>.

We’ll assume that we’ll determine the locale via a query string parameter called locale. We’ll visit locale determination again soon, but for now we’ve got basic locale support in place.

Note: If you’re not familiar with the above code, don’t worry. It’s a React component that we’re using to demonstrate output. However, our focus remains the foundation for our JavaScript i18n.

The Fallback Locale

We’ll want a default locale to use when we can’t find translations for the requested locale.

/src/config/i18n.js (excerpt)

/src/services/lang.js (excerpt)

Initializing and Loading Translation Files

Translation URL Schema

Let’s assume that by default our translation files will be at /translations/en.json.  To foster convention over configuration, let’s provide this as the default configuration for our translation files’ URL schema.

/src/config/i18n.js (excerpt)

The {locale} placeholder will be replaced with the active locale’s code, e.g. 'fr'.

Ok, it’s about time to bring in i18next for some a bit of heavy lifting. Let’s look at how we want our API to work first.

/src/App.js (excerpt)

Note: setState() updates our view model.

Or with optional overrides:

/src/App.js (excerpt)

We can override automatic locale determination by forcing a locale via the locale option. Similarly, we can override the default translation file url schema. During development, we can also set our debug  flag to true to get helpful debug messages in the console as we develop.

Ok, this API looks like a good start. Now, let’s get it working. Our first step is initializing i18next.

/src/services/i18n/lang.js (excerpt)

i18next has its own init function that accepts an options object and a callback. If the library throws during initialization, it will pass a truthy value as the err argument to our callback.

We wrap a convenience function around i18next’s initializer, passing through an options object and providing a common onSuccess, onError callback parameter pattern. To hide this implementation, we encapsulate the initI18n function outside of the lang object.

Let’s use this function to write our own library’s initializer method.

We first grab the locale parameter and make sure that we support it. Then we setup some common i18next options, including the helpful debug option, which I18next helpfully provides for us.

i18next accepts a resources options with JSON key / value translations: so we simply load our current locale’s translation file and feed its JSON to i18next. Any errors are passed onto the caller to handle, whether they be from loading the file or initializing i18next.

Note: i18next provides its own translation file loading capabilities. However, our team already has an HTTP library in place, so we’ll utilize that to keep things DRY and to lower our JavaScript asset sizes.

Now we can add our translation files.

/public/translations/en.json

We can have a similar file for every locale we support. By default, i18next will expect our keys to be within namespaces. The default namespace is translation. We’ll support this namespace in our own library, as it self-documents and allows for greater flexibility in the future.

Getting and Setting the Active Locale

We need the notion of an active or current locale in our library. Let’s write a little method to handle that.

/src/services/i18n/lang.js (excerpt)

Before we activate a given locale, we first make sure that we support it. We defer the caching of the active locale to i18next, and make sure we handle fallback.

Note: We’re not yet handling setting the active locale after initialization. This case is uncommon enough that it can be deferred to a future sprint (and article).

Now, let’s get this baby working for us by refactoring our initializer.

/src/services/i18n/lang.js (excerpt)

Instead of having our fallback logic in our initializer, we now run it whenever the user sets the active(locale). This cleans up our initializer as well.

Determining the Active Locale from the User Request

Remember how we envisioned locales in our URLs? When we wrote our language switcher, they looked something like /foo/bar?locale=fr. Let’s wire that up.

/src/config/i18n.js (excerpt)

A simple getLocaleFromUserRequest method can be added to our library.

/src/services/i18n/lang.js (excerpt)

We use the URLSearchParams object, built into modern browsers, to retrieve the locale query string parameter.

And another quick refactor to round us out:

/src/services/i18n/lang.js (excerpt)

If the developer doesn’t provide a locale explicitly on initialization, a simple suffixing of our app routes will now set the active locale. For example, /categories/front-end?locale=fr sets our active locale to French.

At this point we get a visit from our CEO, who’s wondering what we’ve been doing the last couple of days. We tell her we’ve just built a lot of our scaffolding that will allow multiple languages and locales to be supported on our site. She asks us to show her something and we present a blank page with a language switcher. “We’re doomed,” she sighs. 🙄. Let’s get to our UI.

Translation Retrieval by Key

To show translations we need a translation function that accepts a key in a language file and returns its value. Let’s add that function to our lang library and export it.

/src/services/i18n/lang.js (excerpt)

i18next is doing the work for us here. We simply pass the key and any options to the underlying i18next.t() function. The count option is for plurals. We’ll get to those a bit later.

Now we can use our t() function to output translated copy in our view components.

/src/App.js (excerpt)

The above will output the app_name translation in the active locale, or fallback. It’s really that simple.

Directionality

Our magazine will have Arabic content, and that means we need to handle right-to-left layouts and text directionality. Again, i18next has got our backs.

/src/services/i18n/lang.js (excerpt)

We can update the document’s layout and flow direction early in our app’s lifecycle: our root component is a good place to put this logic for now.

/src/App.js (excerpt)

window.document.dir = lang.dir is all it takes to flip the whole page around. Let’s move on.

Simple Plurals

i18next.t() handles plurals without much intervention. In fact, by deferring translation value resolution to i18next,  our t() function already has plural support built-in.

/src/services/i18n/lang.js (excerpt)

i18next accepts a convention for simple, two-variant plurals in our translation file keys.

/public/translations/en.js (excerpt)

Notice the _plural suffix above. Key foo and foo_plural are respectively considered as the singular and plural of the same phrase by i18next.

Note: i18next uses a {{placeholder}} convention for dynamic values in our translations.

We can now use our good ol’ t() function in our views to get correct pluralization.

Done and dusted.

Multi-variant Plurals

Arabic is a tricky language when it comes to localizing plurals. The article_count phrase above will need special treatment in Arabic.

  • 0 articles → 0 مقالات
  • 1 article → مقال 1
  • 2 articles → مقالان
  • 3-10 articles → 7 مقالات
  • 11+ articles → 200 مقال

Yikes 😟! No worries. Once more, i18next has us covered.

/public/translations/ar.json (excerpt)

i18next has multiple plural support out of the box, and uses a simple index convention to retrieve the right translation. Again, we’re deferring our translation value resolution to i18next.t(), so we get this pluralization support for free. We can now use the same translation key in our views, and handle locale nuances in each locale’s translation files.

Localicious! Alright, let’s get to date and time.

Dates

At time of writing, i18next doesn’t handle date and time formatting out of the box. It does, however, allow us to provide our own formatting via an interpolator. We can create a simple date format adapter and wrap it around the Moment library. i18next can then accept our formatter as an interpolator. Once we’ve got all that connected, we can use date formatting in our translation files.

/public/translations/ar.json (exerpt)

And in our views:

Let’s get that working.

A Date Format Adapter

i18next accepts a formatting function with a format(value, format, locale) signature that returns a string. We’ll be using Moment.js under the hood, so we’ll need to handle its own locale loading. To keep things organized and easy to reason about, it’s probably best to build a separate module for date formatting.

/src/services/i18n/date.js

Moment has its own l10n with support for quite a few locales, which we have to import explicitly. Thankfully, Arabic, English, and French are among them. However, we don’t want to statically load all of our supported locales—minus English, which is built into Moment’s core—like this:

This could be ok for a couple of locales, but wouldn’t scale well at all. It also duplicates our supported locale list, which can be a source of issues in the future. Instead, we dynamically import the given locale on initialization.

We also provide a format() method, wrapping Moment’s formatter and adapting it to what i18next expects.

Let’s wire up our date formatter with our core i18n library.

/src/services/i18n/lang.js (excerpt)

We make sure our date formatter has initialized correctly before we attempt to initialize i18next, and we add an interpolation.format function to the options we give to i18next. interpolation.format conditionally checks if the value its given is a Date, and delegates to our date formatter if it is.

We can now add translations that format dates in our translation files.

/public/translations/ar.json (exerpt)

And in our views:

i18next will now pass the date formatting to our date.format method, which in turn uses Moment.js under the hood. This means we can use all the formatting options available in Moment. The ll format, for example, outputs a localized version of “Jan 21, 2018”.

/public/translations/ar.json (exerpt)

And in our views:

Unescaped Output

Actually the above will output something more like:

Arabic Date with Encoded Slashes

This is due to Moment outputting / in its hexadecimal entity form. i18next is then escaping this output to guard against cross-site scripting (XSS) attacks. To undo that escaping, we can use a special operator that i18next provides.

/public/translations/ar.json (exerpt)

The - above tells i18next not to escape our formatter’s returned value before outputting it via t(). Now we get something that looks like نشر ٢٠١٨/٠١/٢١

Note: Be careful about unescaping values, as you might expose yourself to XSS attacks if the values you’re unescaping are coming from user input. Best to make sure that unescaping is well understood among your translation team.

A Note on Asset Sizes

To get going quickly, we’ve pulled in both i18next and Moment.js. This sped up our development significantly, but also came at the cost of potential asset bloat. I8next is 39KB  (10.1KB gzipped). Moment.js weighs in at 316.2KB (67.4KB gzipped). While an added ~80KB (gzipped) may not seem unreasonable for our app bundle, this kind of thing will add up over time. In the future, we may well want to refactor our lang and date libraries to swap in smaller alternatives to i18next and Moment, or roll our own. Since we built our libraries as adapters, their interfaces don’t have to change when we do refactor, so the rest of the team can keep using our i18n solution with minimal changes to the rest of the code base.

Conclusion

Writing code to localize your app is one task, but working with translations is a completely different story. Many translations for multiple languages may quickly overwhelm you which will lead to the user’s confusion. Fortunately, PhraseApp can make your life as a developer easier! Feel free to learn more about PhraseApp, referring to the Getting Started guide.

As we’ve developed our i18n / l10n library, the rest of the development and translation team have been integrating it into their views. With a few adjustments at the end of the sprint, our magazine app is localizable and we’re ready to serve our multi-lingual content.

Our development lead is pleased that we designed interfaces decoupled from any third-party library, which reduces our lock-in and technical debt. And our CEO, previously anxious about whether we’d be able to deliver our alpha release on time, is now anxious about how expensive our team’s espresso habit is becoming 🤦🏽‍♂️. Oh well.

Beginning JavaScript I18n with i18next and Moment.js
5 (100%) 2 votes
Comments