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