OpenVeo server API for plugins

API Docs for: 7.0.0
Show:

File: lib/grunt/ngDpTask/ngDpTask.js

'use strict';

/**
 * @module grunt
 */

var path = require('path');
var fs = require('fs');
var async = require('async');
var esprima = require('esprima');
var utilApi = process.requireApi('lib/util.js');
var expressionFactory = process.requireApi('lib/grunt/ngDpTask/expressionFactory.js');
var ElementExpression = process.requireApi('lib/grunt/ngDpTask/ElementExpression.js');
var InjectExpression = process.requireApi('lib/grunt/ngDpTask/InjectExpression.js');
var ConfigExpression = process.requireApi('lib/grunt/ngDpTask/ConfigExpression.js');
var RouteExpression = process.requireApi('lib/grunt/ngDpTask/RouteExpression.js');
var FilterExpression = process.requireApi('lib/grunt/ngDpTask/FilterExpression.js');

/**
 * Finds out AngularJS definitions and dependencies for the given content.
 *
 * This is recursive.
 *
 * The following JavaScript expressions are used to identify definitions:
 *   - angular.module('moduleName', [])
 *   - angular.module('moduleName').component()
 *   - angular.module('moduleName').directive()
 *   - angular.module('moduleName').controller()
 *   - angular.module('moduleName').factory()
 *   - angular.module('moduleName').service()
 *   - angular.module('moduleName').constant()
 *   - angular.module('moduleName').service()
 *   - angular.module('moduleName').decorator()
 *   - angular.module('moduleName').filter()
 *   - angular.module('moduleName').config()
 *   - angular.module('moduleName').run()
 *
 * The following JavaScript expressions are used to identify dependencies:
 *   - MyAngularJsElement.$inject = ['Dependency1', 'Dependency2'];
 *   - angular.module('moduleName', ['DependencyModule'])
 *
 * The following JavaScript expressions are used to identify associated modules:
 *   - angular.module('moduleName')
 *
 * @method findDependencies
 * @param {Object} expression The JavaScript expression to analyze
 * @private
 */
function findDependencies(jsExpression) {
  var expression;
  var results = {
    definitions: [],
    dependencies: [],
    module: null
  };
  if (!jsExpression) return results;

  /**
   * Merges results from sub expressions into results for the current expression.
   *
   * @param {Object} newResults Sub expressions results
   * @param {Array} [newResults.definitions] The list of definitions in sub expression
   * @param {Array} [newResults.dependencies] The list of dependencies in sub expression
   * @param {String} [newResults.module] The name of the module the definitions belong to
   */
  function mergeResults(newResults) {
    if (newResults.definitions)
      results.definitions = utilApi.joinArray(results.definitions, newResults.definitions);

    if (newResults.dependencies)
      results.dependencies = utilApi.joinArray(results.dependencies, newResults.dependencies);

    if (newResults.module)
      results.module = newResults.module;
  }

  if (jsExpression.type === 'CallExpression' && jsExpression.callee.type === 'MemberExpression') {
    if (Object.values(ElementExpression.ELEMENTS).indexOf(jsExpression.callee.property.name) > -1) {
      expression = expressionFactory.getElementExpression(jsExpression.callee.property.name, jsExpression);
      if (expression.isValid()) {
        var newResults = {
          definitions: expression.isDefinition() ? [expression.getName()] : [],
          dependencies: expression.getDependencies()
        };

        if (!expression.isDefinition() && expression.getElementType() === ElementExpression.ELEMENTS.MODULE)
          newResults.module = expression.getName();

        mergeResults(newResults);
      }
    } else if (jsExpression.callee.property.name === 'config' || jsExpression.callee.property.name === 'run') {
      expression = new ConfigExpression(jsExpression);

      if (expression.isValid()) {
        mergeResults({
          dependencies: expression.getDependencies()
        });
      }
    } else if (jsExpression.callee.property.name === 'when') {
      expression = new RouteExpression(jsExpression);

      if (expression.isValid()) {
        mergeResults({
          definitions: expression.getDefinitions(),
          dependencies: expression.getDependencies()
        });
      }
    }
  }

  if (jsExpression.type === 'AssignmentExpression' &&
      jsExpression.left.property &&
      jsExpression.left.property.name === '$inject'
  ) {
    expression = new InjectExpression(jsExpression);

    if (expression.isValid()) {
      mergeResults({
        dependencies: expression.getDependencies()
      });
    }
  }

  if (jsExpression.type === 'CallExpression' && jsExpression.callee.name === '$filter') {
    expression = new FilterExpression(jsExpression);

    if (expression.isValid()) {
      mergeResults({
        dependencies: [expression.getDependency()]
      });
    }
  }

  if (Object.prototype.toString.call(jsExpression) === '[object Object]') {
    for (var property in jsExpression)
      mergeResults(findDependencies(jsExpression[property]));
  } else if (Object.prototype.toString.call(jsExpression) === '[object Array]') {
    jsExpression.forEach(function(value) {
      mergeResults(findDependencies(value));
    });
  }

  return results;
}

