NodeBB/NodeBB

View on GitHub
public/src/modules/translator.js

Summary

Maintainability
D
2 days
Test Coverage
'use strict';

(function (factory) {
    function loadClient(language, namespace) {
        return new Promise(function (resolve, reject) {
            jQuery.getJSON([config.assetBaseUrl, 'language', language, namespace].join('/') + '.json?' + config['cache-buster'], function (data) {
                const payload = {
                    language: language,
                    namespace: namespace,
                    data: data,
                };
                $(window).trigger('action:translator.loadClient', payload);
                resolve(payload.promise ? Promise.resolve(payload.promise) : data);
            }).fail(function (jqxhr, textStatus, error) {
                reject(new Error(textStatus + ', ' + error));
            });
        });
    }
    var warn = function () { console.warn.apply(console, arguments); };
    if (typeof define === 'function' && define.amd) {
        // AMD. Register as a named module
        define('translator', [], function () {
            return factory(utils, loadClient, warn);
        });
    } else if (typeof module === 'object' && module.exports) {
        // Node
        (function () {
            var languages = require('../../../src/languages');

            if (global.env === 'development') {
                var winston = require('winston');
                warn = function (a) {
                    winston.warn(a);
                };
            }

            module.exports = factory(require('../utils'), languages.get, warn);
        }());
    }
}(function (utils, load, warn) {
    var assign = Object.assign || jQuery.extend;

    function escapeHTML(str) {
        return utils.escapeHTML(utils.decodeHTMLEntities(
            String(str)
                .replace(/[\s\xa0]+/g, ' ')
                .replace(/^\s+|\s+$/g, '')
        ));
    }

    var Translator = (function () {
        /**
         * Construct a new Translator object
         * @param {string} language - Language code for this translator instance
         * @exports translator.Translator
         */
        function Translator(language) {
            var self = this;

            if (!language) {
                throw new TypeError('Parameter `language` must be a language string. Received ' + language + (language === '' ? '(empty string)' : ''));
            }

            self.modules = Object.keys(Translator.moduleFactories).map(function (namespace) {
                var factory = Translator.moduleFactories[namespace];
                return [namespace, factory(language)];
            }).reduce(function (prev, elem) {
                var namespace = elem[0];
                var module = elem[1];
                prev[namespace] = module;

                return prev;
            }, {});

            self.lang = language;
            self.translations = {};
        }

        Translator.prototype.load = load;

        /**
         * Parse the translation instructions into the language of the Translator instance
         * @param {string} str - Source string
         * @returns {Promise<string>}
         */
        Translator.prototype.translate = function translate(str) {
            // regex for valid text in namespace / key
            var validText = 'a-zA-Z0-9\\-_.\\/';
            var validTextRegex = new RegExp('[' + validText + ']');
            var invalidTextRegex = new RegExp('[^' + validText + '\\]]');

            // 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 = [];

            // to store the state of if we're currently in a top-level token for later
            var inToken = false;

            // 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[i] === '[' && text[i + 1] === '[') {
                        level += 1;
                        i += 1;
                    } else if (text[i] === ']' && text[i + 1] === ']') {
                        level -= 1;
                        i += 1;
                    } else if (level === 0 && text[i] === ',' && text[i - 1] !== '\\') {
                        arr.push(text.slice(brk, i).trim());
                        i += 1;
                        brk = i;
                    }
                    i += 1;
                }
                arr.push(text.slice(brk, i + 1).trim());
                return arr;
            }

            // move to the first [[
            cursor = str.indexOf('[[', cursor);

            // 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 && cursor !== -1) {
                // 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;
                // we're in a token now
                inToken = true;

                // the current level of nesting of the translation strings
                var level = 0;
                var char0;
                var char1;
                // validating the current string is actually a translation
                var textBeforeColonFound = false;
                var colonFound = false;
                var textAfterColonFound = false;
                var commaAfterNameFound = false;

                while (cursor + 2 <= len) {
                    char0 = str[cursor];
                    char1 = str[cursor + 1];
                    // found some text after the double bracket,
                    // so this is probably a translation string
                    if (!textBeforeColonFound && validTextRegex.test(char0)) {
                        textBeforeColonFound = true;
                        cursor += 1;
                    // found a colon, so this is probably a translation string
                    } else if (textBeforeColonFound && !colonFound && char0 === ':') {
                        colonFound = true;
                        cursor += 1;
                    // found some text after the colon,
                    // so this is probably a translation string
                    } else if (colonFound && !textAfterColonFound && validTextRegex.test(char0)) {
                        textAfterColonFound = true;
                        cursor += 1;
                    } else if (textAfterColonFound && !commaAfterNameFound && char0 === ',') {
                        commaAfterNameFound = true;
                        cursor += 1;
                    // a space or comma was found before the name
                    // this isn't a translation string, so back out
                    } else if (!(textBeforeColonFound && colonFound && textAfterColonFound && commaAfterNameFound) &&
                            invalidTextRegex.test(char0)) {
                        cursor += 1;
                        lastBreak -= 2;
                        // no longer in a token
                        inToken = false;
                        if (level > 0) {
                            level -= 1;
                        } else {
                            break;
                        }
                    // if we're at the beginning of another translation string,
                    // we're nested, so add to our level
                    } else if (char0 === '[' && char1 === '[') {
                        level += 1;
                        cursor += 2;
                    // if we're at the end of a translation string
                    } else if (char0 === ']' && char1 === ']') {
                        // if we're at the base level, then this is the end
                        if (level === 0) {
                            // so grab the name and args
                            var currentSlice = str.slice(lastBreak, cursor);
                            var result = split(currentSlice);
                            var name = result[0];
                            var args = result.slice(1);

                            // make a backup based on the raw string of the token
                            // if there are arguments to the token
                            var backup = '';
                            if (args && args.length) {
                                backup = this.translate(currentSlice);
                            }
                            // add the translation promise to the array
                            toTranslate.push(this.translateKey(name, args, backup));
                            // 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
                            inToken = false;
                            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;
                    }
                }

                // skip to the next [[
                cursor = str.indexOf('[[', cursor);
            }

            // ending string of source
            var last = str.slice(lastBreak);

            // if we were mid-token, treat it as invalid
            if (inToken) {
                last = this.translate(last);
            }

            // add the remaining text after the last translation string
            toTranslate.push(last);

            // and return a promise for the concatenated translated string
            return Promise.all(toTranslate).then(function (translated) {
                return translated.join('');
            });
        };

        /**
         * Translates a specific key and array of arguments
         * @param {string} name - Translation key (ex. 'global:home')
         * @param {string[]} args - Arguments for `%1`, `%2`, etc
         * @param {string|Promise<string>} backup - Text to use in case the key can't be found
         * @returns {Promise<string>}
         */
        Translator.prototype.translateKey = function translateKey(name, args, backup) {
            var self = this;

            var result = name.split(':', 2);
            var namespace = result[0];
            var key = result[1];

            if (self.modules[namespace]) {
                return Promise.resolve(self.modules[namespace](key, args));
            }

            if (namespace && result.length === 1) {
                return Promise.resolve('[[' + namespace + ']]');
            }

            if (namespace && !key) {
                warn('Missing key in translation token "' + name + '" for language "' + self.lang + '"');
                return Promise.resolve('[[' + namespace + ']]');
            }

            var translation = this.getTranslation(namespace, key);
            return translation.then(function (translated) {
                // check if the translation is missing first
                if (!translated) {
                    warn('Missing translation "' + name + '" for language "' + self.lang + '"');
                    return backup || key;
                }

                var argsToTranslate = args.map(function (arg) {
                    return self.translate(escapeHTML(arg));
                });

                return Promise.all(argsToTranslate).then(function (translatedArgs) {
                    var out = translated;
                    translatedArgs.forEach(function (arg, i) {
                        var escaped = arg.replace(/%(?=\d)/g, '&#37;').replace(/\\,/g, '&#44;');
                        // fix double escaped translation keys, see https://github.com/NodeBB/NodeBB/issues/9206
                        escaped = escaped.replace(/&amp;lsqb;/g, '&lsqb;')
                            .replace(/&amp;rsqb;/g, '&rsqb;');
                        out = out.replace(new RegExp('%' + (i + 1), 'g'), escaped);
                    });
                    return out;
                });
            });
        };

        /**
         * 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<{ [key: string]: string } | string>}
         */
        Translator.prototype.getTranslation = function getTranslation(namespace, key) {
            var translation;
            if (!namespace) {
                warn('[translator] Parameter `namespace` is ' + namespace + (namespace === '' ? '(empty string)' : ''));
                translation = Promise.resolve({});
            } else {
                this.translations[namespace] = this.translations[namespace] ||
                    this.load(this.lang, namespace).catch(function () { return {}; });
                translation = this.translations[namespace];
            }

            if (key) {
                return translation.then(function (x) {
                    if (typeof x[key] === 'string') return x[key];
                    const keyParts = key.split('.');
                    for (let i = 0; i <= keyParts.length; i++) {
                        if (i === keyParts.length) {
                            // default to trying to find key with the same name as parent or equal to empty string
                            return x[keyParts[i - 1]] !== undefined ? x[keyParts[i - 1]] : x[''];
                        }
                        switch (typeof x[keyParts[i]]) {
                            case 'object':
                                x = x[keyParts[i]];
                                break;
                            case 'string':
                                if (i === keyParts.length - 1) {
                                    return x[keyParts[i]];
                                }

                                return false;

                            default:
                                return false;
                        }
                    }
                });
            }
            return translation;
        };

        /**
         * @param {Node} node
         * @returns {Node[]}
         */
        function descendantTextNodes(node) {
            var textNodes = [];

            function helper(node) {
                if (node.nodeType === 3) {
                    textNodes.push(node);
                } else {
                    for (var i = 0, c = node.childNodes, l = c.length; i < l; i += 1) {
                        helper(c[i]);
                    }
                }
            }

            helper(node);
            return textNodes;
        }

        /**
         * Recursively translate a DOM element in place
         * @param {Element} element - Root element to translate
         * @param {string[]} [attributes] - Array of node attributes to translate
         * @returns {Promise<void>}
         */
        Translator.prototype.translateInPlace = function translateInPlace(element, attributes) {
            attributes = attributes || ['placeholder', 'title'];

            var nodes = descendantTextNodes(element);
            var text = nodes.map(function (node) {
                return utils.escapeHTML(node.nodeValue);
            }).join('  ||  ');

            var attrNodes = attributes.reduce(function (prev, attr) {
                var tuples = Array.prototype.map.call(element.querySelectorAll('[' + attr + '*="[["]'), function (el) {
                    return [attr, el];
                });
                return prev.concat(tuples);
            }, []);
            var attrText = attrNodes.map(function (node) {
                return node[1].getAttribute(node[0]);
            }).join('  ||  ');

            return Promise.all([
                this.translate(text),
                this.translate(attrText),
            ]).then(function (ref) {
                var translated = ref[0];
                var translatedAttrs = ref[1];
                if (translated) {
                    translated.split('  ||  ').forEach(function (html, i) {
                        $(nodes[i]).replaceWith(html);
                    });
                }
                if (translatedAttrs) {
                    translatedAttrs.split('  ||  ').forEach(function (text, i) {
                        attrNodes[i][1].setAttribute(attrNodes[i][0], text);
                    });
                }
            });
        };

        /**
         * Get the language of the current environment, falling back to defaults
         * @returns {string}
         */
        Translator.getLanguage = function getLanguage() {
            var lang;

            if (typeof window === 'object' && window.config && window.utils) {
                lang = utils.params().lang || config.userLang || config.defaultLang || 'en-GB';
            } else {
                var meta = require('../../../src/meta');
                lang = meta.config && meta.config.defaultLang ? meta.config.defaultLang : 'en-GB';
            }

            return lang;
        };

        /**
         * 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();
            }

            Translator.cache[language] = Translator.cache[language] || new Translator(language);

            return Translator.cache[language];
        };

        Translator.cache = {};

        /**
         * Register a custom module to handle translations
         * @param {string} namespace - Namespace to handle translation for
         * @param {Function} factory - Function to return the translation function for this namespace
         */
        Translator.registerModule = function registerModule(namespace, factory) {
            Translator.moduleFactories[namespace] = factory;

            Object.keys(Translator.cache).forEach(function (key) {
                var translator = Translator.cache[key];
                translator.modules[namespace] = factory(translator.lang);
            });
        };

        Translator.moduleFactories = {};

        /**
         * Remove the translator patterns from text
         * @param {string} text
         * @returns {string}
         */
        Translator.removePatterns = function removePatterns(text) {
            var len = text.length;
            var cursor = 0;
            var lastBreak = 0;
            var level = 0;
            var out = '';
            var sub;

            while (cursor < len) {
                sub = text.slice(cursor, cursor + 2);
                if (sub === '[[') {
                    if (level === 0) {
                        out += text.slice(lastBreak, cursor);
                    }
                    level += 1;
                    cursor += 2;
                } else if (sub === ']]') {
                    level -= 1;
                    cursor += 2;
                    if (level === 0) {
                        lastBreak = cursor;
                    }
                } else {
                    cursor += 1;
                }
            }
            out += text.slice(lastBreak, cursor);
            return out;
        };

        /**
         * Escape translator patterns in text
         * @param {string} text
         * @returns {string}
         */
        Translator.escape = function escape(text) {
            return typeof text === 'string' ? text.replace(/\[\[/g, '&lsqb;&lsqb;').replace(/\]\]/g, '&rsqb;&rsqb;') : text;
        };

        /**
         * Unescape escaped translator patterns in text
         * @param {string} text
         * @returns {string}
         */
        Translator.unescape = function unescape(text) {
            return typeof text === 'string' ?
                text.replace(/&lsqb;/g, '[').replace(/\\\[/g, '[')
                    .replace(/&rsqb;/g, ']').replace(/\\\]/g, ']') :
                text;
        };

        /**
         * Construct a translator pattern
         * @param {string} name - Translation name
         * @param {...string} arg - Optional argument for the pattern
         */
        Translator.compile = function compile() {
            var args = Array.prototype.slice.call(arguments, 0).map(function (text) {
                // escape commas and percent signs in arguments
                return String(text).replace(/%/g, '&#37;').replace(/,/g, '&#44;');
            });

            return '[[' + args.join(', ') + ']]';
        };

        return Translator;
    }());

    /**
     * @exports translator
     */
    var adaptor = {
        /**
         * The Translator class
         */
        Translator: Translator,

        compile: Translator.compile,
        escape: Translator.escape,
        unescape: Translator.unescape,
        getLanguage: Translator.getLanguage,

        flush: function () {
            Object.keys(Translator.cache).forEach(function (code) {
                Translator.cache[code].translations = {};
            });
        },

        flushNamespace: function (namespace) {
            Object.keys(Translator.cache).forEach(function (code) {
                if (Translator.cache[code] &&
                    Translator.cache[code].translations &&
                    Translator.cache[code].translations[namespace]
                ) {
                    Translator.cache[code].translations[namespace] = null;
                }
            });
        },


        /**
         * Legacy translator function for backwards compatibility
         */
        translate: function translate(text, language, callback) {
            // TODO: deprecate?

            var cb = callback;
            var lang = language;
            if (typeof language === 'function') {
                cb = language;
                lang = null;
            }

            if (!(typeof text === 'string' || text instanceof String) || text === '') {
                if (cb) {
                    return setTimeout(cb, 0, '');
                }
                return '';
            }

            return Translator.create(lang).translate(text).then(function (output) {
                if (cb) {
                    setTimeout(cb, 0, output);
                }
                return output;
            }, function (err) {
                warn('Translation failed: ' + err.stack);
            });
        },

        /**
         * Add translations to the cache
         */
        addTranslation: function addTranslation(language, namespace, translation) {
            Translator.create(language).getTranslation(namespace).then(function (translations) {
                assign(translations, translation);
            });
        },

        /**
         * Get the translations object
         */
        getTranslations: function getTranslations(language, namespace, callback) {
            callback = callback || function () {};
            Translator.create(language).getTranslation(namespace).then(callback);
        },

        /**
         * Alias of getTranslations
         */
        load: function load(language, namespace, callback) {
            adaptor.getTranslations(language, namespace, callback);
        },

        toggleTimeagoShorthand: function toggleTimeagoShorthand(callback) {
            /* eslint "prefer-object-spread": "off" */
            function toggle() {
                var tmp = assign({}, jQuery.timeago.settings.strings);
                jQuery.timeago.settings.strings = assign({}, adaptor.timeagoShort);
                adaptor.timeagoShort = assign({}, tmp);
                if (typeof callback === 'function') {
                    callback();
                }
            }

            if (!adaptor.timeagoShort) {
                var languageCode = utils.userLangToTimeagoCode(config.userLang);
                if (!config.timeagoCodes.includes(languageCode + '-short')) {
                    languageCode = 'en';
                }

                var originalSettings = assign({}, jQuery.timeago.settings.strings);
                adaptor.switchTimeagoLanguage(languageCode + '-short', function () {
                    adaptor.timeagoShort = assign({}, jQuery.timeago.settings.strings);
                    jQuery.timeago.settings.strings = assign({}, originalSettings);
                    toggle();
                });
            } else {
                toggle();
            }
        },

        switchTimeagoLanguage: function switchTimeagoLanguage(langCode, callback) {
            // Delete the cached shorthand strings if present
            delete adaptor.timeagoShort;

            var stringsModule = 'timeago/locales/jquery.timeago.' + langCode;
            // without undef, requirejs won't load the strings a second time
            require.undef(stringsModule);
            require([stringsModule], function () {
                callback();
            });
        },

        prepareDOM: function prepareDOM() {
            // Add directional code if necessary
            adaptor.translate('[[language:dir]]', function (value) {
                if (value && !$('html').attr('data-dir')) {
                    jQuery('html').css('direction', value).attr('data-dir', value);
                }
            });
        },
    };

    return adaptor;
}));