Last Updated: February 25, 2016
·
7.02K
· filipefmelo

AngularJS and i18n

Hi guys, last week I saw that some of you are having trouble implementing a general solution for i18n in AngularJS. So, here's my 2 cents on the matter.

The problem:
We needed to implement a i18n solution which included YAML support and value replacement so it would be general and easy to customize.

The solution:
We need two things for this to happen properly, a service which can parse YAML and return a JSON object and a LanguageService which will return the user's locale (or force a locale defined by the user, for language change purposes).
So, we wrote a service that loads up a YAML file that is named according to the user's locale (for instance en-us.i18n.yml)

i18n Service

angular.module('MyAppApp.services').service('i18nService', ['yamljs', 'LanguageService', function(yamljs, LanguageService) {
  var errorString = "i18n error: ";
  var self = this;
  self.loaded = false;
  self.isLoading = false;
  self.yaml = {};

  this.replaceValues = function(str, obj, dotnotation) {
    if(obj) {
      var regex;
      for(i in obj) {
        regex = new RegExp("@" + i + "@", "g");
        str = str.replace(regex, obj[i]);
      }
    }
    if(!str) return errorString + dotnotation;
    return str;
  }

  this.recompose = function(obj, string) {

    var parts = string.split('.');
    var newObj = obj[parts[0]];
    if(parts[1]) {
      parts.splice(0, 1);
      var newString = parts.join('.');
      return this.recompose(newObj, newString);
    }
    return newObj;
  }


  this.t = function(dotNotation, objectReplace) {
    if(!self.isLoading) {
      if(self.loaded) {
        return self.replaceValues(self.recompose(self.yaml, dotNotation), objectReplace, dotNotation);
      } else {
        self.isLoading = true;
        yamljs.loadFromFile("yaml/" + LanguageService.getCurrentLanguage() + ".i18n.yml").then(function(o) {
          self.yaml = o;
          self.loaded = true;
          self.isLoading = false;
          return self.replaceValues(self.recompose(self.yaml, dotNotation), objectReplace, dotNotation);

        }, function() {
          console.error("Error loading locale file. Trying default locale file.");
          self.isLoading = true;
          yamljs.loadFromFile("yaml/en-us.i18n.yml").then(function(o) {
            self.yaml = o;
            self.loaded = true;
            self.isLoading = false;
            return self.replaceValues(self.recompose(self.yaml, dotNotation), objectReplace, dotNotation);

          }, function() {
            self.isLoading = false;
            console.error("Error loading default locale YAML file (en-us)");
          });
        });
      }
    }
  };
}]);

YAML JS