/**
 * Fetches a script from a list of scripts.
 *
 * @method findScript
 * @param {Array} scripts The list of scripts
 * @param {String} property The script property used to identify the script to fetch
 * @param {String} value The expected value of the script property
 * @return {Object|Null} The found script or null if not found
 * @private
 */
function findScript(scripts, property, value) {
  for (var i = 0; i < scripts.length; i++) {
    if ((Object.prototype.toString.call(scripts[i][property]) === '[object Array]' &&
        scripts[i][property].indexOf(value) > -1) ||
        (scripts[i][property] === value)
    ) {
      return scripts[i];
    }
  }

  return null;
}

/**
 * Browses a flat list of scripts to find a script longest dependency chains.
 *
 * Each script may have several dependencies, each dependency can also have several dependencies.
 * findLongestDependencyChains helps find the longest dependency chain of one of the script.
 * As the script may have several longest dependency chain, a list of chains is returned.
 *
 * A chain is an ordered list of script paths.
 *
 * This is recursive.
 *
 * @method findLongestDependencyChains
 * @param {Array} scripts The flat list of scripts with for each script:
 *  - **dependencies** The list of dependency names of the script
 *  - **definitions** The list of definition names of the script
 *  - **path** The script path
 * @param {Object} [script] The script to analyze (default to the first one of the list of scripts)
 * @param {Array} [modulesToIgnore] The list of module names to ignore to avoid circular dependencies
 * @return {Array} The longest dependency chains
 * @private
 */
function findLongestDependencyChains(scripts, script, modulesToIgnore) {
  var chains = [];

  if (!script) script = scripts[0];

  // Avoid circular dependencies
  if (modulesToIgnore && script.module && modulesToIgnore.indexOf(script.module) !== -1) return chains;

  // Get script dependencies
  if (script.dependencies && script.dependencies.length) {
    var longestChainLength;

    // Find dependency chains of the script
    script.dependencies.forEach(function(dependency) {
      var definitionScript = findScript(scripts, 'definitions', dependency);

      if (definitionScript)
        chains = chains.concat(findLongestDependencyChains(scripts, definitionScript, script.definitions));
    });

    if (chains.length > 0) {

      // Keep the longest chain(s)
      chains.sort(function(chain1, chain2) {
        // -1 : chain1 before chain2
        // 0 : nothing change
        // 1 : chain1 after chain2

        if (chain1.length > chain2.length)
          return -1;
        else if (chain1.length < chain2.length)
          return 1;
        else return 0;
      });

      longestChainLength = chains[0].length;

      chains = chains.filter(function(chain) {
        if (chain.length === longestChainLength) {
          chain.push(script.path);
          return true;
        }

        return false;
      });

      return chains;
    }
  }

  chains.push([script.path]);
  return chains;
}

/**
 * Builds the dependencies tree.
 *
 * @method buildTree
 * @param {Array} scripts The flat list of scripts with for each script:
 *  - **dependencies** The list of dependency names of the script
 *  - **definitions** The list of definition names of the script
 *  - **path** The script path
 * @return {Array} The list of scripts with their dependencies
 * @private
 */
