App localization code best practices

In this article we're collecting best practices for writing localizable Gaia apps. If you are doing development work on the Gaia project, these should be observed as much as possible. They are also useful to those creating their own Firefox OS apps.

Note: There is another good document that you should take the time to read: Localization content best practices. This is more generic (not Firefox OS-specific), and covers best practices for making content strings as localizable as possible, whereas the following is more about implementing those strings in your code.

UI Localization

The best way to write localizable code is to move as much of l10n logic to declarative HTML as possible. You should always try to mark up your HTML Elements with data-l10n-id and data-l10n-args and just set/remove/update those using JavaScript if needed. You also don't need to put the original content in HTML anymore.

Using a declarative API

Below is an example of a well localized UI with some JavaScript-driven localization. The code is not racy, requires no guards, will work in any locale and react properly to language changes.  Notice that the HTML doesn't have any English content in it.  L10n.js uses its own fallback mechanism and any content defined in the source HTML will be replaced anyways on runtime.

<h1 data-l10n-id="appName" />
<h2 data-l10n-id="summary" />
<article>
  <p id="author" />
  <button id="actionButton" />
</article>

The best way to localize UI elements from JavaScript is to set the data-l10n-id attribute on an element:

appNameHeadingElem.setAttribute('data-l10n-id', 'appName');
actionButtonElem.setAttribute('data-l10n-id', newArticle ? 'saveBtnLabel' : 'updateBtnLabel');

navigator.mozL10n.setAttributes(authorElem, 'articleAuthor', {
  'name': 'John Smith'
});
appName = My App
saveBtnLabel = Save
updateBtnLabel = Update
articleAuthor = The author of this article is {{ name }}

L10n.js has a MutationObserver set that will react to this change and localize the element. You can also set data-l10n-args, as shown here:

var elem = document.getElementById('myelement');
elem.setAttribute('data-l10n-id', 'label1');
elem.setAttribute('data-l10n-args', JSON.stringify({'name': 'John'}));

Although it may be tempting to use HTMLElement.dataset for l10nId or l10nArgs, we do not recommend doing this. Not only is setAttribute() faster, but it also is a more future-proof approach because the data- prefix is used only temporarily and once we move forward with WebAPI standardization effort, data-l10n-id will be replaced with a l10n-id attribute.

Argument substitution

Localized strings can be made to contain placeholders: that is, strings passed in at runtime that should not be localized. For example, suppose the localized string is intended to greet the user:  "Hello, Bob!". The app.properties files might look like this:

// en/app.properties
greeting=Hello {{person}}!
// fr/app.properties
greeting=Bonjour {{person}} !

JavaScript code can then pass these placeholders into webl10n.formatValue() as properties of a JSON object:

var user = "Bob";
navigator.mozL10n.formatValue("greeting", {"person" : user}).then((string) => {
  alert(string);
  // -> "Hello Bob!" if device language is en
  // -> "Bonjour Bob !" if device language is fr
});

Pluralization

The getting started article describes how a translator can handle pluralization by supplying different forms of a string. For example:

tomatoCount={[ plural(n) ]}
tomatoCount[zero]=Vous n'avez pas de tomates :(.
tomatoCount[one]=Vous avez une tomate.
tomatoCount[other] = Vouz avez {{n}} tomates.

This file will return different values for tomatoCount depending on the value passed in as n:

navigator.mozL10n.formatValue("tomatoCount", {"n" : count.value}).then((string) => {
  alert(string);
  // "Vous avez une tomate."
});

Removing localization

If you need to stop the node from being translated/retranslated, you need to remove l10nId from the element.

document.getElementById('node').removeAttribute('data-l10n-id');

One limitation of the current approach is that we do not have a good way to clean up the node, so we rely on users manually removing the translation of the value and localized attributes:

document.getElementById('node').textContent = null;
document.getElementById('node').removeAttribute('placeholder');

In the future we hope to be able to automatically remove translation of the value and attributes when l10nId is unset.

Do not set l10n-id on elements with child elements

