Blog
Internationaliser son application avec AngularJS

Partager

Globe with flags

Récemment, un client nous a demandé d’internationaliser son site en plusieurs langues (anglais, arabe et français notamment).

Le processus d’internationalisation, ce n’est pas simplement traduire les chaines de caractères. C’est aussi prendre en compte les spécificités culturelles, formater correctement les dates, les nombres ou les prix.

Nous allons voir comment il est possible de supporter plusieurs langues avec AngularJS 1.3.x et l’ordonnanceur de tâches GruntJS.

Traduction des chaines et intégration continue avec GruntJS

La première chose à faire et de pouvoir rendre nos chaines traductibles, pour cela on utilise la librairie angular-translate qui se base sur gettext.

L’avantage de cette librairie est qu’elle génère des fichiers « .po » (un par langue) contenant les traductions. Ce format de fichier est exploitable par les principaux logiciels utilisés par les traducteurs (Poedit par exemple).

1. installation

Pour installer le module, rien de plus simple :

bower install angular-gettext

On injecte ensuite le module comme dépendance dans notre application :

angular.module('myApp', ['gettext']);

On inclut le fichier :

<script src="bower_components/angular-gettext/dist/angular-gettext.min.js"></script>

2. Annoter les chaines

Maintenant, on peut commencer à annoter les chaines à traduire dans nos fichiers HTML en utilisant la directive 'translate' :

La façon la plus courante :

<p translate>Hello world</p>

L'interpolation (les variables entre {{}}) est supporté :

 <h1 translate>Article about {{subject}}</h1>

Les attributs sont également traductibles :

<input type="text" placeholder="{{'Email'|translate}}" />

Il est possible de spécifier un commentaire en précisant le contexte afin d'aider le traducteur :

 <h1 translate-comment="Noun" translate>Search</h1> 

Enfin, les pluriels sont gérés:

<span class="nb-request" translate translate-n="numberFriendRequest" translate-plural="You have {{$count}} friend requests">You have 1 friend request</span>

On peut également faire la même chose en JavaScript (en prenant soin d'injecter le service 'gettextCatalog' comme dépendance dans notre controller):

var success = gettextCatalog.getString("Registered Succesfully !");
var dynamic_params = gettextCatalog.getString("Hello {{name}}", { name: "Ruben" });
var plural_string = gettextCatalog.getPlural(3, "Bird", "Birds"); // gettextCatalog.getPlural(COUNT, SINGULAR_STRING, PLURAL_STRING);

Il manque uniquement le support des homonymes qui devrait arriver d'ici peu.

Il est important de noter que la librairie fonctionne uniquement pour des chaines statiques, les boucles telles que :

for (var i = 0; i < elements.length; i++) {
  gettextCatalog.getString(elements[i]);
}

ne produiront pas le résultat espéré.

3. Intégration avec GruntJS

Une fois toutes les chaines de vos fichiers statiques annotées, nous pouvons configurer GruntJS pour effectuer l'extraction.

Cette extraction va générer un fichier '.pot' (portable object template).

On installe le plugin grunt :

npm install grunt-angular-gettext
nggettext_extract: { // Extract translatable strings from html and js files
  pot: {
      files: {
          '<%= dirs.translation %>/template.pot': ['<%= dirs.src %>/**/*.html', '<%= dirs.src %>/**/*.js']
      }
  }
}

A partir de ce fichier template contenant toutes nos chaines, nous pouvons créer des fichiers '.po' sur Poedit pour chaque langue.

Un fichier '.po' est simplement un fichier contenant les chaines issues de notre code en tant que clé et la traduction proposée en tant que valeur. Il est donc important d'écrire son application dans une seule langue.

Ce fichier n'est pas facilement exploitable par les techno webs alors nous générons du json à la place :

nggettext_compile: {
  all: {
    options: {
      format: 'json'
    },
    files: [
        {
          expand: true,
          dot: true,
          cwd: '<%= dirs.translation %>/',
          dest: '<%= dirs.dest %>/languages',
          src: ['*.po'],
          ext: '.json'
        }
    ]
  },
}

Enfin, on enregistre une tâche pour pouvoir rejouer l'extraction et la compilation facilement

grunt.registerTask('translate', ['nggettext_extract', 'nggettext_compile']);

4. Changement de langues à la volée

Maintenant que nos fichiers .json contenant nos traductions sont prêts, nous allons pouvoir les charger.

A l'initialisation, on charge le fichier .json suivant la langue qui est set dans le cookie de notre site :

function readCookie(name) {
    var nameEQ = name + '=';
    var cookie = document.cookie.split(';');
    for (var i = 0; i < cookie.length; i++) {
      var c = cookie[i];
      while (c.charAt(0) === ' ') 
        c = c.substring(1,c.length);
      if (c.indexOf(nameEQ) === 0) 
        return c.substring(nameEQ.length,c.length);
    }
    return null;
  }
  
var lang = readCookie('lang') || 'en';
document.write('<script type="application/json" src="/static/languages/' + lang + '.json"></script>');

Ensuite on créé un template html permettant de changer de langue :

<span data-ng-controller="languageCtrl">
  <a href="javascript:" data-ng-click="changeLanguage('fr')">fr</a>
  <a href="javascript:" data-ng-click="changeLanguage('en')">en</a>
  <a href="javascript:" data-ng-click="changeLanguage('ar')">ar</a>
</span>

Le controlleur associé :

angular.module('myApp').controller('languageCtrl', ['$scope', 'Language', 
  function($scope, Language) {
    $scope.changeLanguage = function changeLanguage(lang) {
      Language.set(lang);
    };  
  }]);
})();

Et enfin le service Language :

