Software localization

Localizing Mithril Applications

Keep your Mithril Single Page Application (SPA) lean with a tiny, bespoke i18n library, and make it ready for localization with ease.
Software localization blog category featured image | Phrase

Mithril, the lightweight, batteries-included SPA (Single Page Application) framework is an attractive option for building a rich JavaScript app. With 13K+ stars on GitHub, it’s fair to say that Mithril is no slouch, but not massive. Mithril is more of a “do-one-thing-well” framework. It gives you modern, component-based reactivity, includes routing and XHR out of the box, and all at under 10kb gzipped. So the framework is more for the performance-conscious (or bloat-averse) artisan. I think it’s definitely a contender for small projects.

But you probably know all this and are now thinking, get on with the localization. Well, at the time of writing, there are no Mithril-specific i18n (internationalization) libraries. We could wire up a third-party i18n library like i18next, but we would be taking on an additional 12.3kb in gzipped bundle size. So wisdom would suggest rolling our own lightweight i18n library to keep things as lean as possible. It’s a tradeoff: a bit more work upfront in exchange for a smaller bundle size and complete control. I think that’s the way to go here. And, as I hope you’ll see by the end of this article, rolling your own i18n library isn’t that hard. I dare say it can be enjoyable. Alright, let’s get started.

🔗 Resource » Learn everything you need to make your JS applications accessible to international users with our Ultimate Guide to JavaScript Localization.

Our demo

We’ll localize a small demo app that reveals use(ful|less) info about Star Wars characters, aptly called Yodizer. Here it is in all its glory.

Demo app main menu | PhraseThe home route displays a list of Star Wars characters

Character details in demo app | PhraseClicking on a character opens a details route

🗒 Note » Shout out to the talented habione 404 on Noun Project for their awesome Yoda icon.

🔗 Resource » All data is from the very cool Star Wars API (SWAPI).

Let’s quickly go over how we put this app together before we localize it.

Versions of dependencies used

In keeping with our lightweight philosophy, we’ve used the minimum number of NPM dependencies to build our demo app.

  • mithril@2.0.4—Not to be confused with the fictional metal found in The Lord of the Rings, although probably named after it
  • webpack-cli@4.8.0—for running NPM webpack build and server scripts
  • webpack-dev-server@4.2.1—handy for bundling and serving with auto-refresh during development
  • webpack@5.52.1—bundles our many modules into a single JavaScript file

We’re also using version 2.0.4 of the ultra-minimal Skeleton CSS framework, which is optional of course.

🗒 Note » If you want to skip ahead to the localization, you can grab what we’re about to build next from the start branch in our GitHub repo. After that, head on down to Beginning localization.

Installation

OK, let’s start by creating a package.json file for our project.

$ npm init -Y

Now we can install our dependencies.

$ npm install --save mithril

$ npm install --save-dev webpack webpack-cli webpack-dev-server

An npm start script that starts our dev server will make life a lot more convenient.

{

  // ...

  "scripts": {

    "start": "webpack-dev-server --config webpack.config.js"

  },

  // ...

}

🔗 Resource » We’re using a simple webpack.config here. You can grab it from our GitHub repo.

An index.html and index.js sound like sound scaffolding.

<!DOCTYPE html>

<html lang="en">

<head>

  <!-- ... -->

  <title>Yodizer</title>

</head>

<body>

<script src="/main.js"></script>

</body>

</html>

import m from "mithril";

m.render(document.body, m("h1", "Hello Mithril"));

With these in place, if we run npm start from the command line, we should see a browser tab open with “Hello Mithril” rendered in the viewport.

🔗 Resource » More options for installing Mithril are available on the official documentation.

Structure

Our demo app is relatively simple.

.

├── public/

│   ├── data/

│   ├── index.html

│   └── styles.css

└── src/

    ├── features/

    │   ├── About/

    │   │   └── AboutPage.js

    │   └── Characters/

    │       ├── characterApi.js

    │       ├── CharacterDetailsPage.js

    │       ├── CharacterDetatilsRow.js

    │       ├── CharacterListPage.js

    │       └── characterModel.js

    ├── Layout/

    │   ├── Layout.js

    │   └── Navbar.js

    ├── App.js

    └── index.js

Our static assets are in a public directory. These include data grabbed from The Star Wars API (SWAPI) and placed in JSON files to mock a backend API.

CharacterListPage.js is our home page. It links to CharacterDetailsPage.js. All our pages are wrapped in a common Layout.js. This all becomes more apparent when we look at the code.

We've swapped out the test code from index.js and used Mithril’s m.route() to set up our app’s URIs.

import m from "mithril";

import App from "./App";

import CharacterListPage from "./features/Characters/CharacterListPage";

import CharacterDetailsPage from "./features/Characters/CharacterDetailsPage";

// ...

m.route(document.body, "/", {

  "/": {

    render: (vnode) => m(App, m(CharacterListPage)),

  },

  // characters/1 will load and display the character with an id=1

  "/characters/:id": {

    render: (vnode) =>

      m(App, m(CharacterDetailsPage, vnode.attrs)),

  },

  // ...

});

The App component is just a root that we’ll use for app-wide logic, like localization, a bit later. For now, it’s largely a passthrough that wraps its children in a common Layout.

import m from "mithril";

import Layout from "./Layout/Layout";

