UNPKG

11.8 kBJavaScriptView Raw
1/**
2 * class BabelFish
3 *
4 * Internalization and localization library that makes i18n and l10n fun again.
5 *
6 * ##### Example
7 *
8 * var BabelFish = require('babelfish'),
9 * i18n = new BabelFish();
10 **/
11
12
13'use strict';
14
15
16var Underscore = require('underscore');
17var Parser = require('./babelfish/parser');
18var Pluralizer = require('./babelfish/pluralizer');
19
20
21// helpers
22////////////////////////////////////////////////////////////////////////////////
23
24
25// Last resort locale, that exists for sure
26var GENERIC_LOCALE = 'en';
27
28
29// flattenParams(obj) -> Object
30//
31// Flattens object into one-level distionary.
32//
33// ##### Example
34//
35// var obj = {
36// abc: { def: 'foo' },
37// hij: 'bar'
38// };
39//
40// flattenParams(obj);
41// // -> { 'abc.def': 'foo', 'hij': 'bar' };
42function flattenParams(obj) {
43 var params = {};
44
45 Underscore.each(obj || {}, function (val, key) {
46 if (val && 'object' === typeof val) {
47 Underscore.each(flattenParams(val), function (sub_val, sub_key) {
48 params[key + '.' + sub_key] = sub_val;
49 });
50 return;
51 }
52
53 params[key] = val;
54 });
55
56 return params;
57}
58
59
60// Merge several continuous `literal` nodes together
61function redistribute(ast) {
62 var nodes = [], last = {};
63
64 Underscore.each(ast, function (node) {
65 if ('literal' === last.type && 'literal' === node.type) {
66 last.text += node.text;
67 return;
68 }
69
70 nodes.push(node);
71 last = node;
72 });
73
74 return nodes;
75}
76
77
78// Compiles given string into translator function. Used to compile phrases,
79// which contains `plurals`, `variables`, etc.
80function compile(str, locale) {
81 var nodes = redistribute(Parser.parse(str)),
82 lang = locale.split('-').shift(),
83 translator;
84
85 if (1 === nodes.length && 'literal' === nodes[0].type) {
86 return nodes[0].text;
87 }
88
89 translator = ["var str = '';"];
90 translator.push("params = this.flattenParams(params);");
91
92 Underscore.each(nodes, function (node, idx) {
93 var anchor = "params['" + node.anchor + "']";
94
95 if ('literal' === node.type) {
96 translator.push("str += " + JSON.stringify(node.text) + ";");
97 return;
98 }
99
100 if ('variable' === node.type) {
101 translator.push(
102 "str += ( 'undefined' === typeof (" + anchor + ") )" +
103 " ? '[missed variable: " + node.anchor + "]'" +
104 " : String(" + anchor + ");"
105 );
106 return;
107 }
108
109 if ('plural' === node.type) {
110 translator.push(
111 "str += ( +(" + anchor + ") != (" + anchor + ") )" +
112 " ? ('[invalid plurals amount: " + node.anchor + "(' + String(" + anchor + ") + ')]')" +
113 " : this.pluralize('" + lang + "', +" + anchor + ", " + JSON.stringify(node.forms) + ");"
114 );
115 return;
116 }
117
118 // should never happen
119 throw new Error('Unknown node type');
120 });
121
122 translator.push("return str;");
123
124 /*jslint evil:true*/
125 return new Function('params', translator.join('\n'));
126}
127
128
129// Returns locale storage. Creates one if needed
130function getLocaleStorage(self, locale) {
131 if (undefined === self._storage[locale]) {
132 self._storage[locale] = {};
133 }
134
135 return self._storage[locale];
136}
137
138
139function mergeTranslations(receiver, transmitter, locale) {
140 Underscore.each(transmitter, function (data, key) {
141 // propose translation. make a copy
142 if (data.locale === locale) {
143 receiver[key] = Underscore.extend({}, data);
144 }
145 });
146
147 return receiver;
148}
149
150
151// recompiles phrases for locale
152function recompile(self, locale) {
153 var fallbacks, fb_locale, old_storage, new_storage;
154
155 fallbacks = (self._fallbacks[locale] || []).slice();
156 old_storage = getLocaleStorage(self, locale);
157 new_storage = mergeTranslations({}, getLocaleStorage(self, self.defaultLocale), self.defaultLocale);
158
159 // mix-in fallbacks
160 while (fallbacks.length) {
161 fb_locale = fallbacks.pop();
162 mergeTranslations(new_storage, getLocaleStorage(self, fb_locale), fb_locale);
163 }
164
165 // mix-in locale overrides
166 self._storage[locale] = mergeTranslations(new_storage, old_storage, locale);
167}
168
169
170// public api (module)
171////////////////////////////////////////////////////////////////////////////////
172
173
174/**
175 * new BabelFish([defaultLocale = 'en'])
176 *
177 * Initiates new instance of BabelFish. It can't be used as function (without
178 * `new` keyword. Use [[BabelFish.create]] for this purpose.
179 **/
180function BabelFish(defaultLocale) {
181 /** read-only
182 * BabelFish#defaultLocale -> String
183 *
184 * Default locale, tht will be used if requested locale has no translation,
185 * and have no fallacks or none of its fallbacks have translation as well.
186 **/
187 Object.defineProperty(this, 'defaultLocale', {
188 value: defaultLocale && String(defaultLocale) || GENERIC_LOCALE
189 });
190
191 // hash of locale => [ fallback1, fallback2, ... ] pairs
192 this._fallbacks = {};
193
194 // hash of fallback => [ locale1, locale2, ... ] pairs
195 this._fallbacksReverse = {};
196
197 // states of compilation per each locale locale => bool pairs
198 this._compiled = {};
199
200 // storage of compiled translations
201 this._storage = {};
202}
203
204
205/** chainable
206 * BabelFish.create([defaultLocale = 'en']) -> BabelFish
207 *
208 * Syntax sugar for constructor:
209 *
210 * new BabelFish('ru')
211 * // equals to:
212 * BabelFish.create('ru');
213 **/
214BabelFish.create = function create(defaultLocale) {
215 return new BabelFish(defaultLocale);
216};
217
218
219// public api (instance)
220////////////////////////////////////////////////////////////////////////////////
221
222
223/** chainable
224 * BabelFish#addPhrase(locale, phrase, translation) -> BabelFish
225 * - locale (String): Locale of translation
226 * - phrase (String|Null): Phrase ID, e.g. `apps.forum`
227 * - translation (String|Object): Translation or an object with nested phrases.
228 *
229 * ##### Errors
230 *
231 * - **TypeError** when `translation` is neither _String_ nor _Object_.
232 *
233 * ##### Example
234 *
235 * i18n.addPhrase('ru-RU',
236 * 'apps.forums.replies_count',
237 * '#{count} %{ответ|ответа|ответов}:count в теме');
238 *
239 * // equals to:
240 * i18n.addPhrase('ru-RU',
241 * 'apps.forums',
242 * { replies_count: '#{count} %{ответ|ответа|ответов}:count в теме' });
243 **/
244BabelFish.prototype.addPhrase = function addPhrase(locale, phrase, translation) {
245 var self = this, t = typeof translation;
246
247 if ('string' !== t && 'object' !== t && translation !== (translation || '').toString()) {
248 throw new TypeError('Invalid translation. String or Object expected');
249 } else if ('object' === t) {
250 // recursive recursion
251 Underscore.each(translation, function (val, key) {
252 self.addPhrase(locale, phrase + '.' + key, val);
253 });
254 return;
255 }
256
257 // compile phrase
258 translation = compile(translation, locale);
259
260 getLocaleStorage(this, locale)[phrase] = {
261 type: ('function' === typeof translation) ? 'function' : 'string',
262 locale: locale,
263 translation: translation
264 };
265
266 // mark all "dependant" locales for recompilation
267 Underscore.each(self._fallbacksReverse[locale] || [], function (locale) {
268 // we need to recompile non-default locales only
269 if (locale !== self.defaultLocale) {
270 self._compiled[locale] = false;
271 }
272 });
273
274 return this;
275};
276
277
278/** chainable
279 * BabelFish#setFallback(locale, fallbacks) -> BabelFish
280 * - locale (String): Target locale
281 * - fallbacks (Array): List of fallback locales
282 *
283 * Set fallbacks for given locale.
284 *
285 * When `locale` has no translation for the phrase, `fallbacks[0]` will be
286 * tried, if translation still not found, then `fallbacks[1]` will be tried
287 * and so on. If none of fallbacks have translation,
288 * [[BabelFish#defaultLocale]] will be tried as last resort.
289 *
290 * ##### Errors
291 *
292 * - throws `Error`, when `locale` equals [[BabelFish#defaultLocale]]
293 *
294 * ##### Example
295 *
296 * i18n.setFallback('ua-UK', ['ua', 'ru']);
297 **/
298BabelFish.prototype.setFallback = function setFallback(locale, fallbacks) {
299 var self = this;
300
301 if (self.defaultLocale === locale) {
302 throw new Error("Default locale can't have fallbacks");
303 }
304
305 // clear out current fallbacks
306 if (!!self._fallbacks[locale]) {
307 Underscore.each(self._fallbacks[locale], function (fallback) {
308 var idx = self._fallbacksReverse[fallback].indexOf(locale);
309 if (-1 !== idx) {
310 delete self._fallbacksReverse[fallback][idx];
311 }
312 });
313 }
314
315 // set new empty stack of fallbacks
316 self._fallbacks[locale] = [];
317
318 // fill in new fallbacks. defaultLocale is appended as last fallback
319 Underscore.each(fallbacks, function (fallback) {
320 if (!self._fallbacksReverse[fallback]) {
321 self._fallbacksReverse[fallback] = [];
322 }
323
324 if (-1 === self._fallbacksReverse[fallback].indexOf(locale)) {
325 self._fallbacksReverse[fallback].push(locale);
326 }
327
328 self._fallbacks[locale].push(fallback);
329 });
330
331 // mark locale for recompilation
332 self._compiled[locale] = false;
333
334 return this;
335};
336
337
338/**
339 * BabelFish#translate(locale, phrase[, params]) -> String
340 * - locale (String): Locale of translation
341 * - phrase (String): Phrase ID, e.g. `app.forums.replies_count`
342 * - params (Object): Params for translation
343 *
344 * ##### Example
345 *
346 * i18n.addPhrase('ru-RU',
347 * 'apps.forums.replies_count',
348 * '#{count} %{ответ|ответа|ответов}:count в теме');
349 *
350 * // ...
351 *
352 * i18n.translate('ru-RU', 'app.forums.replies_count', {count: 1});
353 * // -> '1 ответ'
354 *
355 * i18n.translate('ru-RU', 'app.forums.replies_count', {count: 2});
356 * // -> '2 ответa'
357 **/
358BabelFish.prototype.translate = function translate(locale, phrase, params) {
359 var translator = this.getCompiledData(locale, phrase);
360
361 if ('string' === translator.type) {
362 return String(translator.translation);
363 }
364
365 if ('function' === translator.type) {
366 return translator.translation.call({
367 flattenParams: flattenParams,
368 pluralize: Pluralizer
369 }, params);
370 }
371
372 return locale + ': No translation for [' + phrase + ']';
373};
374
375
376/**
377 * BabelFish#hasPhrase(locale, phrase) -> Boolean
378 * - locale (String): Locale of translation
379 * - phrase (String): Phrase ID, e.g. `app.forums.replies_count`
380 *
381 * Returns whenever or not there's a translation of a `phrase`.
382 **/
383BabelFish.prototype.hasPhrase = function hasPhrase(locale, phrase) {
384 var translator = this.getCompiledData(locale, phrase);
385 return 'string' === translator.type || 'function' === translator.type;
386};
387
388
389/** alias of: BabelFish#translate
390 * BabelFish#t(locale, phrase[, params]) -> String
391 **/
392BabelFish.prototype.t = BabelFish.prototype.translate;
393
394
395/**
396 * BabelFish#getCompiledData(locale, phrase) -> Object
397 * BabelFish#getCompiledData(locale) -> Object
398 * - locale (String): Locale of translation
399 * - phrase (String): Phrase ID, e.g. `app.forums.replies_count`
400 *
401 * Returns compiled "translator", or objet with compiled translators for all
402 * phrases of `locale` if `phrase` was not specified.
403 *
404 * Each translator is an object with fields:
405 *
406 * - **type** _(String)_
407 * - _string_: Simple translation (contains no substitutions)
408 * - _function_: Translation with macroses
409 *
410 * - **locale** _(String|Null)_
411 * Locale of translation. It can differ from requested locale in case when
412 * translation was taken from fallback locale.
413 *
414 * - **translation** _(String|Function)_
415 **/
416BabelFish.prototype.getCompiledData = function getCompiledData(locale, phrase) {
417 var storage, parts;
418
419 // force recompilation if needed
420 if (!this._compiled[locale]) {
421 recompile(this, locale);
422 this._compiled[locale] = true;
423 }
424
425 storage = getLocaleStorage(this, locale);
426
427 // requested FULL storage
428 if (!phrase) {
429 return storage;
430 }
431
432 return storage[phrase] || {};
433};
434
435
436// export module
437module.exports = BabelFish;