From 59ed13b5810b801df61b6c11b48a5d87f3acca08 Mon Sep 17 00:00:00 2001 From: Peter Jaszkowiak Date: Mon, 12 Sep 2016 13:52:50 -0600 Subject: [PATCH] Rewrite translator for efficiency and clarity (#5013) * Rewrite translator for efficiency and clarity * Fixed failing tests * Gorram tabs * Move cache to Translator class, simplify creation * Documentation and linting * Use new translator API where it makes sense * Revert 499fbe21fdd5b7f2735a27e76a300f69039cfbd3 (except translator.d.ts) * promise polyfill for node<4 * Set `load` on the prototype * Comment on Translator#translate * Delete translator.d.ts * Support translations added by plugins --- package.json | 4 +- public/src/modules/translator.js | 683 +++++++++++++++++-------------- src/meta/js.js | 3 +- tests/translator-new.js | 155 +++++++ tests/translator.js | 5 +- 5 files changed, 540 insertions(+), 310 deletions(-) create mode 100644 tests/translator-new.js diff --git a/package.json b/package.json index 9bd9e7406f..0dfef19c29 100644 --- a/package.json +++ b/package.json @@ -13,7 +13,8 @@ "start": "node loader.js", "lint": "eslint --cache .", "pretest": "npm run lint", - "test": "./node_modules/.bin/istanbul cover ./node_modules/.bin/_mocha -- ./tests -t 10000" + "test": "./node_modules/.bin/istanbul cover ./node_modules/.bin/_mocha -- ./tests -t 10000", + "test-windows": "./node_modules/.bin/_mocha.cmd ./tests -t 10000" }, "dependencies": { "async": "~1.5.0", @@ -69,6 +70,7 @@ "passport": "^0.3.0", "passport-local": "1.0.0", "postcss": "^5.0.13", + "promise-polyfill": "^6.0.2", "prompt": "^1.0.0", "redis": "~2.6.2", "request": "^2.44.0", diff --git a/public/src/modules/translator.js b/public/src/modules/translator.js index 70eaad8d8e..d978abe147 100644 --- a/public/src/modules/translator.js +++ b/public/src/modules/translator.js @@ -1,347 +1,416 @@ -;(function(translator) { - "use strict"; - /* globals RELATIVE_PATH, config, define */ +/* global define, jQuery, config, RELATIVE_PATH, utils, window, Promise, winston */ - var S = null; - var stringDefer = null; +(function (factory) { + 'use strict'; + function loadClient(language, filename) { + return Promise.resolve(jQuery.getJSON(config.relative_path + '/language/' + language + '/' + (filename + '.json?v=' + config['cache-buster']))); + } + if (typeof define === 'function' && define.amd) { + // AMD. Register as a named module + define('translator', ['string'], function (string) { + return factory(string, loadClient); + }); + } else if (typeof module === 'object' && module.exports) { + // Node + (function () { + require('promise-polyfill'); + var languages = require('../../../src/languages'); + + function loadServer(language, filename) { + return new Promise(function (resolve, reject) { + languages.get(language, filename + '.json', function (err, data) { + if (err) { + reject(err); + } else { + resolve(data); + } + }); + }); + } - // export the class if we are in a Node-like system. - if (typeof module === 'object' && module.exports === translator) { - exports = module.exports = translator; - S = require('string'); + module.exports = factory(require('string'), loadServer); + })(); } else { - stringDefer = $.Deferred(); - require(['string'], function(stringLib) { - S = stringLib; - stringDefer.resolve(S); - }); + window.translator = factory(window.string, loadClient); } +})(function (string, load) { + 'use strict'; + var assign = Object.assign || jQuery.extend; + function classCallCheck(instance, Constructor) { if (!(instance instanceof Constructor)) { throw new TypeError("Cannot call a class as a function"); } } + + var Translator = function () { + /** + * Construct a new Translator object + * @param {string} language - Language code for this translator instance + */ + function Translator(language) { + classCallCheck(this, Translator); + + if (!language) { + throw new TypeError('Parameter `language` must be a language string. Received ' + language + (language === '' ? '(empty string)' : '')); + } - var languages = {}, - regexes = { - match: /\[\[\w+:[\w\.]+((?!\[\[).)*?\]\]/g, // see tests/translator.js for an explanation re: this monster - split: /[,][\s]*/, - replace: /\]+$/ - }; - - translator.addTranslation = function(language, filename, translations) { - languages[language] = languages[language] || {}; - languages[language].loaded = languages[language].loaded || {}; - languages[language].loading = languages[language].loading || {}; + this.lang = language; + this.translations = {}; + } - if (languages[language].loaded[filename]) { - for (var t in translations) { - if (translations.hasOwnProperty(t)) { - languages[language].loaded[filename][t] = translations[t]; + Translator.prototype.load = load; + + /** + * Parse the translation instructions into the language of the Translator instance + * @param {string} str - Source string + * @returns {Promise} + */ + Translator.prototype.translate = function translate(str) { + // current cursor position + var cursor = 0; + // last break of the input string + var lastBreak = 0; + // length of the input string + var len = str.length; + // array to hold the promises for the translations + // and the strings of untranslated text in between + var toTranslate = []; + + // split a translator string into an array of tokens + // but don't split by commas inside other translator strings + function split(text) { + var len = text.length; + var arr = []; + var i = 0; + var brk = 0; + var level = 0; + + while (i + 2 <= len) { + if (text.slice(i, i + 2) === '[[') { + level += 1; + i += 1; + } else if (text.slice(i, i + 2) === ']]') { + level -= 1; + i += 1; + } else if (level === 0 && text[i] === ',') { + arr.push(text.slice(brk, i).trim()); + i += 1; + brk = i; + } + i += 1; } + arr.push(text.slice(brk, i + 1).trim()); + return arr; } - } else { - languages[language].loaded[filename] = translations; - } - }; - translator.getTranslations = function(language, filename, callback) { - if (languages[language] && languages[language].loaded[filename]) { - callback(languages[language].loaded[filename]); - } else { - translator.load(language, filename, function() { - callback(languages[language].loaded[filename]); - }); - } - }; + // the loooop, we'll go to where the cursor + // is equal to the length of the string since + // slice doesn't include the ending index + while (cursor + 2 <= len) { + // if the current position in the string looks + // like the beginning of a translation string + if (str.slice(cursor, cursor + 2) === '[[') { + // split the string from the last break + // to the character before the cursor + // add that to the result array + toTranslate.push(str.slice(lastBreak, cursor)); + // set the cursor position past the beginning + // brackets of the translation string + cursor += 2; + // set the last break to our current + // spot since we just broke the string + lastBreak = cursor; + + // the current level of nesting of the translation strings + var level = 0; + var sliced; + + while (cursor + 2 <= len) { + sliced = str.slice(cursor, cursor + 2); + // if we're at the beginning of another translation string, + // we're nested, so add to our level + if (sliced === '[[') { + level += 1; + cursor += 2; + // if we're at the end of a translation string + } else if (sliced === ']]') { + // if we're at the base level, then this is the end + if (level === 0) { + // so grab the name and args + var result = split(str.slice(lastBreak, cursor)); + var name = result[0]; + var args = result.slice(1); + + // add the translation promise to the array + toTranslate.push(this.translateKey(name, args)); + // skip past the ending brackets + cursor += 2; + // set this as our last break + lastBreak = cursor; + // and we're no longer in a translation string, + // so continue with the main loop + break; + } + // otherwise we lower the level + level -= 1; + // and skip past the ending brackets + cursor += 2; + } else { + // otherwise just move to the next character + cursor += 1; + } + } + } + // move to the next character + cursor += 1; + } - translator.escape = function(text) { - return typeof text === 'string' ? text.replace(/\[\[([\S]*?)\]\]/g, '\\[\\[$1\\]\\]') : text; - }; + // add the remaining text after the last translation string + toTranslate.push(str.slice(lastBreak, cursor + 2)); - translator.unescape = function(text) { - return typeof text === 'string' ? text.replace(/\\\[\\\[([\S]*?)\\\]\\\]/g, '[[$1]]') : text; - }; + // and return a promise for the concatenated translated string + return Promise.all(toTranslate).then(function (translated) { + return translated.join(''); + }); + }; - translator.getLanguage = function() { - return config.defaultLang; - }; + /** + * Translates a specific key and array of arguments + * @param {string} name - Translation key (ex. 'global:home') + * @param {string[]} args - Arguments for `%1`, `%2`, etc + * @returns {Promise} + */ + Translator.prototype.translateKey = function translateKey(name, args) { + var self = this; + + var result = name.split(':', 2); + var namespace = result[0]; + var key = result[1]; + + var translation = this.getTranslation(namespace, key); + var argsToTranslate = args.map(function (arg) { + return string(arg).collapseWhitespace().decodeHTMLEntities().escapeHTML().s; + }).map(function (arg) { + return self.translate(arg); + }); - translator.prepareDOM = function() { - // Load the appropriate timeago locale file, and correct NodeBB language codes to timeago codes, if necessary - var languageCode; - switch(config.userLang) { - case 'en_GB': - case 'en_US': - languageCode = 'en'; - break; - - case 'fa_IR': - languageCode = 'fa'; - break; - - case 'pt_BR': - languageCode = 'pt-br'; - break; - - case 'nb': - languageCode = 'no'; - break; - - case 'zh_TW': - languageCode = 'zh-TW'; - break; - - case 'zh_CN': - languageCode = 'zh-CN'; - break; - - default: - languageCode = config.userLang; - break; - } + // so we can await all promises at once + argsToTranslate.unshift(translation); - $.getScript(RELATIVE_PATH + '/vendor/jquery/timeago/locales/jquery.timeago.' + languageCode + '.js').done(function() { - $('.timeago').timeago(); - translator.timeagoShort = $.extend({}, jQuery.timeago.settings.strings); + return Promise.all(argsToTranslate).then(function (result) { + var translated = result[0]; + var translatedArgs = result.slice(1); - // Retrieve the shorthand timeago values as well - $.getScript(RELATIVE_PATH + '/vendor/jquery/timeago/locales/jquery.timeago.' + languageCode + '-short.js').done(function() { - // Switch back to long-form - translator.toggleTimeagoShorthand(); + if (!translated) { + return key; + } + var out = translated; + translatedArgs.forEach(function (arg, i) { + out = out.replace(new RegExp('%' + (i + 1), 'g'), arg); + }); + return out; }); - }); + }; - // Add directional code if necessary - translator.translate('[[language:dir]]', function(value) { - if (value) { - $('html').css('direction', value).attr('data-dir', value); + /** + * Load translation file (or use a cached version), and optionally return the translation of a certain key + * @param {string} namespace - The file name of the translation namespace + * @param {string} [key] - The key of the specific translation to getJSON + * @returns {Promise} + */ + Translator.prototype.getTranslation = function getTranslation(namespace, key) { + var translation; + if (!namespace) { + winston.warn('[translator] Parameter `namespace` is ' + namespace + (namespace === '' ? '(empty string)' : '')); + translation = Promise.resolve({}); + } else if (this.translations[namespace]) { + translation = this.translations[namespace]; + } else { + translation = this.load(this.lang, namespace); + this.translations[namespace] = translation; } - }); - }; - translator.toggleTimeagoShorthand = function() { - var tmp = $.extend({}, jQuery.timeago.settings.strings); - jQuery.timeago.settings.strings = $.extend({}, translator.timeagoShort); - translator.timeagoShort = $.extend({}, tmp); - }; + if (key) { + return translation.then(function (x) { + return x[key]; + }); + } + return translation; + }; + + /** + * Get the language of the current environment, falling back to defaults + * @returns {string} + */ + Translator.getLanguage = function getLanguage() { + var lang; - translator.translate = function (text, language, callback) { - if (typeof language === 'function') { - callback = language; - if ('undefined' !== typeof window && config) { - language = utils.params().lang || config.userLang || 'en_GB'; + if (typeof window === 'object' && window.config && window.utils) { + lang = utils.params().lang || config.userLang || config.defaultLang || 'en_GB'; } else { var meta = require('../../../src/meta'); - language = meta.config.defaultLang || 'en_GB'; + lang = meta.config.defaultLang || 'en_GB'; } - } - - if (!text) { - return callback(text); - } - - var keys = text.match(regexes.match); - if (!keys) { - return callback(text); - } + return lang; + }; - translateKeys(keys, text, language, function(translated) { - keys = translated.match(regexes.match); - if (!keys) { - callback(translated); - } else { - translateKeys(keys, translated, language, callback); + /** + * Create and cache a new Translator instance, or return a cached one + * @param {string} [language] - ('en_GB') Language string + * @returns {Translator} + */ + Translator.create = function create(language) { + if (!language) { + language = Translator.getLanguage(); } - }); - }; - function translateKeys(keys, text, language, callback) { - - var count = keys.length; - if (!count) { - return callback(text); - } + Translator.cache[language] = Translator.cache[language] || new Translator(language); - if (S === null) { // browser environment and S not yet initialized - // we need to wait for async require call - stringDefer.then(function () { translateKeys(keys, text, language, callback); }); - return; - } + return Translator.cache[language]; + }; + + Translator.cache = {}; + + return Translator; + }(); + + var adaptor = { + /** + * The Translator class + */ + Translator: Translator, + + /** + * Legacy translator function for backwards compatibility + */ + translate: function translate(text, language, callback) { + // console.warn('[translator] `translator.translate(text, [lang, ]callback)` is deprecated. ' + + // 'Use the `translator.Translator` class instead.'); + + var cb = callback; + var lang = language; + if (typeof language === 'function') { + cb = language; + lang = null; + } - var data = {text: text}; - keys.forEach(function(key) { - translateKey(key, data, language, function(translated) { - --count; - if (count <= 0) { - callback(translated.text); - } + Translator.create(lang).translate(text).then(function (output) { + return cb(output); + }).catch(function (err) { + console.error('Translation failed: ' + err.message); }); - }); - } - - function translateKey(key, data, language, callback) { - key = '' + key; - var variables = key.split(regexes.split); - - var parsedKey = key.replace('[[', '').replace(']]', '').split(':'); - parsedKey = [parsedKey[0]].concat(parsedKey.slice(1).join(':')); - if (!(parsedKey[0] && parsedKey[1])) { - return callback(data); - } - - var languageFile = parsedKey[0]; - parsedKey = ('' + parsedKey[1]).split(',')[0]; - - translator.load(language, languageFile, function(languageData) { - data.text = insertLanguage(data.text, key, languageData[parsedKey], variables); - callback(data); - }); - } - - function insertLanguage(text, key, value, variables) { - if (value) { - variables.forEach(function(variable, index) { - if (index > 0) { - variable = S(variable).chompRight(']]').collapseWhitespace().decodeHTMLEntities().escapeHTML().s; - value = value.replace('%' + index, function() { return variable; }); - } + }, + + /** + * Construct a translator pattern + * @param {string} name - Translation name + * @param {string[]} args - Optional arguments for the pattern + */ + compile: function compile() { + var args = Array.prototype.slice.call(arguments, 0); + + return '[[' + args.join(', ') + ']]'; + }, + + /** + * Escape translation patterns from text + */ + escape: function escape(text) { + return typeof text === 'string' ? text.replace(/\[\[([\S]*?)\]\]/g, '\\[\\[$1\\]\\]') : text; + }, + + /** + * Unescape translation patterns from text + */ + unescape: function unescape(text) { + return typeof text === 'string' ? text.replace(/\\\[\\\[([\S]*?)\\\]\\\]/g, '[[$1]]') : text; + }, + + /** + * Add translations to the cache + */ + addTranslation: function addTranslation(language, filename, translation) { + Translator.create(language).getTranslation(filename).then(function (translations) { + assign(translations, translation); }); - - text = text.replace(key, function() { return value; }); - } else { - var string = key.split(':'); - text = text.replace(key, string[string.length-1].replace(regexes.replace, '')); - } - - return text; - } - - translator.compile = function() { - var args = Array.prototype.slice.call(arguments, 0); - - return '[[' + args.join(', ') + ']]'; - }; - - translator.load = function (language, filename, callback) { - if (isLanguageFileLoaded(language, filename)) { - if (callback) { - callback(languages[language].loaded[filename]); + }, + + /** + * Get the translations object + */ + getTranslations: function getTranslations(language, filename, callback) { + callback = callback || function () {}; + Translator.create(language).getTranslation(filename).then(callback); + }, + + /** + * Alias of getTranslations + */ + load: function load(language, filename, callback) { + adaptor.getTranslations(language, filename, callback); + }, + + /** + * Get the language of the current environment, falling back to defaults + */ + getLanguage: Translator.getLanguage, + + toggleTimeagoShorthand: function toggleTimeagoShorthand() { + var tmp = assign({}, jQuery.timeago.settings.strings); + jQuery.timeago.settings.strings = assign({}, adaptor.timeagoShort); + adaptor.timeagoShort = assign({}, tmp); + }, + prepareDOM: function prepareDOM() { + // Load the appropriate timeago locale file, + // and correct NodeBB language codes to timeago codes, if necessary + var languageCode = void 0; + switch (config.userLang) { + case 'en_GB': + case 'en_US': + languageCode = 'en'; + break; + + case 'fa_IR': + languageCode = 'fa'; + break; + + case 'pt_BR': + languageCode = 'pt-br'; + break; + + case 'nb': + languageCode = 'no'; + break; + + case 'zh_TW': + languageCode = 'zh-TW'; + break; + + case 'zh_CN': + languageCode = 'zh-CN'; + break; + + default: + languageCode = config.userLang; + break; } - } else if (isLanguageFileLoading(language, filename)) { - if (callback) { - addLanguageFileCallback(language, filename, callback); - } - } else { - - languages[language] = languages[language] || {loading: {}, loaded: {}, callbacks: []}; - - languages[language].loading[filename] = true; - - load(language, filename, function(translations) { - languages[language].loaded[filename] = translations; + jQuery.getScript(RELATIVE_PATH + '/vendor/jquery/timeago/locales/jquery.timeago.' + languageCode + '.js').done(function () { + jQuery('.timeago').timeago(); + adaptor.timeagoShort = assign({}, jQuery.timeago.settings.strings); - if (callback) { - callback(translations); - } + // Retrieve the shorthand timeago values as well + jQuery.getScript(RELATIVE_PATH + '/vendor/jquery/timeago/locales/jquery.timeago.' + languageCode + '-short.js').done(function () { + // Switch back to long-form + adaptor.toggleTimeagoShorthand(); + }); + }); - while (languages[language].callbacks && languages[language].callbacks[filename] && languages[language].callbacks[filename].length) { - languages[language].callbacks[filename].pop()(translations); + // Add directional code if necessary + adaptor.translate('[[language:dir]]', function (value) { + if (value) { + jQuery('html').css('direction', value).attr('data-dir', value); } - - languages[language].loading[filename] = false; }); } }; - function isLanguageFileLoaded(language, filename) { - var languageObj = languages[language]; - return languageObj && languageObj.loaded && languageObj.loaded[filename] && !languageObj.loading[filename]; - } - - function isLanguageFileLoading(language, filename) { - return languages[language] && languages[language].loading && languages[language].loading[filename]; - } - - function addLanguageFileCallback(language, filename, callback) { - languages[language].callbacks = languages[language].callbacks || {}; - - languages[language].callbacks[filename] = languages[language].callbacks[filename] || []; - languages[language].callbacks[filename].push(callback); - } - - function load(language, filename, callback) { - if ('undefined' !== typeof window) { - loadClient(language, filename, callback); - } else { - loadServer(language, filename, callback); - } - } - - function loadClient(language, filename, callback) { - $.getJSON(config.relative_path + '/language/' + language + '/' + filename + '.json?v=' + config['cache-buster'], callback); - } - - function loadServer(language, filename, callback) { - var fs = require('fs'), - path = require('path'), - winston = require('winston'), - _ = require('underscore'), - file = require('../../../src/file'), - plugins = require('../../../src/plugins'), - meta = require('../../../src/meta'); - - var hash = language + '/' + filename + '.json'; - - language = language || meta.config.defaultLang || 'en_GB'; - - if (!file.existsSync(path.join(__dirname, '../../language', language))) { - winston.warn('[translator] Language \'' + meta.config.defaultLang + '\' not found. Defaulting to \'en_GB\''); - language = 'en_GB'; - } - - fs.readFile(path.join(__dirname, '../../language', hash), function(err, data) { - var onData = function(data) { - try { - data = JSON.parse(data.toString()); - - if (plugins.customLanguages.hasOwnProperty(hash)) { - _.extendOwn(data, plugins.customLanguages[hash]); - } - } catch (e) { - winston.error('Could not parse `' + filename + '.json`, syntax error? Skipping...'); - data = {}; - } - - callback(data); - }; - - if (err && err.code === 'ENOENT') { - data = '{}'; - } else if (err) { - winston.error('[translator] Error while loading language file: ' + err.message); - return callback({}); - } - - onData(data); - }); - } - - // Use the define() function if we're in AMD land - if (typeof define === 'function' && define.amd) { - define('translator', translator); - - var _translator = translator; - - // Expose a global `translator` object for backwards compatibility - window.translator = { - translate: function() { - if (typeof console !== 'undefined' && console.warn) { - console.warn('[translator] Global invocation of the translator is now deprecated, please `require` the module instead.'); - } - _translator.translate.apply(_translator, arguments); - } - }; - } -})( - typeof exports === 'object' ? exports : - typeof define === 'function' && define.amd ? {} : - translator = {} -); + return adaptor; +}); diff --git a/src/meta/js.js b/src/meta/js.js index 79377826ba..e729937559 100644 --- a/src/meta/js.js +++ b/src/meta/js.js @@ -37,7 +37,8 @@ module.exports = function(Meta) { 'public/src/app.js', 'public/src/ajaxify.js', 'public/src/overrides.js', - 'public/src/widgets.js' + 'public/src/widgets.js', + "./node_modules/promise-polyfill/promise.js" ], // files listed below are only available client-side, or are bundled in to reduce # of network requests on cold load diff --git a/tests/translator-new.js b/tests/translator-new.js new file mode 100644 index 0000000000..22ddf4e9d5 --- /dev/null +++ b/tests/translator-new.js @@ -0,0 +1,155 @@ +'use strict'; +/*global require*/ + +var assert = require('assert'); +var Translator = require('../public/src/modules/translator.js').Translator; + + +describe('new Translator(language)', function(){ + describe('.translate()', function(){ + it('should handle basic translations', function(done) { + var translator = new Translator('en_GB'); + + translator.translate('[[global:home]]').then(function(translated) { + assert.strictEqual(translated, 'Home'); + done(); + }); + }); + + it('should handle language keys in regular text', function(done) { + var translator = new Translator('en_GB'); + + translator.translate('Let\'s go [[global:home]]').then(function(translated) { + assert.strictEqual(translated, 'Let\'s go Home'); + done(); + }); + }); + + it('should accept a language parameter and adjust accordingly', function(done) { + var translator = new Translator('de'); + + translator.translate('[[global:home]]').then(function(translated) { + assert.strictEqual(translated, 'Übersicht'); + done(); + }); + }); + + it('should handle language keys in regular text with another language specified', function(done) { + var translator = new Translator('de'); + + translator.translate('[[global:home]] test').then(function(translated) { + assert.strictEqual(translated, 'Übersicht test'); + done(); + }); + }); + + it('should handle language keys with parameters', function(done) { + var translator = new Translator('en_GB'); + + translator.translate('[[global:pagination.out_of, 1, 5]]').then(function(translated) { + assert.strictEqual(translated, '1 out of 5'); + done(); + }); + }); + + it('should handle language keys inside language keys', function(done) { + var translator = new Translator('en_GB'); + + translator.translate('[[notifications:outgoing_link_message, [[global:guest]]]]').then(function(translated) { + assert.strictEqual(translated, 'You are now leaving Guest'); + done(); + }); + }); + + it('should handle language keys inside language keys with multiple parameters', function(done) { + var translator = new Translator('en_GB'); + + translator.translate('[[notifications:user_posted_to, [[global:guest]], My Topic]]').then(function(translated) { + assert.strictEqual(translated, 'Guest has posted a reply to: My Topic'); + done(); + }); + }); + + it('should handle language keys inside language keys with all parameters as language keys', function(done) { + var translator = new Translator('en_GB'); + + translator.translate('[[notifications:user_posted_to, [[global:guest]], [[global:guest]]]]').then(function(translated) { + assert.strictEqual(translated, 'Guest has posted a reply to: Guest'); + done(); + }); + }); + + it('should properly handle parameters that contain square brackets', function(done) { + var translator = new Translator('en_GB'); + + translator.translate('[[global:pagination.out_of, [guest], [[global:home]]]]').then(function(translated) { + assert.strictEqual(translated, '[guest] out of Home'); + done(); + }); + }); + + it('should properly handle parameters that contain parentheses', function(done) { + var translator = new Translator('en_GB'); + + translator.translate('[[global:pagination.out_of, (foobar), [[global:home]]]]').then(function(translated) { + assert.strictEqual(translated, '(foobar) out of Home'); + done(); + }); + }); + + it('should not translate language key parameters with HTML in them', function(done) { + var translator = new Translator('en_GB'); + + var key = '[[global:403.login, test]]'; + translator.translate(key).then(function(translated) { + assert.strictEqual(translated, 'Perhaps you should try logging in?'); + done(); + }); + }); + + it('should properly escape % and ,', function(done) { + var translator = new Translator('en_GB'); + + var title = 'Test 1, 2, 3 % salmon'; + title = title.replace(/%/g, '%').replace(/,/g, ','); + var key = "[[topic:composer.replying_to, " + title + "]]"; + translator.translate(key).then(function(translated) { + assert.strictEqual(translated, 'Replying to Test 1, 2, 3 % salmon'); + done(); + }); + }); + + it('should throw if not passed a language', function(done) { + assert.throws(function () { + new Translator(); + }, /language string/); + done(); + }); + + }); +}); + +describe('Translator.create()', function(){ + describe('.translate()', function(){ + it('should return an instance of Translator', function(done) { + var translator = Translator.create('en_GB'); + + assert(translator instanceof Translator); + done(); + }); + it('should return the same object for the same language', function(done) { + var one = Translator.create('de'); + var two = Translator.create('de'); + + assert.strictEqual(one, two); + done(); + }); + it('should default to defaultLang', function(done) { + var translator = Translator.create(); + + assert.strictEqual(translator.lang, 'en_GB'); + done(); + }); + + }); +}); diff --git a/tests/translator.js b/tests/translator.js index a86e5b9018..f1ceeaed0d 100644 --- a/tests/translator.js +++ b/tests/translator.js @@ -5,8 +5,11 @@ var assert = require('assert'); var translator = require('../public/src/modules/translator.js'); var plugins = require('../src/plugins'); +var languages = require('../src/languages'); -describe('Translator', function(){ +languages.init(function(){}); + +describe('translator adaptor', function(){ describe('.translate()', function(){ it('should handle basic translations', function(done) { translator.translate('[[global:home]]', function(translated) {