Thursday, February 20, 2014

Angular JS localization with Require JS i18n module

When we started working with AngularJS, one of the first non-trivial challenges we were faced with was to find a way to localize the text in our app.

We had a few basic requirements for the localization solution:

  1. Will not require any changes to my existing controllers.
  2. Will not polute the current $scope.
  3. Be able to define the desired string directly on the markup with a simple syntax.
  4. Be defined as a module that can be minified and compiled with RequireJS.

Some brief research did not produce any options we were completely satisfied with. We then considered the RequireJS i18n module. It is a very simple and elegant solution that's worked very well for me in my previous projects, so I set out to write a small module that will bridge the gap between this module and my Angular JS code and views.

Localization service

Our strategy will be to define a localization service that can consume the localization data provided by the i18n RequireJS plugin. We will assume at this point that you have a working AngularJS app with RequireJS and that you have all the required files in place for the i18n plugin to work. Please refer to the documentation for more information.

    // In localizationService.js

   define(['app', 'i18n!nls/strings'], function (app, localeStrings) {
    'use strict';

    app.factory('locale', function(){
        return localeStrings;
    });

    app.factory('localizationService', ['locale', function (locale) {
        function getLocalizedValue(path){
            var keys = path.split('.');
            return getValue(keys);
        }

        function getValue(keys){
            var level = 0;

            function get(context){
                if(context[keys[level]]){
                    var val = context[keys[level]];

                    if(typeof val === 'string'){
                        return val;
                    }else{
                        level++;
                        return get(val);
                    }
                }else{
                    console.error('Missing localized string for: ', keys);
                }
            }

            return get(locale);
        }

        return {
            getLocalizedString: function(path){
                return getLocalizedValue(path, locale);
            }
        };
    }]);
});

Let's walk through the code starting with our dependencies.

We first define a regular require JS module and define our dependencies. The first dependency, "app", relates to the overall application module in Angular JS.

    // In app.js

    define(['angular'], function(angular){
        return angular.module('myApp', []);
    });

