fossasia/loklak_webclient

View on GitHub
iframely/lib/loader/pluginLoader.js

Summary

Maintainability
D
2 days
Test Coverage
(function(pluginLoader) {

    var fs = require('fs'),
        path = require('path'),
        _ = require('underscore'),
        async = require('async'),
        request = require('request'),
        url = require('url'),
        moment = require('moment'),
        chokidar = require('chokidar');

    var node_jslint = require('jslint'),
        JSLINT = node_jslint.load();

    var utils = require('./utils');

    var PLUGIN_METHODS = utils.PLUGIN_METHODS,
        PLUGINS_FIELDS = utils.PLUGIN_FIELDS,
        PLUGIN_FIELDS_TYPE_DICT = utils.PLUGIN_FIELDS_TYPE_DICT,
        POST_PLUGIN_DEFAULT_PARAMS = utils.POST_PLUGIN_DEFAULT_PARAMS,
        DEFAULT_PARAMS = utils.DEFAULT_PARAMS;

    var plugins = pluginLoader._plugins = {},
        providedParamsDict = pluginLoader._providedParamsDict = {},
        usedParamsDict = pluginLoader._usedParamsDict = {},
        templates = pluginLoader._templates = {},
        metaMappings = {},
        pluginsList = pluginLoader._pluginsList = [],
        postPluginsList = pluginLoader._postPluginsList = [],
        pluginsByDomain = {};

    /*
     * ===================
     * Loading plugins
     * ===================
     * */


    function validateMixins() {
        for(var id in plugins) {

            var plugin = plugins[id];

            plugin.module.mixins && plugin.module.mixins.forEach(function(mixin) {
                if (!(mixin in plugins)) {
                    console.warn('Invalid mixin "' + mixin + '" in plugin "' + id + '"');
                }
            });
        }
    }

    /*
     * Check if all params required by plugins declared by 'provides' plugins field.
     * */
    function validateDependencies() {

        for(var id in plugins) {

            var plugin = plugins[id];

            // Check each plugin method.
            utils.PLUGIN_METHODS.forEach(function(method) {

                if (method in plugin.methods) {

                    var params = plugin.methods[method];

                    var absentParams = params.filter(function(param) {
                        // TODO: move to config.

                        if (DEFAULT_PARAMS.indexOf(param) > -1) {
                            return false;
                        } else if (method === 'prepareLink' && POST_PLUGIN_DEFAULT_PARAMS.indexOf(param) > -1) {
                            return false;
                        }
                        return !(param in providedParamsDict);
                    });

                    if (absentParams.length > 0) {
                        console.warn('No matching getData for params: "' + absentParams.join(', ') + '" in plugin "' + id + '"');
                    }
                }
            });
        }
    }

    function getFileName(filenameWithExt) {
        return filenameWithExt.replace(/\.(js|ejs)$/i, "");
    }

    function getPluginMethods(pluginPath,plugin) {
        var methods = {};
        for (var name in plugin) {
            var func = plugin[name];
            if (typeof func === "function" && PLUGIN_METHODS.indexOf(name) > -1) {

                var funcString = String(func);

                if (CONFIG.DEBUG) {
                    // Test code with JSLint only in debug mode.

                    JSLINT('('+funcString+')', {node: true, sloppy: true, white: true, vars: true, maxerr: Infinity, forin: true});
                    // TODO: better way to detect error.
                    // TODO: show all jslint warnings? find better validation params.
                    if (!JSLINT.tree.first) {
                        console.error('In file ' + pluginPath + ' in function exports. ' + name + ':');
                        JSLINT.data().errors.forEach(function (error) {
                            if (error) {
                                console.error(error.reason);
                                if (error.evidence) {
                                    console.error(error.evidence+'\n');
                                }
                            }
                        });
                        throw new Error('JSLINT could not parse function');
                    }
                }

                var m = funcString.match(/function\s*\(([^)]+)\)/);
                if (m) {
                    var params = m[1].split(/[, ]+/);
                    methods[name] = params.map(function (param) {
                        return param;
                    });
                }
            }
        }
        return methods;
    }

    function loadPluginFile(pluginPath) {

        var bits = pluginPath.split(path.sep);

        if (pluginPath.match(/\.js$/i)) {

            var plugin;
            var pluginDeclaration = {};

            // Load plugin.
            try {
                plugin = require(pluginPath);
            } catch(ex) {
                console.error("Error loading plugin", pluginPath, ex);
                return;
            }

            if (plugin.notPlugin) {
                // Skip utils modules.
                return;
            }

            // Check if have required method.
            var hasSign = _.some(PLUGINS_FIELDS, function(sign) {
                return sign in plugin;
            });
            if (!hasSign) {
                console.warn("No plugin methods in " + pluginPath + ". Add exports.notPlugin = true; to skip this warning.");
                return;
            }

            // Check methods type.
            var error = false;
            PLUGINS_FIELDS.forEach(function(sign) {
                if (sign in plugin && sign in PLUGIN_FIELDS_TYPE_DICT) {
                    if (!(plugin[sign] instanceof PLUGIN_FIELDS_TYPE_DICT[sign])) {
                        console.error('Type error: "' + sign + '" must be instanceof "' + PLUGIN_FIELDS_TYPE_DICT[sign] + '" in', pluginPath);
                        error = true;
                    }
                }
            });
            if (error) {
                return;
            }

            // Check post plugin.
            // TODO: only prapereLink method abailable.
            // TODO: exclude prapereLink method from other plugins.
            pluginDeclaration.post = bits.indexOf('validators') > -1;
            if (pluginDeclaration.post) {
                if (!('prepareLink' in plugin)) {
                    console.error('Type error: "prepareLink" method absent in post plugin', pluginPath);
                    return;
                }
            }

            // ID.
            pluginDeclaration.id = getFileName(bits[bits.length - 1]).toLowerCase();
            if (pluginDeclaration.id in plugins) {
                console.error("Duplicate plugin id (filename)", pluginPath);
                return;
            }

            // Normalize RE.
            if (plugin.re) {
                if (plugin.re instanceof RegExp) {
                    pluginDeclaration.re = [plugin.re];
                } else if (plugin.re instanceof Array) {

                    if (!_.every(plugin.re, function(re) { return re instanceof RegExp; })) {
                        console.error('Invalid RegExp in re of', pluginPath);
                        return;
                    }

                    pluginDeclaration.re = plugin.re;
                } else {
                    console.error('Invalid RegExp or Array in re of', pluginPath);
                    return;
                }
            } else {
                pluginDeclaration.re = [];
            }

            // Fill "plugin provides" dict.
            if (plugin.provides) {

                // Allow non arrays.
                var provides = plugin.provides instanceof Array ? plugin.provides : [plugin.provides];

                provides.forEach(function(param) {

                    if (param === 'self') {
                        // Convert 'self' to plugin id.
                        param = pluginDeclaration.id;
                    }

                    var list = providedParamsDict[param] = providedParamsDict[param] || [];
                    list.push(pluginDeclaration.id);
                });
            }

            // Find domain.
            var domainBitIdx = bits.indexOf('domains');
            if (domainBitIdx > -1) {
                // Domain plugin.
                var domain = bits[domainBitIdx + 1].replace(/^www\./i, "");
                // Remove .js extension if not folder.
                pluginDeclaration.domain = getFileName(domain);
                // Store in pluginsByDomain dict to fast search by domain.
                pluginsByDomain[pluginDeclaration.domain] = pluginDeclaration;
            } else {
                if (plugin.re) {
                    console.warn("re in generic plugin (will never be called)", pluginPath)
                }
            }

            // Find plugin methods params.
            pluginDeclaration.methods = getPluginMethods(pluginPath, plugin);
            for(var method in pluginDeclaration.methods) {
                if (!(method in plugin)) {
                    delete pluginDeclaration.methods[method];
                } else {
                    // Store dict with all plugins params.
                    var params = pluginDeclaration.methods[method];
                    params.forEach(function(param) {
                        var list = usedParamsDict[param] = usedParamsDict[param] || [];
                        list.push(pluginDeclaration.id);
                    });
                }
            }

            pluginDeclaration.module = plugin;

            // Plugins in folder 'custom' or 'core' will be run only on explicit dependency.
            pluginDeclaration.custom = (bits.indexOf('custom') > -1 || bits.indexOf('system') > -1) && !plugin.generic;

            var stat = fs.statSync(pluginPath);
            pluginDeclaration.modified = new Date(stat.mtime);
            pluginDeclaration.getPluginLastModifiedDate = getPluginLastModifiedDate;

            // If no mixins - mark domain plugin 'asks to mixin all generic plugins'.
            if (plugin.mixins) {
                var idx = plugin.mixins.indexOf('*');
                if (idx > -1) {
                    // Remove '*' from array.
                    plugin.mixins.splice(idx, 1);
                    pluginDeclaration.mixinAllGeneric = true;
                }
            }

            // Store plugin.
            plugins[pluginDeclaration.id] = pluginDeclaration;

        } else if (pluginPath.match(/\.ejs$/i)) {

            var id = getFileName(bits[bits.length - 1]).toLowerCase();

            if (id in templates) {
                console.error("Duplicate template id (filename)", pluginPath);
                return;
            }

            templates[id] = pluginPath;
        }
    }

    // Plugin method. Finds modified date, including mixins.
    function getPluginLastModifiedDate() {

        var plugin = this;

        var modified = plugin.modified;

        var mixins = plugin.module.mixins;

        if (mixins) {
            for(var i = 0; i < mixins.length; i++) {

                var mixin = mixins[i];

                var m = plugins[mixin].getPluginLastModifiedDate();

                if (m > modified) {
                    modified = m;
                }
            }
        }

        return modified;
    }

    function loadPluginDir(pluginPath) {

        // Scan plugin dir.
        var plugins = fs.readdirSync(pluginPath);

        plugins.forEach(function(plugin_name) {
            var plugin = path.resolve(pluginPath, plugin_name);
            var stats = fs.statSync(plugin);

            if (stats.isFile()) {
                loadPluginFile(plugin);
            }
        });
    }

    function scanAllPluginsDir(modulePluginsPath) {

        // Scan mudule plugins.
        var plugins = fs.readdirSync(modulePluginsPath);

        plugins.forEach(function(plugin_name) {
            var plugin = path.resolve(modulePluginsPath, plugin_name);
            var stats = fs.statSync(plugin);

            if (stats.isFile()) {
                loadPluginFile(plugin);
            } if (stats.isDirectory()) {
                loadPluginDir(plugin);
            }
        });
    }

    function extractDomain(uri) {
        var m = uri.match(/^(?:https?:\/\/)?([^/]+)/i);
        if (m) {
            return m[1];
        } else {
            return null;
        }
    }

    function extractDomainPatterns(uri) {

        var patterns = [];

        var domain = extractDomain(uri);
        if (!domain) {
            return patterns;
        }

        // Only full domain exact match.
        patterns.push(domain);

        // 'www' workaround.
        var bits = domain.split('.');
        if (bits[0] != 'www') {
            patterns.push('www.' + domain);
        } else {
            // Remove www.
            bits.splice(0, 1);
            domain = bits.join('.');
            patterns.push(domain);
        }

        return patterns;
    }

    pluginLoader.findDomainPlugin = function(uri) {
        var patterns = extractDomainPatterns(uri);

        var record, i = 0;
        while(!record && i < patterns.length) {
            record = pluginsByDomain[patterns[i]];
            i++;
        }

        return record;
    };

    function scanModulesForPlugins() {

        // Scan node_modules dir.
        var modulesRootPath = path.resolve('node_modules');
        var modules_listing = fs.readdirSync(modulesRootPath).map(function(module_name) { return path.resolve(modulesRootPath, module_name); });

        modules_listing.push(path.resolve('.'));

        modules_listing.forEach(function(modulePath) {

            var modulePackagePath = path.resolve(modulePath, 'package.json');

            if (fs.existsSync(modulePackagePath)) {

                // Scan plugins.

                var moduleInfo = require(modulePackagePath);
                if (!moduleInfo["iframely-proxy-plugins"]) {
                    return;
                }

                function loadPlugins() {
                    var bits = Array.prototype.slice.call(arguments, 0);
                    bits.splice(0, 0, modulePath);
                    var modulePluginsPath = path.resolve.apply(path.resolve, bits);
                    if (fs.existsSync(modulePluginsPath)) {
                        scanAllPluginsDir(modulePluginsPath);
                    }
                }

                loadPlugins('plugins', 'domains');
                loadPlugins('plugins', 'custom');
                loadPlugins('plugins', 'links');
                loadPlugins('plugins', 'meta');
                loadPlugins('plugins', 'templates');

                loadPlugins('lib', 'plugins', 'system');
                loadPlugins('lib', 'plugins', 'validators');
            }
        });

        validateMixins();
        validateDependencies();

        for (var pluginId in plugins) {
            var plugin = plugins[pluginId];
            if (plugin.post) {
                postPluginsList.push(plugin);
            } else {
                pluginsList.push(plugin);
            }
        }

        console.log('Iframely plugins loaded:')
        console.log('   - custom domains:', pluginsList.filter(function(p) { return p.domain; }).length);
        console.log('   - generic & meta:', pluginsList.filter(function(p) { return !p.domain && !p.custom; }).length);

        // Low priority - goes first, second plugin will override values.
        pluginsList.sort(function(p1, p2) {

            function getV(p) {
                if (p.module.lowestPriority) {
                    return 0;
                }
                if (p.module.highestPriority) {
                    return 2;
                }
                return 1;
            }

            return getV(p1) - getV(p2);
        });

        // Sort post plugins by name.
        postPluginsList.sort(function(p1, p2) {
            return p1.id < p2.id ? -1: 1;
        });

        // Get meta mappings.
        // Create plugins order.
        var metaMappingsNotSorted = {};
        var mappingsRe = /return \{\s*([^\}]+)\}/im;
        var mappingRe = /[\s\n]*"?([a-z0-9_-]+)"?\s*:\s*([^\n]+)[\s\n]*,?[\s\n]*/img;
        pluginsList.forEach(function(p, idx) {
            p.order = idx + 1; // Not 0.
            if (p.module.getMeta) {
                var func = p.module.getMeta.toString();
                var mappingsMatch = func.match(mappingsRe);
                if (mappingsMatch) {
                    var mappingStr = mappingsMatch[1];
                    var mappingMatch;
                    while (mappingMatch = mappingRe.exec(mappingStr)) {
                        var key = mappingMatch[1];
                        var source = mappingMatch[2];

                        source = source.replace(/,?[\s\n]*$/gi, "");

                        var sources = metaMappingsNotSorted[key] = metaMappingsNotSorted[key] || [];
                        sources.push({
                            source: source,
                            pluginId: p.id
                        });
                    }
                }
            }
        });

        var keys = _.keys(metaMappingsNotSorted);
        keys.sort();
        keys.forEach(function(key) {
            metaMappings[key] = metaMappingsNotSorted[key];
        });
    }

    scanModulesForPlugins();

})(exports);