minifyify
Version:
Minify your browserify bundles without losing the sourcemap
465 lines (393 loc) • 11.9 kB
JavaScript
var Minifier
, _ = {
each: require('lodash.foreach')
, bind: require('lodash.bind')
, assign: require('lodash.assign')
, defaults: require('lodash.defaults')
}
, fs = require('fs')
, path = require('path')
, tmp = require('tmp')
, concat = require('concat-stream')
, through = require('through')
, uglify = require('uglify-js')
, SM = require('source-map')
, convertSM = require('convert-source-map')
, filter = require('transform-filter')
, SMConsumer = SM.SourceMapConsumer
, SMGenerator = SM.SourceMapGenerator;
Minifier = function (opts) {
/*
* Handle options/defaults
*/
opts = opts || {};
var defaults = {
minify: true
, source: 'bundle.js'
, map: 'bundle.map'
, sourcesContent: true
, compressPath: function (filePath) {
// noop
return filePath;
}
}
, compressTarget;
this.opts = _.defaults(opts, defaults);
/* Turn a string compressPath into a function */
if(typeof this.opts.compressPath == 'string') {
compressTarget = this.opts.compressPath;
this.opts.compressPath = function (p) {
return path.relative(compressTarget, p);
}
}
/*
* Instance variables
*/
this.registry = {}; // Keep source maps and code by file
/**
* Browserify runs transforms with a different context
* but we always want to refer to ourselves
*/
this.transformer = _.bind(this.transformer, this);
// Apply glob string include/exclude filters
this.transformer = filter(this.transformer, {
include : opts.include,
exclude : opts.exclude,
base : opts.base
});
return this;
};
/*
* Registers maps and code by file
*/
Minifier.prototype.registerMap = function (file, code, map) {
this.registry[file] = {code:code, map:map};
};
/*
* Gets map by file
*/
Minifier.prototype.mapForFile = function (file) {
if(!this.fileExists(file)) {
throw new Error('ENOFILE');
}
return this.registry[file].map;
};
/*
* Gets code by file
*/
Minifier.prototype.codeForFile = function (file) {
if(!this.fileExists(file)) {
throw new Error('ENOFILE');
}
return this.registry[file].code;
};
Minifier.prototype.fileExists = function (file) {
return (this.registry[file] != null);
}
/*
* Compresses code before Browserify touches it
* Does nothing if minify is false
*/
Minifier.prototype.transformer = function (file) {
var self = this
, basedir = this.opts.basedir || process.cwd()
, buffs = []
, write
, end
, throughStream;
//Normalize the path separators to match what Browserify stores in the original Source Map
file = this.normalizePath(file);
write = function (data) {
if(self.opts.minify) {
buffs.push(data);
}
else {
this.queue(data);
}
}
end = function () {
if(self.opts.minify === false) {
this.queue(null)
return
}
var thisStream = this
, unminCode = buffs.join('')
, originalCode = false
, existingMap = convertSM.fromSource(unminCode)
, finish;
existingMap = existingMap ? existingMap.toObject() : false;
if(existingMap && existingMap.sourcesContent && existingMap.sourcesContent.length) {
originalCode = convertSM.removeComments(existingMap.sourcesContent[0]);
existingMap = JSON.stringify(existingMap);
}
// Only accept existing maps with sourcesContent
else {
existingMap = false;
}
finish = function (tempExistingMapFile, cleanupCallback) {
// Don't minify JSON!
if(file.match(/\.json$/)) {
try {
thisStream.queue(JSON.stringify(JSON.parse(unminCode)))
}
catch(e) {
console.error('failed to parse JSON in ' + file)
thisStream.queue(unminCode)
}
}
else if (file.match(/\.css$/)) {
thisStream.queue(unminCode)
}
else {
var opts = {
fromString: true
, outSourceMap: (self.opts.map ? self.opts.map : undefined)
, inSourceMap: (self.opts.map ? tempExistingMapFile : undefined)
};
if (typeof self.opts.uglify === 'object') {
self.sanitizeObject(self.opts.uglify);
_.assign(opts, self.opts.uglify);
}
try {
var min = uglify.minify(unminCode, opts);
thisStream.queue(convertSM.removeMapFileComments(min.code).trim());
if(self.opts.map) {
self.registerMap(file, originalCode || unminCode, new SMConsumer(min.map));
}
}
catch(e) {
console.error('uglify-js failed on '+file+' : ' + e.toString());
thisStream.queue(unminCode);
}
finally {
if (typeof cleanupCallback === 'function') {
cleanupCallback();
}
}
}
// Otherwise we'll never finish
thisStream.queue(null);
}
if(existingMap) {
tmp.file(function (err, path, fd, cleanupCallback) {
if(err) {
cleanupCallback();
throw err;
}
fs.writeFile(path, existingMap, function (err) {
if(err) {
cleanupCallback();
throw err;
}
finish(path, cleanupCallback);
});
});
}
else {
finish();
}
}
throughStream = through(write, end);
throughStream.call = function () {
throw new Error('Transformer is a transform. Correct usage: `bundle.transform(minifier.transformer)`.')
}
return throughStream;
};
/*
* Consumes the output stream from Browserify
*/
Minifier.prototype.consumer = function (cb) {
var self = this;
return concat(function(data) {
if(!self.opts.minify) {
// Keep browserify's sourcemap
return cb(null, data.toString(), null);
}
else if (!self.opts.map) {
// Remove browserify's inline sourcemap
return cb(null, convertSM.removeComments(data.toString()), null);
}
else {
var bundle = self.decoupleBundle(data);
if(bundle === false) {
if(self.opts.minify)
throw new Error('Browserify must be in debug mode for minifyify to consume sourcemaps')
return cb(null, convertSM.removeComments(data.toString()));
}
// Re-maps the browserify sourcemap
// to the original source using the
// uglify sourcemap
bundle.map = self.transformMap(bundle.map);
bundle.code = bundle.code + '\n//# sourceMappingURL=' + self.opts.map
cb(null, bundle.code, bundle.map);
}
});
};
Minifier.prototype.sanitizeObject = function (opts) {
if(opts._ !== undefined) delete opts._;
for (var key in opts) {
if(typeof opts[key] === 'object' && opts[key] != null) {
this.sanitizeObject(opts[key]);
}
}
return opts;
};
/*
* Given a SourceMapConsumer from a bundle's map,
* transform it so that it maps to the unminified
* source
*/
Minifier.prototype.transformMap = function (bundleMap) {
var self = this
, generator = new SMGenerator({
file: self.opts.source
})
// Map File -> The lowest numbered line in the bundle (offset)
, bundleToMinMap = {}
/*
* Helper function that maps minified source to a line in the browserify bundle
*/
, mapSourceToLine = function (source, line) {
var target = bundleToMinMap[source];
if(!target || target > line) {
bundleToMinMap[source] = line;
}
}
, hasNoMappings = function (file) {
return bundleToMinMap[file] == null;
}
/*
* Helper function that gets the line
*/
, lineForSource = function (source) {
if(hasNoMappings(source)) {
throw new Error('ENOFILE: ' + source);
}
var target = bundleToMinMap[source];
return target;
}
, missingSources = {};
// Figure out where my minified files went in the bundle
bundleMap.eachMapping(function (mapping) {
var source = self.normalizePath(mapping.source);
if(self.fileExists(source)) {
mapSourceToLine(source, mapping.generatedLine);
}
// Not a known source, pass thru the mapping
else {
generator.addMapping({
generated: {
line: mapping.generatedLine
, column: mapping.generatedColumn
}
, original: {
line: mapping.originalLine
, column: mapping.originalColumn
}
, source: self.opts.compressPath(mapping.source)
, name: mapping.name
});
missingSources[mapping.source] = true;
}
});
if(process.env.debug) {
console.log(' [DEBUG] Here is where Browserify put your modules:');
_.each(bundleToMinMap, function (line, file) {
console.log(' [DEBUG] line ' + line + ' "' + self.opts.compressPath(file) + '"');
});
}
// Add sourceContent for missing sources
if (self.opts.sourcesContent) {
_.each(missingSources, function (v, source) {
generator.setSourceContent(self.opts.compressPath(source), bundleMap.sourceContentFor(source));
});
}
// Map from the hi-res sourcemaps to the browserify bundle
if(process.env.debug) {
console.log(' [DEBUG] Here is how I\'m mapping your code:');
}
self.eachSource(function (file, code) {
// Ignore files with no mappings
if(!self.fileExists(file) || hasNoMappings(file)) {
if(process.env.debug) {
throw new Error('File with no mappings: ' + file)
}
return;
}
var offset = lineForSource(file) - 1
, fileMap = self.mapForFile(file)
, transformedFileName = self.opts.compressPath(file);
if(process.env.debug) {
console.log(' [DEBUG] Now mapping "' + transformedFileName + '"');
}
fileMap.eachMapping(function (mapping) {
var transformedMapping = self.transformMapping(transformedFileName, mapping, offset);
if(process.env.debug) {
console.log(' [DEBUG] Generated [' + transformedMapping.generated.line +
':' + transformedMapping.generated.column + '] > [' +
mapping.originalLine + ':' + mapping.originalColumn + '] Original');
}
generator.addMapping( transformedMapping );
});
if (self.opts.sourcesContent) {
generator.setSourceContent(transformedFileName, code);
}
});
return generator.toString();
};
/*
* Given a mapping (from SMConsumer.eachMapping)
* return a new mapping (for SMGenerator.addMapping)
* resolved to the original source
*/
Minifier.prototype.transformMapping = function (file, mapping, offset) {
return {
generated: {
line: mapping.generatedLine + offset
, column: mapping.generatedColumn
}
, original: {
line: mapping.originalLine
, column: mapping.originalColumn
}
, source: file
, name: mapping.name
}
};
/*
* Iterates over each code file, executes a function
*/
Minifier.prototype.eachSource = function (cb) {
var self = this;
_.each(Object.keys(this.registry).sort(), function(file) {
cb(file, self.codeForFile(file), self.mapForFile(file));
});
};
/*
* Given source with embedded sourcemap, seperate the two
* Returns the code and SourcemapConsumer object seperately
*/
Minifier.prototype.decoupleBundle = function (src) {
if(typeof src != 'string')
src = src.toString();
var map = convertSM.fromSource(src);
// The source didn't have a sourcemap in it
if(!map) {
return false;
}
return {
code: convertSM.removeComments(src)
, map: new SMConsumer( map.toObject() )
};
};
Minifier.prototype.normalizePath = function (file) {
// Is file a relative path?
if (!/^\w:|^\//.test(file)) {
return file.replace(/\\/g, '/');
}
// Resolve absolute paths relative to basedir
// Force '/' as path separator
var basedir = this.opts.basedir || process.cwd();
return path.relative(basedir, file).replace(/\\/g, '/');
}
module.exports = Minifier;