Source: file.js

/*
 * deploy-asset
 * https://github.com/qiu8310/deploy-asset
 *
 * Copyright (c) 2015 Zhonglei Qiu
 * Licensed under the MIT license.
 */

var path = require('x-path'),
  fs = require('fs'),
  crypto = require('crypto');

var _ = require('lodash'),
  log = require('npmlog'),
  async = require('async'),
  alter = require('alter'),
  rm = require('rimraf').sync,
  mkdirp = require('mkdirp').sync;

var patterns = require('./patterns'),
  Uploader = require('./uploader');

/**
 * 所有的文件类型
 *
 * 包括 `HTML`, `JS`, `CSS`, `JSON`, `STATIC` 五大类
 *
 * @memberof File
 * @type {Object}
 */
var TYPE = {
  HTML: 'html',
  JS: 'js',
  CSS: 'css',
  JSON: 'json',
  STATIC: 'static'
};

/**
 * 所有文件的 hash,hash 的 key 是文件路径, value 是 {@link File} 对象
 * @private
 * @type {Object}
 */
var MAP = {};
var typeValues = _.values(TYPE);
var OPTS = null;


/**
 * 获取远程的 basename
 *
 * @param {String} oldBasename
 * @param {String} path
 * @param {Buffer} content
 * @returns {String}
 * @private
 */
function _reBasename(oldBasename, path, content) {
  if (_.isFunction(OPTS.rename)) {
    var rtn = OPTS.rename(oldBasename, path, content);
    if (_.isString(rtn)) {
      return rtn;
    } else {
      log.warn('Specified rename function did not return string, ignore it.');
    }
  }

  var len = _.isNumber(OPTS.rename) ? OPTS.rename : 8;

  if (len === -1) { return oldBasename; }

  var hash, md5 = crypto.createHash('md5');
  md5.update(content.toString());
  hash = md5.digest('hex');

  if (len === 0) {
    var ext = oldBasename.split('.').pop().slice(-10); // 最长 10 位
    return hash + '.' + ext;
  }

  return oldBasename.replace(/(\.\w*)$/, '-' + hash.substr(0, len) + '$1');
}

/**
 * 根据后缀名来判断文件的类型
 * @param {String} filepath
 * @returns {String}
 * @private
 */
function _detectFileType(filepath) {
  return _.find(typeValues, function(k) {
    return OPTS[k + 'Exts'] && OPTS[k + 'Exts'].indexOf(path.extname(filepath).substr(1)) >= 0;
  }) || TYPE.STATIC;
}

/**
 * 自定义的文件节点
 *
 * @param {String} file - 文件路径
 * @param {Object} opts
 * @param {File.TYPE} [opts.type = File.TYPE.STATIC] - 指定文件的类型
 * @param {String} rootDir - 此文件的根目录,用于计算原程目录
 * @class
 */
function File(file, opts, rootDir) {

  var relativeDir = path.relative(rootDir, path.dirname(file));

  /**
   * 所有引用了此文件的 Files
   * @type {Array}
   */
  this.callers = [];

  /**
   * 所有此文件包含的资源
   *
   * @example
   * {start: 34, end 60, filepath: ...}
   *
   * @type {Array}
   */
  this.assets = [];

  /**
   * 当前文件的路径
   * @type {String}
   */
  this.path = file;

  /**
   * 当前文件的相对目录
   * @type {String}
   */
  this.dirname = path.dirname(file);

  /**
   * 此文件的根目录,用于计算相对目录
   * @type {String}
   */
  this.rootDir = rootDir;

  /**
   * 此文件的相对目录,原程目录可以参考
   * @type {String}
   */
  this.relativeDir = relativeDir;

  /**
   * 当前文件的内容
   * @type {Buffer}
   */
  this.content = fs.readFileSync(file);

  /**
   * 当前文件的 basename
   * @type {String}
   */
  this.basename = path.basename(file);

  /**
   * 当前文件的 extension,不包含 '.'
   * @type {String}
   */
  this.ext = path.extname(file).substr(1);

  /**
   * 当前文件的类型
   * @type {String}
   */
  this.type = opts.type || _detectFileType(file);


  /**
   * 标识文件是否被上传了
   * @type {Boolean}
   */
  this.uploaded = false;

  /**
   * 远程服务器上的文件信息
   * @type {Object}
   */
  var newBasename = _reBasename(this.basename, this.path, this.content);
  if (OPTS.suffix) {
    newBasename = newBasename.split('.');
    newBasename[newBasename.length > 1 ? newBasename.length - 2 : 0] += OPTS.suffix;
    newBasename = newBasename.join('.');
  }

  this.remote = {
    basename: OPTS.prefix + newBasename,
    relative: OPTS.flat ? '' : relativeDir,
    path: null
  };
}


