Tuesday, February 25, 2014

Swapping I18n Strategies in Polymer


Thanks to last night's efforts, I believe that I have a much stronger strategy for internationalizing custom Polymer elements. Tonight, I hope to put that to the test by swapping out my current strategy, based on the i18next JavaScript library for a custom written one, similar to the one I used when internationalizing the Dart version.

The existing strategy needs to support two properties and one method. The properties are codified in the Polymer definition as the locale and labels attributes:
<polymer-element name="polymer-translate" attributes="locale labels">
  <script src="polymer_translate.js"></script>
</polymer-element>
The method that my i18n strategy needs to support is, not surprisingly, translate(). This translate() method updates the labels that are shared in common between the element being translated and the translation strategy. The i18next based translate() looks like:
Polymer('polymer-translate', {
  labels: {},
  // ...
  translate: function() {
    this.labels = {
      hello: i18n.translate('app.hello'),
      colorize: i18n.translate('app.colorize'),
      how_many: i18n.translate('app.how_many'),
      instructions: i18n.translate('app.instructions'),
      // ...
    };
  },
  // ...
});
I think the best place to get started in this effort is to comment out the original <polymer-translate> element in the to-be-translated <hello-you> for a new <polymer-translate-custom>:
<link rel="import" href="../bower_components/polymer/polymer.html">
<link rel="import" href="polymer-translate.html">
<link rel="import" href="polymer-translate-custom.html">
<polymer-element name="hello-you" attributes="locale">
  <template>
    <!-- ... -->
    <!-- <polymer-translate locale={{locale}} labels="{{labels}}"></polymer-translate> -->
    <polymer-translate-custom locale={{locale}} labels="{{labels}}"></polymer-translate>
  </template>
  <script src="hello_you.js"></script>
</polymer-element>
This identifies something of a weakness right away in my approach. The <hello-you> Polymer had been looking for its translation strategy with a simple querySelector('polymer-translate'). That will no longer work now that the strategy could be either <polymer-translate> or <polymer-translate-custom>. For now, I look at all of the Polymer's children for an active “translate” element:
Polymer('hello-you', {
  // ...
  ready: function() {
    var i18n_re = /translate/i;
    for (var i=0; i<this.shadowRoot.children.length; i++) {
      var el = this.shadowRoot.children[i];
      if (i18n_re.test(el.tagName)) this.i18n = el;
    }
  },
  // ...
});
I think I need something more robust than that, but I defer that for another day. For now, I am ready for the custom translation strategy, starting with the template definition:
<link rel="import" href="../bower_components/polymer-ajax/polymer-ajax.html">
<polymer-element name="polymer-translate-custom" attributes="locale labels">
  <template>
    <style>
      :host {
        display: none;
      }
    </style>
  </template>
  <script src="polymer_translate_custom.js"></script>
</polymer-element>
In order to support the same API as the original <polymer-translate>, I am supporting the same attributes: locale and labels. Since I am hand-coding this solution rather than relying on an i18n library, I use <polymer-ajax> from the Polymer Elements project. If this were Dart, I might hand-code the HttpRequest of JSON translation data, but this is JavaScript, so I'll take help in the form of <polymer-ajax>, thank you very much.

As for the backing class to <polymer-translate-custom>, I need to support the translate(), so I start there:
Polymer('polymer-translate-custom', {
  labels: {},
  localizations: {},
  // ...
  translate: function() {
    if (!this.localizations[this.locale]) return;

    var l10n = this.localizations[this.locale];
    this.labels.hello = l10n.hello;
    this.labels.colorize = l10n.colorize;
    this.labels.instructions = l10n.instructions;
    this.labels.how_many = l10n.how_many;
    this.labels.language = l10n.language;

    var count = this.labels.__count__;
    if (count == "" || count == "0" || count == undefined)
      this.labels.count_message = l10n.balloon_zero;
    else if (count == "1")
      this.labels.count_message = l10n.balloon_one;
    else
      this.labels.count_message = l10n.balloon_plural.replace('__count__', count);
  }
});
If there is no localization for the current locale, the initial guard clause exits immediately. Otherwise, I assign the label value from the corresponding localized values. Easy-peasy. Well, easy except for the pluralization of the number-of-balloons label. Since I no longer have a fancy library, I am stuck doing that by hand. Given a translation like:
{
  "hello": "¡Hola!",
  "colorize": "Colorear",
  "how_many": "¿Cuántos?",
  "instructions": "Preséntese para una experiencia personalizada increíble!",
  "language": "Lengua",
  "balloon_zero": "No tengo globos rojos.",
  "balloon_one": "Tengo un globo rojo.",
  "balloon_plural": "Tengo __count__ globos rojos."
}
The pluralization conditional will choose balloon_zero if the current __count__ bound variable/label (see last night) is zero-like. If it is one, then the balloon_one localization is used. Otherwise, the balloon_plural is used, substituting the current balloon count for the __count__ string in the localization file.

That does it for the strategy implementation. I am not quite done, though, as I still need to load the translation files, which I do when the <polymer-translate-custom> element is ready:
Polymer('polymer-translate-custom', {
  // ...
  ready: function() {
    this._loadTranslations();
  },

  _loadTranslations: function() {
    this._loadLocale('en');
    this._loadLocale('fr');
    this._loadLocale('es');
  },
  // ...
});
The _loadLocale() private method is where that <polymer-ajax> Polymer Element comes in handy:
  _loadLocale: function(_l) {
    var that = this;

    var ajax = document.createElement('polymer-ajax');
    ajax.url = '/locales/' + _l + '/translation.json';
    ajax.auto = true;
    ajax.handleAs = "json";
    ajax.addEventListener('polymer-response', function(e) {
      that.localizations[_l] = e.detail.response;
      that.translate();
    });
  },
  // ...
Mercifully, <polymer-ajax> element makes JSON XHRs a breeze—even in JavaScript. I set the URL to point to the same i18n localization files, tell it to parse the response as JSON and then listen for the response to come in. I do not even have to start the request thanks to the auto attribute. When the response comes in, I update the localization for the currently requested locale, then translate.

And it works. I can view a French version of my Polymer:



And a quick update the the Language drop down later, I have the Spanish version:



There are still a few outstanding questions to explore in the next day or two. But this strategy solution to i18n seems like it might be just the ticket for Patterns in Polymer.


Day #1,037

2 comments:

  1. This comment has been removed by the author.

    ReplyDelete
  2. I just implemented a customer i18n solution for my Polymer single page application. I took a similar approach when it comes to loading the translations, i.e., using core-ajax for that.
    Here's what I came up with in a few hours: https://github.com/stefanasseg/polymer-spa-i18n-example

    ReplyDelete