angular.module('StoreApp.services').service('yamljs', ['$http', '$q', function($http, $q) {
  var self = this;
// YAML - Core - Copyright TJ Holowaychuk <tj@vision-media.ca> (MIT Licensed)
// Modified by Filipe Melo <filipe.melo@centralway.com> as an AngularJS Service in 17.04.2013

// --- Helpers

  /**
   * Return 'near "context"' where context
   * is replaced by a chunk of _str_.
   *
   * @param  {string} str
   * @return {string}
   * @api public
   */

  var context = function(str) {
    if(typeof str !== 'string') return ''
    str = str
      .slice(0, 25)
      .replace(/\n/g, '\\n')
      .replace(/"/g, '\\\"')
    return 'near "' + str + '"'
  }

// --- Lexer

  /**
   * YAML grammar tokens.
   */

  var tokens = [
    ['comment', /^#[^\n]*/],
    ['indent', /^\n( *)/],
    ['space', /^ +/],
    ['true', /^\b(enabled|true|yes|on)\b/],
    ['false', /^\b(disabled|false|no|off)\b/],
    ['null', /^\b(null|Null|NULL|~)\b/],
    ['string', /^"(.*?)"/],
    ['string', /^'(.*?)'/],
    ['timestamp', /^((\d{4})-(\d\d?)-(\d\d?)(?:(?:[ \t]+)(\d\d?):(\d\d)(?::(\d\d))?)?)/],
    ['float', /^(\d+\.\d+)/],
    ['int', /^(\d+)/],
    ['doc', /^---/],
    [',', /^,/],
    ['{', /^\{(?![^\n\}]*\}[^\n]*[^\s\n\}])/],
    ['}', /^\}/],
    ['[', /^\[(?![^\n\]]*\][^\n]*[^\s\n\]])/],
    [']', /^\]/],
    ['-', /^\-/],
    [':', /^[:]/],
    ['string', /^(?![^:\n\s]*:[^\/]{2})(([^:,\]\}\n\s]|(?!\n)\s(?!\s*?\n)|:\/\/|,(?=[^\n]*\s*[^\]\}\s\n]\s*\n)|[\]\}](?=[^\n]*\s*[^\]    \}\s\n]\s*\n))*)(?=[,:\]\}\s\n]|$)/],
    ['id', /^([\w][\w -]*)/]
  ];

  /**
   * Tokenize the given _str_.
   *
   * @param  {string} str
   * @return {array}
   * @api private
   */

  var tokenize = function(str) {
    var token, captures, ignore, input,
      indents = 0, lastIndents = 0,
      stack = [], indentAmount = -1

    // Windows new line support (CR+LF, \r\n)
    str = str.replace(/\r\n/g, "\n");

    while(str.length) {
      for(var i = 0, len = tokens.length; i < len; ++i)
        if(captures = tokens[i][1].exec(str)) {
          token = [tokens[i][0], captures],
            str = str.replace(tokens[i][1], '')
          switch(token[0]) {
            case 'comment':
              ignore = true
              break
            case 'indent':
              lastIndents = indents
              // determine the indentation amount from the first indent
              if(indentAmount == -1) {
                indentAmount = token[1][1].length
              }

              indents = token[1][1].length / indentAmount
              if(indents === lastIndents)
                ignore = true
              else if(indents > lastIndents + 1)
                throw new SyntaxError('invalid indentation, got ' + indents + ' instead of ' + (lastIndents + 1))
              else if(indents < lastIndents) {
                input = token[1].input
                token = ['dedent']
                token.input = input
                while(--lastIndents > indents)
                  stack.push(token)
              }
          }
          break
        }
      if(!ignore)
        if(token)
          stack.push(token),
            token = null
        else
          throw new SyntaxError(context(str))
      ignore = false
    }
    return stack
  }