function buildTree(scripts) {
  var chains = [];
  var tree = {
    children: []
  };
  var currentTreeNode = tree;

  // Get the longest dependency chain for each script with the highest dependency
  // as the first element of the chain
  scripts.forEach(function(script) {
    chains = chains.concat(findLongestDependencyChains(scripts, script));
  });

  // Sort chains by length with longest chains first
  chains.sort(function(chain1, chain2) {
    // -1 : chain1 before chain2
    // 0 : nothing change
    // 1 : chain1 after chain2

    if (chain1.length > chain2.length)
      return -1;
    else if (chain1.length < chain2.length)
      return 1;
    else return 0;
  });

  // Add each chain to the tree
  chains.forEach(function(chain) {

    // Add each element of the chain as a child of its parent
    chain.forEach(function(scriptPath) {
      var currentScript = findScript(scripts, 'path', scriptPath);
      var alreadyExists = false;

      if (!currentTreeNode.children)
        currentTreeNode.children = [];

      // Check if current script does not exist in node children
      for (var i = 0; i < currentTreeNode.children.length; i++) {
        if (currentTreeNode.children[i].path === currentScript.path) {
          alreadyExists = true;
          break;
        }
      }

      // Add script to the tree
      if (!alreadyExists)
        currentTreeNode.children.push(currentScript);

      currentTreeNode = currentScript;
    });

    currentTreeNode = tree;
  });

  return tree;
}

/**
 * Retrieves CSS and JS files from tree of scripts in a flattened order.
 *
 * This is recursive.
 *
 * @method getTreeResources
 * @param {Object} node The node from where to start
 * @param {String} node.path The file path
 * @param {Array} node.styles The list of css / scss file paths
 * @return {Object} An object with:
 *   - **childrenCss** The list of children CSS files
 *   - **childrenJs** The list of children CSS files
 *   - **subChildrenCss** The list of sub children CSS files
 *   - **subChildrenJs** The list of sub children JS files
 * @private
 */
function getTreeResources(node) {
  var resources = {childrenCss: [], childrenJs: [], subChildrenCss: [], subChildrenJs: []};

  // Add css and js of node children then dedupe
  if (node.children) {
    node.children.forEach(function(subNode) {
      var subResources = getTreeResources(subNode);
      resources.childrenJs = utilApi.joinArray(resources.childrenJs, [subNode.path]);
      resources.childrenCss = utilApi.joinArray(resources.childrenCss, subNode.styles);
      resources.subChildrenCss = utilApi.joinArray(resources.subChildrenCss, subResources.childrenCss);
      resources.subChildrenJs = utilApi.joinArray(resources.subChildrenJs, subResources.childrenJs);
      resources.subChildrenCss = utilApi.joinArray(resources.subChildrenCss, subResources.subChildrenCss);
      resources.subChildrenJs = utilApi.joinArray(resources.subChildrenJs, subResources.subChildrenJs);
    });
  } else {

    // Add current node css and js then dedupe
    resources.childrenCss = utilApi.joinArray(resources.childrenCss, node.styles);
    resources.childrenJs = utilApi.joinArray(resources.childrenJs, [node.path]);

  }

  return resources;
}

/**
 * Retrieves CSS and JS files from tree of scripts in a flattened order.
 *
 * @method getResources
 * @param {Object} tree The tree of resources
 * @return {Object} An object with:
 *   - **css** The list of css files in the right order
 *   - **js** The list of js files in the right order
 * the list of JS files
 * @private
 */
function getResources(tree) {
  var resources = getTreeResources(tree);
  return {
    css: utilApi.joinArray(resources.childrenCss, resources.subChildrenCss),
    js: utilApi.joinArray(resources.childrenJs, resources.subChildrenJs)
  };
}

