UNPKG

@retailmenot/roux-sass-importer

Version:

A node-sass custom importer for Roux ecosystem ingredients.

204 lines (185 loc) 7.12 kB
'use strict'; var _ = require('lodash'); var path = require('path'); var pathParse = require('path-parse'); var roux = require('@retailmenot/roux'); var Pantry = require('@retailmenot/roux/lib/pantry'); var util = require('util'); function importOnce(importCache, value) { if (importCache[value.file]) { return { contents: '' }; } importCache[value.file] = true; return value; }; function getImportPath(importCache, pantry, ingredientName) { var ingredient = pantry.ingredients[ingredientName]; if (!ingredient) { return new Error( util.format('No such ingredient "%s/%s"', pantry.name, ingredientName)); } if (!ingredient.entryPoints.sass) { return new Error( util.format( '"%s/%s" has no Sass entry point', pantry.name, ingredientName ) ); } return importOnce(importCache, { file: path.resolve(ingredient.path, ingredient.entryPoints.sass.filename) }); } /** * Get a node-sass custom importer[1] with the provided configuration * * The importer returns `NODE_SASS_NULL` unless it matches one of the following * patterns: * * - @<namespace>/<pantry>/<ingredient> * - <pantry>/<ingredient> * * If the value of `url` matches one of the above, the importer attempts to * look up the Sass entry point for the named pantry and ingredient. If * successful, an absolute path to the entry point is returned (possibly * asynchronously). * * If the pantry is not found, an `Error` is returned. If the pantry is found * but does not contain the named ingredient, an `Error` is returned. If both * the pantry and ingredient are found but the ingredient does not have a Sass * entry point, an `Error` is returned. * * The importer caches pantries it looks up. The cache can be primed by means of * the optional `config.pantries` parameter. If the named pantry is cached, * the above process completes synchronously. If not cached, the pantry is * looked up in the locations named by `config.pantrySearchPaths`. The first * matching pantry found is cached and the above process performed. * * @param {*} NODE_SASS_NULL The object to return when node-sass should do its * thing. You should pass require('node-sass').NULL from your webpack config * in the repo that's consuming this module. * @param {Object} [config] - the importer configuration * @param {Object} [config.pantries] - the cache of pantries to use, defaults to * `{}`. Should be {name:pantry} mappings. If pantry is a string, it will be * passed to `@retailmenot/roux.initialize` and cached across calls * to the importer. * @param {string[]} [config.pantrySearchPaths=['$CWD/node_modules'] - the paths * to search for pantries in if not found in the cache * * [1]: https://github.com/sass/node-sass#importer--v200---experimental */ module.exports = function (NODE_SASS_NULL, config) { if (arguments.length < 1) { throw new Error('Argument NODE_SASS_NULL is required.'); } config = roux.normalizeConfig(config); config.pantries = _.mapValues(config.pantries, function (pantry, name) { if (_.isString(pantry)) { return roux.initialize({ name: name, path: pantry }, config); } return pantry; }); /** * node-sass custom importer[1] for ingredients in the Roux ecosystem. * * The importer will use `this` to store a cache of ingredient paths * that it has already resolved, and return an empty string as the file's * contents in cases where the file has already returned the file. This will * effectively deduplicate the sass output. * * `node-sass` will: * * @param {Object} config - the importer configuration * @param {Object} config.pantries - the cache of pantries to use * @param {string[]} config.pantrySearchPaths - the paths to search for * pantries in if not found in the cache * @param {string} url - the original import path (provided by node-sass: see * [1]) * @param {string} prev - the absolute path to the file importing `url` * (provided by node-sass: see [1]) * @param {function} done - a callback function to invoke on async completion * (provided by node-sass: see [1]) * * [1]: https://github.com/sass/node-sass#importer--v200---experimental */ return function Importer(url, prev, done) { var pantry; this._rouxImportOnceCache = this._rouxImportOnceCache || {}; // If we are being asked to resolve a relative url, we want to let // the sass importer do its thing by returning NODE_SASS_NULL, however // if that relative URL refers to a file that is a child of a pantry, // then we want to add it to this._rouxImportOnceCache, and prevent it from // showing up in the output if we encounter an @import for that // same file again. To do this, we resolve the relative `url` with // respect to `prev` and if the resolved fully qualified URL is a child // of one of the pantries in config.pantries, we apply our caching logic. if (url.charAt(0) === '.') { var baseDir = pathParse(prev).dir; // this will lack the .scss extension so it does not necessarily // map to a file on the filesystem var absoluteImportUrl = path.resolve(baseDir, url); // Check our pantries to see if the @import is refering to one of its // children. If so, apply caching logic and return as appropriate. var pantries = Object.keys(config.pantries); for (var i = 0; i < pantries.length; ++i) { pantry = config.pantries[pantries[i]]; if (!Pantry.isPantry(pantry)) { continue; } // Figure out where it lives var absolutePantryUrl = path.resolve(pantry.path); // If the import is a child of the pantry, apply our caching logic if (absoluteImportUrl.indexOf(absolutePantryUrl) === 0) { return this._rouxImportOnceCache[absoluteImportUrl] === true ? { contents: '' } : NODE_SASS_NULL; } } } var parsedPath = roux.parseIngredientPath(url); if (parsedPath == null) { // url was not a valid ingredient name, so pass the @import path // along unmodified return NODE_SASS_NULL; } pantry = config.pantries[parsedPath.pantry]; // There are three posibilities: // 1. The pantry already exists and is initialized // 2. The pantry is a promise that will resolve to the initialized pantry // 3. The pantry is completely undefined // This if block handles cases (2, 3) if (!pantry || (pantry && _.isFunction(pantry.then))) { // The promise that will ultimately resolve to a pantry // is either the pantry (case 2), which is already a promise, // or the promise returned by calling roux.resolve (case 3). (pantry || roux.resolve(parsedPath.pantry, config)) .then(function (pantry) { config.pantries[parsedPath.pantry] = pantry; done( getImportPath( this._rouxImportOnceCache, pantry, parsedPath.ingredient ) ); }.bind(this)) .catch(function (errs) { var err = new Error(util.format('Failed to resolve %s', url)); err.errors = errs; done(err); }); return undefined; } return getImportPath( this._rouxImportOnceCache, pantry, parsedPath.ingredient ); }; };