Photo remix available thanks to the courtesy of miguelb. CC BY 2.0

In one of our projects, we are using gettext for i18n. We were putting Handlebars x-handlebars-template templates directly in Haml templates to provide translated views for frontend part - all translations were made on backend. Recently we have rewritten our frontend to React and decided not to use ruby for translations anymore.

Transitioning from backend to frontend

During rewrite, we created an simple API endpoint on backend that was returning translation for given key and locale and mixed it with React component that was asynchronously getting translations. The code was pretty simple and was using jQuery promises:

React = require('react')
{span} = React.DOM

cache = {}

lookupCache = (key, locale) ->
  cache[locale] ||= {}
  cache[locale][key]

updateCache = (key, locale, translation) ->
  cache[locale] ||= {}
  cache[locale][key] = translation

translate = (key, locale) ->
  if translation = lookupCache(key, locale)
    new $.Deferred()
      .resolve(translation)
      .promise()
  else
    $.ajax
      url: '/api/gettext'
      data:
        key: key
        locale: locale
      dataType: 'JSON'
      type: 'GET'
    .then (response) ->
      updateCache(key, locale, response.translation)
    .fail ->
      key

Translation = React.createClass
  displayName: 'translation'

  getInitialState: ->
    translation: null

  componentDidMount: ->
    translate(@props.key, @props.locale)
      .always (translation) =>
        if @isMounted()
          @setState(translation: translation)

  render: ->
    if @state.translation
      span(null, @state.translation)
    else
      span(null, '...')

module.exports = (key, locale) ->
  React.createElement(Translation, key: key, locale: locale)

i18next - move your gettext to frontend

This approach was good for quick start but did not scale - it required multiple ajax calls to backend on each page render, so we decided to find something better. After some research we have chosen i18next - full-featured i18n JS library that have pretty good compatibility with gettext (including pluralization rules). With i18next you can easily return translations using almost the same API as in gettext:

# {"key": "translation"}
i18n.t('key') # => translation for key

This library also supports variables inside translation keys:

# {"key with __variable__": "translation with __variable__"}
i18n.t('key with __variable__', {variable: 'value'}) # => translation with value

It has also sprintf support:

# {"Some text with string %s and number %d": "Hello %s! You're number %d!"}
i18n.t('Some text with string %s and number %d', {postProcess: 'sprintf', sprintf: ['world', 1]}) # => Hello world! You're number 1!

And supports plurar forms (even for languages with multiple plural forms):

# {"key": "__count__ banana", "key_plural": "__count__ bananas"}
i18n.t("key", {count: 0}) # => 0 bananas
i18n.t("key", {count: 1}) # => 1 banana
i18n.t("key", {count: 5}) # => 5 bananas

There are much more configuration options and features in i18next library so you’d better look at their docs.

To convert our gettext .po files to json format readable by i18next, we’re using i18next-conv tool and store generated json in public/locale directory of our Rails app. Here’s a simple script we’re using during deploy to compile JS translations (script/compile_js_i18n):

#!/bin/bash
npm install .
for locale in de en fr pl; do
    for file in acme.po support.po tags.po; do
        ./node_modules/.bin/i18next-conv -l $locale -s locale/$locale/$file -t public/locale/$locale/${file/.po/.json}
    done
done

To use it, just run script/compile_js_i18n in your app’s root directory (but make sure you have node & npm installed and "i18next-conv": "~> 0.1.4" line in your package.json file before). What’s great about i18next-conv, it has built-in plural forms for many languages.

i18next has also a bunch of initialization options. Here’s our setup that works in our app:

i18n.init
  ns: 
    defaultNs: 'acme'
    namespaces: ['acme', 'support', 'tags']
  lngWhiteList: ['de', 'en', 'fr', 'pl']
  fallbackLng: 'en'
  resGetPath: '/locale/%{lng}/%{ns}.json'
  interpolationPrefix: '%{'
  interpolationSuffix: '}'
  keyseparator: '<'
  nsseparator: '>'

Some of those initialization options need more explanation. First, we’re using variable interpolation in our gettext translations. They have format different than i18next defaults (%{variable_name} instead of __variable_name__) so we had to set interpolationPrefix and interpolationSuffix. Second, since we’re using english translations as gettext msgids (usually full sentences), we need to change key and namespace separator (keyseparator and nsseparator options). The default key separator in i18next is a dot (.) and namespace separator is a colon (:) and that was making most of our translations useless, since they were not translated at all when translation key contained . or :. We also had to change resGetPath since we decided to store our json in public/locale (e.g. public/locale/en/acme.json for acme namespace). In our app, we wrapped initialization code in i18n CommonJS module for easier use:

i18n = require('i18next')
i18n.init(
  ns: 
    defaultNs: 'acme'
    namespaces: ['acme', 'support', 'tags']
  lngWhiteList: ['de', 'en', 'fr', 'pl']
  fallbackLng: 'en'
  resGetPath: '/locale/%{lng}/%{ns}.json'
  interpolationPrefix: '%{'
  interpolationSuffix: '}'
  keyseparator: '<'
  nsseparator: '>'
})

module.exports = i18n

With this helper, you don’t need to initialize library each time you use it, it would be initialized only once, on first use.

By default, i18next retrieves translations asynchronously, using ajax get requests to the endpoint set in resGetPath when you set locale using i18n.setLng method. setLng method accepts locale as first parameter and optional callback that would be fired after loading translations. You can make use of it in your’s app bootstrap code:

React = require('react')
i18n = require('i18n')
Gui = React.createFactory(require('gui'))

class App
  constructor: (locale, node) ->
    i18n.setLng locale, =>
      # ...
      React.render(Gui(), node)
      # ...

# ...

new App(window.locale, document.body)

Having this setup we can just use regular i18next API in our React components:

i18n = require('i18n')

module.exports = React.createClass
  displayName: 'Foo'

  render: ->
    React.DOM.span(null, i18n.t('Hello world!'))

i18next has much more features and integrations, including localStorage caching, jQuery integration and ruby gem that can automatically rebuild your javascript translations from YAML files. Have a look at their docs for further information.