One of the important considerations is that when you set l10n-id on an element, L10n.js takes over the rendering of that element, so any child nodes will be overwritten with localized strings. In the future we may even get something similar to the Shadow DOM, to render localized versions of an element.

If you need to localize DOM Fragment, see below.

Do not use mozL10n.get

One of the commonly used anti-patterns from older l10n paradigms is a synchronous method used to retrieve l10n strings from a bundle. We recommend avoiding using mozL10n.get since it is synchronous and requires you to guard your code with mozL10n.once() or mozL10n.ready(); it also doesn't work with retranslation. The method is deprecated and will be removed soon.

Instead of writing this:

// BAD

var elem1 = document.createElement('p');
elem1.textContent = navigator.mozL10n.get('helloMsg');

var elem2 = document.createElement('input');
elem2.placeholder = navigator.mozL10n.get('msgPlaceholder');

var elem3 = document.createElement('button');
elem3.ariaLabel = navigator.mozL10n.get('volumeLabel');

// .properties
helloMsg = Hello World
msgPlaceholder = Enter password
volumeLabel = Switch volume

Use this:

// GOOD

var elem1 = document.createElement('p');
elem1.setAttribute('data-l10n-id', 'helloMsg');

var elem2 = document.createElement('input');
elem2.setAttribute('data-l10n-id', 'passwordInput');

var elem3 = document.createElement('button');
elem3.setAttribute('data-l10n-id', 'volumeButton');

// .properties
helloMsg = Hello World
passwordInput.placeholder = Enter password
volumeButton.ariaLabel = Switch volume

In rare cases when you can't use setAttribute nor mozL10n.setAttributes, you can use a non-racy asynchronous  L10n.formatValue() method.

mozL10n.ready will cause memory leaks

When you provide a callback function to mozL10n.ready(), the function won't get properly garbage collected if it is not explicitly removed. More importantly, if it is a method that is bound to an object, the object instance won't go away. See bug 1135256 for more details. For example, if you have a JavaScript object representing a list item widget and attach a method as a callback with mozL10n.ready(), the JavaScript object will never get garbage collected. A pattern that could help mitigate this issue, is to have a singleton listener in your script that handles locale changes for all object instances with a WeakMap, here is a partial example:

var instances = new WeakMap();

function MyThing(container) {
  // used for finding this instance in the DOM
  container.classList.add('my-thing');
  instances.set(container, this);
  if (navigator.mozL10n.readyState === 'complete') {
    this.localize();
  }
}

MyThing.prototype.localize = function() { /* ... */ };

navigator.mozL10n.ready(function() {
  // Look for all the containers of this object type, and retrieve instances.
  for (var container of document.querySelectorAll('.my-thing')) {
    var obj = instances.get(container);
    if (obj) obj.localize();
  }
});

Passing strings outside of the app

Sometimes your strings are intended to be submitted to another API instead of being displayed to the user. Examples of such scenarios are Bluetooth API, alert(), confirm() etc.

In scenarios like that, you should use an asynchronous and forward compatible mozL10n.formatValue method which returns a promise with the value.

Instead of writing this:

// BAD

alert(navigator.mozL10n.get('myL10nId', {var1: "value"}));

Use this:

// GOOD

navigator.mozL10n.formatValue('myL10nId', {var1: "value"}).then((string) => {
  alert(string);
});

One particular scenario is Notifications API, which has it's own NotificationsHelper described below.

It's also important to remember that the paradigm is to carry l10nIds around the app, and only resolve them in the View code right before displaying. So instead of:

// BAD 
function sendText(msg) {
  navigator.mozMobileMessage.send(number, msg);
}

var msgs = {
  confirmation: navigator.mozL10n.get('confirmationMessage'),
};

sendText(msgs.confirmation);

use this:

// GOOD

function sendText(l10nId) {
  navigator.mozL10n.formatValue(l10nId).then(msg => {
    navigator.mozMobileMessage.send(number, msg);
  });
}

var msgs = {
  confirmation: 'confirmationMessage'
};

sendText(msgs.confirmation);