const App = {

  view(vnode) {

    return m(Layout, vnode.children);

  },

};

export default App;

import m from "mithril";

import Navbar from "./Navbar";

const Layout = {

  view(vnode) {

    return m(".container", [

      m(".row", m(".twelve.columns", m(Navbar))),

      m(".row", m(".twelve.columns", vnode.children)),

    ]);

  },

};

export default Layout;

Using Skeleton’s CSS grid, Layout displays a shared Navbar above its given content/children.

We won’t cover every file in our demo here. A quick stop at our home route’s component, CharacterListPage, could be prudent, however.

import m from "mithril";

import { fetchCharacters } from "./characterApi";

import { characterListFromApi } from "./characterModel";

let state = {

  status: "loading",

  list: [],

};

const CharacterListPage = {

  oncreate() {

    // GET character JSON from our mock API and

    // transform it for our view

    fetchCharacters().then((results) => {

      state.list = characterListFromApi(results);

      state.status = "idle";

    });

  },

  view() {

    return [

      // Btw, imho, hyperscript > JSX, just saying

      m("h1", "Star Wars Characters"),

      state.status === "loading"

        ? m("p", "Loading...")

        : m("table.u-full-width", [

            m(

              "thead",

              m("tr", [

                m("th", "Name"),

                m("th", "Birth year"),

                m("th", "Last edited"),

              ]),

            ),

            m(

              "tbody",

              state.list.map((character) =>

                m("tr", [

                  m(

                    "td",

                    // Link to characer details route/component

                    m(

                      m.route.Link,

                      {

                        href: `/characters/${character.id}`,

                      },

                      character.name,

                    ),

                  ),

                  m("td", character.birth_year),

                  m("td", character.last_edited),

                ]),

              ),

            ),

          ]),

    ];

  },

};

export default CharacterListPage;

This is all basic Mithril, so I won’t bore you with an exposition of the code. Suffice it to say that we’re grabbing Star Wars character JSON from our mock API, showing a loading indicator as it pipes down the network, and rendering it in a table. The name of each of our epic heroes/villains links to their details page.

🗒 Note » Check out the all the code of our app before localization from the start branch in our GitHub repo.

Beginning localization

At this point our strings are hard-coded and our dates aren’t formatted. We need to get localizing. Let’s break our strings out into translation files.

Translation files

The simplest format for translation message files is JSON. We’ll support English and Arabic in this app, but our solution will be extensible to any number of locales.

{

  "app_name": "Yodizer",

  "star_wars_characters": "Star Wars Characters",

  "about": "About"

}

{

  "app_name": "يودايزر",

  "star_wars_characters": "شخصيات ستار وورز",

  "about": "نبذة عن"

}

A little localization library

We need a way to load our message files and to display translated messages from the active locale. Thus begins a localization library.

import m from "mithril";

const defaultLocale = "en";

const messageUrl = "/lang/{locale}.json";

const i18n = {

  defaultLocale,

  currentLocale: "",

  messages: {}, // loadAndSetLocale() populates these

  status: "loading",

  t,

  loadAndSetLocale,

};

export function t(key) {

  return i18n.messages[key] || key;

}

function loadAndSetLocale(newLocale) {

  if (i18n.currentLocale === newLocale) {

    return;

  }

  i18n.status = "loading";

  fetchMessages(newLocale, (messages) => {

    i18n.messages = messages;

    i18n.currentLocale = newLocale;

    i18n.status = "idle";

  });

}

function fetchMessages(locale, onComplete) {

  m.request(messageUrl.replace("{locale}", locale)).then(

    onComplete,

  );

}

export default i18n;

Using our library, we can call i18n.loadAndSetLocale("ar") to load our Arabic message files and set the active locale as Arabic in one fell swoop. We can also use the i18n.t("app_title") function to display our app title, for example, in the active locale.

The i18n.messages = messages line above is where the magic happens. After the given locale's messages load from the network, i18n.messages is replaced with the new locale's messages. Once our app refreshes, all calls to t() will return messages from this new locale.

Let’s update our root component, App, to load the default locale after it mounts.

import m from "mithril";

import i18n from "./services/i18n";

import Layout from "./Layout/Layout";

const App = {

  oncreate() {

    i18n.loadAndSetLocale(i18n.defaultLocale);

  },

  view(vnode) {

    return i18n.status === "loading"

      ? m("p", "Loading...")

      : m(Layout, vnode.children);

  },

};

export default App;

A handy bit of global state, i18n.status, allows us to show a loading indicator when a translation file is being loaded and ensures that we show the nested component hierarchy only after our translation messages are ready.

We can now make use of our t() function, replacing our hard-coded strings with calls that dynamically show translated strings in the active locale.

import m from "mithril";

import { t } from "../services/i18n";

const Navbar = {

  view() {

    return m(

      ".navbar.u-full-width",

      m(".navbar-brand", [

        m("img[src=/img/yoda-icon.png]"),

        m(".navbar-brand-title", t("app_name")),

      ]),

      m(".navbar-menu", [

        m(

          m.route.Link,

          { href: "/" },

          t("star_wars_characters"),

        ),

        m(m.route.Link, { href: "/about" }, t("about")),

      ]),

    );

  },

};

export default Navbar;

Et voila! Here's our Navbar rendered in English ("en").

Navbar rendered in English | Phrase

