yui-pathogen-encoder
Version:
Enables pathogen encoding in YUI Loader
1,311 lines (1,201 loc) • 57.1 kB
JavaScript
/*
* Copyright 2013 Yahoo! Inc. All rights reserved.
* Copyrights licensed under the BSD License.
* See the accompanying LICENSE.txt file for terms.
*/
/*jslint nomen:true, node:true */
"use strict";
var libfs = require('fs'),
libmkdirp = require('mkdirp'),
liboop = require('yui/oop'),
libpath = require('path'),
libpromise = require('yui/promise'),
ScanFS = require('scanfs'),
libsemver = require('semver'),
libwatch = require('watch'),
Bundle = require('./bundle'),
imports = {
log: console.log
},
DEFAULT_RULESET = 'main',
DEFAULT_RULESETS = require('./rulesets'),
DEFAULT_SELECTOR = '{}',
DEFAULT_MAX_PACKAGES_DEPTH = 9999,
DEFAULT_VERSION = '0.0.1', // for bundles w/out a version or parent
EVENT_UPDATED = 'Updated',
EVENT_DELETED = 'Deleted',
EVENT_BUNDLE_UPDATED = 'bundleUpdated';
/**
* The Locator walks the filesystem and gives semantic meaning to
* files in the application.
* @module Locator
*/
/**
* @class Locator
* @constructor
* @param {object} [options] Options for how the configuration files are located.
* @param {string} [options.applicationDirectory] Where the application will be found.
* If not given it defaults to the current working directory.
* @param {string} [options.buildDirectory] Where to put generated files.
* If not given, generated files are put directly inside the application directory
* (and likely alongside files managed by SCM).
* If a relative path is given, it is relative to `options.applicationDirectory`.
* @param {function} [options.bundleBuildDirectoryParser] A function to determine
* a bundle's build directory name.
* @param {Bundle} options.bundleBuildDirectoryParser.bundle An instance of,
* Bundle, @see ./bundle.js
* @return {string} Bundle build directory basename. Defaults is bundle name
* and version seperated by a dash. @see this._nameBuildDirectory()
* @param {integer} options.maxPackageDepth Maximum depth in `node_modules/` to walk.
* Defaults to 9999.
*
* @example (note constructor "BundleLocator" is exported as "Locator")
* var locOpts = {
* applicationDirectory: __dirname,
* buildDirectory: 'build',
* bundleBuildDirectoryParser: function (bundle) {
* // default is: return bundle.name + '-' + bundle.version;
* return bundle.name + '-' + (new Date().getTime());
* },
* maxPackageDepth: 5
* },
* locator = new Locator(locOpts);
*/
function BundleLocator(options) {
var self = this;
this._options = options || {};
if (this._options.applicationDirectory) {
this._options.applicationDirectory = libpath.resolve(process.cwd(), this._options.applicationDirectory);
} else {
this._options.applicationDirectory = process.cwd();
}
if (this._options.buildDirectory) {
this._options.buildDirectory = libpath.resolve(this._options.applicationDirectory, this._options.buildDirectory);
}
this._options.maxPackageDepth = this._options.maxPackageDepth || DEFAULT_MAX_PACKAGES_DEPTH;
this._cacheRulesetsPath = {}; // package directory: path to rulesets file
this._bundles = {};
this._bundlePaths = {}; // path: name
this._fileQueue = [];
this._bundleUpdates = {}; // name: object describing why the update happened
this._plugins = [];
this._pluginAPI = {
getBundle: function (bundleName) {
return self.getBundle(bundleName);
},
getRootBundleName: function () {
return self._rootBundleName;
},
getBundleFiles: function (bundleName, filter) {
var bundle,
files = [];
bundle = self._bundles[bundleName];
if (!bundle) {
throw new Error('Unknown bundle "' + bundleName + '"');
}
Object.keys(bundle.files).forEach(function (fullpath) {
var res = {
ext: libpath.extname(fullpath).substr(1)
};
if (self._filterResource(res, filter)) {
files.push(fullpath);
}
});
return files;
},
getBundleResources: function (bundleName, filter) {
var bundle,
ress = [];
bundle = self._bundles[bundleName];
if (!bundle) {
throw new Error('Unknown bundle "' + bundleName + '"');
}
self._walkBundleResources(bundle, filter, function (res) {
ress.push(res);
});
return ress;
},
promise: function (fn) {
return new libpromise.Promise(fn);
},
writeFileInBundle: function (bundleName, relativePath, contents, options) {
return new libpromise.Promise(function (fulfill, reject) {
var bundle,
path;
bundle = self._bundles[bundleName];
if (!bundle) {
reject(new Error('Unknown bundle "' + bundleName + '"'));
return;
}
path = libpath.resolve(bundle.buildDirectory ||
bundle.baseDirectory, relativePath);
libfs.readFile(path, options, function (err, originalContents) {
originalContents = (originalContents && originalContents.toString()) || originalContents;
if (!err && originalContents === contents) {
// noop if the content is not getting updated.
self._fileQueue.push([EVENT_UPDATED, path]);
fulfill(path);
return;
}
libmkdirp(libpath.dirname(path), null, function (err) {
if (err) {
reject(err);
} else {
libfs.writeFile(path, contents, options, function (err) {
if (err) {
reject(err);
} else {
self._fileQueue.push([EVENT_UPDATED, path]);
fulfill(path);
}
});
}
});
});
});
}
};
}
BundleLocator.prototype = {
/**
* Plugs code into the locator to be called during filesystem events.
* The filter must specify something, since it is an error for a plugin
* to be called for all resources.
* @method plug
* @param {LocatorPlugin} plugin The plugin to be used.
* @chainable
*/
plug: function (plugin) {
this._plugins.push({
filter: plugin.describe || {},
plugin: plugin
});
return this;
},
/**
* Parses the directory to turn it into a bundle.
* @method parseBundle
* @async
* @param {string} dir The directory for the bundle.
* @param {object} [options] Options for processing the bundle.
* @param {function} [options.bundleBuildDirectoryParser] A function to
* determine a bundle's build directory name.
* @param {Bundle} options.bundleBuildDirectoryParser.bundle An instance
* of, Bundle, @see ./bundle.js
* @return {string} Bundle build directory basename. Defaults is bundle
* name and version seperated by a dash. @see this._nameBuildDirectory()
*
* @example (note constructor "BundleLocator" is exported as "Locator")
* var locator = new Locator();
* locator.parseBundle(__dirname, {
* bundleBuildDirectoryParser: function (bundle) {
* // default is: return bundle.name + '-' + bundle.version;
* return bundle.name + '-' + (new Date().getTime());
* }
* }).then(myFunc);
*
* @return {Promise/A+} A promise that will be fulfilled with the bundle.
*/
parseBundle: function (dir, options) {
var self = this;
this._rootDirectory = dir;
if (options && ('bundleBuildDirectoryParser' in options)) {
this._options.bundleBuildDirectoryParser = options.bundleBuildDirectoryParser;
}
return this._walkNPMTree(dir).then(function (bundleSeeds) {
bundleSeeds = self._filterBundleSeeds(bundleSeeds);
var promises = [];
bundleSeeds.forEach(function (bundleSeed) {
var opts = (bundleSeed.baseDirectory === dir) ? options : {};
promises.push(self._walkBundle(bundleSeed, opts));
});
return libpromise.batch.apply(libpromise, promises).then(function () {
return new libpromise.Promise(function (fulfill, reject) {
self._rootBundleName = self._bundlePaths[dir];
if (self._fileQueue.length) {
self._processFileQueue().then(function () {
fulfill(self._bundles[self._getBundleNameByPath(dir)]);
}, reject);
}
});
});
});
},
/**
* Returns the named bundle, no matter how deeply found.
* @method getBundle
* @param {string} name The name of the bundle to retrieve.
* @return {Bundle|undefined} The named bundle, or undefined if not found.
*/
getBundle: function (name) {
return this._bundles[name];
},
/**
* Returns the root of the bundles. This is the root bundle that was parsed
* by the call to `parseBundle()`.
* @method getRootBundle
* @return {Bundle} The root bundle.
*/
getRootBundle: function () {
return this._bundles[this._rootBundleName];
},
/**
* Lists resources in all the bundles.
* @method listAllResources
* @param {object} filter Filter for deciding which resources to list.
* @param {string|[string]} [filter.extensions] The filesystem extensions (NOT including dot) to return.
* @param {string|[string]} [filter.types] The resources types to return.
* @return {[LocatorResource]} An array of resources.
*/
listAllResources: function (filter) {
var self = this,
ress = [];
Object.keys(this._bundles).forEach(function (bundleName) {
var bundle = self._bundles[bundleName];
self._walkBundleResources(bundle, filter, function (res) {
ress.push(res);
});
});
return ress;
},
/**
* Returns a list of all located bundle names.
* The names are not ordered.
* @method listBundleNames
* @param {Function} [filter] function to execute on each bundle.
* If no `filter` is supplied, all bundle names will be returned. The
* function will receive the following argument:
* @param {Object} filter.bundle the current bundle being iterated on
* @param {boolean} filter.return Return true to indicate that the
* bundle name should be returned in the list. Otherise the bundle
* name will be skipped.
* @return {Array} list of bundles
*/
listBundleNames: function (filter) {
var bundleName,
bundles = this._bundles,
bundleNames = [];
if ('function' !== typeof filter) {
return Object.keys(this._bundles);
}
for (bundleName in bundles) {
if (bundles.hasOwnProperty(bundleName)) {
if (filter(bundles[bundleName])) {
bundleNames.push(bundleName);
}
}
}
return bundleNames;
},
/**
* Watches a filesystem directory for changes.
* Changes are communicated to the plugins via the `fileUpdated`,
* `fileDeleted`, `resourceUpdated`, and `resourceDeleted` methods.
* @method watch
* @param {string} dir The directory to watch.
* @return {Promise/A+} A promise that will be fulfilled once watching is setup.
*/
watch: function (dir) {
var self = this;
return new libpromise.Promise(function (fulfill) {
libwatch.createMonitor(dir, {ignoreDotFiles: true}, function (monitor) {
// Ignore files written to buildDirectory that don't
// happen via api.writeFileInBundle()
monitor.on('created', function (path) {
if (!self._isBuildFile(path)) {
self._fileQueue.push([EVENT_UPDATED, path, {watch: 'added'}]);
self._processFileQueue().then(null, function (err) {
imports.log('Error processing file ' + path);
imports.log(err.stack);
});
}
});
monitor.on('changed', function (path) {
if (!self._isBuildFile(path)) {
self._fileQueue.push([EVENT_UPDATED, path, {watch: 'updated'}]);
self._processFileQueue().then(null, function (err) {
imports.log('Error processing file ' + path);
imports.log(err.stack);
});
}
});
monitor.on('removed', function (path) {
if (!self._isBuildFile(path)) {
self._fileQueue.push([EVENT_DELETED, path, {watch: 'deleted'}]);
self._processFileQueue().then(null, function (err) {
imports.log('Error processing file ' + path);
imports.log(err.stack);
});
}
});
fulfill();
});
});
},
/**
* Returns the name of the bundle to which the path belongs.
* @private
* @method _getBundleNameByPath
* @param {string} findPath The path to consider.
* @return {string} The name the bundle to which the findPath most closely matches.
*/
_getBundleNameByPath: function (findPath) {
// FUTURE OPTIMIZATION: use a more complicated datastructure for faster lookups
var found = {}, // length: path
longest;
Object.keys(this._bundlePaths).forEach(function (bundlePath) {
if (0 === findPath.indexOf(bundlePath) &&
(findPath.length === bundlePath.length ||
'/' === findPath.charAt(bundlePath.length))) {
found[bundlePath.length] = bundlePath;
}
});
longest = Math.max.apply(Math, Object.keys(found));
return this._bundlePaths[found[longest]];
},
/**
* Creates the seed of a potential bundle.
*
* The seed includes:
* <dl>
* <dt> baseDirectory {string} </dt>
* <dd> The directory of the bundle. </dd>
* <dt> name {string} </dt>
* <dd> The name of the bundle. </dd>
* <dt> version {string} </dt>
* <dd> The version of the bundle. </dd>
* <dt> npmDepth {integer} </dt>
* <dd> The depth in the NPM package dependency tree. </dd>
* <dt> options {object} </dt>
* <dd> Options for the bundle, taken in part from the `"locator"` section of `package.json` </dd>
* </dl>
* @private
* @method _makeBundleSeed
* @param {string} baseDirectory Full path to the bundle.
* @param {string} name The name of the bundle.
* Might be overrriden by the name in package.json (if it exists).
* @param {string} version The version to use if not specified in the package.json.
* Might be overriden by the version in package.json (if it exists).
* @param {object} [pkg] Contents of the bundles package.json.
* @param {object} [options] Additional options to apply. Lower priority
* than those found in package.json.
* @return {object} The bundle seed, as described above.
*/
_makeBundleSeed: function (baseDirectory, name, version, pkg, options) {
var seed;
seed = {
baseDirectory: baseDirectory,
name: name,
version: version
};
if (pkg) {
seed.name = pkg.name;
seed.version = pkg.version;
seed.options = pkg.locator;
}
if (options) {
if (seed.options) {
// merge options under seed.options
liboop.mix(seed.options, options, false, null, 0, false);
} else {
seed.options = options;
}
}
return seed;
},
/**
* Make the name of a bundle's build dir using it's name + version, or the
* a function
* @param {Bundle} bundle Object with bundle metadata. @see ./bundle.js
* @param {string} buildDirectory The build directory for all bundles.
* @return {string} The full path to this bundle's build drectory.
*/
_nameBuildDirectory: function (bundle, buildDirectory) {
var namerFn = this._options.bundleBuildDirectoryParser,
dirname;
dirname = 'function' === typeof namerFn ?
namerFn(bundle) : bundle.name + '-' + bundle.version;
return libpath.resolve(buildDirectory, dirname);
},
/**
* Makes a bundle out of a directory.
* @private
* @method _makeBundle
* @async
* @param {object} seed The seed bundle, @see _makeBundleSeed()
* @param {Bundle} parent Parent bundle. Only the root bundle doesn't have a parent.
* @return {Promise/A+} A promise that will be fulfilled with the new bundle,
* or undefined if the path isn't a locator bundle.
*/
_makeBundle: function (seed, parent) {
var self = this;
return new libpromise.Promise(function (fulfill, reject) {
var bundle,
ruleset = self._loadRuleset(seed),
msg;
if (seed.options.location) {
// This is fairly legacy, and we might be able to remove it.
seed.baseDirectory = libpath.resolve(seed.baseDirectory, seed.options.location);
}
if (!ruleset) {
msg = 'Bundle "' + seed.name + '" has unknown ruleset ' + JSON.stringify(seed.options.ruleset);
if (seed.options.rulesets) {
msg += ' in rulesets ' + JSON.stringify(seed.options.rulesets);
}
reject(new Error(msg));
return;
}
bundle = new Bundle(seed.baseDirectory, seed.options);
bundle.name = seed.name;
bundle.version = seed.version;
bundle.type = ruleset._name;
self._bundles[bundle.name] = bundle;
self._bundlePaths[bundle.baseDirectory] = bundle.name;
if (self._options.buildDirectory) {
bundle.buildDirectory = self._nameBuildDirectory(
bundle, self._options.buildDirectory);
self._bundlePaths[bundle.buildDirectory] = bundle.name;
}
// wire into parent
if (parent) {
if (!parent.bundles) {
parent.bundles = {};
}
parent.bundles[bundle.name] = bundle;
}
fulfill(bundle);
});
},
/**
* Turns the path into a resource in the associated bundle, if applicable.
* @private
* @method _processFileQueue
* @async
* @return {Promise/A+} A promise that will be fulfilled once
* the path is processed.
*/
_processFileQueue: function () {
var self = this,
event,
eventType,
fullPath,
eventAttrs;
event = this._fileQueue.shift();
eventType = event[0];
fullPath = event[1];
eventAttrs = event[2] || {};
return new libpromise.Promise(function (fulfill, reject) {
var bundleName,
bundle,
ruleset,
relativePath,
pathParts,
subBundleSeed,
res;
bundleName = self._getBundleNameByPath(fullPath);
bundle = self._bundles[bundleName];
if (bundle.baseDirectory === fullPath.substr(0, bundle.baseDirectory.length)) {
relativePath = fullPath.substr(bundle.baseDirectory.length + 1);
} else if (bundle.buildDirectory === fullPath.substr(0, bundle.buildDirectory.length)) {
relativePath = fullPath.substr(bundle.buildDirectory.length + 1);
}
// This mainly happens during watch(), since we skip node_modules
// in _walkBundle().
if (relativePath.indexOf('node_modules') === 0) {
pathParts = relativePath.split(libpath.sep);
while (pathParts[0] === 'node_modules' && pathParts.length >= 2) {
pathParts.shift();
bundleName = pathParts.shift();
}
relativePath = pathParts.join(libpath.sep);
bundle = self._bundles[bundleName];
// The package's directory is not a resource (... and is mostly uninteresting).
if (!relativePath) {
imports.log(
'NPM package "' + bundleName + '" ' + eventAttrs.watch + ' during watch().',
'Please restart app for changes to take effect.'
);
fulfill();
return;
}
// unknown bundle
if (!bundle) {
fulfill();
return;
}
}
ruleset = self._loadRuleset(bundle);
if (ruleset._skip && self._ruleSkip(fullPath, relativePath, ruleset._skip)) {
fulfill();
return;
}
if (ruleset._bundles) {
subBundleSeed = self._ruleBundles(fullPath, relativePath, ruleset._bundles, bundle);
if (subBundleSeed) {
// sub-bundle inherits options.rulesets from parent
if (!subBundleSeed.options) {
subBundleSeed.options = {};
}
if (!subBundleSeed.options.rulesets) {
subBundleSeed.options.rulesets = bundle.options.rulesets;
}
return self._makeBundle(subBundleSeed, bundle).then(fulfill, reject);
}
}
// This is the base "meta" for a file. If a rule matches we'll
// augment this.
res = {
bundleName: bundleName,
fullPath: fullPath,
relativePath: relativePath,
ext: libpath.extname(fullPath).substr(1)
};
return self._onFile(res, ruleset, eventType).then(fulfill, reject);
}).then(function () {
// This handles the list of initially walked paths/resources.
// The list can change as new files are added.
if (self._fileQueue.length) {
return self._processFileQueue();
}
return self._processBundleUpdates();
});
},
/**
* For all bundles that have been update, inform the plugins (that care).
* @private
* @method _processBundleUpdates
* @async
* @return {Promise/A+} A promise that will be fulfilled once bundle updates have been processed.
*/
_processBundleUpdates: function () {
var self = this,
bundlePromises = [];
Object.keys(this._bundleUpdates).forEach(function (bundleName) {
var evt = self._bundleUpdates[bundleName],
pluginPromises = [];
evt.bundle = self._bundles[bundleName];
// clear this now so that we can detect changes during handling of the bundle
self._bundleUpdates = self._objectExclude(self._bundleUpdates, [bundleName]);
self._plugins.forEach(function (plugin) {
if (plugin.plugin[EVENT_BUNDLE_UPDATED]) {
pluginPromises.push(new libpromise.Promise(function (fulfill, reject) {
var ret;
try {
ret = plugin.plugin[EVENT_BUNDLE_UPDATED](evt, self._pluginAPI);
} catch (err) {
reject(err);
}
if (libpromise.Promise.isPromise(ret)) {
ret.then(fulfill, reject);
} else {
fulfill();
}
}));
}
});
bundlePromises.push(libpromise.batch.apply(libpromise, pluginPromises));
});
return libpromise.batch.apply(libpromise, bundlePromises).then(function () {
// It doesn't make sense to look for -just- bundle updates, since
// bundle updates are triggered by file changes.
if (self._fileQueue.length) {
return self._processFileQueue();
}
});
},
/**
* Processes the "_skip" rule to decide if the path should be skipped.
* @private
* @method _ruleSkip
* @param {string} fullPath The full path to the file.
* @param {string} relativePath The path relative to the bundle.
* @param {object} rule The skip rule to process.
* @return {boolean} True if the path should be skipped, false otherwise.
*/
_ruleSkip: function (fullPath, relativePath, rule) {
var r, regex;
for (r = 0; r < rule.length; r += 1) {
regex = rule[r];
if (regex.test(relativePath)) {
return true;
}
}
return false;
},
/**
* Processes the "_bundles" rule looking for child bundles.
* Returns a "bundle seed" as described by _makeBundleSeed().
*
* @private
* @method _ruleBundles
* @param {string} fullPath The full path to the file.
* @param {string} relativePath The path relative to the parent bundle.
* @param {object} rule The bundles rule.
* @param {Bundle} parent The parent bundle.
* @return {object|undefined} The processing options if the path is a child bundle.
*/
_ruleBundles: function (fullPath, relativePath, rule, parent) {
var r,
matches,
defaultVersion = DEFAULT_VERSION,
pkg;
if (parent) {
defaultVersion = parent.version;
}
for (r = 0; r < rule.length; r += 1) {
matches = relativePath.match(rule[r].regex);
if (matches) {
try {
pkg = require(libpath.resolve(fullPath, 'package.json'));
} catch (packageErr) {
// It's OK for a sub-bundle to not have a package.json.
}
return this._makeBundleSeed(fullPath, matches[1], defaultVersion, pkg, rule[r].options);
}
}
},
/**
* Handles the file.
* @private
* @method _onFile
* @async
* @param {object} res Metadata about the file.
* @param {object} ruleset Rules to attempt on the file.
* @param {string} eventType What is happening to the file.
* @return {Promise/A+} A promise that will be fulfilled once
* the file is handled.
*/
_onFile: function (res, ruleset, eventType) {
var self = this,
fileEvent = 'file' + eventType,
bundle = this._bundles[res.bundleName],
evt = {
file: res,
bundle: bundle
},
promises = [];
if (EVENT_DELETED === eventType) {
bundle.files = this._objectExclude(bundle.files, [res.fullPath]);
} else {
bundle.files[res.fullPath] = true;
}
if (!this._bundleUpdates[res.bundleName]) {
this._bundleUpdates[res.bundleName] = {files: {}, resources: {}};
}
this._bundleUpdates[res.bundleName].files[res.relativePath] = res;
this._plugins.forEach(function (plugin) {
if (self._filterResource(res, plugin.filter) && plugin.plugin[fileEvent]) {
promises.push(new libpromise.Promise(function (fulfill, reject) {
var ret;
try {
ret = plugin.plugin[fileEvent](evt, self._pluginAPI);
} catch (err) {
reject(err);
}
if (libpromise.Promise.isPromise(ret)) {
ret.then(fulfill, reject);
} else {
fulfill();
}
}));
}
});
return libpromise.batch.apply(libpromise, promises).then(function () {
var ruleName,
rule,
match;
for (ruleName in ruleset) {
if (ruleset.hasOwnProperty(ruleName)) {
// Rules that start with "_" are special directives,
// and have already been handle by the time we get here.
if ('_' !== ruleName.charAt(0)) {
rule = ruleset[ruleName];
match = res.relativePath.match(rule.regex);
if (match) {
res.name = match[rule.nameKey || 1];
res.type = ruleName;
if (rule.subtypeKey) {
res.subtype = match[rule.subtypeKey] || '';
}
if (rule.selectorKey && match[rule.selectorKey]) {
res.selector = match[rule.selectorKey];
} else {
res.selector = DEFAULT_SELECTOR;
}
return self._onResource(res, eventType);
}
}
}
}
});
},
/**
* Handles the resource.
* @private
* @method _onResource
* @async
* @param {object} res The resource.
* @param {string} eventType What is happening to the resource.
* @return {Promise/A+} A promise that will be fulfilled once
* the resource is handled.
*/
_onResource: function (res, eventType) {
var self = this,
resourceEvent = 'resource' + eventType,
evt = {resource: res},
bundle = this._bundles[res.bundleName],
type = res.type,
subtype,
selector = res.selector,
name = res.name,
promises = [];
if (res.type) {
if (EVENT_DELETED === eventType) {
if (res.hasOwnProperty('subtype')) {
subtype = res.subtype;
bundle.resources[selector][type][subtype] = this._objectExclude(bundle.resources[selector][type][subtype], [name]);
if (0 === Object.keys(bundle.resources[selector][type][subtype]).length) {
bundle.resources[selector][type] = this._objectExclude(bundle.resources[selector][type], [subtype]);
}
} else {
bundle.resources[selector][type] = this._objectExclude(bundle.resources[selector][type], [name]);
}
if (0 === Object.keys(bundle.resources[selector][type]).length) {
bundle.resources[selector] = this._objectExclude(bundle.resources[selector], [type]);
}
if (0 === Object.keys(bundle.resources[selector]).length) {
bundle.resources = this._objectExclude(bundle.resources, [selector]);
}
} else {
if (!bundle.resources[selector]) {
bundle.resources[selector] = {};
}
if (!bundle.resources[selector][type]) {
bundle.resources[selector][type] = {};
}
if (res.hasOwnProperty('subtype')) {
subtype = res.subtype;
if (!bundle.resources[selector][type][subtype]) {
bundle.resources[selector][type][subtype] = {};
}
bundle.resources[selector][type][subtype][name] = res;
} else {
bundle.resources[selector][type][name] = res;
}
}
if (!this._bundleUpdates[res.bundleName]) {
this._bundleUpdates[res.bundleName] = {files: {}, resources: {}};
}
this._bundleUpdates[res.bundleName].resources[res.relativePath] = res;
}
this._plugins.forEach(function (plugin) {
if (self._filterResource(res, plugin.filter) && plugin.plugin[resourceEvent]) {
promises.push(new libpromise.Promise(function (fulfill, reject) {
var ret;
try {
ret = plugin.plugin[resourceEvent](evt, self._pluginAPI);
} catch (err) {
reject(err);
}
if (libpromise.Promise.isPromise(ret)) {
ret.then(fulfill, reject);
} else {
fulfill();
}
}));
}
});
return libpromise.batch.apply(libpromise, promises);
},
/**
* Determines whether a resource is filtered or not.
* @private
* @method _filterResource
* @param {LocatorResource} res The resource to filter.
* @param {object} filter The filter.
* @return {boolean} Whether resource passes the filter.
*/
_filterResource: function (res, filter) {
if (!filter || !Object.keys(filter).length) {
return false;
}
if (filter.extensions) {
// sugar for users
if ('string' === typeof filter.extensions) {
filter.extensions = filter.extensions.split(',');
}
if (filter.extensions.indexOf(res.ext) === -1) {
return false;
}
}
if (filter.types) {
// sugar for users
if ('string' === typeof filter.types) {
filter.types = filter.types.split(',');
}
if (filter.types.indexOf(res.type) === -1) {
return false;
}
}
return true;
},
/**
* Walks the resources in the bundle, optionally applying the filter along the way.
* @private
* @method _walkBundleResources
* @param {Bundle} bundle The bundle containing resources.
* @param {object} filter Filter for deciding which resources to return.
* @param {string|[string]} [filter.extensions] The filesystem extensions (NOT including dot) for which resources to return.
* @param {string|[string]} [filter.types] The resources types for which resources to return.
* @param {function} callback Callback to call for each resources.
* @param {LocatorResource} callback.res The resource.
* @param {nothing} callback.return Return value of callback is ignored.
*/
_walkBundleResources: function (bundle, filter, callback) {
var self = this,
ruleset = self._loadRuleset(bundle);
Object.keys(bundle.resources).forEach(function (selector) {
Object.keys(bundle.resources[selector]).forEach(function (resType) {
var rule = ruleset[resType];
if (rule.subtypeKey) {
Object.keys(bundle.resources[selector][resType]).forEach(function (subtype) {
Object.keys(bundle.resources[selector][resType][subtype]).forEach(function (name) {
var res = bundle.resources[selector][resType][subtype][name];
if (self._filterResource(res, filter)) {
callback(res);
}
});
});
} else {
Object.keys(bundle.resources[selector][resType]).forEach(function (name) {
var res = bundle.resources[selector][resType][name];
if (self._filterResource(res, filter)) {
callback(res);
}
});
}
});
});
},
/**
* Walks a directory and returns a list of metadata about locator packages
* installed in that directory (including the package for the directory itself).
* @private
* @method _walkNPMTree
* @async
* @param {string} dir Directory to walk.
* @param {integer} _depth [internal] Depth of the directory being walked.
* Internally used for recursion.
* @return {Promise/A+} A promise that will be fulfilled with information
* about locator packages in the directory. If the directory is not an NPM
* package then the promise will be fulfilled with an undefined value.
*/
_walkNPMTree: function (dir, _depth) {
var self = this;
_depth = _depth || 0;
return new libpromise.Promise(function (fulfill, reject) {
var pkg,
seed,
seeds = [];
try {
pkg = require(libpath.resolve(dir, 'package.json'));
if ((0 === _depth) && (!pkg.locator)) {
pkg.locator = {};
}
seed = self._makeBundleSeed(dir, libpath.basename(dir), DEFAULT_VERSION, pkg);
if (seed.options) {
seed.npmDepth = _depth;
seeds.push(seed);
}
} catch (packageErr) {
// Some build environments leave extraneous directories in
// node_modules and we should ignore them gracefully.
// (trello board:Modown card:124)
if ('MODULE_NOT_FOUND' === packageErr.code) {
fulfill(undefined);
} else {
reject(packageErr);
}
return;
}
if (_depth < self._options.maxPackageDepth) {
libfs.readdir(libpath.join(dir, 'node_modules'), function (readdirErr, subdirs) {
var subpkgsPromises = [];
if (readdirErr) {
if ('ENOENT' === readdirErr.code) {
// missing node_modules/ directory is OK
fulfill(seeds);
return;
}
reject(readdirErr);
return;
}
subdirs.forEach(function (subdir) {
if ('.' === subdir.substring(0, 1)) {
return;
}
subpkgsPromises.push(
self._walkNPMTree(libpath.join(dir, 'node_modules', subdir), _depth + 1)
);
});
return libpromise.batch.apply(libpromise, subpkgsPromises).then(function (subpkgsResults) {
// merge in found packages
if (subpkgsResults && Array.isArray(subpkgsResults)) {
subpkgsResults.forEach(function (subpkgResults) {
if (subpkgResults && subpkgResults.length) {
seeds = seeds.concat(subpkgResults);
}
});
}
fulfill(seeds);
return seeds;
}, function (batchErr) {
reject(batchErr);
});
});
} else {
fulfill(seeds);
}
});
},
/**
* Figures out which bundles to use from the list.
* The returned list is sorted first by NPM package depth then by name.
* @private
* @method _filterBundleSeeds
* @param {array} all List of all bundle seeds from NPM packages.
* @return {array} The metas of the packages to actually use.
*/
_filterBundleSeeds: function (all) {
var byDepth = {}, // name: depth: [metas]
out = [];
all.forEach(function (seed) {
if (!byDepth[seed.name]) {
byDepth[seed.name] = {};
}
if (!byDepth[seed.name][seed.npmDepth]) {
byDepth[seed.name][seed.npmDepth] = [];
}
byDepth[seed.name][seed.npmDepth].push(seed);
});
Object.keys(byDepth).forEach(function (name) {
var pkgDepths = byDepth[name],
depths,
minDepth,
maxDepth,
seeds;
depths = Object.keys(pkgDepths);
minDepth = Math.min.apply(Math, depths);
maxDepth = Math.max.apply(Math, depths);
seeds = pkgDepths[minDepth];
if (1 === seeds.length) {
if (minDepth !== maxDepth) {
imports.log('multiple "' + name + '" packages found, using ' + seeds[0].baseDirectory);
}
out.push(seeds[0]);
return;
}
seeds.sort(function (a, b) {
return libsemver.rcompare(a.version, b.version);
});
imports.log('multiple "' + name + '" packages found, using ' + seeds[0].baseDirectory);
out.push(seeds[0]);
});
return out;
},
/**
* Creates a bundle from an NPM package, and queues up files in the package.
* @private
* @method _walkBundle
* @param {object} bundleSeed Metadata about the package. See the docs for _makeBundleSeed()
* for format of this metadata.
* @return {Promise/A+} A promise that will be fulfilled with the bundle
* made from the NPM package.
*/
_walkBundle: function (bundleSeed) {
var self = this,
parentName,
parent;
// TODO -- merge options (second arg) over bundleSeed.options
parentName = self._getBundleNameByPath(libpath.dirname(bundleSeed.baseDirectory));
parent = self._bundles[parentName];
return this._makeBundle(bundleSeed, parent).then(function (bundle) {
self._bundles[bundle.name] = bundle;
return new libpromise.Promise(function (fulfill, reject) {
var walker = new ScanFS(null, function (err, fullPath) {
var subpath = fullPath.slice(bundle.baseDirectory.length);
if (-1 !== subpath.indexOf('node_modules')) {
return 'ignored';
}
if ('.' === libpath.basename(fullPath).substr(0, 1)) {
return 'ignored';
}
if (self._isBuildFile(fullPath)) {
return 'ignored';
}
});
walker.on('file', function (err, fullPath) {
self._fileQueue.push([EVENT_UPDATED, fullPath]);
});
walker.on('dir', function (err, fullPath) {
self._fileQueue.push([EVENT_UPDATED, fullPath]);
});
walker.on('done', function (err) {
if (err) {
reject(err);
} else {
fulfill(bundle);
}
});
walker.absolutely(bundle.baseDirectory);
});
});
},
/**
* Loads the rulesets for the bundle (or seed).
* @private
* @method _loadRuleset
* @param {Bundle|object} bundle The bundle (or bundle seed, see _makeBundleSeed())
* to load the ruleset for.
* @return {object} The ruleset, or undefined if we couldn't load it.
*/
_loadRuleset: function (bundle) {
var cacheKey = bundle.baseDirectory,
rulesetsPath,
rulesets,
dir,
name,
rules;
rulesetsPath = this._cacheRulesetsPath[cacheKey];
if (rulesetsPath) {
rulesets = require(rulesetsPath);
} else {
if (bundle.options && bundle.options.rulesets) {
try {
rulesetsPath = libpath.resolve(bundle.baseDirectory, bundle.options.rulesets);
rulesets = require(rulesetsPath);
} catch (errLocal) {
if ('MODULE_NOT_FOUND' !== errLocal.code) {
throw errLocal;
}
}
if (!rulesets) {
dir = bundle.baseDirectory;
while (dir) {
try {
rulesetsPath = libpath.resolve(dir, bundle.options.rulesets);
rulesets = require(rulesetsPath);
break;
} catch (errDir) {
if ('MODULE_NOT_FOUND' !== errDir.code) {
throw errDir;
}
}
try {
rulesetsPath = libpath.resolve(dir, 'node_modules', bundle.options.rulesets);
rulesets = require(rulesetsPath);
break;
} catch (errDep) {
if ('MODULE_NOT_FOUND' !== errDep.code) {
throw errDep;
}
}
// not found, iterate
dir = libpath.dirname(dir);
if ('node_modules' === libpath.basename(dir)) {
dir = libpath.dirname(dir);
}
if (this._rootDirectory && dir.length < this._rootDirectory.length) {
return;
}
}
}
if (rulesets) {
this._cacheRulesetsPath[cacheKey] = rulesetsPath;
}
} else {
rulesets = DEFAULT_RULESETS;
}
}
if (!rulesets) {
return;
}
name = (bundle.options && bundle.options.ruleset) || DEFAULT_RULESET;
rules = rulesets[name];
if (rules) {
rules._name = name;
}
return rules;
},
/**
* Determines whether file is in the build directory.
* @private
* @method _isBuildFile
* @param {string} path Full path to file.
* @return {boolean} true if the file is in the build directory
*/
_isBuildFile: function (path) {
var buildDir = this._options.buildDirectory;
if (!buildDir) {
return false;
}
if (buildDir !== path.substr(0, buildDir.length)) {
return false;
}
return buildDir.length === path.length || libpath.sep === path.charAt(buildDir.length);
},
/**
* Creates a new object with the certain keys excluded.
* This is used instead of "delete" since that has performance implications in V8.
* @private
* @method _objectExclude
* @param {object} srcObject The original object.
* @param {array} excludeKeys The keys to exclude from the results.
* @return {object} A version of the original object with the keys excluded.
*/
_objectExclude: function (srcObject, excludeKeys) {
var destObject = {},
key;
for (key in srcObject) {
if (srcObject.hasOwnProperty(key)) {
if (-1 === excludeKeys.indexOf(key)) {
destObject[key] = srcObject[key];
}
}
}
return destObject;
}
};
/**
* This is not a class instantiated by locator, but is here to document the
* API of locator plugins.
* @class LocatorPlugin
*/
/**
* An object used to describe the plugin to humans and to the locator.
* @property describe
* @type object
* @optional
*/
/**
* Text summary for humans.
* @property describe.summary
* @type string
* @optional
*/
/**
* A default list of extensions for which the plugin should be used.
* This cal be overridden by the application developer when then call
* `locator.plug()`.
* @property describe.extensions
* @type string|[string]
* @optional
*/
/**
* Method called when a file is added or updated.
* @method fileUpdated
* @optional
* @async
* @param {object} event A description of the events.
* @param {LocatorFileMeta} event.file Metadata about the file that was added or updated.
* @param {LocatorPluginAPI} api An "api" object that has utility metho