g13013/broccoli-compass

View on GitHub
index.js

Summary

Maintainability
B
5 hrs
Test Coverage
var path = require('path');
var exec = require('child_process').exec;
var rimraf = require('rimraf').sync;
var quickTemp = require('quick-temp');
var symlinkOrCopy = require('symlink-or-copy').sync;
var merge = require('merge');
var dargs = require('dargs');
var Writer = require('broccoli-writer');
var walkCachedSync = require('./lib/walk_cached_sync');
var mkdirp = require('mkdirp').sync;
var rsvp = require('rsvp');
var fs = require('fs');
var urlRe = /url\('((?:\/|.{2})?[^\?\)']+)/g;

var ignoredOptions = [
      'compassCommand',
      'ignoreErrors',
      'cleanOutput',
      'files'
    ];

//TODO: collect sass/scss on construct to build the list css generated files for copy.

/**
 * Executes the cmdLine statement in a Promise.
 * @param cmdLine   The compass compile command line statement.
 * @param options   The options for exec.
 * @returns {exports.Promise}
 */
function compile(cmdLine, options) {
  return new rsvp.Promise(function(resolve, reject) {
    exec(cmdLine, options, function(err, stdout, stderr) {
      if (err) {
        // Provide a robust error message in case of failure.
        // compass sends errors to sdtout, so it's important to include that
        err.message = '\n[broccoli-compass] ' + err +
                      '    directory: ' + options.cwd + '\n' +
                      '    cmdline: ' + cmdLine + '\n' +
                      '    stderr: ' + stderr + '\n' +
                      (stdout && '    stdout:  ' + stdout + '\n' || '\n');

        return reject(err);
      }

      resolve();
    });
  });
}

/**
 * Walks the tree and looks for css files under css directory, if `cleanOutput` is TRUE, css content is read to extract
 * images and fonts paths to add them to the queue. Note that this function simply return srcDir if `cleanOutput` is false.
 *
 * @param  {String} srcDir  Source directory
 * @param  {String} destDir Destination
 */
function moveToDest(srcDir, destDir) {
  if (!this.options.cleanOutput) {
    return srcDir;
  }
  var content, cssDir, src;
  var copiedCache = this.copiedCache || {};
  var copied = {};
  var options = this.options;
  var tree = this.walkDir(srcDir, {cache: this.cache});
  var cache = tree.paths;
  var generated = tree.changed;
  var linkedFiles = [];
  for (var i = 0; i < generated.length; i += 1) {
    file = generated[i];
    if (cache[file].isDirectory || copiedCache[file] === cache[file].statsHash) {
      continue;
    }
    src = srcDir + '/' + file;
    if (file.substr(-4) === '.css') {
      content = fs.readFileSync(src);
      cssDir = path.dirname(file);
      while ((linkedFile = urlRe.exec(content))) {
        linkedFile = (linkedFile[1][0] === '/') ? linkedFile[1].substr(1) : path.normalize(cssDir + '/' + linkedFile[1]);
        linkedFiles.push(linkedFile);
      }
    }
    mkdirp(destDir + '/' + path.dirname(file));
    symlinkOrCopy(src, destDir + '/' + file);
    copied[file] = cache[file].statsHash;
  }

  for (i = 0; i < linkedFiles.length; i += 1) {
    file = linkedFiles[i];
    if (file in copied) { continue; }
    if (!cache[file] || copiedCache[file] !== cache[file].statsHash) {
      copied[file] = cache[file] && cache[file].statsHash;
      mkdirp(destDir + '/' + path.dirname(file));
      symlinkOrCopy(srcDir + '/' + file, destDir + '/' + file);
    }
  }
  this.copiedCache = copied;
  return destDir;
}

/**
 * 
 * Compass compiler generates css files, images and the .sass-cache folder, we could compile in srcDir
 * and issue changed files to dest but it will pollute srcDir, to avoid this, we compile in a mirrored tmp dir
 * 
 * This function, recursively symlink files (not folders) in a temporary dir.
 *
 * TODO: copy symlink only changed files
 * @param  {String} srcDir  Source directory
 */