And if we change the defaultLocale to "ar" (Arabic):

Navbar rendered in Arabic | Phrase

Instead of hard-coded strings in our UI, we now have a simple dynamic translation system. That’s a good start to localizing Yodizer.

Accessing the current locale

It’s not unheard of that we need to fork our logic based on the currently active locale. We’ve accounted for this in our library by providing an i18n.currentLocale bit of state.

// In our views, for example

import i18n from "../services/i18n"

// ...

i18n.currentLocale // => "ar" when the active locale is Arabic

This can be helpful when loading localized data from an API or setting the layout direction of our pages, to mention some examples.

Fallback and supported locales

At times, requests might come into our app that attempt to load a locale that we don’t have translations for. It’s handy at times like these to fall back to a default locale. Earlier, we configured the defaultLocale as English. Let’s build on this, making our supported locales explicit.

import m from "mithril";

const defaultLocale = "en";

// Having human-friendly names mapped to locale codes will help with

// displaying them in our UI later.

const supportedLocales = {

  en: "English",

  ar: "Arabic (العربية)",

};

const messageUrl = "/lang/{locale}.json";

const i18n = {

  defaultLocale,

  supportedLocales,

  currentLocale: "",

  messages: {},

  status: "loading",

  t,

  loadAndSetLocale,

  supported,

};

export function t(key) {

  return i18n.messages[key] || key;

}

function loadAndSetLocale(newLocale) {

  if (i18n.currentLocale === newLocale) {

    return;

  }

  i18n.status = "loading";

  fetchMessages(newLocale, (messages) => {

    i18n.messages = messages;

    i18n.currentLocale = newLocale;

    i18n.status = "idle";

  });

}

function supported(locale) {

  return Object.keys(supportedLocales).indexOf(locale) > -1;

}

function fetchMessages(locale, onComplete) {

  m.request(messageUrl.replace("{locale}", locale)).then(

    onComplete,

  );

}

export default i18n;

OK, now let’s use our new supported() function. We’ll update our existing loadAndSetLocale() to check if the given locale is supported, falling back to our default locale if it isn’t.

// ...

function loadAndSetLocale(newLocale) {

  if (i18n.currentLocale === newLocale) {

    return;

  }

  const resolvedLocale = supported(newLocale)

    ? newLocale

    : defaultLocale;

  i18n.status = "loading";

  fetchMessages(resolvedLocale, (messages) => {

    i18n.messages = messages;

    i18n.currentLocale = resolvedLocale;

    i18n.status = "idle";

  });

}

function supported(locale) {

  return Object.keys(supportedLocales).indexOf(locale) > -1;

}

// ...

With that in place, if we were to loadAndSetLocale("es") (Spanish), our default English locale would be loaded instead.

🗒 Note » Si tenemos usuarios de Yodizer en español, se recomienda que agreguemos español a nuestras configuraciones regionales admitidas y proporcionemos traducciones al español. (Translate)

Localized routing

It makes good sense that https://example.com/en/about and https://example.com/ar/about point to translated versions of the about page. After all, if you send a URL to your friend, you would want them to look at the same page as you are, in the same language. So, given that we’re building a SPA, why don’t we localize our routes.

import m from "mithril";

import i18n from "./services/i18n";

import App from "./App";

import CharacterListPage from "./features/Characters/CharacterListPage";

import CharacterDetailsPage from "./features/Characters/CharacterDetailsPage";

import AboutPage from "./features/About/AboutPage";

m.route(document.body, "/", {

  "/": {

    onmatch: () => m.route.set(`/${i18n.defaultLocale}`),

  },

  "/:locale": {

    render: (vnode) => m(App, m(CharacterListPage)),

  },

  "/:locale/characters/:id": {

    render: (vnode) =>

      m(App, m(CharacterDetailsPage, vnode.attrs)),

  },

  "/:locale/about": {

    render: () => m(App, m(AboutPage)),

  },

});

/ now redirects to /en (given that "en" is the default locale configured in our app). We’ve also added a route prefix param, :locale, to all of our app’s routes other than the root /. This ensures that all our URIs are localized, and we can use the current URI as the single source of truth for the active locale.

We need to make a more few changes to load the active locale from the current URI. Let’s get back to code.

Setting the active locale from the route

We've added a locale param to all of our routes, but we're not doing anything useful with it. Let’s add a function to our i18n library that reads the locale from the current route and sets it as the active locale. To keep things organized, we’ll place this function in a new module, i18nRouting.

import m from "mithril";

import i18n from "./i18n";

const localeParam = "locale";

export function setLocaleFromRoute() {

  const routeLocale = m.route.param(localeParam);

  if (routeLocale === i18n.currentLocale) {

    return;

  }

  if (i18n.supported(routeLocale)) {

    i18n.loadAndSetLocale(routeLocale);

  } else {

    m.route.set(`/${i18n.defaultLocale}`);

  }

}

setLocaleFromRoute() avoids loading a locale if it’s already loaded. The function also falls back to our default locale via an m.route.set() redirect if the requested locale isn’t supported by our app.

We can now load and set the active locale from the current route. Let’s do that in the App component, since it wraps all of our other components, and doing it there means we set the locale once.

import m from "mithril";

import i18n from "./services/i18n";

import { setLocaleFromRoute } from "./services/i18nRouting";

