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.
Written by Mandingo Brown
Related protips
1 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