For more details on how to handle scenarios where not all cases are to be localized and handling localizable API, please see the Writing APIs that operate on L10nIDs section of this article.

Localizing DOM Fragments

mozL10n uses an approach called DOM Overlays that allows for overlaying localization strings with HTML. If you want to make this code localizable:

<p>See our <a href="http://www.mozilla.org">website</a> for more information!</p>

Write it this way:

.properties:
seeLink = See our <a>website</a> for more information!

html:
<p data-l10n-id="seeLink"><a href="http://www.mozilla.org" class="external big"></a></p>

Date/Time Formatting

For Date/Time and also Number and Currency formatting we're using Intl API. An example:

// simple version for when performance is not crucial:, 

element.textContent = (new Date()).toLocaleString(navigator.languages, {
  month: 'numeric',
  day: '2-digit',
  year: 'short',
});

If you want to localize time, you should use shared/js/date_time_helper.js to get navigator.mozHour12 and then:

element.textContent = (new Date()).toLocaleString(navigator.languages, {
  hour12: navigator.mozHour12,
  hour: 'numeric',
  minute: 'numeric',
  second: 'numeric'
});

navigator.mozHour12 will return proper true, false or undefined, where undefined will result in Intl API picking the default setting for the given language.

If you need to format a lot of elements, then it is significantly more performant to create one formatter and use it on all elements:

var formatter = Intl.DateTimeFormatter(navigator.languages, {
  year: 'short',
  day: 'numeric',
  month: 'numeric'
});

for (var i = 0; i < messages.length; i++) {
  message.element.textContent = formatter.format(message.date);
}

The only thing you need to remember is that if you cache the formatter you need to set event listeners on languageschange event and if your formatter formats hour then also timeformatchange event to reset the formatters and reformat the elements.

For relative dates, we currently have mozL10n.DateTimeFormat.relativeDate(date) API which returns a promise.

Notice: mozL10n.DateTimeFormat.localeFormat and mozL10n.DateTimeFormat.fromNow are deprecated and will be removed soon.

How to write code that operates on user-provided strings or l10nIDs

In this example, we may have a song title that is not localizable, or a string "Unknown Track" that comes from our localization resources.

There are two patterns to approach this:

Pattern 1

<h1 id="titleElement />
function updateScreen(track) {
  var titleElement = document.getElementById('titleElement');
  if (track.title) {
    navigator.mozL10n.setAttributes(titleElement, 'trackTitle', {
      'title': track.title
    });
  } else {
    navigator.mozL10n.setAttributes(titleElement, 'trackTitleUnknown');
  }
}
trackTitle = {{ title }}
trackTitleUnknown = Unknown Track

Pattern 2

<h1 id="titleElement />
function updateScreen(track) {
  var titleElement = document.getElementById('titleElement');
  if (track.title) {
    titleElement.removeAttribute('data-l10n-id');
    titleElement.textContent = track.title;
  } else {
    titleElement.setAttribute('data-l10n-id', 'trackTitleUnknown');
  }
}
trackTitleUnknown = Unknown Track

These approaches are similar, but in the future Pattern 1 may become the default as we move toward fully localizable HTML trees.

In both patterns notice that we do not set l10n-id or l10n-args in the HTML because we will only set the value when we first load the track in JavaScript, so setting it in HTML is a waste of resources (l10n.js will attempt to translate it if you add it there).

It's also important to notice that we currently don't do any cleanup magic when you remove these attributes, so you need to clean it up yourself. This may change in the future.

Writing code that iterates over many l10n strings

If you have multiple strings (for example error codes), it may be tempting to use mozL10n.get to test if there is a translation for the string and if not set some generic response. That's not a good pattern because first it uses mozL10n.get, and second it confuses missing strings with strings that should not be there, creating hard to reproduce bugs and edge cases.

Instead you should create a list of localized strings and test against it, like this:

var l10nCodes = [
  'ERROR_MISSING',
  'ERROR_UNKNOWN',
  'ERROR_TIMEOUT'
];

if (l10nCodes.indexOf(code) !== -1) {
  elem.setAttribute('data-l10n-id', code);
} else {
  elem.setAttribute('data-l10n-id', 'ERROR_UNKNOWN');
}