function makeCompileDir(srcDir) {
  var src;
  var sassCacheDir;
  var paths = this.cache.paths;
  var list = Object.keys(paths);

  quickTemp.makeOrRemake(this, 'sassCompileDir');

  sassCacheDir = this.sassCompileDir + '/../.sass-cache';
  for (var i = 0; i < list.length; i++) {
    sub = list[i];
    if (paths[sub].isDirectory) {
      fs.mkdirSync(this.sassCompileDir + '/' + sub);
      continue;
    }
    symlinkOrCopy(srcDir + '/' + sub, this.sassCompileDir + '/' + sub);
  }
  if (!fs.existsSync(sassCacheDir)) {
    fs.mkdirSync(sassCacheDir);
  }
  try {
    symlinkOrCopy(sassCacheDir, this.sassCompileDir + '/.sass-cache');
  } catch (err) {
    console.log('[broccoli-compass] Warning: can\'t symlink or copy .sass-cache directory:\n\t', err.message);
  }
}

/**
 * broccoli-compass Constructor.
 * @param inputTree   Any Broccoli tree.
 * @param files       [Optional] An array of sass files to compile.
 * @param options     The compass options.
 * @returns {CompassCompiler}
 */
function CompassCompiler(inputTree, files, options) {
  options = arguments.length > 2 ? (options || {}) : (files || {});
  if (arguments.length > 2) {
    console.log('[broccoli-compass] DEPRECATION: passing files to broccoli-compass constructor as second parameter is deprecated, ' +
                'use options.files instead');
    options.files = files;
  }

  if (!(this instanceof CompassCompiler)) {
    return new CompassCompiler(inputTree, options);
  }

  if (options.exclude) {
    console.log('[broccoli-compass] DEPRECATION: The exclude option has been deprecated in favour of the `cleanOutput` option');
  }

  this.options = merge(true, this.defaultOptions);
  merge(this.options, options);
  options = this.options;
  options.files = (options.files instanceof Array) ? options.files : [];
  this.generateCmdLine();
  this.inputTree = inputTree;
}

CompassCompiler.prototype = Object.create(Writer.prototype);
CompassCompiler.prototype.constructor = CompassCompiler;
CompassCompiler.prototype.compile = compile;
CompassCompiler.prototype.moveToDest = moveToDest;
CompassCompiler.prototype.prepareCompileDir = makeCompileDir;
CompassCompiler.prototype.generateCmdLine = function () {
  var value;
  var filtredOptions = {};
  var options = this.options;
  var cmd = [options.compassCommand, 'compile'];
  var cmdArgs = cmd.concat(this.options.files); // specific files to compile
  // dargs doesn't escape spaces, we filter and escape before using it ;(
  for (var key in options) {
    if (ignoredOptions.indexOf(key) !== -1) { continue; }
    value = options[key];
    if (typeof value === 'string' && value.indexOf(' ') !== -1) {
      filtredOptions[key] = '"' + value + '"';
      continue;
    }
    filtredOptions[key] = value;
  }

  this.cmdLine = cmdArgs.concat( dargs(filtredOptions) ).join(' ');
  return this.cmdLine;
};

CompassCompiler.prototype.read = function (readTree) {
  var cleanOutput = this.options.cleanOutput;
  return readTree(this.inputTree).then(function (srcDir) {
    this.cache = this.walkDir(srcDir, {cache: this.cache});
    if (this.cache.changed.length === 0) {
      return this.lastDestDir;
    }
    this.prepareCompileDir(srcDir);
    if (cleanOutput) {
      quickTemp.makeOrRemake(this, 'tmpDestDir');
    }
    return this.write(srcDir, this.tmpDestDir).then(function (destDir) {
      this.lastDestDir = destDir;
      return destDir;
    }.bind(this));
  }.bind(this));
};

CompassCompiler.prototype.write = function (srcDir, destDir) {
  var self = this;
  var ignoreErrors = this.options.ignoreErrors;
  return this.compile(this.cmdLine, {cwd: this.sassCompileDir})
    .then(function () {
      try {
        rimraf(self.sassCompileDir + '/.sass-cache');
      } catch(err) {
        console.log('[broccoli-compass] Warning: cannot unlink or delete .sass-cache directory:\n\t', err.message);
      }
      return self.moveToDest(self.sassCompileDir, destDir);
    })
    .catch(function (err) {
      var msg = err.message || err;
      if (ignoreErrors === false) {
        throw err;
      } else {
        console.log(msg);
      }
    });
};

// instead of using broccoli-cache-writer we use a builtin function in order to reuse stats
// as we need to check changed files after.
CompassCompiler.prototype.walkDir = walkCachedSync;

/**
 * Default options that are merged onto given options making sure these options
 * are always set.
 */
CompassCompiler.prototype.defaultOptions = {
  // plugin options
  cleanOutput: false,
  ignoreErrors: false,
  compassCommand: 'compass'
};

module.exports = CompassCompiler;