import Layout from "./Layout/Layout";

const App = {

  oncreate: setLocaleFromRoute,

  onupdate: setLocaleFromRoute,

  view(vnode) {

    return i18n.status === "loading"

      ? m("p", "Loading...")

      : m(Layout, vnode.children);

  },

};

export default App;

OK, we’re actually setting the locale more than once 😬, since we have to do it in both oncreate and onupdate. This is because oncreate is only called when called the App component mounts, which will happen once when our app first loads. If a request with a new locale comes in after App has mounted, oncreate won’t fire, so we won’t load the new locale. To remedy this we call setLocaleFromRoute on every update. This is fine since setLocaleFromRoute won’t load a locale that’s already loaded.

With that in place, hitting a route now causes our app to load the locale in the route path.

Demo app loading the English locale in the route path | Phrase

Demo app loading the Arabic locale in the route path | Phrase

Localized links

Unless the user switches the locale (coming later), once a locale is loaded we need to assume that the user wants all subsequent content in that locale. This means that all of our inner app links need to be prefixed with the active locale. This can get very tedious very quickly, and it's error-prone. What we really want here is a way to easily render localized links without worrying about the /:locale prefix. What’s that you say? We need a factory function? I couldn't agree more.

import m from "mithril";

import i18n from "./i18n";

// ...

// In case we want to manually localized a URI

export function localizeHref(href) {

  return "/" + i18n.currentLocale + href;

}

/**

 * `localizedLink("/uri", children)`

 * or

 * `localizedLink("/uri", attrs, children)`

 * @param {string} href

 * @returns Vnode

 */

export function localizedLink(href, ...args) {

  // Handle optional middle attr arg

  const [attrs, children] =

    args.length === 1 ? [{}, args[0]] : args;

  return m(

    m.route.Link,

    {

      ...attrs,

      href: localizeHref(href),

    },

    children,

  );

}

We can use localizedLink() to create Vnodes that represent <a> tags with localized href attributes.

// In our views...

localizedLink("/about", "About")

// => <a href="#!/en/about">About</a>

localizedLink("/about", {style: {color: "red"}}, "About")

// => <a href="#!/en/about" style="color:red">About</a>

OK, now we can localize our app’s links with ease.

import m from "mithril";

import i18n from "../../services/i18n";

import { localizedLink } from "../../services/i18nRouting";

// ...

const { t } = i18n;

// ...

const CharacterListPage = {

  // ...

  view() {

    return [

      m("h1", t("star_wars_characters")),

      state.status === "loading"

        ? m("p", "Loading...")

        : m("table.u-full-width", [

            // ...

            m(

              "tbody",

              state.list.map((character) =>

                m("tr", [

                  m(

                    "td",

                    localizedLink(

                      `/characters/${character.id}`,

                      character.name,

                    ),

                  ),

                  m("td", character.birth_year),

                  m("td", character.last_edited),

                ]),

              ),

            ),

          ]),

    ];

  },

};

export default CharacterListPage;

Now clicking a character's name in the CharacterListPage goes to the localized details route for that character.

Demo app loading the English locale in the character path | Phrase

Demo app loading the Arabic locale in the character path | Phrase

A localized route generator

Let’s go back to our routes for a minute.

import m from "mithril";

import i18n from "./services/i18n";

import App from "./App";

import CharacterListPage from "./features/Characters/CharacterListPage";

import CharacterDetailsPage from "./features/Characters/CharacterDetailsPage";

import AboutPage from "./features/About/AboutPage";

m.route(document.body, "/", {

  "/": {

    onmatch: () => m.route.set(`/${i18n.defaultLocale}`),

  },

  "/:locale": {

    render: (vnode) => m(App, m(CharacterListPage)),

  },

  "/:locale/characters/:id": {

    render: (vnode) =>

      m(App, m(CharacterDetailsPage, vnode.attrs)),

  },

  "/:locale/about": {

    render: () => m(App, m(AboutPage)),

  },

});

Having to manually add the :locale prefix to nearly every route in our app doesn’t scale well. We can do better. Let's write a little mapping function to take care of this.

// ...

const localeParam = "locale";

export function localizedRoutes(routes) {

  const result = {};

  Object.keys(routes).forEach((path) => {

    result["/:" + localeParam + path] = routes[path];

  });

  return result;

}

// ...

localizedRoutes spins over the keys of its given routes object and returns an object where those keys are localized. We can use this funky-fresh function to clean up our index.js routes.

import m from "mithril";

import i18n from "./services/i18n";

import { localizedRoutes } from "./services/i18nRouting";

import App from "./App";

import CharacterListPage from "./features/Characters/CharacterListPage";

import CharacterDetailsPage from "./features/Characters/CharacterDetailsPage";

import AboutPage from "./features/About/AboutPage";

m.route(document.body, "/", {

  "/": {

    onmatch: () => m.route.set(`/${i18n.defaultLocale}`),

  },

  ...localizedRoutes({

    "/": {

      render: (vnode) => m(App, m(CharacterListPage)),

    },

    "/characters/:id": {

      render: (vnode) =>

        m(App, m(CharacterDetailsPage, vnode.attrs)),

    },

    "/about": {

      render: () => m(App, m(AboutPage)),

    },

  }),

});

Building a language switcher