The second dependency refers to our localized strings file served by the i18n module:

    // In nls/strings.js

    define( { 
        root: {
            header: {
                'title': 'My app',
                ...
            },
            settings: 'Settings',
            links: 'Links'
        ...
    });

We later define a simple AngularJS module named locale that we will later use for easily injecting a mock locale into our unit tests. We will take a look at that later.

    app.factory('locale', function(){
        return localeStrings;
    });

We then proceed to define our actual service. First we pass in our recently created module locale as a dependendency of our service. The getLocalizedValue method is where we need to focus. If you remember, our strings.js file contains strings in a hierarchical fashion; therefore, we can refer to some strings by navigating down the object tree - for instance header.title.

We will like our code to receive a string that could be a simple key settings or a complex key defining a particular string header.title, so first we split our key into its different components path.split('.') and then we proceed to recursively navigate through each level of the tree until we get the string we are looking for. The recursion takes place with the get() function.

If we reach a level with no value, we will simply log an error to the console so we can debug any invalid strings.

    if(context[keys[level]]){
        ...
    }else{
        console.error('Missing localized string for: ', keys);
    }

Directive

Let's define an Angular JS directive to consume our service directly on our views:

    define(['app', 'localizationService'], function(app){
        app.directive('myAppI18n', ['localizationService', function(localizationService){
                return {
                    restrict:"A",
                    link:function (scope, elm, attrs) {
                        var string = localizationService.getLocalizedString(attrs.tappI18n);
                        elm.text(string);
                    }
                };
            }]);
    });

The directive is very simple. We are just basically leveraging our localizationService to set the text of the element. With this, we'll be able to easily define the strings we want to use in our views:

    <span data-my-app-i18n="header.title"></span>

Which will render as the following:

    <span data-my-app-i18n="header.title">My App</span>

Filter

Another option is to define a custom filter to return your localized strings:

    app.filter('i18n', ['localizationService', function(localizationService){
        return function(input){
            return localizationService.getLocalizedString(input);
        }
    }]);

Which will allow to specify your strings in your markup in the following way:

<span>{{"header.title" | i18n }}</span>

Unit tests

Let's add some good unit tests for our localizationService. We will mock out the locale dependency to be able inject any data we want. To cover the details of unit testing an Angular JS module is outside of the scope of this post, but feel free to checkout this video that explains most of the details that you need to know to write a unit test like this. We are using Jasmine and the Angular Mocks library for this test:

define(['angular-mocks'], function(Squire){
    'use strict';

    var service, localeMock;

    describe('Localization service: localizationService', function(){
        beforeEach(angular.mock.module('globalAdmin'));

        beforeEach(function(){
            localeMock = {};
            
            module(function($provide){
                $provide.value('locale', localeMock);
            });
        });

        beforeEach(inject(function ($injector) {
            service = $injector.get('localizationService');
        }));

        it('reads string in root', function(){
            localeMock.myString = 'hello world';
            localeMock.anotherString = 'hello again';
            expect(service.getLocalizedString('myString')).toBe('hello world');
            expect(service.getLocalizedString('anotherString')).toBe('hello again');
        });

        it('reads a string several levels deep', function(){
            localeMock.home = {
                header: {
                    title: 'hello world'
                },
                other: {
                    title: 'stuff'
                }
            };

            expect(service.getLocalizedString('home.header.title')).toBe('hello world');
            expect(service.getLocalizedString('home.other.title')).toBe('stuff');
        });

        it('logs error if it cant find a string', function(){
            spyOn(console, 'error');
            service.getLocalizedString('something');
            expect(console.error).toHaveBeenCalledWith('Missing localized string for: ', ['something']);
        });
    });
});

Note

Only the default strings.js file will be compiled. All other languages will be loaded as separate files. This is a limitation (or feature depending on your point of view) of the requireJS i18n module.

Conclusion

And we are done! I hope you find this solution helpful. Any suggestions on how to improve it or other localization libraries that we missed are more than welcome. Thanks!

14 comments:

  1. Did you guys never consider: http://pascalprecht.github.io/angular-translate/
    It's pretty awesome and I believe handles all the cases you were going for above.

    ReplyDelete
  2. I haven't looked into it extensively, but it looks like a very complete library. In our case for this particular app, we needed something very simple and this worked well for us.

    ReplyDelete
  3. How can I use this with many languages? For example: en, es, ua, ru.

    ReplyDelete
    Replies
    1. Hi Dennis. Check out the documentation for the require js i18n bundle here: http://requirejs.org/docs/api.html#i18n. Basically the idea is that you define different files for the different languages and the plugin takes care of the rest. Hope this helps!

      Delete
  4. Hi Carlos, Very nice article. Do you have the code base for the same or a fiddle through which I can refer the flow. Please let me know.

    Thanks,
    Anirban

    ReplyDelete
    Replies
    1. Hi Anirban. Sorry, at the moment I don't have a working demo or a github repo for the code, but I'll try to squeeze it in as soon as I can and I'll post back here the link. Thanks the comment!

      Delete
  5. Hi, Carlos! A good online tool to translate an app's software strings is the localization platform https://poeditor.com/
    An account over there allows unlimited projects, languages and contributors. The interface is easy to use and collaborative in nature, so you can use it very well to crowdsource translations.

    ReplyDelete

  6. Another easy way to support i18n (localization) is to use Locale.js. (https://github.com/cwtuan/Locale.js). It's simple and you can use it in any javascript project not just in AngularJS.

    ReplyDelete
  7. It's very tough to help overcome within the modern day competitive computer software market and the achievements as well as failing of a solution significantly is determined by globalization. To get around the world achievements, computer software localization services are very important.

    ReplyDelete
  8. Hello everyone! I believe you might find useful posts about software localization on this blog I recommend reading. https://aboutlocalization.wordpress.com/ It covers various and useful topics about the world of localization, including translation and internationalization.

    ReplyDelete