@retailmenot/roux
Version:
Roux SDK and specification
527 lines (463 loc) • 15.3 kB
JavaScript
;
var _ = require('lodash');
var assert = require('assert');
var path = require('path');
var pathParse = require('path-parse');
var Promise = require('bluebird');
var util = require('util');
var debug = require('debug')('roux:sdk');
var fs = Promise.promisifyAll(require('fs'));
var globAsync = Promise.promisify(require('glob'));
var Ingredient = require('./lib/ingredient');
var Pantry = require('./lib/pantry');
var defaultPredicates = {
assets: /^assets$/,
handlebars: /^index.hbs$/,
javaScript: /^index.js$/,
model: /^model.js$/,
preview: /^preview.hbs$/,
previewScript: /^preview.js$/,
sass: /^index.scss$/
};
function IngredientHasNoSuchEntrypointError(pantry, ingredient, entryPoint) {
this.message = util.format(
'Ingredient "%s/%s" has no "%s" entry point',
pantry,
ingredient,
entryPoint
);
}
util.inherits(IngredientHasNoSuchEntrypointError, Error);
function IngredientDoesNotExistError(pantry, ingredient) {
this.message = util.format(
'Pantry "%s" has no ingredient "%s"',
pantry,
ingredient
);
}
util.inherits(IngredientDoesNotExistError, Error);
function PantryDoesNotExistError(pantryBasePath) {
this.message = util.format('Pantry %s does not exist.', pantryBasePath);
}
util.inherits(PantryDoesNotExistError, Error);
function PantryNotADirectoryError(pantryBasePath) {
this.message = util.format(
'Invalid pantry "%s", not a directory',
pantryBasePath
);
}
util.inherits(PantryNotADirectoryError, Error);
function hasEntryPointAsync(ingredientPath, predicate) {
if (_.isRegExp(predicate)) {
predicate = predicate.test.bind(predicate);
}
return Promise.try(
function () {
return fs.readdirAsync(ingredientPath);
})
.then(function (files) {
var promises = [];
// We allow the predicate to decide asynchronously for one or more
// filenames. If the predicate decides synchronously that any file
// is a valid entrypoint, we immediately resolve to true. We also
// save any promises of a decision from the predicate in case we
// need them later.
var entryPoint = _.find(files, function (file) {
var result = predicate(file);
if (result === true) {
// the file matched the predicate, so we don't need to check
// further
return true;
}
if (result && _.isFunction(result.then)) {
// the predicate hasn't decided yet, so save its promise of a
// decision in case we need it
promises.push(result.then(function (decision) {
return decision === true ? file : undefined;
}));
}
return false;
});
if (!_.isUndefined(entryPoint)) {
// predicate decided 'yes' synchronously
return Promise.resolve({
filename: entryPoint
});
} else if (promises.length < 1) {
// predicate decided 'no' synchronously
return Promise.resolve(null);
}
// predicate has not decided for all files, so wait for it to do so
return Promise.all(promises).then(function (results) {
var entryPoint = _.find(results, _.isString);
return entryPoint ? {
filename: entryPoint
} : null;
});
});
}
/**
* Initialize a Pantry instance for a pantry of ingredients
*
* @param {Object} config - configuration for the Pantry
* @param {string} config.name - the name of the pantry
* @param {string} config.path - the path to the pantry
* @param {Object} [config.predicates] - RegExp or Function predicates to decide
* if an ingredient contains an entrypoint
* @param {Function} [callback] - Node.js-style callback for the initialized
* Pantry instance or the error if initialization fails
*
* @return {Promise} - promise of the initialized Pantry instance
*/
function initialize(config, callback) {
debug('initializing with config', config);
assert.ok(_.isObject(config), 'config object is required');
assert.ok(_.isString(config.name), 'config.name is required');
assert.ok(_.isString(config.path), 'config.path is required');
var pantryConfig = {
name: config.name,
path: config.path
};
config = config || {};
config.predicates = _.defaults({}, config.predicates, defaultPredicates);
var pantryBasePath = path.resolve(config.path);
var pantryPathPrefix = '';
var promise = Promise.try(function () {
return fs.statAsync(pantryBasePath)
.catch(function () {
throw new PantryDoesNotExistError(pantryBasePath);
});
})
.then(function (pantryStats) {
if (!pantryStats.isDirectory()) {
throw new PantryNotADirectoryError(pantryBasePath);
}
})
.then(function () {
// if package.json exists in the pantry root directory and the
// roux.pantryRoot key exists, resolve that path from the pantry root to
// determine the actual directory that ingredients live under.
var packageJsonPath = path.join(pantryBasePath, 'package.json');
return fs.readFileAsync(packageJsonPath)
.then(function (packageJsonBuffer) {
var packageJson = JSON.parse(packageJsonBuffer);
if (_.isString(packageJson.roux.pantryRoot)) {
pantryPathPrefix = packageJson.roux.pantryRoot;
return path.join(pantryBasePath, pantryPathPrefix);
}
return pantryBasePath;
})
.catch(function () {
return pantryBasePath;
});
})
.then(function (pantryPath) {
pantryConfig.path = pantryPath;
return globAsync('**/ingredient.md', {
cwd: pantryPath,
strict: true
});
})
.then(function (ingredients) {
var ingredientPaths = [];
pantryConfig.ingredients = _.chain(ingredients)
.map(function (ingredient) {
// we get foo/bar/ingredient.md and need foo/bar
var ingredientPath = path.resolve(
pantryBasePath,
pantryPathPrefix,
pathParse(ingredient).dir
);
// save the path for detecting nested ingredients
ingredientPaths.push(ingredientPath);
return ingredientPath;
})
.filter(function (ingredientPathToFilter) {
// to verify that a given ingredient is not nested in any other
// ingredient, we check that the given ingredient's path does not
// begin with the path to any other ingredient; we append path.sep to
// avoid false positives like this: path/to/foo and path/to/foobar
return _.every(ingredientPaths, function (ingredientPath) {
return !_.startsWith(
ingredientPathToFilter,
ingredientPath + path.sep
);
});
})
.reduce(function (result, ingredient) {
var name = path.relative(
path.join(pantryBasePath, pantryPathPrefix),
ingredient
);
result[name] = new Ingredient({
name: name,
path: ingredient,
pantryName: config.name,
entryPoints: {}
});
return result;
},
{})
.value();
return Promise.all(
_.map(pantryConfig.ingredients,
function (ingredient) {
// for each predicate, call it with the ingredient path
return Promise.all(
_.map(config.predicates, function (predicate, predicateName) {
return hasEntryPointAsync(ingredient.path, predicate)
.then(function (result) {
ingredient.entryPoints[predicateName] = result;
});
})
);
})
)
.then(function () {
return new Pantry(pantryConfig);
}
);
});
if (typeof callback === 'function') {
promise.asCallback(callback);
}
return promise;
}
/**
* @typedef ParsedIngredientPath
* @type Object
* @property {string} pantry - the (possibly namespaced) pantry name
* @property {string} ingredient - the ingredient name
*/
/**
* Parse the pantry and ingredient names out of an ingredient path
*
* If passed a valid Roux ingredient path, this function returns an object with
* the pantry and ingredient names. If passed a string that is not a valid path,
* it returns `null`. If passed anything else, it throws an `Error`.
*
* @param {string} ingredientPath - the path to parse
*
* @return {ParsedIngredientPath} - the parsed ingredient path or null
*/
function parseIngredientPath(ingredientPath) {
assert(_.isString(ingredientPath), '`ingredientPath` must be a string');
var split = ingredientPath.split('/');
var pantry, ingredient;
if (Pantry.isValidName(split[0])) {
// the pantry name is not scoped
pantry = split[0];
ingredient = split.slice(1).join('/');
} else {
// the pantry name is scoped
pantry = split.slice(0, 2).join('/');
ingredient = split.slice(2).join('/');
}
if (!Pantry.isValidName(pantry) || !Ingredient.isValidName(ingredient)) {
return null;
}
return {
pantry: pantry,
ingredient: ingredient
};
}
/**
* Resolve a pantry, ingredient or entry point
*
* If passed a valid pantry name, this function returns a promise of the
* corresponding `Pantry` instance. If passed valid names of a pantry and
* ingredient, it returns a promise of the corresponding `Ingredient` instance.
* If passed valid names of a pantry, ingredient, and entry point, it returns a
* promise of the absolute path to the corresponding entry point file.
*
* If passed invalid names, it throws an error. If the pantry, ingredient, or
* entry point cannot be found, the returned promise is rejected with an error.
*
* @param {string} pantry - pantry name
* @param {string} [ingredient] - ingredient name
* @param {Object} [entryPoint] - entry point name
* @param {Object} [config] - configuration object
* @param {Object} config.pantries - a cache of `Pantry` instances. Alternately,
* the values here can be file paths. The pantries will be initialized, and
* the config.pantries object modified to contain the initialized pantries.
* @param {string[]} config.pantrySearchPaths - the paths to search for pantries
* in if not found in the cache
*
* @return {Promise} - promise of the pantry, ingredient, or entry point
*/
function resolve(pantry, ingredient, entryPoint, config) {
if (!_.isString(pantry)) {
throw new TypeError('`pantry` must be a string');
}
// check for config passed thru ingredient or entryPoint
if (arguments.length === 2 && _.isObject(ingredient)) {
config = ingredient;
ingredient = '';
} else if (arguments.length === 3 && _.isObject(entryPoint)) {
config = entryPoint;
entryPoint = '';
}
if (!_.isUndefined(ingredient) && !_.isString(ingredient)) {
throw new TypeError('`ingredient` must be a string');
}
if (!_.isUndefined(entryPoint) && !_.isString(entryPoint)) {
throw new TypeError('`entryPoint` must be a string');
}
if (!Pantry.isValidName(pantry)) {
throw new Error(util.format('"%s" is not a valid pantry name', pantry));
}
if (ingredient && !Ingredient.isValidName(ingredient)) {
throw new Error(
util.format('"%s" is not a valid ingredient name', ingredient)
);
}
config = normalizeConfig(config);
return Promise.try(function () {
if (pantry in config.pantries) {
if (_.isString(config.pantries[pantry])) {
// User passed their own path. Initialize the pantry now.
return initAndCachePantry(config.pantries, {
name: pantry,
path: config.pantries[pantry]
});
}
// return the pantry from the cache
return config.pantries[pantry];
}
// try to find it in one of the provided search paths.
return Promise.any(config.pantrySearchPaths.map(
function (searchPath) {
debug(
'searching in %s for pantry %s',
searchPath,
pantry
);
return initAndCachePantry(config.pantries, {
name: pantry,
path: path.resolve(searchPath, pantry)
});
}));
})
.then(function (pantryInstance) {
assert(
Pantry.isPantry(pantryInstance),
util.format(
'Pantry must be a pantry. Got: %s',
typeof pantryInstance
)
);
if (!ingredient) {
// we are resolving a pantry, so we're done
return pantryInstance;
}
if (!pantryInstance.ingredients.hasOwnProperty(ingredient)) {
throw new IngredientDoesNotExistError(pantry, ingredient);
}
var ingredientInstance = pantryInstance.ingredients[ingredient];
if (!entryPoint) {
// we are resolving an ingredient, so we're done
return ingredientInstance;
}
if (!ingredientInstance.entryPoints[entryPoint]) {
throw new IngredientHasNoSuchEntrypointError(
pantry,
ingredient,
entryPoint
);
}
return path.join(
ingredientInstance.path,
ingredientInstance.entryPoints[entryPoint].filename
);
})
.catch(function (error) {
debug('got error', error);
debug(error.stack);
if (error instanceof Promise.AggregateError) {
if (_.every(error, function (e) {
// Ensure that every error is a PantryDoesNotExistError
// and if so throw the first instance of that error.
// Otherwise, just throw the aggregate error.
return e instanceof PantryDoesNotExistError;
})) {
throw error[0];
} else if (_.every(error, function (e) {
// Do the same thing for PantryNotADirectoryError
return e instanceof PantryNotADirectoryError;
})) {
throw error[0];
}
}
throw error;
});
}
/**
* Proxies to initialize but adds the result to the pantryCache
* before returning it.
* @param {Object} pantryCache cache of pantry objects. The result of
* initialize will be stored in pantryCache[config.name]
* @param {Object} config config to be passed to initialize
* @return {Promise} promise for the initialized pantry. Once
* resolved pantryCache will contain the initialized pantry.
*/
function initAndCachePantry(pantryCache, config) {
return initialize(config)
.then(function (initializedPantry) {
// cache the pantry for next time
pantryCache[config.name] = initializedPantry;
return initializedPantry;
});
}
/**
* Normalize an config common to a Roux pantry
*
* Some modules, such as roux-sass-importer may want to expose a similar api to
* resolve. This method validates and initializes the configuration accepted by
* the resolve method.
*
* @param {Object} [config] - configuration object
* @param {Object} config.pantries - a cache of `Pantry` instances
* @param {string[]} config.pantrySearchPaths - the paths to search for pantries
* @param {Object} defaults defaults to use when initializing the config
*
* @return {Object} valid and initialized config
*/
function normalizeConfig(config, defaults) {
if (_.isUndefined(config)) {
config = {};
}
if (!_.isObject(config)) {
throw new TypeError('`config` must be an object');
}
if (_.isUndefined(defaults)) {
defaults = {};
}
if (!_.isObject(defaults)) {
throw new TypeError('`defaults` must be an object');
}
config = _.defaults({}, config, defaults, {
pantries: {},
pantrySearchPaths: [path.resolve('node_modules')]
});
if (!_.isObject(config.pantries)) {
throw new TypeError('`config.pantries` must be an object');
}
if (!_.isArray(config.pantrySearchPaths)) {
throw new TypeError('`config.pantrySearchPaths` must be an Array');
}
return config;
}
module.exports = {
initialize: initialize,
parseIngredientPath: parseIngredientPath,
resolve: resolve,
normalizeConfig: normalizeConfig,
errors: {
PantryDoesNotExistError: PantryDoesNotExistError,
PantryNotADirectoryError: PantryNotADirectoryError,
IngredientDoesNotExistError: IngredientDoesNotExistError,
IngredientHasNoSuchEntrypointError: IngredientHasNoSuchEntrypointError
},
Ingredient: Ingredient,
Pantry: Pantry
};