Of course, we can’t really assume that our user will enter localized routes manually into their browser’s address bar to change locales. Why don’t we enhance our UX with a language switcher dropdown UI?

First, we’ll add a function that swaps the :locale in the current route to a new locale.

// ...

export function currentPathToLocale(newLocale) {

  const pathParts = m.route.get().split("/");

  pathParts[1] = newLocale;

  return pathParts.join("/");

}

We can then use this function in our shiny new switcher.

import m from "mithril";

import i18n from "../../services/i18n";

import { currentPathToLocale } from "../../services/i18nRouting";

function changeLocale(newLocale) {

  // Redirect to current route with new locale replacing

  // current locale

  m.route.set(currentPathToLocale(newLocale));

}

const LangSwitcher = {

  view() {

    return m(

      ".lang-switcher",

      m(

        "select",

        {

          onchange: (e) => changeLocale(e.target.value),

          value: i18n.currentLocale,

        },

        // Shape of i18n.supportedLocales is { en: "English", ...}

        Object.keys(i18n.supportedLocales).map((code) =>

          m(

            `option[value=${code}]`,

            i18n.supportedLocales[code],

          ),

        ),

      ),

    );

  },

};

export default LangSwitcher;

LangSwitcher wraps a <select>, rendering its <option>s from our configured i18n.supportedLocales. When a new option is selected, our LangSwitcher will redirect to the current route, except with the new locale. So if we’re currently at /ar/characters/2, selecting English from the LangSwitcher will cause the app to redirect to /en/characters/2. It’s that easy.

After plopping our LangSwitcher in our Navbar, we get a nice little UI for selecting the active locale.

Demo app route path with language switcher | Phrase

Basic messages

Now that we’ve laid the groundwork for our i18n library, we can go back to our translations, and how they’re used in our app. We’ve already covered basic messages, but I’ll ask your patience as the completionist in me runs through a recap.

Our translation messages sit in JSON files, one per locale to be exact.

{

  "app_name": "Yodizer",

  "star_wars_characters": "Star Wars Characters",

  "about": "About",

  // ...

}

{

  "app_name": "يودايزر",

  "star_wars_characters": "شخصيات ستار ورز",

  "about": "نبذة عن",

  // ...

}

Our views can access the messages of the active locale via our terse t() function.

// In our views...

t("app_name")

// => "Yodizer" when currentLocale === "en"

// => "يودايزر" when currentLocale === "ar"

Interpolation

Dynamic values, those that change at runtime, often need to find their way into our otherwise static translated messages. In other words, we need to accommodate the following use case.

// In our en message file

{

  "hello_user": "Hello, {username}"

}

// In our view...

t("hello_user", {username: "Adam"});

// => "Hello, Adam" when currentLocale === "en"

Another update to our i18n library will get us sorted.

// ...

export function t(key, interpolations = {}) {

  const message = i18n.messages[key] || key;

  return interpolate(message, interpolations);

}

function interpolate(message, interpolations) {

  return Object.keys(interpolations).reduce(

    (msg, variableName) =>

      msg.replace(

        new RegExp(`{\\s*${variableName}\\s*}`, "g"),

        interpolations[variableName],

      ),

    message,

  );

}

// ...

Tenacious t() now takes two arguments, the second being an optional map of interpolations . An example of interpolations is {username: "Malia Duboff"}. Given this, t() will replace all instances of {username} in the message with Malia Duboff.

Let’s use our newfound interpolation to inject a label before our character’s name in the title of the CharacterDetailsPage.

{

  // ...

  "character_name": "Character — {name}",

  // ...

}

{

  // ...

  "character_name": "شخصية — {name}",

  // ...

}

import m from "mithril";

import i18n from "../../services/i18n";

// ...

const { t } = i18n;

let state = {

  status: "loading",

  details: {},

};

// ...

const CharacterDetailsPage = {

  // ...

  view() {

    const { details } = state;

    return m(

      "[",

      state.status === "loading"

        ? m("p", "Loading...")

        : [

            m(

              "h1",

              t("character_name", { name: details.name }),

            ),

            m(".character-details", [

              // ...

            ]),

          ],

    );

  },

};

export default CharacterDetailsPage;

And with that, our title is rendered with interpolation.

English locale with interpolation | Phrase

Arabic locale with interpolation | Phrase

🗒 Note » We can have as many interpolated values in a message as we want. A message that looks like "greeting": “Hello, {username}, you’ve been with us for {days} days.” can be accessed with t("greeting", {username: "Adam", days: 229}) just fine.

Plurals

Pluralization in i18n is about selecting the correct form of a message depending on the count of items. We might want to show the number of Star Wars characters we have at the top of our character list page, for example. Our loaded list of characters is dynamic, which means that we could have zero or more characters on the page. In English, we'd need to cover the following forms:

  • "1 character" (the one form)
  • "0 characters" (the other form)
  • "28 characters" (also the other form)

This is relatively straightforward in English, but other languages have significantly more complex plural rules. Arabic, for example, has six plural forms.

🔗 Resource » Take a look at the Unicode CLDR Language Plural Rules page for an exhaustive list.

Thankfully, because of the Intl.PluralRules object built into JavaScript, we can add plurals to our i18n library without too much pain. First, let’s see how we would present plural cases in our translated messages.

