node package manager
Easy collaboration. Discover, share, and reuse code in your team. Create a free org »

@tableflip/i18nify

i18nify

Browserify transform for build time i18n using browserify.

Getting started

You have a webapp, using browserify and hbsfy to transform your handlebars templates:

index.html

<!doctype html>
<div id="msg"></div>
<script src="bundle.js"></script>

template.hbs

<p data-i18n>Hello {{name}}!</p>

(Note the data-i18n attribute above)

main.js

var html = require('./template.hbs')
document.getElementById('msg').innerHTML = html({name: 'Dave'})

Create a translation file in Jed format:

translations/es/dict.json

{
  "messages": {
    "" : {
      "domain" : "messages",
      "lang" : "es",
      "plural_forms" : "nplurals=2; plural=(n != 1);"
    },
    "Hello {{name}}": ["Hola {{name}}"]
  }
}

Create Spanish bundle.js using i18nify and hbsfy transforms:

browserify main.js -o bundle.js -t [ i18nify --path translations --lang es ] -t hbsfy

Tada! When you run your app you see:

<!doctype html>
<div id="msg">Hola Dave</div>
<script src="bundle.js"></script>

Parameters

--lang (required)

The language of the translation file to use. Note that --path and --lang are joined together to find the translation file like so:

path.join(params.path || process.cwd(), params.lang, 'dict.json')

--path (optional)

The path to translation files, relative to process.cwd().

--domain (optional)

The domain of your messages. Default "messages".

Tell me more

Internationalizing an app comes at a cost; it can reduce the readability of templates, and affect the run-time performance of the app. gettext has some limitations but is well supported and has been successfully helping translate apps for years.

As such, the i18nify transform is a small part of a bigger i18n implementation that aims to:

  • Use a gettext compatible api.
  • Handle simple translations, pluralisation and date formatting.
  • Keep the HTML templates as clear and readable as possible.
  • As much as possible, do the work of translating up front, at build time.

SOLD! SHOW ME AN EXAMPLE APP!
https://github.com/tableflip/i18n-browserify

gettext

The gettext api supports simple text-replacement translations and locale-sensitive pluralization. This app uses jed.js to provided a gettext compatible api for front-end code.

gettext works with dictionaries stored as .po (Portable Object) human-readable files, and .mo (Machine Object) files optimised for robots. jed.js uses dictionaries stored as the JSON equivalent of the .po files that can be automatically derived from existing .po files via tools like po2json

gettext has no built in support for locale-sensitive date formatting, so we use moment.js for that.

Readable templates

To identify the simple phrases that should be translated we add a data-i18n attribute to elements.

<h3 data-i18n>Login</h3>
<p class="login__lead" data-i18n>
  Please enter your email and password to log in
</p>

gettext recommends using the default language as the key for looking up translations where possible, so we use the trimmed text content of these elements as the phrase to translate, so <h3 data-i18n>Login</h3> becomes a gettext lookup for "Login" in the chosen locale.

Standard handlebars style variable substitution is supported, so:

<h1 data-i18n>Welcome {{name}}</h1>

...would use Welcome {{name}} as the key to look up, and the entry in the Spanish locale dictionary would look like:

"Welcome {{name}}"["Hola {{name}}"]

The translation is done first, and then the templates standard variable substitution fills out the {{name}} at run time...

Simple translations at build time

The data-i18n magic is done at build time. The i18ify transform is used with browserify to process the templates, translating the text content of the elements before handing over to the handlebars transform, that converts the html into a javascript function. As the translation happens before the templates are compiled, the standard handlebars variable substitution still works, even within phrases that are translated.

As such these translations are done up-front, before deployment, creating language specific app bundles, via browserify:

"bundle_es""browserify app.js -o dist/es/bundle.js -t [ i18ify --lang es ] -t hbsfy",

In code Jed translations at build time

If you use a jed compatible API and call your localisation var i18n, the i18nify transform will also translate calls to simple translations in your code.

For example, i18n.translate('some key').fetch() will be replaced with 'some key' (or the translation for "some key").

Context sensitive translations at runtime

Some phrases can only be translated when we know the value of the variables in them. The most common case is pluralisation, where the number of things changes at run time, "There is 1 new alert" vs "There are 3 new alerts"