Writing APIs that operate on L10nIDs

One of the more interesting cases to deal with is when you write an API that is supposed to receive L10nIDs. In the simplest case, it looks like this:

function updateTitle(titleL10nId) {
  document.getElementById('titleElement').setAttribute('data-l10n-id', titleL10nId);
}

but if you have a case where you may also need l10nArgs and/or plain strings for cases like the above example, or even HTML fragment to inject (in the future this will be replaced by DOM Overlays), the full pattern we recommend is this:

// titleL10n may be:
// a string -> l10nId
// an object -> {id: l10nId, args: l10nArgs}
// an object -> {raw: string}
// an object -> {html: string}
function updateTitle(titleL10n) {
  if (typeof(titleL10n) === 'string') {
    elem.textContent = ''; // not needed if you're not adding line 25-29 [1]
    elem.setAttribute('data-l10n-id', titleL10n);
    return;
  }

  if (titleL10n.id) {
    elem.textContent = ''; // not needed if you're not adding line 25-29 [1]
    navigator.mozL10n.setAttributes(elem, titleL10n.id, titleL10n.args);
    return;
  }

  if (titleL10n.raw) {
    elem.removeAttribute('data-l10n-id');
    elem.textContent = titleL10n.raw;
    return;
  }

  if (titleL10n.html) {
    elem.removeAttribute('data-l10n-id');
    elem.innerHTML = titleL10n.html;
    return;
  }
}

[1] If your code supports HTML fragments and a flow in which first the node may receive {html: string} and later must be switched to l10nId based translation, you need to clean textContent. Otherwise L10n.js will complain about setting l10nId on a node with children nodes. See "Untranslation" section in this article for more background.

Of course, you don't have to support cases you don't need. The HTML case is rarely needed, and l10nArgs or raw may not be needed either. But following this schema for l10n parameters allows you to get it working and later extend the supported cases without altering the API.

One of the consequences of our current limitations regarding unsetting l10nId is that we don't have a clean way to clear the DOM Fragment from the old translation. If your API has potential to have

Testing

When writing tests, we discourage testing the values of the nodes. It makes the test useless in other locales, it will not work with asynchronous translation, and in the future it won't work if we change how we present localization of DOM Elements.

Gaia provides a shared mock_l10n that you should use.

The best strategy is to isolate your test to make sure it sets the proper l10n-id and l10n-args attributes:

assert.equal(elem.getAttribute('data-l10n-id'), 'myExpectedId');

or:

var l10nAttrs = navigator.mozL10n.getAttributes(elem);
assert.equal(l10nAttrs.id, 'myExpectedId');
assert.deepEqual(l10nAttrs.args, {'name': 'John'});

mock_l10n also provides an assertL10n helper function:

l10nAssert(element, 'l10nId');

l10nAssert(element2, { raw: 'string' });

l10nAssert(element3, {
  id: 'l10nId',
});

l10nAssert(element4, {
  id: 'l10nId',
  args: {
    user: 'John'
  }
});

l10nAssert(element5, { html: "<span>Foo</span>" });

Notification API

So, you want to send a Notification. The W3C API expects you to pass title and body as a string. Gaia offers you a NotificationHelper that works with mozL10n.

Instead of:

var title = navigator.mozL10n.get('notification_title');
var body = navigator.mozL10n.get('notification_body', {user: "John"});

var notification = new Notification(title, {
  body: body,
});

// set onclick handler for the notification
notification.onclick = myCallback;

you should write:

NotificationHelper.send('notification_title', {
  bodyL10n: {id: 'notification_body', args: {user: "John"}}
}).then(function(notification) {
  notification.addEventListener('click', myCallback);
});

NotificationHelper uses handles title and bodyL10n the same way as described in "Writing APIs that operate on L10nIDs" section of this article, so you can pass a string or an object.

Document Tags and Contributors

 Contributors to this page: chrisdavidmills, gandalf, eeejay, EragonJ, stasm
 Last updated by: chrisdavidmills,