├── example ├── i18n.json └── example1.html ├── README.md └── l10n.js /example/i18n.json: -------------------------------------------------------------------------------- 1 | { 2 | "de":{ 3 | "test":"Das ist ein Test.", 4 | "test.title":"Klick mich!" 5 | }, 6 | "en":{ 7 | "test":"This is a test.", 8 | "test.title":"Click me!" 9 | }, 10 | "fr":{ 11 | "test":"Ceci est un test.", 12 | "test.title":"cliquez-moi!" 13 | }, 14 | "eo":{ 15 | "test":"Tio estas testo.", 16 | "test.title":"Alklaku min!" 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /example/example1.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 |
16 | 17 | 18 | 23 | 24 | 25 | 26 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # client-side, cross-browser l10n for modern web applications 2 | 3 | Unlike other i18n/l10n libraries, html10n.js provides: 4 | 5 | * declarative localization: elements with `l10n-*` attributes are automatically translated when the document is loaded 6 | * named variables instead of printf-like `%s` tokens 7 | * a simple and full-featured pluralization system 8 | 9 | Thanks to @fabi1cazenave for his original work on [webL10n](https://github.com/fabi1cazenave/webL10n/wiki) on which this project is based. Instead of featuring some `*.ini`/`*.properties`/`*.lol` format, this project expects translations to be provided in JSON for easier handling in JavaScript and better client-side performance. 10 | 11 | ## Example 12 | 13 | Here’s a quick way to get a multilingual HTML page: 14 | 15 | ```html 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | ``` 26 | 27 | * l10n resource files are associated to the HTML document with a `` element 28 | * translatable elements carry a `data-l10n-id` attribute and optionally a `data-l10n-args` attribute containing the message arguments as JSON. 29 | * l10n resources are stored in a bullet-proof `*.json` file: 30 | 31 | ```json 32 | { 33 | "en": { 34 | "test": "This is a test", 35 | "test.title": "click me!" 36 | }, 37 | "fr": { 38 | "test": "Ceci est un test", 39 | "test.title": "cliquez-moi!" 40 | } 41 | } 42 | ``` 43 | 44 | 45 | # JavaScript API 46 | 47 | `html10n.js` exposes a rather simple API, all contained in the `html10n` object. 48 | 49 | * `localized` event: fired when the page has been translated; 50 | * `localize` method: set the ISO-639-1 code of the current locale and start translating the document; 51 | * `get` method: get a translated string. 52 | 53 | ```javascript 54 | html10n.bind("localized", function() { 55 | console.log("Localized!"); 56 | }); 57 | 58 | html10n.localize('fr'); 59 | ``` 60 | 61 | Do note, that html1n.js waits for the document to be fully loaded, until it loads translation. You could circumvent this by calling `html10n.index()` manually, but it's not advised. 62 | 63 | ```javascript 64 | var message = html10n.get('test'); 65 | alert(message); 66 | ``` 67 | 68 | You will probably use the gettext-like alias: 69 | 70 | ```javascript 71 | alert(_('test')); 72 | ``` 73 | 74 | To handle complex strings, the `get()` method can accept optional arguments: 75 | 76 | ```javascript 77 | alert(_('welcome', { user: "John" })); 78 | ``` 79 | 80 | where `welcome` is defined like this: 81 | 82 | ```json 83 | { 84 | "en": { 85 | "welcome": "welcome, {{user}}!" 86 | }, 87 | "fr": { 88 | "welcome": "bienvenue, {{user}} !" 89 | } 90 | } 91 | ``` 92 | 93 | ### l10n arguments 94 | 95 | You can specify a default value in JSON for any argument in the HTML document with the `data-l10n-args` attribute. In the last example, that would be: 96 | 97 | ```html 98 |

Welcome!

99 | ``` 100 | 101 | ### include rules 102 | 103 | If you don’t want to have all your locales in a single file, simply use an include rule in your locale files to include another language: 104 | 105 | ```js 106 | { "en": { 107 | // ... 108 | }, 109 | "fr": "/locales/fr.json", 110 | "nl": "/locales/nl.json" 111 | } 112 | ``` 113 | 114 | ### Pluralization 115 | 116 | The following string might be gramatically incorrect when `n` equals zero or one: 117 | 118 | ``` 119 | "unread": "You have {{n}} unread messages" 120 | ``` 121 | 122 | This can be solved by using the pre-defined `plural()` macro: 123 | 124 | ```json 125 | { 126 | "en": { 127 | "unreadMessages": "You have {{n}} unread {[ plural(n) one: message, other: messages ]}" 128 | } 129 | } 130 | ``` 131 | 132 | Here, `plural()` is a macro taking a selector param based on which it picks on of the provided options. 133 | 134 | `plural()` chooses between `zero | one | two | few | many | other`, depending on the param (`n`) and the current language, as specified in the [Unicode plural rules](http://www.unicode.org/cldr/charts/supplemental/language_plural_rules.html). If one of these indexes isn’t specified in your message, `other` will be used instead. 135 | 136 | 137 | ### Macros 138 | Macros are defined in `html10n.macros` as functions taking the following arguments: `(key, param, opts)`, where 139 | 140 | * `key` (String) is the key of the current message 141 | * `param` (mixed) is the value of the argument, passed as the macro parameter 142 | * `opts` (Object) is a hash of options that may be used to produce the output (s. `plural()` macro above) 143 | 144 | All macros should return a string that will replace the macro in the message. 145 | 146 | ### Translating DOM properties 147 | By default, we currently assume that all strings are applied to the `textContent` DOM node property. 148 | However, you can modify other properties of the relevant DOM nodes by choosing a message id that ends with the property name like so: 149 | 150 | ```json 151 | { 152 | "welcome.innerHTML": "welcome, {{user}}!" 153 | } 154 | ``` 155 | 156 | 157 | # Browser support 158 | Should work with Firefox, Chrome, Opera and Internet Explorer 6 to 10. 159 | 160 | 161 | # License 162 | MIT license. 163 | -------------------------------------------------------------------------------- /l10n.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) 2012 Marcel Klehr 3 | * Copyright (c) 2011-2012 Fabien Cazenave, Mozilla 4 | * 5 | * Permission is hereby granted, free of charge, to any person obtaining a copy 6 | * of this software and associated documentation files (the "Software"), to 7 | * deal in the Software without restriction, including without limitation the 8 | * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or 9 | * sell copies of the Software, and to permit persons to whom the Software is 10 | * furnished to do so, subject to the following conditions: 11 | * 12 | * The above copyright notice and this permission notice shall be included in 13 | * all copies or substantial portions of the Software. 14 | * 15 | * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING 20 | * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS 21 | * IN THE SOFTWARE. 22 | */ 23 | window.html10n = (function(window, document, undefined) { 24 | 25 | // fix console 26 | var console = window.console || {}; 27 | (function() { 28 | var noop = function() {}; 29 | var names = ["log", "debug", "info", "warn", "error", "assert", "dir", "dirxml", "group", "groupEnd", "time", "timeEnd", "count", "trace", "profile", "profileEnd"]; 30 | for (var i = 0; i < names.length; ++i) { 31 | if (!console[names[i]]) { 32 | console[names[i]] = noop; 33 | } 34 | } 35 | }()); 36 | 37 | // fix Array#forEach in IE 38 | // taken from https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/forEach 39 | if (!Array.prototype.forEach) { 40 | Array.prototype.forEach = function(fn, scope) { 41 | for(var i = 0, len = this.length; i < len; ++i) { 42 | if (i in this) { 43 | fn.call(scope, this[i], i, this); 44 | } 45 | } 46 | }; 47 | } 48 | 49 | // fix Array#indexOf in, guess what, IE! <3 50 | // taken from https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/indexOf 51 | if (!Array.prototype.indexOf) { 52 | Array.prototype.indexOf = function (searchElement /*, fromIndex */ ) { 53 | "use strict"; 54 | if (this == null) { 55 | throw new TypeError(); 56 | } 57 | var t = Object(this); 58 | var len = t.length >>> 0; 59 | if (len === 0) { 60 | return -1; 61 | } 62 | var n = 0; 63 | if (arguments.length > 1) { 64 | n = Number(arguments[1]); 65 | if (n != n) { // shortcut for verifying if it's NaN 66 | n = 0; 67 | } else if (n != 0 && n != Infinity && n != -Infinity) { 68 | n = (n > 0 || -1) * Math.floor(Math.abs(n)); 69 | } 70 | } 71 | if (n >= len) { 72 | return -1; 73 | } 74 | var k = n >= 0 ? n : Math.max(len - Math.abs(n), 0); 75 | for (; k < len; k++) { 76 | if (k in t && t[k] === searchElement) { 77 | return k; 78 | } 79 | } 80 | return -1; 81 | } 82 | } 83 | 84 | /** 85 | * MicroEvent - to make any js object an event emitter (server or browser) 86 | */ 87 | 88 | var MicroEvent = function(){} 89 | MicroEvent.prototype = { 90 | bind: function(event, fct){ 91 | this._events = this._events || {}; 92 | this._events[event] = this._events[event] || []; 93 | this._events[event].push(fct); 94 | }, 95 | unbind: function(event, fct){ 96 | this._events = this._events || {}; 97 | if( event in this._events === false ) return; 98 | this._events[event].splice(this._events[event].indexOf(fct), 1); 99 | }, 100 | trigger: function(event /* , args... */){ 101 | this._events = this._events || {}; 102 | if( event in this._events === false ) return; 103 | for(var i = 0; i < this._events[event].length; i++){ 104 | this._events[event][i].apply(this, Array.prototype.slice.call(arguments, 1)) 105 | } 106 | } 107 | }; 108 | /** 109 | * mixin will delegate all MicroEvent.js function in the destination object 110 | * @param {Object} the object which will support MicroEvent 111 | */ 112 | MicroEvent.mixin = function(destObject){ 113 | var props = ['bind', 'unbind', 'trigger']; 114 | if(!destObject) return; 115 | for(var i = 0; i < props.length; i ++){ 116 | destObject[props[i]] = MicroEvent.prototype[props[i]]; 117 | } 118 | } 119 | 120 | /** 121 | * Loader 122 | * The loader is responsible for loading 123 | * and caching all necessary resources 124 | */ 125 | function Loader(resources) { 126 | this.resources = resources 127 | this.cache = {} // file => contents 128 | this.langs = {} // lang => strings 129 | } 130 | 131 | Loader.prototype.load = function(lang, cb) { 132 | if(this.langs[lang]) return cb() 133 | 134 | if (this.resources.length > 0) { 135 | var reqs = 0; 136 | for (var i=0, n=this.resources.length; i < n; i++) { 137 | this.fetch(this.resources[i], lang, function(e) { 138 | reqs++; 139 | if(e) console.warn(e) 140 | 141 | if (reqs < n) return;// Call back once all reqs are completed 142 | cb && cb() 143 | }) 144 | } 145 | } 146 | } 147 | 148 | Loader.prototype.fetch = function(href, lang, cb) { 149 | var that = this 150 | 151 | if (this.cache[href]) { 152 | this.parse(lang, href, this.cache[href], cb) 153 | return; 154 | } 155 | 156 | var xhr = new XMLHttpRequest() 157 | xhr.open('GET', href, /*async: */true) 158 | if (xhr.overrideMimeType) { 159 | xhr.overrideMimeType('application/json; charset=utf-8'); 160 | } 161 | xhr.onreadystatechange = function() { 162 | if (xhr.readyState == 4) { 163 | if (xhr.status == 200 || xhr.status === 0) { 164 | var data = JSON.parse(xhr.responseText) 165 | that.cache[href] = data 166 | // Pass on the contents for parsing 167 | that.parse(lang, href, data, cb) 168 | } else { 169 | cb(new Error('Failed to load '+href)) 170 | } 171 | } 172 | }; 173 | xhr.send(null); 174 | } 175 | 176 | Loader.prototype.parse = function(lang, currHref, data, cb) { 177 | if ('object' != typeof data) { 178 | cb(new Error('A file couldn\'t be parsed as json.')) 179 | return 180 | } 181 | 182 | // dat alng ain't here, man! 183 | if (!data[lang]) { 184 | var msg = 'Couldn\'t find translations for '+lang 185 | , l 186 | if(~lang.indexOf('-')) lang = lang.split('-')[0] // then let's try related langs 187 | for(l in data) { 188 | if(lang != l && l.indexOf(lang) === 0 && data[l]) { 189 | lang = l 190 | break; 191 | } 192 | } 193 | if(lang != l) return cb(new Error(msg)) 194 | } 195 | 196 | if ('string' == typeof data[lang]) { 197 | // Import rule 198 | 199 | // absolute path 200 | var importUrl = data[lang] 201 | 202 | // relative path 203 | if(data[lang].indexOf("http") != 0 && data[lang].indexOf("/") != 0) { 204 | importUrl = currHref+"/../"+data[lang] 205 | } 206 | 207 | this.fetch(importUrl, lang, cb) 208 | return 209 | } 210 | 211 | if ('object' != typeof data[lang]) { 212 | cb(new Error('Translations should be specified as JSON objects!')) 213 | return 214 | } 215 | 216 | this.langs[lang] = data[lang] 217 | // TODO: Also store accompanying langs 218 | cb() 219 | } 220 | 221 | 222 | /** 223 | * The html10n object 224 | */ 225 | var html10n = 226 | { language : null 227 | } 228 | MicroEvent.mixin(html10n) 229 | 230 | html10n.macros = {} 231 | 232 | html10n.rtl = ["ar","dv","fa","ha","he","ks","ku","ps","ur","yi"] 233 | 234 | /** 235 | * Language-Script fallbacks for Language-Region language tags, for languages that 236 | * varies heavily on writing form and two-part locale expansion is not feasible. 237 | * See also: https://tools.ietf.org/html/rfc4646 (RFC 4646) 238 | */ 239 | html10n.scripts = { 240 | 'zh-tw': 'zh-hant', 241 | 'zh-hk': 'zh-hant', 242 | 'zh-cn': 'zh-hans' 243 | } 244 | 245 | /** 246 | * Get rules for plural forms (shared with JetPack), see: 247 | * http://unicode.org/repos/cldr-tmp/trunk/diff/supplemental/language_plural_rules.html 248 | * https://github.com/mozilla/addon-sdk/blob/master/python-lib/plural-rules-generator.p 249 | * 250 | * @param {string} lang 251 | * locale (language) used. 252 | * 253 | * @return {Function} 254 | * returns a function that gives the plural form name for a given integer: 255 | * var fun = getPluralRules('en'); 256 | * fun(1) -> 'one' 257 | * fun(0) -> 'other' 258 | * fun(1000) -> 'other'. 259 | */ 260 | function getPluralRules(lang) { 261 | var locales2rules = { 262 | 'af': 3, 263 | 'ak': 4, 264 | 'am': 4, 265 | 'ar': 1, 266 | 'asa': 3, 267 | 'az': 0, 268 | 'be': 11, 269 | 'bem': 3, 270 | 'bez': 3, 271 | 'bg': 3, 272 | 'bh': 4, 273 | 'bm': 0, 274 | 'bn': 3, 275 | 'bo': 0, 276 | 'br': 20, 277 | 'brx': 3, 278 | 'bs': 11, 279 | 'ca': 3, 280 | 'cgg': 3, 281 | 'chr': 3, 282 | 'cs': 12, 283 | 'cy': 17, 284 | 'da': 3, 285 | 'de': 3, 286 | 'dv': 3, 287 | 'dz': 0, 288 | 'ee': 3, 289 | 'el': 3, 290 | 'en': 3, 291 | 'eo': 3, 292 | 'es': 3, 293 | 'et': 3, 294 | 'eu': 3, 295 | 'fa': 0, 296 | 'ff': 5, 297 | 'fi': 3, 298 | 'fil': 4, 299 | 'fo': 3, 300 | 'fr': 5, 301 | 'fur': 3, 302 | 'fy': 3, 303 | 'ga': 8, 304 | 'gd': 24, 305 | 'gl': 3, 306 | 'gsw': 3, 307 | 'gu': 3, 308 | 'guw': 4, 309 | 'gv': 23, 310 | 'ha': 3, 311 | 'haw': 3, 312 | 'he': 2, 313 | 'hi': 4, 314 | 'hr': 11, 315 | 'hu': 0, 316 | 'id': 0, 317 | 'ig': 0, 318 | 'ii': 0, 319 | 'is': 3, 320 | 'it': 3, 321 | 'iu': 7, 322 | 'ja': 0, 323 | 'jmc': 3, 324 | 'jv': 0, 325 | 'ka': 0, 326 | 'kab': 5, 327 | 'kaj': 3, 328 | 'kcg': 3, 329 | 'kde': 0, 330 | 'kea': 0, 331 | 'kk': 3, 332 | 'kl': 3, 333 | 'km': 0, 334 | 'kn': 0, 335 | 'ko': 0, 336 | 'ksb': 3, 337 | 'ksh': 21, 338 | 'ku': 3, 339 | 'kw': 7, 340 | 'lag': 18, 341 | 'lb': 3, 342 | 'lg': 3, 343 | 'ln': 4, 344 | 'lo': 0, 345 | 'lt': 10, 346 | 'lv': 6, 347 | 'mas': 3, 348 | 'mg': 4, 349 | 'mk': 16, 350 | 'ml': 3, 351 | 'mn': 3, 352 | 'mo': 9, 353 | 'mr': 3, 354 | 'ms': 0, 355 | 'mt': 15, 356 | 'my': 0, 357 | 'nah': 3, 358 | 'naq': 7, 359 | 'nb': 3, 360 | 'nd': 3, 361 | 'ne': 3, 362 | 'nl': 3, 363 | 'nn': 3, 364 | 'no': 3, 365 | 'nr': 3, 366 | 'nso': 4, 367 | 'ny': 3, 368 | 'nyn': 3, 369 | 'om': 3, 370 | 'or': 3, 371 | 'pa': 3, 372 | 'pap': 3, 373 | 'pl': 13, 374 | 'ps': 3, 375 | 'pt': 3, 376 | 'rm': 3, 377 | 'ro': 9, 378 | 'rof': 3, 379 | 'ru': 11, 380 | 'rwk': 3, 381 | 'sah': 0, 382 | 'saq': 3, 383 | 'se': 7, 384 | 'seh': 3, 385 | 'ses': 0, 386 | 'sg': 0, 387 | 'sh': 11, 388 | 'shi': 19, 389 | 'sk': 12, 390 | 'sl': 14, 391 | 'sma': 7, 392 | 'smi': 7, 393 | 'smj': 7, 394 | 'smn': 7, 395 | 'sms': 7, 396 | 'sn': 3, 397 | 'so': 3, 398 | 'sq': 3, 399 | 'sr': 11, 400 | 'ss': 3, 401 | 'ssy': 3, 402 | 'st': 3, 403 | 'sv': 3, 404 | 'sw': 3, 405 | 'syr': 3, 406 | 'ta': 3, 407 | 'te': 3, 408 | 'teo': 3, 409 | 'th': 0, 410 | 'ti': 4, 411 | 'tig': 3, 412 | 'tk': 3, 413 | 'tl': 4, 414 | 'tn': 3, 415 | 'to': 0, 416 | 'tr': 0, 417 | 'ts': 3, 418 | 'tzm': 22, 419 | 'uk': 11, 420 | 'ur': 3, 421 | 've': 3, 422 | 'vi': 0, 423 | 'vun': 3, 424 | 'wa': 4, 425 | 'wae': 3, 426 | 'wo': 0, 427 | 'xh': 3, 428 | 'xog': 3, 429 | 'yo': 0, 430 | 'zh': 0, 431 | 'zu': 3 432 | }; 433 | 434 | // utility functions for plural rules methods 435 | function isIn(n, list) { 436 | return list.indexOf(n) !== -1; 437 | } 438 | function isBetween(n, start, end) { 439 | return start <= n && n <= end; 440 | } 441 | 442 | // list of all plural rules methods: 443 | // map an integer to the plural form name to use 444 | var pluralRules = { 445 | '0': function(n) { 446 | return 'other'; 447 | }, 448 | '1': function(n) { 449 | if ((isBetween((n % 100), 3, 10))) 450 | return 'few'; 451 | if (n === 0) 452 | return 'zero'; 453 | if ((isBetween((n % 100), 11, 99))) 454 | return 'many'; 455 | if (n == 2) 456 | return 'two'; 457 | if (n == 1) 458 | return 'one'; 459 | return 'other'; 460 | }, 461 | '2': function(n) { 462 | if (n !== 0 && (n % 10) === 0) 463 | return 'many'; 464 | if (n == 2) 465 | return 'two'; 466 | if (n == 1) 467 | return 'one'; 468 | return 'other'; 469 | }, 470 | '3': function(n) { 471 | if (n == 1) 472 | return 'one'; 473 | return 'other'; 474 | }, 475 | '4': function(n) { 476 | if ((isBetween(n, 0, 1))) 477 | return 'one'; 478 | return 'other'; 479 | }, 480 | '5': function(n) { 481 | if ((isBetween(n, 0, 2)) && n != 2) 482 | return 'one'; 483 | return 'other'; 484 | }, 485 | '6': function(n) { 486 | if (n === 0) 487 | return 'zero'; 488 | if ((n % 10) == 1 && (n % 100) != 11) 489 | return 'one'; 490 | return 'other'; 491 | }, 492 | '7': function(n) { 493 | if (n == 2) 494 | return 'two'; 495 | if (n == 1) 496 | return 'one'; 497 | return 'other'; 498 | }, 499 | '8': function(n) { 500 | if ((isBetween(n, 3, 6))) 501 | return 'few'; 502 | if ((isBetween(n, 7, 10))) 503 | return 'many'; 504 | if (n == 2) 505 | return 'two'; 506 | if (n == 1) 507 | return 'one'; 508 | return 'other'; 509 | }, 510 | '9': function(n) { 511 | if (n === 0 || n != 1 && (isBetween((n % 100), 1, 19))) 512 | return 'few'; 513 | if (n == 1) 514 | return 'one'; 515 | return 'other'; 516 | }, 517 | '10': function(n) { 518 | if ((isBetween((n % 10), 2, 9)) && !(isBetween((n % 100), 11, 19))) 519 | return 'few'; 520 | if ((n % 10) == 1 && !(isBetween((n % 100), 11, 19))) 521 | return 'one'; 522 | return 'other'; 523 | }, 524 | '11': function(n) { 525 | if ((isBetween((n % 10), 2, 4)) && !(isBetween((n % 100), 12, 14))) 526 | return 'few'; 527 | if ((n % 10) === 0 || 528 | (isBetween((n % 10), 5, 9)) || 529 | (isBetween((n % 100), 11, 14))) 530 | return 'many'; 531 | if ((n % 10) == 1 && (n % 100) != 11) 532 | return 'one'; 533 | return 'other'; 534 | }, 535 | '12': function(n) { 536 | if ((isBetween(n, 2, 4))) 537 | return 'few'; 538 | if (n == 1) 539 | return 'one'; 540 | return 'other'; 541 | }, 542 | '13': function(n) { 543 | if ((isBetween((n % 10), 2, 4)) && !(isBetween((n % 100), 12, 14))) 544 | return 'few'; 545 | if (n != 1 && (isBetween((n % 10), 0, 1)) || 546 | (isBetween((n % 10), 5, 9)) || 547 | (isBetween((n % 100), 12, 14))) 548 | return 'many'; 549 | if (n == 1) 550 | return 'one'; 551 | return 'other'; 552 | }, 553 | '14': function(n) { 554 | if ((isBetween((n % 100), 3, 4))) 555 | return 'few'; 556 | if ((n % 100) == 2) 557 | return 'two'; 558 | if ((n % 100) == 1) 559 | return 'one'; 560 | return 'other'; 561 | }, 562 | '15': function(n) { 563 | if (n === 0 || (isBetween((n % 100), 2, 10))) 564 | return 'few'; 565 | if ((isBetween((n % 100), 11, 19))) 566 | return 'many'; 567 | if (n == 1) 568 | return 'one'; 569 | return 'other'; 570 | }, 571 | '16': function(n) { 572 | if ((n % 10) == 1 && n != 11) 573 | return 'one'; 574 | return 'other'; 575 | }, 576 | '17': function(n) { 577 | if (n == 3) 578 | return 'few'; 579 | if (n === 0) 580 | return 'zero'; 581 | if (n == 6) 582 | return 'many'; 583 | if (n == 2) 584 | return 'two'; 585 | if (n == 1) 586 | return 'one'; 587 | return 'other'; 588 | }, 589 | '18': function(n) { 590 | if (n === 0) 591 | return 'zero'; 592 | if ((isBetween(n, 0, 2)) && n !== 0 && n != 2) 593 | return 'one'; 594 | return 'other'; 595 | }, 596 | '19': function(n) { 597 | if ((isBetween(n, 2, 10))) 598 | return 'few'; 599 | if ((isBetween(n, 0, 1))) 600 | return 'one'; 601 | return 'other'; 602 | }, 603 | '20': function(n) { 604 | if ((isBetween((n % 10), 3, 4) || ((n % 10) == 9)) && !( 605 | isBetween((n % 100), 10, 19) || 606 | isBetween((n % 100), 70, 79) || 607 | isBetween((n % 100), 90, 99) 608 | )) 609 | return 'few'; 610 | if ((n % 1000000) === 0 && n !== 0) 611 | return 'many'; 612 | if ((n % 10) == 2 && !isIn((n % 100), [12, 72, 92])) 613 | return 'two'; 614 | if ((n % 10) == 1 && !isIn((n % 100), [11, 71, 91])) 615 | return 'one'; 616 | return 'other'; 617 | }, 618 | '21': function(n) { 619 | if (n === 0) 620 | return 'zero'; 621 | if (n == 1) 622 | return 'one'; 623 | return 'other'; 624 | }, 625 | '22': function(n) { 626 | if ((isBetween(n, 0, 1)) || (isBetween(n, 11, 99))) 627 | return 'one'; 628 | return 'other'; 629 | }, 630 | '23': function(n) { 631 | if ((isBetween((n % 10), 1, 2)) || (n % 20) === 0) 632 | return 'one'; 633 | return 'other'; 634 | }, 635 | '24': function(n) { 636 | if ((isBetween(n, 3, 10) || isBetween(n, 13, 19))) 637 | return 'few'; 638 | if (isIn(n, [2, 12])) 639 | return 'two'; 640 | if (isIn(n, [1, 11])) 641 | return 'one'; 642 | return 'other'; 643 | } 644 | }; 645 | 646 | // return a function that gives the plural form name for a given integer 647 | var index = locales2rules[lang.replace(/-.*$/, '')]; 648 | if (!(index in pluralRules)) { 649 | console.warn('plural form unknown for [' + lang + ']'); 650 | return function() { return 'other'; }; 651 | } 652 | return pluralRules[index]; 653 | } 654 | 655 | /** 656 | * pre-defined 'plural' macro 657 | */ 658 | html10n.macros.plural = function(key, param, opts) { 659 | var str 660 | , n = parseFloat(param); 661 | if (isNaN(n)) 662 | return; 663 | 664 | // initialize _pluralRules 665 | if (!this._pluralRules) 666 | this._pluralRules = getPluralRules(html10n.language); 667 | var index = this._pluralRules(n); 668 | 669 | // try to find a [zero|one|two] key if it's defined 670 | if (n === 0 && ('zero') in opts) { 671 | str = opts['zero']; 672 | } else if (n == 1 && ('one') in opts) { 673 | str = opts['one']; 674 | } else if (n == 2 && ('two') in opts) { 675 | str = opts['two']; 676 | } else if (index in opts) { 677 | str = opts[index]; 678 | } 679 | 680 | return str; 681 | }; 682 | 683 | /** Prepare localization context: 684 | * 685 | * - Populate translations with strings for the indicated languages 686 | * - adding in non-qualified versions of language codes immediately 687 | * after any qualified (e.g., "en" for "en-GB") 688 | * - Trigger "localized" event. 689 | * 690 | * @param {array} langs: diminishing-precedence lang codes, or one string. 691 | */ 692 | html10n.localize = function(langs) { 693 | var that = this, 694 | candidates = []; 695 | // if a single string, bundle it as an array: 696 | if ('string' == typeof langs) { 697 | langs = [langs]; 698 | } 699 | 700 | // Determine candidates from langs: 701 | // - Omitting empty strings 702 | // - Adding in non-qualified versions of country-qualified codes. 703 | langs.forEach(function(lang) { 704 | var splat; 705 | if(!lang) { return; } 706 | (candidates.indexOf(lang) == -1) && candidates.push(lang); 707 | splat = lang.split('-'); 708 | if (splat[1]) { 709 | (candidates.indexOf(splat[0]) == -1) && candidates.push(splat[0]); 710 | } 711 | }); 712 | 713 | // Append script fallbacks for region-specific locales if applicable 714 | for (var lang in html10n.scripts) { 715 | i = candidates.indexOf(lang); 716 | if (~i) candidates.splice(i, 0, html10n.scripts[lang]) 717 | } 718 | 719 | this.build(candidates, function(er, translations) { 720 | html10n.translations = translations 721 | html10n.translateElement(translations) 722 | that.trigger('localized') 723 | }) 724 | } 725 | 726 | /** 727 | * Triggers the translation process 728 | * for an element 729 | * @param translations A hash of all translation strings 730 | * @param element A DOM element, if omitted, the document element will be used 731 | */ 732 | html10n.translateElement = function(translations, element) { 733 | element = element || document.documentElement 734 | 735 | var children = element? getTranslatableChildren(element) : document.childNodes; 736 | for (var i=0, n=children.length; i < n; i++) { 737 | this.translateNode(translations, children[i]) 738 | } 739 | 740 | // translate element itself if necessary 741 | this.translateNode(translations, element) 742 | } 743 | 744 | function asyncForEach(list, iterator, cb) { 745 | var i = 0 746 | , n = list.length 747 | iterator(list[i], i, function each(err) { 748 | if(err) console.log(err) 749 | i++ 750 | if (i < n) return iterator(list[i],i, each); 751 | cb() 752 | }) 753 | } 754 | 755 | function getTranslatableChildren(element) { 756 | if(!document.querySelectorAll) { 757 | if (!element) return [] 758 | var nodes = element.getElementsByTagName('*') 759 | , l10nElements = [] 760 | for (var i=0, n=nodes.length; i < n; i++) { 761 | if (nodes[i].getAttribute('data-l10n-id')) 762 | l10nElements.push(nodes[i]); 763 | } 764 | return l10nElements 765 | } 766 | return element.querySelectorAll('*[data-l10n-id]') 767 | } 768 | 769 | html10n.get = function(id, args) { 770 | var translations = html10n.translations 771 | if(!translations) { 772 | if (! html10n.quiet) { 773 | console.warn('No translations available (yet)'); 774 | } 775 | return; 776 | } 777 | if(!translations[id]) return console.warn('Could not find string '+id) 778 | 779 | // apply macros 780 | var str = translations[id] 781 | 782 | str = substMacros(id, str, args) 783 | 784 | // apply args 785 | str = substArguments(str, args) 786 | 787 | return str 788 | } 789 | 790 | // replace {{arguments}} with their values or the 791 | // associated translation string (based on its key) 792 | function substArguments(str, args) { 793 | var reArgs = /\{\{\s*([a-zA-Z_\-\.]+)\s*\}\}/, 794 | translations = html10n.translations, 795 | match; 796 | 797 | while (match = reArgs.exec(str)) { 798 | if (!match || match.length < 2) 799 | return str // argument key not found 800 | 801 | var arg = match[1] 802 | , sub = '' 803 | if (args && (arg in args)) { 804 | sub = args[arg] 805 | } else if (arg in translations) { 806 | sub = translations[arg] 807 | } else { 808 | console.warn('Could not satisfy argument {{' + arg + '}}' + 809 | ' for string "' + str + '"'); 810 | return str 811 | } 812 | 813 | str = str.substring(0, match.index) + sub + str.substr(match.index + match[0].length) 814 | } 815 | 816 | return str 817 | } 818 | 819 | // replace {[macros]} with their values 820 | function substMacros(key, str, args) { 821 | var regex = /\{\[\s*([a-zA-Z]+)\(([a-zA-Z]+)\)((\s*([a-zA-Z]+)\: ?([^,]+?),?)+)*\s*\]\}/ //.exec('{{n}} {[plural(n) one: Bomba, other: Bombe]} ]}') 822 | , match 823 | 824 | while(match = regex.exec(str)) { 825 | // a macro has been found 826 | // Note: at the moment, only one parameter is supported 827 | var macroName = match[1] 828 | , paramName = match[2] 829 | , optv = match[3] 830 | , opts = {} 831 | 832 | if (!(macroName in html10n.macros)) continue 833 | 834 | if(optv) { 835 | optv.match(/(?=\s*)([a-zA-Z]+)\: ?([^,\]]+)(?=,?)/g).forEach(function(arg) { 836 | var parts = arg.split(':') 837 | , name = parts[0] 838 | , value = parts[1].trim() 839 | opts[name] = value 840 | }) 841 | } 842 | 843 | var param 844 | if (args && paramName in args) { 845 | param = args[paramName] 846 | } else if (paramName in html10n.translations) { 847 | param = translations[paramName] 848 | } 849 | 850 | // there's no macro parser: it has to be defined in html10n.macros 851 | var macro = html10n.macros[macroName] 852 | str = str.substr(0, match.index) + macro(key, param, opts) + str.substr(match.index+match[0].length) 853 | } 854 | 855 | return str 856 | } 857 | 858 | /** 859 | * Applies translations to a DOM node (recursive) 860 | */ 861 | html10n.translateNode = function(translations, node) { 862 | var str = {} 863 | 864 | // get id 865 | str.id = node.getAttribute('data-l10n-id') 866 | if (!str.id) return 867 | 868 | if(!translations[str.id]) return console.warn('Couldn\'t find translation key '+str.id) 869 | 870 | // get args 871 | if(window.JSON) { 872 | str.args = node.getAttribute('data-l10n-args') ? JSON.parse(node.getAttribute('data-l10n-args')) : []; 873 | }else{ 874 | try{ 875 | str.args = eval(node.getAttribute('data-l10n-args')) 876 | }catch(e) { 877 | console.warn('Couldn\'t parse args for '+str.id) 878 | } 879 | } 880 | 881 | str.str = html10n.get(str.id, str.args) 882 | 883 | // get attribute name to apply str to 884 | var prop 885 | , index = str.id.lastIndexOf('.') 886 | , attrList = // allowed attributes 887 | { "title": 1 888 | , "innerHTML": 1 889 | , "alt": 1 890 | , "textContent": 1 891 | , "placeholder": 1 892 | , "value": 1 893 | } 894 | if (index > 0 && str.id.substr(index + 1) in attrList) { // an attribute has been specified 895 | prop = str.id.substr(index + 1) 896 | } else { // no attribute: assuming text content by default 897 | prop = document.body.textContent ? 'textContent' : 'innerText' 898 | } 899 | 900 | // Apply translation 901 | if (node.children.length === 0 || prop != 'textContent') { 902 | node[prop] = str.str; 903 | node.setAttribute('aria-label', str.str); // Sets the aria-label 904 | // The idea of the above is that we always have an aria value 905 | // This might be a bit of an abrupt solution but let's see how it goes 906 | } else { 907 | var children = node.childNodes, 908 | found = false 909 | for (var i=0, n=children.length; i < n; i++) { 910 | if (children[i].nodeType === 3 && /\S/.test(children[i].textContent)) { 911 | if (!found) { 912 | children[i].nodeValue = str.str 913 | found = true 914 | } else { 915 | children[i].nodeValue = '' 916 | } 917 | } 918 | } 919 | if (!found) { 920 | console.warn('Unexpected error: could not translate element content for key '+str.id, node) 921 | } 922 | } 923 | } 924 | 925 | /** 926 | * Builds a translation object from a list of langs (loads the necessary translations) 927 | * @param langs Array - a list of langs sorted by priority (default langs should go last) 928 | */ 929 | html10n.build = function(langs, cb) { 930 | var that = this 931 | , build = {} 932 | 933 | asyncForEach(langs, function (lang, i, next) { 934 | if(!lang) return next(); 935 | that.loader.load(lang, next) 936 | }, function() { 937 | var lang 938 | langs.reverse() 939 | 940 | // loop through the priority array... 941 | for (var i=0, n=langs.length; i < n; i++) { 942 | lang = langs[i] 943 | 944 | if(!lang) continue; 945 | if(!(lang in that.loader.langs)) {// uh, we don't have this lang availbable.. 946 | // then check for related langs 947 | if(~lang.indexOf('-')) lang = lang.split('-')[0]; 948 | for(var l in that.loader.langs) { 949 | if(lang != l && l.indexOf(lang) === 0) { 950 | lang = l 951 | break; 952 | } 953 | } 954 | if(lang != l) continue; 955 | } 956 | 957 | // ... and apply all strings of the current lang in the list 958 | // to our build object 959 | for (var string in that.loader.langs[lang]) { 960 | build[string] = that.loader.langs[lang][string] 961 | } 962 | 963 | // the last applied lang will be exposed as the 964 | // lang the page was translated to 965 | that.language = lang 966 | } 967 | cb(null, build) 968 | }) 969 | } 970 | 971 | /** 972 | * Returns the language that was last applied to the translations hash 973 | * thus overriding most of the formerly applied langs 974 | */ 975 | html10n.getLanguage = function() { 976 | return this.language; 977 | } 978 | 979 | /** 980 | * Returns the direction of the language returned be html10n#getLanguage 981 | */ 982 | html10n.getDirection = function() { 983 | if(!this.language) return 984 | var langCode = this.language.indexOf('-') == -1? this.language : this.language.substr(0, this.language.indexOf('-')) 985 | return html10n.rtl.indexOf(langCode) == -1? 'ltr' : 'rtl' 986 | } 987 | 988 | /** 989 | * Index all s 990 | */ 991 | html10n.index = function () { 992 | // Find all s 993 | var links = document.getElementsByTagName('link') 994 | , resources = [] 995 | for (var i=0, n=links.length; i < n; i++) { 996 | if (links[i].type != 'application/l10n+json') 997 | continue; 998 | resources.push(links[i].href) 999 | } 1000 | this.loader = new Loader(resources) 1001 | this.trigger('indexed') 1002 | } 1003 | 1004 | if (document.addEventListener) // modern browsers and IE9+ 1005 | document.addEventListener('DOMContentLoaded', function() { 1006 | html10n.index() 1007 | }, false) 1008 | else if (window.attachEvent) 1009 | window.attachEvent('onload', function() { 1010 | html10n.index() 1011 | }, false) 1012 | 1013 | // gettext-like shortcut 1014 | if (window._ === undefined) 1015 | window._ = html10n.get; 1016 | 1017 | return html10n 1018 | })(window, document) 1019 | --------------------------------------------------------------------------------