/**
 * Defines a grunt task to build the list of sources (css and js) of an AngularJS application.
 *
 *     // Register task
 *     var openVeoApi = require('@openveo/api');
 *     grunt.registerMultiTask('ngDp', openVeoApi.grunt.ngDpTask(grunt));
 *
 *     // Configure task
 *     grunt.initConfig({
 *       'ngDp': {
 *         options: {
 *           basePath: '/path/to/the/',
 *           cssPrefix: '../../other/css/path/',
 *           jsPrefix: '../../other/js/path/'
 *         },
 *         app1: {
 *           src: '/path/to/the/app1/**\/*.*',
 *           dest: '/path/to/the/app1/topology.json'
 *         },
 *         app2: {
 *           src: '/path/to/the/app2**\/*.*',
 *           dest: '/path/to/the/app2/topology.json'
 *         }
 *       }
 *     });
 *
 *     // Ouput example (/path/to/the/app1/topology.json)
 *     {
 *       js: ['../..other/js/path/app1/file1.js', '../..other/js/path/app1/file2.js', [...]],
 *       css: ['../..other/css/path/app1/file1.css', '../..other/css/path/app1/file2.css', [...]]
 *     }
 *
 * AngularJS applications, which respect components architecture, need to be loaded in the right order as some
 * components may depend on other components. This task helps build an array of JavaScript files and css / scss files
 * in the right order.
 *
 * For this to work, each module must be declared in a separated file and a single file should not define AngularJS
 * elements belonging to several different modules.
 *
 * Available options are:
 *   - basePath: The base path which will be replaced by the cssPrefix or jsPrefix
 *   - cssPrefix: For CSS / SCSS files, replace the basePath by this prefix
 *   - jsPrefix: For JS files, replace the basePath by this prefix
 *
 * @class ngDpTask
 * @static
 */
module.exports = function(grunt) {
  return function() {
    var done = this.async();
    var asyncFunctions = [];
    var options = this.options({
      basePath: '',
      cssPrefix: '',
      jsPrefix: ''
    });

    /**
     * Generates a file with the list of JS and CSS files in the right order.
     *
     * @param {Array} sourceFiles The list of grunt source files
     * @param {String} destination The destination file which will contain the list of JS files and CSS files
     * @param {Function} callback The function to call when it's done with:
     *   - **Error** If an error occurred, null otherwise
     */
    var generateSourcesFile = function(sourceFiles, destination, callback) {
      var readAsyncFunctions = [];
      var sources = [];
      var scripts = [];
      var styles = [];

      sourceFiles.forEach(function(sourceFile) {
        readAsyncFunctions.push(function(callback) {
          grunt.verbose.writeln('read file ' + sourceFile);

          fs.stat(sourceFile, function(error, fileStat) {
            if (fileStat) {
              fileStat.path = sourceFile;
              sources = sources.concat(fileStat);
            }
            callback(error);
          });
        });
      });

      // Get stats for all source files
      async.parallel(readAsyncFunctions, function(error, results) {
        if (error) return callback(error);
        var analyzeAsyncFunctions = [];

        // Analyze all source files and distinguish JS and CSS files
        sources.forEach(function(source) {
          if (source.isFile()) {
            var pathDescriptor = path.parse(source.path);

            if (pathDescriptor.ext === '.js') {

              // JavaScript files
              analyzeAsyncFunctions.push(function(callback) {
                fs.readFile(source.path, function(error, content) {
                  grunt.verbose.writeln('analyze ' + source.path);
                  var contentString = content.toString();

                  // Try to find parents of the source
                  var programExpressions = esprima.parseScript(contentString);
                  var script = findDependencies(programExpressions);
                  script.path = source.path.replace(options.basePath, options.jsPrefix);
                  script.styles = [];
                  scripts.push(script);
                  callback(error);
                });
              });
            } else if (pathDescriptor.ext === '.css' ||
                       pathDescriptor.ext === '.scss'
            ) {

              // CSS / SCSS files
              styles.push(source.path.replace(options.basePath, options.cssPrefix));

            }
          }
        });

        async.parallel(analyzeAsyncFunctions, function(error) {
          if (error) return callback(error);

          // Associate css files with scripts
          styles.forEach(function(style) {
            for (var i = 0; i < scripts.length; i++) {
              var originalScriptPath = scripts[i].path.replace(options.jsPrefix, '');
              var originalStylePath = style.replace(options.cssPrefix, '');
              if (path.dirname(originalScriptPath) === path.dirname(originalStylePath)) {
                scripts[i].styles.push(style);
                return;
              }
            }
          });

          // Create final file
          grunt.file.write(destination, JSON.stringify(getResources(buildTree(scripts))));
          grunt.log.oklns('Ordered styles and scripts have been saved in ' + destination);

          callback();
        });
      });
    };

    // Iterates through src-dest pairs
    this.files.forEach(function(srcDestFile) {
      asyncFunctions.push(function(callback) {
        generateSourcesFile(srcDestFile.src, srcDestFile.dest, callback);
      });
    });

    async.series(asyncFunctions, function(error) {
      if (error)
        grunt.fail.fatal(error);

      done();
    });
  };
};