/**
 * 解析单个 pattern,得到文件中的资源
 * @param {Object} pattern - {@link patterns} item
 * @param {Array} relativeDirs
 * @returns {Array}
 * @private
 */
File.prototype._getAssetsFromPattern = function(pattern, relativeDirs) {
  var file = this;
  log.silly('\tpattern desc', pattern.msg);

  var all = [];
  file.content.toString().replace(pattern.re, function(raw, src, index) {
    // 如果是以 \w+: 或 // 开头的文件 ,则忽略,如 http://xxx.com/jq.js, //xxx.com/jq.js, javascript:;
    if (/^(?:\w+:|\/\/)/.test(src)) { return raw; }

    // 去掉 src 中的 ? 及 # 之后的字符串
    src = src.replace(/[\?|#].*$/, '').trim();

    if (!src) { return raw; }

    // 如果是绝对路径,需要把当前路径放到相对路径中去
    if (src[0] === '/' && relativeDirs.indexOf('.') < 0) { relativeDirs.unshift('.'); }

    // 用指定的函数过滤下
    var start = index + raw.indexOf(src);
    var end = start + src.length;
    if (_.isFunction(pattern.inFilter)) { src = pattern.inFilter(src); }

    var filepath = _.find(relativeDirs, function(dir) {return path.isFileSync(path.join(dir, src)); });

    // 文件需要存在 但并不存在 时
    if (pattern.exists && !filepath) {
      var logType = OPTS.force ? 'warn' : 'error';
      if (/\{.*\}|<.*>/.test(src)) {
        log[logType]('template string can\'t resolve to local file', '%s in %s at', src, file.path, start);
      } else {
        log[logType]('file not exists', '%s in %s at %d', src, file.path, start);
      }
      if (!OPTS.force) {
        log.error('Use force option to proceed');
        throw new Error('File ' + src + ' not exists');
      }
    }

    if (filepath) {
      filepath = path.join(filepath, src);
      log.silly('\t found asset', start + ' ' + filepath);
      var asset = {
        type: pattern.type !== 'unknown' && pattern.type || _detectFileType(filepath),
        relative: pattern.relative,
        start: start,
        end: end,
        filepath: filepath
      };
      if (_.isFunction(pattern.outFilter)) { asset.outFilter = pattern.outFilter; }
      all.push(asset);
    }

    return raw;
  });

  return all;
};

/**
 * 查找当前文件所引用的其它资源
 * @returns {Array}
 */
File.prototype.findAssets = function() {
  var pts = patterns[this.type] || [];

  if (!pts.length) { return []; }

  var file = this;

  // 如果是 JS 或 JSON 文件,则相对路径是调用它文件的目录,CSS 和 HTML 文件中的资源都是相对于此文件本身的
  var relativeDirs = [file.dirname];
  if (_.contains([TYPE.JSON, TYPE.JS], file.type) && file.callers.length) {
    file.callers.forEach(function(f) { relativeDirs.unshift(f.dirname); });
  }

  log.verbose('Finding assets in ' + file.type + ' file', file.path + ' ...');

  var result = pts.reduce(function(all, pattern) {

    return all.concat(file._getAssetsFromPattern(pattern, relativeDirs));

  }, []);

  log.verbose(' found assets', _.map(result, function(it) { return [it.start, it.filepath];}));
  return result;
};

/**
 * 添加 file 到 {@link File#callers}
 * @param {File} file
 * @returns {Boolean}
 */
File.prototype.addCaller = function(file) {
  return _.contains(this.callers, file) ? false : this.callers.push(file);
};

/**
 * 添加 asset 到 {@link File#assets}
 * @param {Object}  asset
 * @param {File.TYPE} asset.type
 * @param {Integer}   asset.start
 * @param {Integer}   asset.end
 * @param {String}    asset.filepath
 * @param {Boolean}   asset.relative
 * @param {Function}  asset.outFilter - Come from {@link patterns} item's outFilter
 */
File.prototype.addAsset = function(asset) {
  this.assets.push(asset);
};


/**
 * 调用 uploader 上传所有文件
 * @param {Uploader} uploader
 * @param {Object} opts
 * @param {Function} cb
 * @private
 */
function _upload(uploader, opts, cb) {

  var ignores = (OPTS.inspectOnly || []).concat(OPTS.unuploadFiles);

  var files = _.filter(_.values(MAP), function(file) { return !_.includes(ignores, file.path); });

  if (files.length === 0) {
    log.warn('Your files are ignored', ignores);
    log.warn('No files need to upload');
    // ftp 可能已经连接了,所以这里不能退出,否则 ftp 无法关闭连接
  } else {
    _.each(files, function (file) { file.uploaded = true; });
    log.info('You have ' + files.length + ' files need upload, the max concurrent number is ' + opts.eachUploadLimit);
  }

  if (uploader.enableBatchUpload) {
    uploader.batchUploadFiles(files, cb);
  } else {
    async.eachLimit(files, opts.eachUploadLimit, function (file, next) {
      log.info('Start uploading ...', file.path);
      uploader.uploadFile(file, function(err) {
        if (err) {
          err.file = file.path;
        } else {
          log.info('  end uploaded', file.path);
        }
        next(err);
      });
    }, cb);
  }
}

/**
 * for File.inspect
 *
 * @param {Array} files
 * @param {File} caller
 * @private
 */
function _inspect (files, caller) {
  files.forEach(function(f) {
    var opts = {};

    // f 可能是 asset
    if (f.filepath) {
      opts.type = f.type;
      f = f.filepath;
    }

    if (!MAP[f]) {
      var file = new File(f, opts, OPTS.dir);
      MAP[f] = file;

      if (caller) { file.addCaller(caller); }

      if (!_.includes(OPTS.unbrokenFiles, file.path)) {
        var assets = file.findAssets();

        assets.forEach(file.addAsset.bind(file));

        _inspect(assets, file);
      }
    }
  });
}


var _rHost = /^((?:\w+:)?\/\/[^\/]+)/;
/**
 * 得到相对 url 路径
 * @param {String} ref
 * @param {String} target
 * @returns {String}
 * @private
 */
function _getRelativePath(ref, target) {
  // 首先两个地址的域名要相同
  var host;
  _rHost.test(ref);
  host = RegExp.$1;
  _rHost.test(target);
  if (host && host === RegExp.$1) {
    return path.relative(path.dirname(ref.substr(host.length)), target.substr(host.length));
  }

  return target;
}

/**
 * 更新文件的引用
 *
 * @private
 */
function _update () {
  if (OPTS.outDir) {
    mkdirp(OPTS.outDir);
    rm(OPTS.outDir);
    log.info('Empty out dir', OPTS.outDir);
  }

  _.each(MAP, function(file) {
    if (file.assets.length) {
      var useRelativeAssetPath = !_.includes(OPTS.useAbsoluteRefFiles, file.path);
      file.content = new Buffer(alter(file.content.toString(), file.assets.map(function(asset) {
        var str = MAP[asset.filepath].remote.path;
        if (asset.outFilter) { str = asset.outFilter(str); }

        if (asset.relative && useRelativeAssetPath) {
          str = _getRelativePath(file.remote.path, str);
        }

        return {start: asset.start, end: asset.end, str: str};
      })));
    }

    if (OPTS.outDir) {
      var dir = path.join(OPTS.outDir, path.dirname(file.remote.path.replace(_rHost, '').substr(1))),
        filepath = path.join(dir, file.remote.basename);
      mkdirp(dir);
      log.info('Write to file', filepath);
      fs.writeFileSync(filepath, file.content, {encoding: null});
    }
  });
}

/**
 * 分析文件并上传文件
 *
 * @param {Array} files - 所有需要分析的文件
 * @param {Object} daOpts - 从 {@link da} 传过来的配置项
 * @param {Function} cb - 文件上传后的回调函数
 */
File.inspect = function(files, daOpts, cb) {
  OPTS = daOpts;
  MAP = {};

  try {
    log.profiler('da', 'inspect start');
    _inspect(files);
    log.profiler('da', 'inspect end');

    var uploader = Uploader.instance(daOpts.uploader, daOpts.uploaderOptions);

    log.profiler('da', 'update local files start');
    _.each(MAP, function(file) {
      uploader.setFileRemotePath(file);
      file.remote.basename = path.basename(file.remote.path);
    });
    _update();
    log.profiler('da', 'update local files end');

    if (!daOpts.dry) {
      log.profiler('da', 'upload to remote start');

      _upload(uploader, daOpts, function(err) {
        log.profiler('da', 'upload to remote end');
        cb(err, MAP);
      });

    } else {
      cb(null, MAP);
    }

  } catch (err) { cb(err); }
};


File.TYPE = TYPE;

module.exports = File;