ember-introjs
Version:
An Ember Component for intro.js
497 lines (398 loc) • 14.3 kB
JavaScript
'use strict';
var fs = require('fs');
var path = require('path-posix');
var mkdirp = require('mkdirp');
var walkSync = require('walk-sync');
var Minimatch = require('minimatch').Minimatch;
var arrayEqual = require('array-equal');
var Plugin = require('broccoli-plugin');
var symlinkOrCopy = require('symlink-or-copy');
var debug = require('debug');
var FSTree = require('fs-tree-diff');
var rimraf = require('rimraf');
var BlankObject = require('blank-object');
var heimdall = require('heimdalljs');
var existsSync = require('exists-sync');
function ApplyPatchesSchema() {
this.mkdir = 0;
this.rmdir = 0;
this.unlink = 0;
this.change = 0;
this.create = 0;
this.other = 0;
this.processed = 0;
this.linked = 0;
}
function makeDictionary() {
var cache = new BlankObject();
cache['_dict'] = null;
delete cache['_dict'];
return cache;
}
// copied mostly from node-glob cc @isaacs
function isNotAPattern(pattern) {
var set = new Minimatch(pattern).set;
if (set.length > 1) {
return false;
}
for (var j = 0; j < set[0].length; j++) {
if (typeof set[0][j] !== 'string') {
return false;
}
}
return true;
}
Funnel.prototype = Object.create(Plugin.prototype);
Funnel.prototype.constructor = Funnel;
function Funnel(inputNode, _options) {
if (!(this instanceof Funnel)) { return new Funnel(inputNode, _options); }
var options = _options || {};
Plugin.call(this, [inputNode], {
annotation: options.annotation,
persistentOutput: true,
needsCache: false
});
this._includeFileCache = makeDictionary();
this._destinationPathCache = makeDictionary();
this._currentTree = new FSTree();
this._isRebuild = false;
var keys = Object.keys(options || {});
for (var i = 0, l = keys.length; i < l; i++) {
var key = keys[i];
this[key] = options[key];
}
this.destDir = this.destDir || '/';
this.count = 0;
if (this.files && typeof this.files === 'function') {
// Save dynamic files func as a different variable and let the rest of the code
// still assume that this.files is always an array.
this._dynamicFilesFunc = this.files;
delete this.files;
} else if (this.files && !Array.isArray(this.files)) {
throw new Error('Invalid files option, it must be an array or function (that returns an array).');
}
if ((this.files || this._dynamicFilesFunc) && (this.include || this.exclude)) {
throw new Error('Cannot pass files option (array or function) and a include/exlude filter. You can have one or the other');
}
if (this.files) {
if (this.files.filter(isNotAPattern).length !== this.files.length) {
console.warn('broccoli-funnel does not support `files:` option with globs, please use `include:` instead');
this.include = this.files;
this.files = undefined;
}
}
this._setupFilter('include');
this._setupFilter('exclude');
this._matchedWalk = this.canMatchWalk();
this._instantiatedStack = (new Error()).stack;
this._buildStart = undefined;
}
function isMinimatch(x) {
return x instanceof Minimatch;
}
Funnel.prototype.canMatchWalk = function() {
var include = this.include;
var exclude = this.exclude;
if (!include && !exclude) { return false; }
var includeIsOk = true;
if (include) {
includeIsOk = include.filter(isMinimatch).length === include.length;
}
var excludeIsOk = true;
if (exclude) {
excludeIsOk = exclude.filter(isMinimatch).length === exclude.length;
}
return includeIsOk && excludeIsOk;
};
Funnel.prototype._debugName = function() {
return this.description || this._annotation || this.name || this.constructor.name;
};
Funnel.prototype._debug = function(message) {
debug('broccoli-funnel:' + (this._debugName())).apply(null, arguments);
};
Funnel.prototype._setupFilter = function(type) {
if (!this[type]) {
return;
}
if (!Array.isArray(this[type])) {
throw new Error('Invalid ' + type + ' option, it must be an array. You specified `' + typeof this[type] + '`.');
}
// Clone the filter array so we are not mutating an external variable
var filters = this[type] = this[type].slice(0);
for (var i = 0, l = filters.length; i < l; i++) {
filters[i] = this._processPattern(filters[i]);
}
};
Funnel.prototype._processPattern = function(pattern) {
if (pattern instanceof RegExp) {
return pattern;
}
var type = typeof pattern;
if (type === 'string') {
return new Minimatch(pattern);
}
if (type === 'function') {
return pattern;
}
throw new Error('include/exclude patterns can be a RegExp, glob string, or function. You supplied `' + typeof pattern +'`.');
};
Funnel.prototype.shouldLinkRoots = function() {
return !this.files && !this.include && !this.exclude && !this.getDestinationPath;
};
Funnel.prototype.build = function() {
this._buildStart = new Date();
this.destPath = path.join(this.outputPath, this.destDir);
if (this.destPath[this.destPath.length -1] === '/') {
this.destPath = this.destPath.slice(0, -1);
}
var inputPath = this.inputPaths[0];
if (this.srcDir) {
inputPath = path.join(inputPath, this.srcDir);
}
if (this._dynamicFilesFunc) {
this.lastFiles = this.files;
this.files = this._dynamicFilesFunc() || [];
// Blow away the include cache if the list of files is new
if (this.lastFiles !== undefined && !arrayEqual(this.lastFiles, this.files)) {
this._includeFileCache = makeDictionary();
}
}
var linkedRoots = false;
if (this.shouldLinkRoots()) {
linkedRoots = true;
/**
* We want to link the roots of these directories, but there are a few
* edge cases we must account for.
*
* 1. It's possible that the original input doesn't actually exist.
* 2. It's possible that the output symlink has been broken.
* 3. We need slightly different behavior on rebuilds.
*
* Behavior has been modified to always having an `else` clause so that
* the code is forced to account for all scenarios. Not accounting for
* all scenarios made it possible for initial builds to succeed without
* specifying `this.allowEmpty`.
*/
var inputPathExists = existsSync(inputPath);
// This is specifically looking for broken symlinks.
var outputPathExists = existsSync(this.outputPath);
// Doesn't count as a rebuild if there's not an existing outputPath.
this._isRebuild = this._isRebuild && outputPathExists;
if (this._isRebuild) {
if (inputPathExists) {
// Already works because of symlinks. Do nothing.
} else if (!inputPathExists && this.allowEmpty) {
// Make sure we're safely using a new outputPath since we were previously symlinked:
rimraf.sync(this.outputPath);
// Create a new empty folder:
mkdirp.sync(this.destPath);
} else { // this._isRebuild && !inputPathExists && !this.allowEmpty
// Need to remove it on the rebuild.
// Can blindly remove a symlink if path exists.
rimraf.sync(this.outputPath);
}
} else { // Not a rebuild.
if (inputPathExists) {
// We don't want to use the generated-for-us folder.
// Instead let's remove it:
rimraf.sync(this.outputPath);
// And then symlinkOrCopy over top of it:
this._copy(inputPath, this.destPath);
} else if (!inputPathExists && this.allowEmpty) {
// Can't symlink nothing, so make an empty folder at `destPath`:
mkdirp.sync(this.destPath);
} else { // !this._isRebuild && !inputPathExists && !this.allowEmpty
throw new Error('You specified a `"srcDir": ' + this.srcDir + '` which does not exist and did not specify `"allowEmpty": true`.');
}
}
this._isRebuild = true;
} else {
this.processFilters(inputPath);
}
this._debug('build, %o', {
in: new Date() - this._buildStart + 'ms',
linkedRoots: linkedRoots,
inputPath: inputPath,
destPath: this.destPath
});
};
function ensureRelative(string) {
if (string.charAt(0) === '/') {
return string.substring(1);
}
return string;
}
Funnel.prototype._processEntries = function(entries) {
return entries.filter(function(entry) {
// support the second set of filters walk-sync does not support
// * regexp
// * excludes
return this.includeFile(entry.relativePath);
}, this).map(function(entry) {
var relativePath = entry.relativePath;
entry.relativePath = this.lookupDestinationPath(relativePath);
this.outputToInputMappings[entry.relativePath] = relativePath;
return entry;
}, this);
};
Funnel.prototype._processPaths = function(paths) {
return paths.
slice(0).
filter(this.includeFile, this).
map(function(relativePath) {
var output = this.lookupDestinationPath(relativePath);
this.outputToInputMappings[output] = relativePath;
return output;
}, this);
};
Funnel.prototype.processFilters = function(inputPath) {
var nextTree;
var instrumentation = heimdall.start('derivePatches');
var entries;
this.outputToInputMappings = {}; // we allow users to rename files
if (this.files && !this.exclude && !this.include) {
entries = this._processPaths(this.files);
// clone to be compatible with walkSync
nextTree = FSTree.fromPaths(entries, { sortAndExpand: true });
} else {
if (this._matchedWalk) {
entries = walkSync.entries(inputPath, { globs: this.include, ignore: this.exclude });
} else {
entries = walkSync.entries(inputPath);
}
entries = this._processEntries(entries);
nextTree = FSTree.fromEntries(entries, { sortAndExpand: true });
}
var patches = this._currentTree.calculatePatch(nextTree);
this._currentTree = nextTree;
instrumentation.stats.patches = patches.length;
instrumentation.stats.entries = entries.length;
var outputPath = this.outputPath;
instrumentation.stop();
instrumentation = heimdall.start('applyPatch', ApplyPatchesSchema);
patches.forEach(function(entry) {
this._applyPatch(entry, inputPath, outputPath, instrumentation.stats);
}, this);
instrumentation.stop();
};
Funnel.prototype._applyPatch = function applyPatch(entry, inputPath, _outputPath, stats) {
var outputToInput = this.outputToInputMappings;
var operation = entry[0];
var outputRelative = entry[1];
if (!outputRelative) {
// broccoli itself maintains the roots, we can skip any operation on them
return;
}
var outputPath = _outputPath + '/' + outputRelative;
this._debug('%s %s', operation, outputPath);
switch (operation) {
case 'unlink' :
stats.unlink++;
fs.unlinkSync(outputPath);
break;
case 'rmdir' :
stats.rmdir++;
fs.rmdirSync(outputPath);
break;
case 'mkdir' :
stats.mkdir++;
fs.mkdirSync(outputPath);
break;
case 'change':
stats.change++;
/* falls through */
case 'create':
if (operation === 'create') {
stats.create++;
}
var relativePath = outputToInput[outputRelative];
if (relativePath === undefined) {
relativePath = outputToInput['/' + outputRelative];
}
this.processFile(inputPath + '/' + relativePath, outputPath, relativePath);
break;
default: throw new Error('Unknown operation: ' + operation);
}
};
Funnel.prototype.lookupDestinationPath = function(relativePath) {
if (this._destinationPathCache[relativePath] !== undefined) {
return this._destinationPathCache[relativePath];
}
// the destDir is absolute to prevent '..' above the output dir
if (this.getDestinationPath) {
return this._destinationPathCache[relativePath] = ensureRelative(path.join(this.destDir, this.getDestinationPath(relativePath)));
}
return this._destinationPathCache[relativePath] = ensureRelative(path.join(this.destDir, relativePath));
};
Funnel.prototype.includeFile = function(relativePath) {
var includeFileCache = this._includeFileCache;
if (includeFileCache[relativePath] !== undefined) {
return includeFileCache[relativePath];
}
// do not include directories, only files
if (relativePath[relativePath.length - 1] === '/') {
return includeFileCache[relativePath] = false;
}
var i, l, pattern;
// Check for specific files listing
if (this.files) {
return includeFileCache[relativePath] = this.files.indexOf(relativePath) > -1;
}
if (this._matchedWalk) {
return true;
}
// Check exclude patterns
if (this.exclude) {
for (i = 0, l = this.exclude.length; i < l; i++) {
// An exclude pattern that returns true should be ignored
pattern = this.exclude[i];
if (this._matchesPattern(pattern, relativePath)) {
return includeFileCache[relativePath] = false;
}
}
}
// Check include patterns
if (this.include && this.include.length > 0) {
for (i = 0, l = this.include.length; i < l; i++) {
// An include pattern that returns true (and wasn't excluded at all)
// should _not_ be ignored
pattern = this.include[i];
if (this._matchesPattern(pattern, relativePath)) {
return includeFileCache[relativePath] = true;
}
}
// If no include patterns were matched, ignore this file.
return includeFileCache[relativePath] = false;
}
// Otherwise, don't ignore this file
return includeFileCache[relativePath] = true;
};
Funnel.prototype._matchesPattern = function(pattern, relativePath) {
if (pattern instanceof RegExp) {
return pattern.test(relativePath);
} else if (pattern instanceof Minimatch) {
return pattern.match(relativePath);
} else if (typeof pattern === 'function') {
return pattern(relativePath);
}
throw new Error('Pattern `' + pattern + '` was not a RegExp, Glob, or Function.');
};
Funnel.prototype.processFile = function(sourcePath, destPath /*, relativePath */) {
this._copy(sourcePath, destPath);
};
Funnel.prototype._copy = function(sourcePath, destPath) {
var destDir = path.dirname(destPath);
try {
symlinkOrCopy.sync(sourcePath, destPath);
} catch(e) {
if (!existsSync(destDir)) {
mkdirp.sync(destDir);
}
try {
fs.unlinkSync(destPath);
} catch(e) {
}
symlinkOrCopy.sync(sourcePath, destPath);
}
};
module.exports = Funnel;