Skip to main content

A simple client-side i18n tool

Yesterday I published a little side project called i18n.js (github.com) that aims to be a simple client-side internationalization tool. Originally developed while I was working on my webpage (diogosimoes.com), despite still being quite callow, it already offers some interesting benefits. You can give it a look, if you wish, in here: codepen.io.

It is worth noticing that I rely on jQuery to perform some DOM manipulation. I know this can be quite controversial, especially these days when for some reason (maybe because of all the excitement around ES6?) everyone seems to be so eager to bash jQuery. I will leave this hot topic for a later post of its own. But I would still like to point out that due to the nature of this utilitary, DOM manipulation would be needed one way or another. And jQuery is still one of the leaniest and most mature ways of traversing and manipulating the DOM, and managing and binding DOM event handlers.

If you look into i18n.js you can see I am simply adding the I18n constructor to the window object and adding a few functions to the I18n prototype.
The I18n constructor doesn't do much. It assigns a default language value if none is provided (english) and then it loads a JSON object containing the multi-language labels, stores it as a property named contents and adds a function named prop to this object holding the labels (I'll explain in detail what it does further below).

this.I18n = function (defaultLang) {
    var lang = defaultLang || 'en';
    this.language = lang;

    (function (i18n) {
        $.getJSON('localized-content.json', function (json) {
            i18n.contents = json;
            i18n.contents.prop = function (key) {
                var result = this;
                var keyArr = key.split('.');
                for (var index = 0; index < keyArr.length; index++) {
                    var prop = keyArr[index];
                    result = result[prop];
                }
                return result;
            };
            i18n.localize();
        });
    })(this);
};

In the end it calls localize() which is where most of the action happens. This function starts by checking if the i18n object has a contents object. Then a helper function is declared, uninventively named dfs(), that performs a traverse of the contents object using a depth first search. Within this function, another helper is declared. It is a isLeaf() test with the particularity that, in this case, we want to consider leaves not the last nodes of the tree but the parents of those (you'll see why next).

this.I18n.prototype.localize = function () {
    var contents = this.contents;
    if (!this.hasCachedContents()) {
        return;
    }
    var dfs = function (node, keys, results) {
        var isLeaf = function (node) {
            for (var prop in node) {
                if (node.hasOwnProperty(prop)) {
                    if (typeof node[prop] === 'string') {
                        return true;
                    }
                }
            }
        }
        for (var prop in node) {
            if (node.hasOwnProperty(prop) && typeof node[prop] === 'object') {
                var myKey = keys.slice();
                myKey.push(prop);
                if (isLeaf(node[prop])) {
                    //results.push(myKey.reduce((prev, current) => prev + '.' + current));  //not supported in older mobile browser
                    results.push(myKey.reduce( function (previousValue, currentValue, currentIndex, array) {
                        return previousValue + '.' + currentValue;
                    }));
                } else {
                    dfs(node[prop], myKey, results);
                }
            }
        }
        return results;
    };

    ...

You can see while no leaf is reached, dfs() is called recursively. When a leaf is reached, the array containing all the keys visited to reach that node is reduced to produce a dot notation key, which in turn is pushed into the results array. Notice the commented line: in the first version I was using an arrow function to perform the reduce, but later found out that Chrome on Android 4.3 (JellyBean) does not support it and had to adopt a more traditional approach. The arrow functions are just more compact and neat. Both implementations are syntactically equivalent.

To put it in context, if your contents object looked like this:

{
    "mySite": {
        "title": {
            "en": "My Website",
            "pt": "A minha página"
        },
        "section": {
            "article": {
                "summary": {
                    "en": "An abridged description of this article...",
                    "pt": "Uma descrição resumida deste artigo..."
                }
            }
        }
    }
}

then dfs() would return an array like this:

[
    "mySite.title",
    "mySite.section.article.summary"
]

You can see now why we had the special isLeaf() test, because we don't want to consider the language properties. Each key is resolved until a language property is found.

Then we just iterate this array and make sure that, for each key, all markup with references to it is resolved. Resolving can happen through replacing either the html node content, the placeholder attribute or the value attribute, depending on which data-i18n extension attribute is being used. You can see now that prop() function mentioned before being used. It just navigates down the contents object to match a given key and retrieve a dictionary with all the <language,value> pairs for that specific key.

    ...

    var keys = dfs(contents, [], []);
    for (var index = 0; index < keys.length; index++) {
        var key = keys[index];
        if (contents.prop(key).hasOwnProperty(this.language)) {
            $('[data-i18n="'+key+'"]').text(contents.prop(key)[this.language]);
            $('[data-i18n-placeholder="'+key+'"]').attr('placeholder', contents.prop(key)[this.language]);
            $('[data-i18n-value="'+key+'"]').attr('value', contents.prop(key)[this.language]);
        } else {
            $('[data-i18n="'+key+'"]').text(contents.prop(key)['en']);
            $('[data-i18n-placeholder="'+key+'"]').attr('placeholder', contents.prop(key)['en']);
            $('[data-i18n-value="'+key+'"]').attr('value', contents.prop(key)['en']);
        }
    }
};

You can try it out and, if you are accustomed to providing i18n over server requests, you'll notice an upswing, with almost instant content swap. Still there is plenty of room for improvement. For instance, the available keys list is being generated everytime localize() is called. Those keys should be memoized.

In any case, I find the usefulness of this handy utilitary to be indisputable. Do you disagree? Drop me a line then.
Good night and good coding.

Comments

Comments powered by Disqus