• Post category:IOS
  • Post comments:0 Comments
  • Post author:
  • Post published:22/09/2021
  • Post last modified:22/09/2021

Mithril, the lightweight, batteries-included SPA (Single Page Application) framework is an attractive option for building a rich JavaScript app. With close to 13k stars on GitHub as I write this, 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.

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.

The home route displays a list of Star Wars characters

 

Clicking 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.

  • [email protected]—Not to be confused with the fictional metal found in The Lord of the Rings, although probably named after it
  • [email protected]—for running NPM webpack build and server scripts
  • [email protected]—handy for bundling and serving with auto-refresh during development
  • [email protected]—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 only 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").

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

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.

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.

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.

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.

🗒 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.

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

Concluding

This article is a work in progress. Stay tuned for plurals, number formatting, data formatting, and layout direction (right-to-left) in the upcoming weeks. We hope you’ve enjoyed what we have so far, 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.

What do you think? Would you rather use an off-the-shelf library? Let us know in the comments below. And if you’re in the market for a prebuilt i18n library, check our articles The Best JavaScript I18n Libraries and Beginning JavaScript I18n with i18next and Moment.js.

And if you’re looking to keep your localization process lean as it scales, take a look at Phrase. 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.

Leave a Reply