This is handled in the templates via a handlebars helper.

<p>
  {{ngettext "There is 1 new alert" "There are %d new alerts" alerts}}
</p>

where ngettext is the pluralisation function from gettext, provided to the templates as:

Handlebars.registerHelper('ngettext', function (one, other, count) {
  var res = i18n.translate(one).ifPlural(count, other).fetch(count)
  return res
})

In the above example, i18n is an instance of jed.js, initialised with dictionary for the current locale. It uses the more readable api, which maps onto specific gettext api calls. Jed supports both styles, so calling ngetext is equivalent to asking ifPlural

var getText = i18n.sprintf(i18n.ngettext(one, other, count), count)
var chained = i18n.translate(one).ifPlural(count, other).fetch(count)
console.assert(getText === chained)

Bundling a locale; aliasing i18n

As we have to do some translations at runtime, we have to include a dictionary in the bundle, but we don't want to include every possible language, just the translations for a specific locale.

We hide those details in the i18n package. At build time we alias i18n to point to the right locale data and the rest of the app code simply uses i18n to get translations for the chosen locale.

The locale specific files are in ./i18n/<language-code>. Each locale contains a dictionary of translations and a js file that sets up locale specific customisations.

For the default en locale, we only have to initialise jed.js, but for other locales we also configure moment.js to use the right locale for it's date formatting:

// Configure moment.js for Spanish 
var moment = require('moment')
require('moment/locale/es')
moment.locale('es')
 
// Configure gettext 
var i18n = require('../i18n.js')
module.exports = i18n(require('./dict.json'))
module.exports.moment = moment

With this file we can coax browserify to include the right dictionaries in the current bundle, rather than all of them.

The rest of the app sees this module exposed as i18n by aliasing of i18n to a specific locale. This is done using a browserify transform called pkgify which let's us map a package name to a file at build time:

"bundle_es""browserify app.js -o dist/es/bundle.js -t [ pkgify --packages [ --i18n i18n/es/es.js ] ]"

where [ --i18n i18n/es/es.js ] is re-writing calls to require('i18n') as require('./i18n/es/es.js')

Building a locale specific bundle

Browserify is doing a lot of work for us, so we capture the command line configuration in npm run scripts in the package.json

"scripts"{
  "watch": "watchify app.js -o dist/en/bundle.js -t [ pkgify --packages [ --i18n i18n/en/en.js ] ] -t hbsfy",
  "bundle": "browserify app.js -o dist/en/bundle.js -t [ pkgify --packages [ --i18n ./i18n/en/en.js ] ] -t hbsfy",
  "bundle_de": "browserify app.js -o dist/de/bundle.js -t [ pkgify --packages [ --i18n i18n/de/de.js ] ] -t [ i18ify --lang de ] -t hbsfy",
  "bundle_es": "browserify app.js -o dist/es/bundle.js -t [ pkgify --packages [ --i18n i18n/es/es.js ] ] -t [ i18ify--lang es ] -t hbsfy"
},

The bundle commands only differ on an language code string so there is an exercise for the interested reader to optimise these commands.

The output of the commands are locale specific app bundles found in /dist/<locale>/bundle.js which contain all our app code and language specific templates.

They can be run by an npm start

Translating outside of templates and other advanced stories

If you're forced to do some translating in your app code, you can simply require the i18n module and call the api directly:

i18n
  .translate("There is 1 ship in your account")
  .ifPlural(totalShips, "There are %d ships in your account")
  .fetch(totalShips),

Which supports pluralisation and variable substution in the output.

Date formatting is handled by moment which is configured and exposed as i18n.moment, and can be used in conjunction with variable substitution in translated text.

i18n
  .translate("There will be 1h of scheduled maintenance on %s")
  .fetch(i18n.moment("2015-12-25").format('LLL'))

Bonus points

  • Where we translate ahead of time as part of the build we add the path to the source file as an html data attribute. During dev we can use that info to show tooltips to help developers and translators to figure out where in the app the text is from.
  • jed.js supports a "missing_key_callback" function which we map to add a warning to the console where the current dictionary is asked to translated key it doesn't have.

References


A (╯°□°)╯︵TABLEFLIP side project.