mincer
Version:
Web assets processor. Native JavaScript port of Sprockets.
548 lines (435 loc) • 13.5 kB
JavaScript
/** internal
* class Base
*
* Base class for [[Environment]] and [[Index]].
*
*
* ##### INCLUDES
*
* - [[Engines]]
* - [[Mime]]
* - [[Processing]]
* - [[Paths]]
**/
'use strict';
// stdlib
var fs = require('fs');
var path = require('path');
var crypto = require('crypto');
// 3rd-party
var _ = require('lodash');
// internal
var getter = require('./common').getter;
var prop = require('./common').prop;
var mixin = require('./common').mixin;
var isAbsolute = require('./common').isAbsolute;
var VERSION = require('./version');
var AssetAttributes = require('./asset_attributes');
var StaticAsset = require('./assets/static');
var ProcessedAsset = require('./assets/processed');
var BundledAsset = require('./assets/bundled');
////////////////////////////////////////////////////////////////////////////////
/**
* new Base()
**/
var Base = module.exports = function Base() {};
mixin(Base.prototype, require('./helpers/paths'));
mixin(Base.prototype, require('./helpers/mime'));
mixin(Base.prototype, require('./helpers/caching'));
mixin(Base.prototype, require('./helpers/processing'));
mixin(Base.prototype, require('./helpers/compressing'));
mixin(Base.prototype, require('./helpers/configuring'));
mixin(Base.prototype, require('./helpers/engines'));
// helper that defines property which fires expireIndex()
// each time it gets changed
function attr_with_expire_index(name, value) {
var __name__ = '__' + name + '__';
// set underlying value
prop(Base.prototype, __name__, value, { writable: true });
// provide getters/setter
Object.defineProperty(Base.prototype, name, {
get: function () {
return this[__name__];
},
set: function (val) {
this.expireIndex();
this[__name__] = val;
}
});
}
/**
* Base#cache -> Mixed
*
* Cache backend.
*
* Default: `null`.
**/
attr_with_expire_index('cache', null);
/**
* Base#digestAlgorithm -> String
*
* Digest algorithm: `sha1` or `md5`.
* See Node manual on `crypto` module.
*
* Default: `md5`.
**/
attr_with_expire_index('digestAlgorithm', 'md5');
/**
* Base#version -> String
*
* Environment version.
*
* environment.version = '2.0'
**/
attr_with_expire_index('version', '');
/**
* Base#digest -> crypto.Hash
*
* Returns a `crypto.Hash` instance for the `Environment`.
*
* This value serves two purposes. If two `Environment`s have the
* same digest value they can be treated as equal. This is more
* useful for comparing environment states between processes rather
* than in the same. Two equal `Environment`s can share the same
* cached assets.
*
* The value also provides a seed digest for all `Asset` digests.
* Any change in the environment digest will affect all of its assets.
**/
getter(Base.prototype, 'digest', function () {
// Do not cache, so the caller can safely mutate it with `.update`
var digest = crypto.createHash(this.digestAlgorithm);
// Mixin Mincer release version and custom environment version.
// So any new releases will affect all your assets.
digest.update(VERSION, 'utf8');
digest.update(this.version, 'utf8');
return digest;
});
// helper that overrides defined method `name` with wrapper that expireIndex()
// and calls `func` when provided before calling original method
function func_proxy_with_expire_index(name, func) {
var orig = Base.prototype[name];
Base.prototype[name] = function () {
this.expireIndex();
if (func) {
func.apply(this, arguments);
}
orig.apply(this, arguments);
};
}
//
// override [[Paths]] mixin methods
//
func_proxy_with_expire_index('prependPath');
func_proxy_with_expire_index('appendPath');
func_proxy_with_expire_index('clearPaths');
//
// override [[Mime]] mixin methods
//
func_proxy_with_expire_index('registerMimeType', function (mimeType, ext) {
this.__trail__.appendExtensions(ext);
});
//
// override [[Engines]] mixin methods
//
func_proxy_with_expire_index('registerEngine', function (ext, klass) {
this.addEngineToTrail(ext, klass);
});
//
// override [[Processing]] mixin methods
//
func_proxy_with_expire_index('registerPreprocessor');
func_proxy_with_expire_index('registerPostprocessor');
func_proxy_with_expire_index('registerBundleProcessor');
func_proxy_with_expire_index('unregisterPreprocessor');
func_proxy_with_expire_index('unregisterPostprocessor');
func_proxy_with_expire_index('unregisterBundleProcessor');
/**
* Base#resolve(logicalPath[, options = {}[, fn]]) -> String
* - logicalPath (String)
* - options (Object)
* - fn (Function)
*
* Finds the expanded real path for a given logical path by
* searching the environment's paths.
*
* env.resolve("application.js")
* # => "/path/to/app/javascripts/application.js.coffee"
*
* An Error with `code = 'FileNotFound'` is raised if the file does not exist.
**/
Base.prototype.resolve = function (logicalPath, options, fn) {
var err, resolved, search;
if (fn) {
search = this.attributesFor(logicalPath).searchPaths;
return this.__trail__.find(search, options, function (pathname) {
var bower, extname;
if (!_.includes([ 'component.json', 'bower.json' ], path.basename(pathname))) {
return fn(pathname);
}
bower = require(pathname);
if (_.isString(bower.main)) {
return fn(path.join(path.dirname(pathname), bower.main));
}
if (_.isArray(bower.main)) {
extname = path.extname(logicalPath);
_.forEach(bower.main, function (mainfile) {
if (extname === '' || extname === path.extname(mainfile)) {
return fn(path.join(path.dirname(pathname), mainfile));
}
});
}
return null;
});
}
resolved = this.resolve(logicalPath, options, function (pathname) {
return pathname;
});
if (!resolved) {
err = new Error('Could not find file \'' + logicalPath + '\'');
err.code = 'FileNotFound';
throw err;
}
return resolved;
};
/**
* Base#entries(pathname) -> Array
* - pathname (String)
*
* Proxy to `Hike.Trail#entries`. Works like `fs.readdirSync`.
* Subclasses may cache this method.
**/
Base.prototype.entries = function (pathname) {
return this.__trail__.entries(pathname);
};
/**
* Base#stat(pathname) -> fs.Stats
* - pathname (String)
*
* Proxy to `Hike.Trail#stat`. Works like `fs.statSync`.
* Subclasses may cache this method.
**/
Base.prototype.stat = function (pathname) {
return this.__trail__.stat(pathname);
};
/**
* Base#getFileDigest(pathname) -> String
* - pathname (String)
*
* Read and compute digest of filename.
* Subclasses may cache this method.
**/
Base.prototype.getFileDigest = function (pathname) {
var stat = this.stat(pathname);
if (stat && stat.isDirectory()) {
// If directory, digest the list of filenames
return this.digest.update(this.entries(pathname).join(',')).digest('hex');
}
// If file, digest the contents
return this.digest.update(fs.readFileSync(pathname)).digest('hex');
};
/** internal
* Base#attributesFor(pathname) -> AssetAttributes
* - pathname (String)
*
* Returns a `AssetAttributes` for `pathname`
**/
Base.prototype.attributesFor = function (pathname) {
return new AssetAttributes(this, pathname);
};
/** internal
* Base#contentTypeOf(pathname) -> String
* - pathname (String)
*
* Returns content type of `pathname`
**/
Base.prototype.contentTypeOf = function (pathname) {
return this.attributesFor(pathname).contentType;
};
/**
* Base#findAsset(pathname[, options = {}]) -> Asset|Null
* - pathname (String)
* - options (Object)
*
* Find asset by logical path or expanded path.
**/
Base.prototype.findAsset = function (pathname, options) {
var logical_path = pathname, expanded_path;
if (isAbsolute(pathname)) {
if (!this.stat(pathname)) {
return null;
}
logical_path = this.attributesFor(pathname).logicalPath;
} else {
try {
pathname = this.resolve(logical_path);
if (path.extname(logical_path) === '') {
expanded_path = this.attributesFor(pathname).logicalPath;
logical_path += path.extname(expanded_path);
}
} catch (err) {
if (err.code === 'FileNotFound') {
return null;
}
throw err;
}
}
return this.buildAsset(logical_path, pathname, options);
};
/**
* Base#eachEntry(root, iterator) -> Void
* - root (String)
* - iterator (Function)
*
* Calls `iterator` on each found file or directory in alphabetical order:
*
* env.eachEntry('/some/path', function (entry) {
* console.log(entry);
* });
* // -> "/some/path/a"
* // -> "/some/path/a/b.txt"
* // -> "/some/path/a/c.txt"
* // -> "/some/path/b.txt"
**/
Base.prototype.eachEntry = function (root, iterator) {
var self = this, paths = [];
this.entries(root).forEach(function (filename) {
var pathname = path.join(root, filename),
stats = self.stat(pathname);
if (!stats) {
// File not found - silently skip it.
// It might happen only if we got "broken" symlink in real life.
// See https://github.com/nodeca/mincer/issues/18
return;
}
paths.push(pathname);
if (stats.isDirectory()) {
self.eachEntry(pathname, function (subpath) {
paths.push(subpath);
});
}
});
paths.sort().forEach(iterator);
};
/**
* Base#eachFile(iterator) -> Void
* - iterator (Function)
*
* Calls `iterator` for each file found within all registered paths.
**/
Base.prototype.eachFile = function (iterator) {
var self = this;
this.paths.forEach(function (root) {
self.eachEntry(root, function (pathname) {
if (!self.stat(pathname).isDirectory()) {
iterator(pathname);
}
});
});
};
// Returns true if there were no filters, or `filename` matches at least one
function matches_filter(filters, logicalPath, filename) {
if (filters.length === 0) {
return true;
}
return _.some(filters, function (filter) {
if (_.isRegExp(filter)) {
return filter.test(logicalPath);
}
if (_.isFunction(filter)) {
return filter(logicalPath, filename);
}
// prepare string to become RegExp.
// mimics shell's globbing
filter = filter.toString().replace(/\*\*|\*|\?|\\.|\./g, function (m) {
if (m[0] === '*') {
return m === '**' ? '.+?' : '[^/]+?';
}
if (m[0] === '?') {
return '[^/]?';
}
if (m[0] === '.') {
return '\\.';
}
// handle `\\.` part
return m;
});
// prepare RegExp
filter = new RegExp('^' + filter + '$');
return filter.test(logicalPath);
});
}
// Returns logicalPath for `filename` if it matches given filters
function logical_path_for_filename(self, filename, filters) {
var logical_path = self.attributesFor(filename).logicalPath;
if (matches_filter(filters, logical_path, filename)) {
return logical_path;
}
// If filename is an index file, retest with alias
if (path.basename(filename).split('.').shift() === 'index') {
logical_path = logical_path.replace(/\/index\./, '.');
if (matches_filter(filters, logical_path, filename)) {
return logical_path;
}
}
}
/**
* Base#eachLogicalPath(filters, iterator) -> Void
* - filters (Array)
* - iterator (Function)
*
* Calls `iterator` on each found logical path (once per unique path) that
* matches at least one of the given filters.
*
* Each filter might be a `String`, `RegExp` or a `Function`.
**/
Base.prototype.eachLogicalPath = function (filters, iterator) {
var self = this, files = {};
this.eachFile(function (filename) {
var logical_path = logical_path_for_filename(self, filename, filters);
if (logical_path && !files[logical_path]) {
iterator(logical_path, filename);
files[logical_path] = true;
}
});
};
// circular call protection helper.
// keeps array of required pathnames until the function
// that originated protection finishes it's execution
var circular_calls = null;
function circular_call_protection(pathname, callback) {
var reset = (circular_calls === null),
calls = circular_calls || (circular_calls = []);
try {
if (calls.indexOf(pathname) >= 0) {
throw new Error('Circular dependency detected: ' + pathname +
' has already been required');
}
calls.push(pathname);
return callback();
} finally {
if (reset) {
circular_calls = null;
}
}
}
// creates instance of [[StaticAsset]], [[BundledAsset]] or [[ProcessedAsset]]
Base.prototype.buildAsset = function (logicalPath, pathname, options) {
var self = this;
options = options || {};
// If there are any processors to run on the pathname, use
// `BundledAsset`. Otherwise use `StaticAsset` and treat is as binary.
if (this.attributesFor(pathname).processors.length === 0) {
return new StaticAsset(this.index, logicalPath, pathname);
}
if (options.bundle) {
return new BundledAsset(this.index, logicalPath, pathname);
}
return circular_call_protection(pathname, function () {
return new ProcessedAsset(self.index, logicalPath, pathname);
});
};
// Returns cache key for given `pathname` based on options
Base.prototype.cacheKeyFor = function (pathname, options) {
return pathname + String(options.bundle ? 1 : 0);
};