angular.module(myApp.services').service('Language', ['$locale', '$http', 'gettextCatalog', '$cookies',
  function($locale, $http, gettextCatalog, $cookies) {
    var supportedLanguages = ['en', 'fr', 'ar']; //Add the supported languages by MySign in this array
    var lang = 'en'; //by default, set the lang cookie in english but we might change it
    
    this.init = function init() {
      if ($cookies.devmode)
        gettextCatalog.debug = true; //display [MISSING] in front of every missing translation
        
      //If the cookie lang is not set, try to fetch the language of the navigator, otherwise, we use english.
      if (!$cookies.lang) {
        var language = window.navigator.language || window.navigator.userLanguage;
        language = language.substr(0,2); // get the lang to the ISO639-1 format 
        console.info('cookie lang not set, language found', language);
        if (supportedLanguages.indexOf(language) !== -1)
          $cookies.lang = language;
        else
          $cookies.lang = lang; 
      }
      gettextCatalog.baseLanguage = lang; //the labels in the app are written in english
      this.set($cookies.lang, 'init');
    };
      
    this.get = function get() {
      return lang;
    };
      
    this.set = function set(language, context) {
      console.info('Set language', language);
      if (supportedLanguages.indexOf(language) === -1)
      {
        console.warn('Language not supported', language);
        return;
      }
      lang = language;
      $locale.id = lang;
      $cookies.lang = lang;
      
      if (typeof context === 'undefined' || context !== 'init') {
        $http({
          method: 'GET',
          url: '/static/languages/'+lang+'.json',
          cache: gettextCatalog.cache,
          transformResponse: function(json) {
            json = json.substr('loaded('.length);
            json = json.substr(0, json.length-1);
            return JSON.parse(json);
          }
        }).success(function (data) {
          for (var lang in data)
            if (data.hasOwnProperty(lang)) {
              gettextCatalog.setStrings(lang, data[lang]);
            }
          gettextCatalog.setCurrentLanguage(lang);
        });
      }
    };  
  }]);

N.B: Dans le cas où votre site ne comporterait pas beaucoup de labels ou de langues, il est aussi possible de mettre toutes les traductions dans un seul fichier : translations.js et de le charger directement au début du site.

Localisation de l'application

Maintenant que les chaines sont traduites, on peut s’occuper de localiser les dates, nombres et le formatage des monnaies.

Pour cela on fait appel à angular-18n :

bower install angular-i18n

Le principal problème est que cette librairie officielle Angular ne gère pas le changement de locale à la volée (sans rechargement de page). On ne peut donc prendre en compte que le dernier fichier javascript de configuration qui a été chargé.

Pour y remédier, on peut utiliser la librairie angular-dynamic-locale :

bower angular-dynamic-locale

On configure le provider pour spécifier où se trouvent les fichiers (ils seront chargés de façon asynchrone) :

angular.module('app').config(['tmhDynamicLocaleProvider', function(tmhDynamicLocaleProvider) {
  tmhDynamicLocaleProvider.localeLocationPattern('/static/languages/angular-locale_{{locale}}.js');
}]);

On peut ensuite faire évoluer notre service 'Language' en injectant la dépendance 'tmhDynamicLocale' et en ajoutant la ligne suivante dans notre fonction set :

tmhDynamicLocale.set(lang);

Les filtres AngularJS 'date','number' et 'currency' se basent sur ces locales et formateront correctement vos chaines.

Par exemple, le code suivant :

 <span id="currency-default">{{'1256.84' | currency}}</span> 

produira '$1,234.56' avec la locale 'en' chargée mais '1 234,56€' avec locale 'fr'.

Classes CSS et attributs

La dernière étape pour supporter l’internationalisation passe par un css adapté.

La taille des textes varient selon les langues (par exemple un paragraphe allemand pourra être jusqu’à 40% plus long que la version anglaise). La signification de certains pictogrammes peut aussi changer en fonction des cultures.

Il est donc indispensable de pouvoir changer l’apparence de la page en fonction de la langue.

On spécifie une classe représentant la langue sur le body et un attribut lang sur la balise html :

<html data-ng-app="myApp" lang="{{lang}}" data-ng-controller="rootCtrl">
  <body class="{{lang}}" dir="{{dir}}">
  </body>
</html>

Le controller "root" se charge de mettre à jour ces attributs :

.controller('rootCtrl', ['$scope', '$rootScope', 'Language', 
  function($scope, $rootScope, Language) {
    //Watcher on the lang to update the text directionality ('dir' attribute) and the lang class for the body and html tag
    $scope.$watch(function () {
      return Language.get();
    },
    function(newVal, oldVal) {
      $scope.lang = newVal;
      $scope.dir = $scope.lang === 'ar' ? 'rtl' : 'ltr';
    });  
}]);

Et on peut ensuite définir des règles css (scss dans notre cas) adaptées

body {
  font-size : 14px ;
  &.fr {
    font-size : 13px ;
  }
}

Pour finir, il convient de porter attention au sens de lecture de la page, notamment pour l’arabe. Pour cela on utilise l’attribut 'dir' qui avec la valeur rtl ou ltr (right to left/left to right).

La plupart des navigateurs rendent les éléments natifs différemment . Par exemple, si l’utilisateur choisi la langue arabe (rtl), la flèche sur les select est à gauche, le texte des inputs est collé à droite, les labels apparaissent à droite des inputs, etc.

Conclusion

Nous venons de découvrir comment utiliser angular-gettext et angular-i18n pour apporter le support i18n à votre web app sans rechargement de page.

L'i18n est la brique indispensable pour le l10n (la localization). Une fois le socle technique posé, il est nécessaire d'adapter votre produit aux spécificités du marché visé.

N’hésitez pas à poser vos questions pour avoir plus d’explications ou partager vos façons d’internationaliser vos applications.

comments powered by Disqus