{

  // ...

  "character_count": {

    "plural": {

      "other": "{count} characters",

      "one": "{count} character"

    }

  },

  // ...

}

{

  // ...

  // Remember, Arabic has fix plural forms...

  "character_count": {

    "plural": {

      "other": "{count} شخصية",

      "zero": "لا توجد شخصيات",

      "one": "شخصية واحدة",

      "two": "شخصيتان",

      "few": "{count} شخصيات",

      "many": "{count} شخصية"

    }

  },

  // ...

}

Our pluralized messages are objects that map plural form keys to their respective form messages. The keys ("other", "one", "zero", etc.) are the ones returned by the Intl.PluralRules object’s select() method, which we’ll make use of to extend the functionality of our trusty t() function.

// ...

export function t(key, interpolations = {}) {

  const message = i18n.messages[key] || key;

  const pluralizedMessage = pluralForm(

    message,

    interpolations.count,

  );

  return interpolate(pluralizedMessage, interpolations);

}

function pluralForm(message, count) {

  if (!message["plural"]) {

    return message;

  }

  const rules = new Intl.PluralRules(i18n.currentLocale);

  return message.plural[rules.select(count)];

}

// ...

Before we process any interpolations, we check if our message is an object with a plural key and pluck out the right plural form of the message if it is.

Now we can make use of our new plural powers to add that character count to our list header.

// ...

import i18n from "../../services/i18n";

// ...

const { t } = i18n;

let state = {

  status: "loading",

  list: [],

};

// ...

const CharacterListPage = {

  // ...

  view() {

    return [

      m(".header", [

        m("h1", t("star_wars_characters")),

        m(

          "span",

          t("character_count", {

            count: state.list.length,

          }),

        ),

      ]),

      // ...

    ];

  },

};

export default CharacterListPage;

🗒 Note » The count variable is required for plural messages, and will be interpolated as any other variable when it appears as {count} in plural forms.

And with that, we have localized plurals.

Demo app route path with localized English plurals | Phrase

Demo app route path with localized Arabic plurals | Phrase

Number formatting

You may have noticed that the Arabic plural message above is showing a Western Arabic 5 character. Arabic officially uses Eastern Arabic numerals, so our 5 should be ٥ in Arabic.

We also have to consider currencies, units, and symbols (the percent sign, etc.); all can change from culture to culture. This is where number formatting comes in, and again, a handy Intl object, Intl.NumberFormat , comes as part of JavaScript to help us out.

Let’s start by adding a number() function to our library to facilitate the formatting of our countables.

//...

const i18n = {

  defaultLocale,

  supportedLocales,

  currentLocale: "",

  messages: {},

  status: "loading",

  t,

  number,

  loadAndSetLocale,

  supported

};

//...

export function number(num, options = {}) {

  const formatter = new Intl.NumberFormat(

    i18n.currentLocale,

    options,

  );

  return formatter.format(num);

}

// ...

A simple wrapper for Intl.NumberFormat, our number() function gives us all the power of the built-in object. And with the options parameter, our views can now show localized units, currencies, and more.

// In our views...

import i18n from "../../services/i18n";

i18n.number(12.84);

// => "١٢٫٨٤" when currentLocale === "ar"

// => "12.84" when currentLocale === "en"

i18n.number(12.84, { style: "currency", currency: "EUR" });

// => "١٢٫٨٤ €" when currentLocale === "ar"

// => "€ 12.84" when currentLocale === "en"

i18n.number(12.84, {

  style: "unit",

  unit: "kilobyte",

  unitDisplay: "long",

  maximumFractionDigits: 1,

});

// => "١٢٫٨ كيلوبايت" when currentLocale === "ar"

// => "12.8 kilobytes" when currentLocale === "en"

🔗 Resource » The MDN page for the Intl.NumberFormat constructor has a good rundown of all its formatting options.

Browser differences and fully qualified locales

When the active locale is "ar", the above formats will render as expected in Firefox and Safari. Chrome, however, will fall back to Western Arabic numerals.

// This is what i18n.number(...) calls under the hood

new Intl.NumberFormat("ar", {

  style: "currency",

  currency: "EUR",

}).format(12.84);

// => "١٢٫٨٤ €" in Firefox

// => "€ 12.84" in Chrome

This is remedied by passing the Intl.NumberFormat constructor a fully qualified locale: one that has a country associated with it. If we set our locale as "ar-EG" (Egyptian Arabic), then all is well with the world.

new Intl.NumberFormat("ar-EG", {

  style: "currency",

  currency: "EUR",

}).format(12.84);

// => "١٢٫٨٤ €" in Firefox

// => "١٢٫٨٤ €" in Chrome

So we tame this cross-browser chaos by ensuring that our number() function uses fully qualified locales.

// ...

const defaultLocale = "en";

const supportedLocales = {

  en: "English",

  ar: "Arabic (العربية)",

};

const defaultFullyQualifiedLocales = {

  en: "en-US",

  ar: "ar-EG",

};

// ...

export function number(num, options = {}) {

  const formatter = new Intl.NumberFormat(

    defaultFullyQualifiedLocales[i18n.currentLocale],

    options,

  );

  return formatter.format(num);

}

// ...

Now our numbers will appear in their locales' number system in Safari, Firefox, and Chrome.

🗒 Note » Needless to say, if our app was using fully qualified locales anyway, no mapping would be necessary here.

