'use strict';
var _ = require('lodash'),
spawn = require('child_process').spawn,
zlib = require('zlib');
var util = require('./util'),
preprocess = require('./preprocess'),
postprocess = require('./postprocess');
var fs = util.fs,
path = util.path;
/** Used to specify the default minifer modes. */
var DEFAULT_MODES = ['simple', 'advanced', 'hybrid'];
/** The minimum version of Java required for the Closure Compiler. */
var JAVA_MIN_VERSION = '1.7.0';
/** Native method shortcut. */
var push = Array.prototype.push;
/** Used to extract version numbers. */
var reDigits = /^[.\d]+/;
/** The Closure Compiler optimization modes. */
var optimizationModes = {
'simple': 'simple_optimizations',
'advanced': 'advanced_optimizations'
};
/*----------------------------------------------------------------------------*/
/**
* Minifies a given lodash `source` and invokes the `options.onComplete`
* callback when finished. The `onComplete` callback is invoked with one
* argument: (outputSource).
*
* @param {string|string[]} [source=''] The source to minify or array of commands.
* -o, --output - Write output to a given path/filename.
* -s, --silent - Skip status updates normally logged to the console.
* -t, --template - Applies template specific minifier options.
*
* @param {Object} [options={}] The options object.
* @param {string} [options.outputPath] Write output to a given path/filename.
* @param {boolean} [options.isSilent] Skip status updates normally logged to the console.
* @param {boolean} [options.isTemplate] Applies template specific minifier options.
* @param {Function} [options.onComplete] The function called once minification has finished.
*/
function minify(source, options) {
// Convert commands to an options object.
if (_.isArray(source)) {
options = source;
// Used to specify the source map URL.
var sourceMapURL;
// Used to report invalid command-line arguments.
var invalidArgs = _.reject(options, function(value, index, options) {
if (/^(?:-o|--output)$/.test(options[index - 1]) ||
/^modes=.*$/.test(value)) {
return true;
}
var result = _.includes([
'-m', '--source-map',
'-o', '--output',
'-s', '--silent',
'-t', '--template'
], value);
if (!result && /^(?:-m|--source-map)$/.test(options[index - 1])) {
sourceMapURL = value;
return true;
}
return result;
});
// Report invalid arguments.
if (!_.isEmpty(invalidArgs)) {
console.log('\nInvalid argument' + (_.size(invalidArgs) > 1 ? 's' : '') + ' passed:', invalidArgs.join(', '));
return;
}
var filePath = path.normalize(_.last(options)),
outputPath = path.join(path.dirname(filePath), path.basename(filePath, '.js') + '.min.js');
outputPath = _.reduce(options, function(result, value, index) {
if (/-o|--output/.test(value)) {
result = path.normalize(options[index + 1]);
var dirname = path.dirname(result);
fs.mkdirpSync(dirname);
result = path.join(fs.realpathSync(dirname), path.basename(result));
}
return result;
}, outputPath);
options = {
'filePath': filePath,
'isMapped': getOption(options, '-m') || getOption(options, '--source-map'),
'isSilent': getOption(options, '-s') || getOption(options, '--silent'),
'isTemplate': getOption(options, '-t') || getOption(options, '--template'),
'modes': getOption(options, 'modes', DEFAULT_MODES),
'outputPath': outputPath,
'sourceMapURL': sourceMapURL
};
source = fs.readFileSync(filePath, 'utf8');
}
else {
options = _.cloneDeep(options);
}
source = _.toString(source);
options.filePath = path.normalize(options.filePath);
options.modes = _.get(options, 'modes', DEFAULT_MODES);
options.outputPath = path.normalize(options.outputPath);
if (options.isMapped) {
_.pull(options.modes, 'advanced', 'hybrid');
}
if (options.isTemplate) {
_.pull(options.modes, 'advanced');
}
new Minify(source, options);
}
/**
* The Minify constructor used to keep state of each `minify` invocation.
*
* @private
* @constructor
* @param {string} source The source to minify.
* @param {Object} options The options object.
* @param {string} [options.outputPath=''] Write output to a given path/filename.
* @param {boolean} [options.isMapped] Specify creating a source map for the minified source.
* @param {boolean} [options.isSilent] Skip status updates normally logged to the console.
* @param {boolean} [options.isTemplate] Applies template specific minifier options.
* @param {Function} [options.onComplete] The function called once minification has finished.
*/
function Minify(source, options) {
// Juggle arguments.
if (_.isObject(source)) {
options = source;
source = options.source || '';
}
var modes = options.modes;
this.compiled = { 'simple': {}, 'advanced': {} };
this.hybrid = { 'simple': {}, 'advanced': {} };
this.uglified = {};
this.filePath = options.filePath;
this.isMapped = !!options.isMapped;
this.isSilent = !!options.isSilent;
this.isTemplate = !!options.isTemplate;
this.modes = modes;
this.outputPath = options.outputPath;
this.source = source;
this.sourceMapURL = options.sourceMapURL;
this.onComplete = options.onComplete || function(data) {
var outputPath = this.outputPath,
sourceMap = data.sourceMap;
fs.writeFileSync(outputPath, data.source, 'utf8');
if (sourceMap) {
fs.writeFileSync(getMapPath(outputPath), sourceMap, 'utf8');
}
};
// Begin the minification process.
if (this.isMapped) {
uglify.call(this, source, onUglify.bind(this));
} else if (_.includes(modes, 'simple')) {
closureCompiler.call(this, source, 'simple', onClosureSimpleCompile.bind(this));
} else if (_.includes(modes, 'advanced')) {
onClosureSimpleGzip.call(this);
} else {
onClosureAdvancedGzip.call(this);
}
}
/*----------------------------------------------------------------------------*/
/**
* Asynchronously checks if Java 1.7 is installed. The callback is invoked
* with one argument: (success).
*
* @private
* @type Function
* @param {Function} callback The function called once the status is resolved.
*/
var checkJava = (function() {
var result;
return function(callback) {
if (result != null) {
_.defer(callback, result);
return;
}
var java = spawn('java', ['-version']);
java.stderr.on('data', function(data) {
java.stderr.removeAllListeners('data');
var version = _.get(/(?:java|openjdk) version "(.+)"/.exec(data.toString()), 1, '');
result = compareVersion(version, JAVA_MIN_VERSION) > -1;
if (result) {
callback(result);
} else {
java.emit('error');
}
});
java.on('error', function() {
result = false;
callback(result);
});
};
}());
/**
* Compares two version strings to determine if the first is greater than,
* equal to, or less then the second.
*
* @private
* @param {string} [version=''] The version string to compare to `other`.
* @param {string} [other=''] The version string to compare to `version`.
* @returns {number} Returns `1` if greater then, `0` if equal to, or `-1` if
* less than the second version string.
*/
function compareVersion(version, other) {
version = splitVersion(version);
other = splitVersion(other);
var index = -1,
verLength = version.length,
othLength = other.length,
maxLength = Math.max(verLength, othLength),
diff = Math.abs(verLength - othLength);
push.apply(verLength == maxLength ? other : version, _.range(0, diff, 0));
while (++index < maxLength) {
var verValue = version[index],
othValue = other[index];
if (verValue > othValue) {
return 1;
}
if (verValue < othValue) {
return -1;
}
}
return 0;
}
/**
* Resolves the source map path from the given output path.
*
* @private
* @param {string} outputPath The output path.
* @returns {string} Returns the source map path.
*/
function getMapPath(outputPath) {
return path.join(path.dirname(outputPath), path.basename(outputPath, '.js') + '.map');
}
/**
* Gets the value of a given name from the `options` array. If no value is
* available the `defaultValue` is returned.
*
* @private
* @param {Array} options The options array to inspect.
* @param {string} name The name of the option.
* @param {*} defaultValue The default option value.
* @returns {*} Returns the option value.
*/
function getOption(options, name, defaultValue) {
var isArr = _.isArray(defaultValue);
return _.reduce(options, function(result, value) {
if (isArr) {
value = optionToArray(name, value);
return _.isEmpty(value) ? result : value;
}
value = optionToValue(name, value);
return value == null ? result : value;
}, defaultValue);
}
/**
* Compress a source with Gzip. Yields the gzip buffer and any exceptions
* encountered to a callback function.
*
* @private
* @param {string} source The source to gzip.
* @param {Function} callback The function called once the process has completed.
* @param {Buffer} result The gzipped source buffer.
*/
function gzip(source, callback) {
return _.size(zlib.gzip) > 2
? zlib.gzip(source, { 'level': zlib.Z_BEST_COMPRESSION } , callback)
: zlib.gzip(source, callback);
}
/**
* Converts a comma separated option value into an array.
*
* @private
* @param {string} name The name of the option to inspect.
* @param {string} string The options string.
* @returns {Array} Returns the new converted array.
*/
function optionToArray(name, string) {
return _.compact(_.invokeMap((optionToValue(name, string) || '').split(/, */), 'trim'));
}
/**
* Extracts the option value from an option string.
*
* @private
* @param {string} name The name of the option to inspect.
* @param {string} string The options string.
* @returns {string|undefined} Returns the option value, else `undefined`.
*/
function optionToValue(name, string) {
var result = (result = string.match(RegExp('^' + name + '(?:=([\\s\\S]+))?$'))) && (result[1] ? result[1].trim() : true);
if (result === 'false') {
return false;
}
return result || undefined;
}
/**
* Splits a version string by its decimal points into its numeric components.
*
* @private
* @param {string} [version=''] The version string to split.
* @returns {number[]} Returns the array of numeric components.
*/
function splitVersion(version) {
if (version == null) {
return [];
}
var result = String(version).match(reDigits);
return result ? _.map(result[0].split('.'), Number) : [];
}
/*----------------------------------------------------------------------------*/
/**
* Compress a source using the Closure Compiler. Yields the minified result
* and any exceptions encountered to a callback function.
*
* @private
* @param {string} source The JavaScript source to minify.
* @param {string} mode The optimization mode.
* @param {string} [label] The label to log.
* @param {Function} callback The function called once the process has completed.
*/
function closureCompiler(source, mode, label, callback) {
var compiler = require('closure-compiler'),
isSilent = this.isSilent,
isTemplate = this.isTemplate,
modes = this.modes,
outputPath = this.outputPath;
var options = {
'charset': isTemplate ? 'utf8': 'ascii',
'compilation_level': optimizationModes[mode],
'warning_level': 'quiet'
};
if (callback == null && typeof label == 'function') {
callback = label;
label = null;
}
if (label == null) {
label = 'the Closure Compiler (' + mode + ')';
}
checkJava(function(success) {
// Skip using the Closure Compiler if Java is not installed.
if (!success) {
if (!isSilent) {
console.warn('The Closure Compiler requires Java %s. Skipping...', JAVA_MIN_VERSION);
}
_.pull(modes, 'advanced', 'hybrid');
callback();
return;
}
source = preprocess(source, { 'isTemplate': isTemplate });
// Remove the copyright header to make other modifications easier.
var license = _.get(/^(?:\s*\/\/.*|\s*\/\*[^*]*\*+(?:[^\/][^*]*\*+)*\/)*\s*/.exec(source), 0, '');
source = source.replace(license, '');
var hasIIFE = /^;?\(function[^{]+{/.test(source),
isStrict = /^;?\(function[^{]+{\s*["']use strict["']/.test(source);
// To avoid stripping the IIFE, convert it to a function call.
if (hasIIFE) {
source = source
.replace(/\(function/, '__iife__$&')
.replace(/\.call\(this\)\)([\s;]*(?:\n\/\/.+)?)$/, ', this)$1');
}
if (!isSilent) {
console.log('Compressing %s using %s...', path.basename(outputPath, '.js'), label);
}
compiler.compile(source, options, function(error, output) {
if (error) {
callback(error);
return;
}
// Restore IIFE and move exposed vars inside the IIFE.
if (hasIIFE) {
output = output
.replace(/\b__iife__\b/, '')
.replace(/^((?:var (?:[$\w]+=(?:!0|!1|null)[,;])+)?)([\s\S]*?function[^{]+{)/, '$2$1')
.replace(/,\s*this\)([\s;]*(?:\n\/\/.+)?)$/, '.call(this))$1');
}
// Inject "use strict" directive.
if (isStrict) {
output = output.replace(/^[\s\S]*?function[^{]+{/, '$&"use strict";');
}
// Restore copyright header.
if (license) {
output = license + output;
}
callback(error, output);
});
});
}
/**
* Compress a source using UglifyJS. Yields the minified result and any
* exceptions encountered to a callback function.
*
* @private
* @param {string} source The JavaScript source to minify.
* @param {string} [label] The label to log.
* @param {Function} callback The function called once the process has completed.
*/
function uglify(source, label, callback) {
var uglifyJS = require('uglify-js'),
sourceMapURL = this.isMapped && (this.sourceMapURL || path.basename(getMapPath(this.outputPath)));
if (callback == null && typeof label == 'function') {
callback = label;
label = null;
}
if (label == null) {
label = 'UglifyJS';
}
if (!this.isSilent) {
console.log('Compressing %s using %s...', path.basename(this.outputPath, '.js'), label);
}
try {
source = preprocess(source, { 'isTemplate': this.isTemplate });
var result = uglifyJS.minify(source, {
'fromString': true,
'outSourceMap': sourceMapURL,
'compress': {
'collapse_vars': true,
'negate_iife': false,
'pure_getters': true,
'unsafe': true,
'warnings': false
},
'output': _.assign({
'ascii_only': !this.isTemplate,
'max_line_len': 500
}, sourceMapURL ? {} : {
'comments': /@license/
})
});
} catch (e) {
var error = e;
result = {};
}
result.map = !sourceMapURL ? null : JSON.stringify(_.assign(JSON.parse(result.map), {
'file': path.basename(this.outputPath),
'sources': [path.basename(this.filePath)]
}));
_.defer(callback, error, result.code, result.map);
}
/*----------------------------------------------------------------------------*/
/**
* The Closure Compiler callback for simple optimizations.
*
* @private
* @param {Object} [error] The error object.
* @param {string} result The minified source.
*/
function onClosureSimpleCompile(error, result) {
if (error) {
throw error;
}
if (result != null) {
result = postprocess(result);
this.compiled.simple.source = result;
gzip(result, onClosureSimpleGzip.bind(this));
}
else {
onClosureSimpleGzip.call(this);
}
}
/**
* The Closure Compiler `gzip` callback for simple optimizations.
*
* @private
* @param {Object} [error] The error object.
* @param {Buffer} result The gzipped source buffer.
*/
function onClosureSimpleGzip(error, result) {
if (error) {
throw error;
}
if (result != null) {
if (!this.isSilent) {
console.log('Done. Size: %d bytes.', _.size(result));
}
this.compiled.simple.gzip = result;
}
if (_.includes(this.modes, 'advanced')) {
closureCompiler.call(this, this.source, 'advanced', onClosureAdvancedCompile.bind(this));
} else {
onClosureAdvancedGzip.call(this);
}
}
/**
* The Closure Compiler callback for advanced optimizations.
*
* @private
* @param {Object} [error] The error object.
* @param {string} result The minified source.
*/
function onClosureAdvancedCompile(error, result) {
if (error) {
throw error;
}
if (result != null) {
result = postprocess(result);
this.compiled.advanced.source = result;
gzip(result, onClosureAdvancedGzip.bind(this));
}
else {
onClosureAdvancedGzip.call(this);
}
}
/**
* The Closure Compiler `gzip` callback for advanced optimizations.
*
* @private
* @param {Object} [error] The error object.
* @param {Buffer} result The gzipped source buffer.
*/
function onClosureAdvancedGzip(error, result) {
if (error) {
throw error;
}
if (result != null) {
if (!this.isSilent) {
console.log('Done. Size: %d bytes.', _.size(result));
}
this.compiled.advanced.gzip = result;
}
uglify.call(this, this.source, onUglify.bind(this));
}
/**
* The UglifyJS callback.
*
* @private
* @param {Object} [error] The error object.
* @param {string} result The minified source.
* @param {string} map The source map output.
*/
function onUglify(error, result, map) {
if (error) {
throw error;
}
result = postprocess(result, !!map);
_.assign(this.uglified, { 'source': result, 'sourceMap': map });
gzip(result, onUglifyGzip.bind(this));
}
/**
* The UglifyJS `gzip` callback.
*
* @private
* @param {Object} [error] The error object.
* @param {Buffer} result The gzipped source buffer.
*/
function onUglifyGzip(error, result) {
if (error) {
throw error;
}
if (result != null) {
if (!this.isSilent) {
console.log('Done. Size: %d bytes.', _.size(result));
}
this.uglified.gzip = result;
}
// Minify the already Closure Compiler simple optimized source using UglifyJS.
var modes = this.modes;
if (_.includes(modes, 'hybrid')) {
if (_.includes(modes, 'simple')) {
uglify.call(this, this.compiled.simple.source, 'hybrid (simple)', onSimpleHybrid.bind(this));
} else if (_.includes(modes, 'advanced')) {
onSimpleHybridGzip.call(this);
}
} else {
onComplete.call(this);
}
}
/**
* The hybrid callback for simple optimizations.
*
* @private
* @param {Object} [error] The error object.
* @param {string} result The minified source.
*/
function onSimpleHybrid(error, result) {
if (error) {
throw error;
}
result = postprocess(result);
this.hybrid.simple.source = result;
gzip(result, onSimpleHybridGzip.bind(this));
}
/**
* The hybrid `gzip` callback for simple optimizations.
*
* @private
* @param {Object} [error] The error object.
* @param {Buffer} result The gzipped source buffer.
*/
function onSimpleHybridGzip(error, result) {
if (error) {
throw error;
}
if (result != null) {
if (!this.isSilent) {
console.log('Done. Size: %d bytes.', _.size(result));
}
this.hybrid.simple.gzip = result;
}
// Minify the already Closure Compiler advance optimized source using UglifyJS.
if (_.includes(this.modes, 'advanced')) {
uglify.call(this, this.compiled.advanced.source, 'hybrid (advanced)', onAdvancedHybrid.bind(this));
} else {
onComplete.call(this);
}
}
/**
* The hybrid callback for advanced optimizations.
*
* @private
* @param {Object} [error] The error object.
* @param {string} result The minified source.
*/
function onAdvancedHybrid(error, result) {
if (error) {
throw error;
}
result = postprocess(result);
this.hybrid.advanced.source = result;
gzip(result, onAdvancedHybridGzip.bind(this));
}
/**
* The hybrid `gzip` callback for advanced optimizations.
*
* @private
* @param {Object} [error] The error object.
* @param {Buffer} result The gzipped source buffer.
*/
function onAdvancedHybridGzip(error, result) {
if (error) {
throw error;
}
if (result != null) {
if (!this.isSilent) {
console.log('Done. Size: %d bytes.', _.size(result));
}
this.hybrid.advanced.gzip = result;
}
// Finish by choosing the smallest compressed file.
onComplete.call(this);
}
/**
* The callback executed after the source is minified and gzipped.
*
* @private
*/
function onComplete() {
var objects = [
this.compiled.simple,
this.compiled.advanced,
this.uglified,
this.hybrid.simple,
this.hybrid.advanced
];
var gzips = _.compact(_.map(objects, 'gzip'));
// Select the smallest gzipped file and use its minified counterpart as the
// official minified release (ties go to the Closure Compiler).
var min = _.size(_.minBy(gzips, 'length'));
// Pass the minified source to the "onComplete" callback.
_.each(objects, _.bind(function(data) {
if (_.size(data.gzip) == min) {
data.outputPath = this.outputPath;
this.onComplete(data);
return false;
}
}, this));
}
module.exports = minify;
|