// --- Parser

  /**
   * Initialize with _tokens_.
   */

  function Parser(tokens) {
    this.tokens = tokens
  }

  /**
   * Look-ahead a single token.
   *
   * @return {array}
   * @api public
   */

  Parser.prototype.peek = function() {
    return this.tokens[0]
  }

  /**
   * Advance by a single token.
   *
   * @return {array}
   * @api public
   */

  Parser.prototype.advance = function() {
    return this.tokens.shift()
  }

  /**
   * Advance and return the token's value.
   *
   * @return {mixed}
   * @api private
   */

  Parser.prototype.advanceValue = function() {
    return this.advance()[1][1]
  }

  /**
   * Accept _type_ and advance or do nothing.
   *
   * @param  {string} type
   * @return {bool}
   * @api private
   */

  Parser.prototype.accept = function(type) {
    if(this.peekType(type))
      return this.advance()
  }

  /**
   * Expect _type_ or throw an error _msg_.
   *
   * @param  {string} type
   * @param  {string} msg
   * @api private
   */

  Parser.prototype.expect = function(type, msg) {
    if(this.accept(type)) return
    throw new Error(msg + ', ' + context(this.peek()[1].input))
  }

  /**
   * Return the next token type.
   *
   * @return {string}
   * @api private
   */

  Parser.prototype.peekType = function(val) {
    return this.tokens[0] &&
      this.tokens[0][0] === val
  }

  /**
   * space*
   */

  Parser.prototype.ignoreSpace = function() {
    while(this.peekType('space'))
      this.advance()
  }

  /**
   * (space | indent | dedent)*
   */

  Parser.prototype.ignoreWhitespace = function() {
    while(this.peekType('space') ||
      this.peekType('indent') ||
      this.peekType('dedent'))
      this.advance()
  }

  /**
   *   block
   * | doc
   * | list
   * | inlineList
   * | hash
   * | inlineHash
   * | string
   * | float
   * | int
   * | true
   * | false
   * | null
   */

  Parser.prototype.parse = function() {
    switch(this.peek()[0]) {
      case 'doc':
        return this.parseDoc()
      case '-':
        return this.parseList()
      case '{':
        return this.parseInlineHash()
      case '[':
        return this.parseInlineList()
      case 'id':
        return this.parseHash()
      case 'string':
        return this.advanceValue()
      case 'timestamp':
        return this.parseTimestamp()
      case 'float':
        return parseFloat(this.advanceValue())
      case 'int':
        return parseInt(this.advanceValue())
      case 'true':
        this.advanceValue();
        return true
      case 'false':
        this.advanceValue();
        return false
      case 'null':
        this.advanceValue();
        return null
    }
  }

  /**
   * '---'? indent expr dedent
   */

  Parser.prototype.parseDoc = function() {
    this.accept('doc')
    this.expect('indent', 'expected indent after document')
    var val = this.parse()
    this.expect('dedent', 'document not properly dedented')
    return val
  }

  /**
   *  ( id ':' - expr -
   *  | id ':' - indent expr dedent
   *  )+
   */

  Parser.prototype.parseHash = function() {
    var id, hash = {}
    while(this.peekType('id') && (id = this.advanceValue())) {
      this.expect(':', 'expected semi-colon after id')
      this.ignoreSpace()
      if(this.accept('indent'))
        hash[id] = this.parse(),
          this.expect('dedent', 'hash not properly dedented')
      else
        hash[id] = this.parse()
      this.ignoreSpace()
    }
    return hash
  }

  /**
   * '{' (- ','? ws id ':' - expr ws)* '}'
   */

  Parser.prototype.parseInlineHash = function() {
    var hash = {}, id, i = 0
    this.accept('{')
    while(!this.accept('}')) {
      this.ignoreSpace()
      if(i) this.expect(',', 'expected comma')
      this.ignoreWhitespace()
      if(this.peekType('id') && (id = this.advanceValue())) {
        this.expect(':', 'expected semi-colon after id')
        this.ignoreSpace()
        hash[id] = this.parse()
        this.ignoreWhitespace()
      }
      ++i
    }
    return hash
  }

  /**
   *  ( '-' - expr -
   *  | '-' - indent expr dedent
   *  )+
   */

  Parser.prototype.parseList = function() {
    var list = []
    while(this.accept('-')) {
      this.ignoreSpace()
      if(this.accept('indent'))
        list.push(this.parse()),
          this.expect('dedent', 'list item not properly dedented')
      else
        list.push(this.parse())
      this.ignoreSpace()
    }
    return list
  }

  /**
   * '[' (- ','? - expr -)* ']'
   */

  Parser.prototype.parseInlineList = function() {
    var list = [], i = 0
    this.accept('[')
    while(!this.accept(']')) {
      this.ignoreSpace()
      if(i) this.expect(',', 'expected comma')
      this.ignoreSpace()
      list.push(this.parse())
      this.ignoreSpace()
      ++i
    }
    return list
  }

  /**
   * yyyy-mm-dd hh:mm:ss
   *
   * For full format: http://yaml.org/type/timestamp.html
   */

  Parser.prototype.parseTimestamp = function() {
    var token = this.advance()[1]
    var date = new Date
    var year = token[2]
      , month = token[3]
      , day = token[4]
      , hour = token[5] || 0
      , min = token[6] || 0
      , sec = token[7] || 0

    date.setUTCFullYear(year, month - 1, day)
    date.setUTCHours(hour)
    date.setUTCMinutes(min)
    date.setUTCSeconds(sec)
    date.setUTCMilliseconds(0)
    return date
  }

  /**
   * Evaluate a _str_ of yaml.
   *
   * @param  {string} str
   * @return {mixed}
   * @api private
   */

  var parse = function(str) {
    return (new Parser(tokenize(str))).parse();
  }

  /**
   * Load YAML from a file
   *
   * @param  {string} filename
   * @param  {object} ctx
   * @param  {function} fn
   * @api public
   */

  this.loadFromFile = function(filename, ctx, fnSuccess, fnError) {
    var deferred = $q.defer();
    $http({ method: 'GET', url: filename }).success(function(response) {
      deferred.resolve(parse(response));
    }).error(function() {
        deferred.reject({});
    });

    return deferred.promise;
  };

  return this;
}]);

And of course an example YAML file:

YAML file

login:
  title: "Login"
welcome:
  title: "Welcome @user@"

How to use it? Here goes:

//Javascript after injecting the service into your controller, you can use it this way
var translated_login_title = i18nService.t("login.title"); //should return "Login"

var replacement_object = {
    user: "CoderWall"
}
var translated_and_replaced_welcome_title = i18nService.t("welcome.title", replacement_object); //should return "Welcome CoderWall"

This concludes the i18n how to, if you have any question, write them down in the comments section.

1 Response
Add your response

Hey there! Nice article, and yea we're already working on a defacto solution for i18n in angular apps. Maybe you're interested on helping out? Checkout angular-translate right here: http://pascalprecht.github.io/angular-translate

over 1 year ago ·