Localizing the counter variable in plurals

Our number function isn’t called by t() when processing plurals, which means our Arabic plurals still render in Western Arabic numerals.

Translated character with Western Arabic numbers | Phrase

Let’s correct this in our t() implementation.

//...

const defaultFullyQualifiedLocales = {

  en: "en-US",

  ar: "ar-EG",

};

// ...

export function t(key, interpolations = {}) {

  const message = i18n.messages[key] || key;

  const pluralizedMessage = pluralForm(

    message,

    interpolations.count,

  );

  const numberFormattedInterpolations =

    formatNumbersInObject(interpolations);

  return interpolate(

    pluralizedMessage,

    numberFormattedInterpolations,

  );

}

export function number(num, options = {}) {

  const formatter = new Intl.NumberFormat(

    defaultFullyQualifiedLocales[i18n.currentLocale],

    options,

  );

  return formatter.format(num);

}

// ...

function formatNumbersInObject(obj) {

  const result = {};

  Object.keys(obj).forEach((key) => {

    const value = obj[key];

    result[key] =

      typeof value === "number" ? number(value) : value;

  });

  return result;

}

// ...

Our new formatNumbersInObject() takes an object like { count: 12, days: 234 } and returns a new one that looks more like { count: "١٢", days: "٢٣٤" } when the active locale is Arabic. Of course, it uses our number() function to make this transformation.

In t(), we pass our interpolations parameter into our new transformer before we interpolate. Our plural messages now show the count variable in the numeral system of the active locale.

Translated character with Arabic numbers | Phrase

Date formatting

Date formatting in JavaScript is very similar to number formatting in that we use a Intl.DateTimeFormat object. Let’s whip up a quick addition to our i18n library: date().

// ...

const defaultFullyQualifiedLocales = {

  en: "en-US",

  ar: "ar-EG",

};

// ...

const i18n = {

  defaultLocale,

  supportedLocales,

  currentLocale: "",

  messages: {},

  status: "loading",

  t,

  number,

  date,

  loadAndSetLocale,

  supported,

  // ...

};

// ...

export function date(date, options = {}) {

  const formatter = new Intl.DateTimeFormat(

   defaultFullyQualifiedLocales[i18n.currentLocale],

   options,

  );

  // Wrap in new Date() to parse date strings...

  return formatter.format(new Date(date));

}

// ...

We make sure to fully qualify the active locale; we can control the formatting precisely when we provide a country, along with our language, to the Intl formatter.

It’s also handy to be able to accept either JavaScript Date objects or parseable date strings when formatting. Since the formatter only accepts Date objects, we parse the given date parameter to a Date before we pass it to the formatter.

That’s about it. We can now use our new function in our views.

🔗 Resource » Our options param is passed to the Intl.DateTimeFormat constructor, so the latter’s myriad formatting options are available to us.

import i18n from "../../services/i18n";

i18n.date(new Date(2021, 9, 4));

// => "10/4/2021" when currentLocale === "en"

// => "٤/١٠‏/٢٠٢١" when currentLocale === "ar"

i18n.date("Oct 4 2021");

// => "10/4/2021" when currentLocale === "en"

// => "٤/١٠‏/٢٠٢١" when currentLocale === "ar"

i18n.date("Oct 4 2021", { dateStyle: "long" });

// => "October 4, 2021" when currentLocale === "en"

// => "٤ أكتوبر ٢٠٢١" when currentLocale === "ar"

i18n.date("Oct 4 2021", {

  weekday: "long",

  year: "numeric",

  month: "short",

  day: "2-digit",

});

// => "Monday, Oct 04, 2021" when currentLocale == "en"

// => "الاثنين، ٠٤ أكتوبر ٢٠٢١" when currentLocale == "ar"

🗒 Note » Oddly enough, the JavaScript Date constructor takes a zero-based month index, so new Date(2021, 9) is equivalent to October 2021.

Ok, let’s use our dandy date() function to format the dates in Yodizer.

//...

import i18n from "../../services/i18n";

//...

let state = {

  status: "loading",

  list: [],

};

//...

const CharacterListPage = {

  //...

  view() {

    return [

      //...

        m("table.u-full-width", [

            //...

            m(

              "tbody",

              state.list.map((character) =>

                m("tr", [

                  //...

                  m(

                    "td",

                    i18n.date(character.last_edited, {

                      dateStyle: "full",

                    }),

                  ),

                ]),

              ),

            ),

          ]),

    ];

  },

};

// ...

That’s all it takes to get those dates under control.

English date formats in English version of the demo app | Phrase

Arabic date formats in Arabic version of the demo app | Phrase

Listening to locale changes

Parts of our app often need to react when our locale changes. For example, we might want to reload our Star Wars character data, shown in the newly active locale. We can accomplish this by implementing the observer pattern in our library.

// ...

const i18n = {

  defaultLocale,

  supportedLocales,

  currentLocale: "",

  messages: {},

  onChangeListeners: [],

  status: "loading",

  t,

  number,

  date,

  loadAndSetLocale,

  supported,

  addOnChangeListener,

  removeOnChangeListener,

};

// ...

function loadAndSetLocale(newLocale) {

  if (i18n.currentLocale === newLocale) {

    return;

  }

  const resolvedLocale = supported(newLocale)

    ? newLocale

    : defaultLocale;

  i18n.status = "loading";

  fetchMessages(resolvedLocale, (messages) => {

    i18n.messages = messages;

    i18n.currentLocale = resolvedLocale;

    i18n.status = "idle";

    i18n.onChangeListeners.forEach((listener) =>

      listener(i18n.currentLocale),

    );

  });

}

// ...

function fetchMessages(locale, onComplete) {

  m.request(messageUrl.replace("{locale}", locale)).then(

    onComplete,

  );

}

function addOnChangeListener(listener) {

  i18n.onChangeListeners.push(listener);

}

function removeOnChangeListener(listener) {

  i18n.onChangeListeners = i18n.onChangeListeners.filter(

    (currentListener) => currentListener !== listener,

  );

}

export default i18n;

Exposing addOnChangeListener() and removeOnChangeListener() allows us to register and deregister callback functions, respectively. The registered listeners are called in our loadAndSetLocale() function, right after the new locale’s messages are loaded in.

Our Star Wars character data is separated per locale, so it needs to be reloaded when the locale changes. This is a perfect place to register a listener using our handy new addOnChangeListener() function.

import m from "mithril";

import i18n from "../../services/i18n";

import { localizedLink } from "../../services/i18nRouting";

import { fetchCharacters } from "./characterApi";

import { characterListFromApi } from "./characterModel";

const { t } = i18n;

let state = {

  status: "loading",

  list: [],

};

function loadCharacters() {

  state.status = "loading";

  fetchCharacters(i18n.currentLocale).then((results) => {

    state.list = characterListFromApi(results);

    state.status = "idle";

  });

}

const CharacterListPage = {

  oncreate() {

    loadCharacters();

    i18n.addOnChangeListener(loadCharacters);

  },

  onremove() {

    i18n.removeOnChangeListener(loadCharacters);

  },

  view() {

     // Show character list...

  },

};

export default CharacterListPage;

Of course, we would be leaving dangling references to our listener in our i18n module if we didn’t deregister the listener when the component is removed from the DOM. These hanging danglers could cause memory leaks among other issues. removeOnChangeListener() in the Mithril onremove lifecycle method is just what the doctor ordered here.

Locale direction

Arabic, Hebrew, Urdu, and other languages are written right-to-left, and pages laid out in those languages often need to follow suit. Let’s add direction awareness and switching to both our app and i18n library to accommodate these spicy scripts.

// ...

const i18n = {

  defaultLocale,

  supportedLocales,

  currentLocale: "",

  messages: {},

  onChangeListeners: [],

  status: "loading",

  t,

  number,

  date,

  loadAndSetLocale,

  supported,

  dir,

  addOnChangeListener,

  removeOnChangeListener,

};

// ...

/**

 * @returns {"ltr"|"rtl"} left-to-right or right-to-left

 */

function dir(locale = i18n.currentLocale) {

  return locale === "ar" ? "rtl" : "ltr";

}

// ...

export default i18n;

Our app only supports English and Arabic at the moment, so we’ll get away with a trivial dir() function for now. As we add locales to our app, we’ll probably want a more scalable implementation.

But what we have is enough to switch the direction of our pages. HTML natively supports directionality in its <html dir="rtl"> attribute (default is "ltr"). Let’s make use of the attribute, along with new dir() function, in our App.

import m from "mithril";

import i18n from "./services/i18n";

import { setLocaleFromRoute } from "./services/i18nRouting";

import Layout from "./Layout/Layout";

function updateHtmlLocalization() {

  // Update <html dir> attribute

  document.documentElement.dir = i18n.dir();

}

const App = {

  oncreate() {

    i18n.addOnChangeListener(updateHtmlLocalization);

    setLocaleFromRoute();

  },

  onupdate: setLocaleFromRoute,

  onremove() {

    i18n.removeOnChangeListener(updateHtmlLocalization);

  },

  view(vnode) {

    return i18n.status === "loading"

      ? m("p", "Loading...")

      : m(Layout, vnode.children);

  },

};

export default App;

Observing changes to the locale, we set the document.documentElement.dir attribute to the direction of our current locale. Our pages now flow right-to-left in Arabic.

And that's a wrap. Here's localized Yodizer:

Finished demo app | Phrase

🔗 Resource » Get the complete code of our demo app from our GitHub repo.

Concluding

We hope you’ve enjoyed this little guide, and that you’ve seen that rolling your own i18n library to localize a Mithril app is more fun than difficult. And, the whole app in this article, with localization, comes to 12kb gzipped. That lean footprint is the value of going custom, and for using Mithril, itself under 10kb.

If you’re in the market for a prebuilt i18n library, check out The Best JavaScript I18n Libraries. And if you’re looking to keep your localization process lean as it scales, take a look at Phrase Strings, the leanest, fastest, and most reliable software localization platform. With its CLI and Bitbucket, GitHub, and GitLab sync, your translations can be automatically pushed to Phrase as part of your dev workflow. Translators can pick up the translations in the Phrase web console, and use its easy UI with machine learning and smart suggestions to do their thing.

Once translations are ready, they can sync back to your project automatically. You just set it and forget it, leaving you to focus on the code you love. Not only that, Phrase offers Over the Air translations for mobile, supports a multitude of translation file formats, and much, much more. Check out all the features Phrase has to offer, and give it a spin with a 14-day free trial.