blob: 94fbc8b4bdfdd58d27663b6aff462386e4c67541 [file] [log] [blame]
/* jshint quotmark: false */
'use strict';
var FS = require('fs'),
PATH = require('path'),
chalk = require('chalk'),
mkdirp = require('mkdirp'),
promisify = require('util.promisify'),
readdir = promisify(FS.readdir),
readFile = promisify(FS.readFile),
writeFile = promisify(FS.writeFile),
SVGO = require('../svgo.js'),
YAML = require('js-yaml'),
PKG = require('../../package.json'),
encodeSVGDatauri = require('./tools.js').encodeSVGDatauri,
decodeSVGDatauri = require('./tools.js').decodeSVGDatauri,
checkIsDir = require('./tools.js').checkIsDir,
regSVGFile = /\.svg$/,
noop = () => {},
svgo;
/**
* Command-Option-Argument.
*
* @see https://github.com/veged/coa
*/
module.exports = require('coa').Cmd()
.helpful()
.name(PKG.name)
.title(PKG.description)
.opt()
.name('version').title('Version')
.short('v').long('version')
.only()
.flag()
.act(function() {
// output the version to stdout instead of stderr if returned
process.stdout.write(PKG.version + '\n');
// coa will run `.toString` on the returned value and send it to stderr
return '';
})
.end()
.opt()
.name('input').title('Input file, "-" for STDIN')
.short('i').long('input')
.arr()
.val(function(val) {
return val || this.reject("Option '--input' must have a value.");
})
.end()
.opt()
.name('string').title('Input SVG data string')
.short('s').long('string')
.end()
.opt()
.name('folder').title('Input folder, optimize and rewrite all *.svg files')
.short('f').long('folder')
.val(function(val) {
return val || this.reject("Option '--folder' must have a value.");
})
.end()
.opt()
.name('output').title('Output file or folder (by default the same as the input), "-" for STDOUT')
.short('o').long('output')
.arr()
.val(function(val) {
return val || this.reject("Option '--output' must have a value.");
})
.end()
.opt()
.name('precision').title('Set number of digits in the fractional part, overrides plugins params')
.short('p').long('precision')
.val(function(val) {
return !isNaN(val) ? val : this.reject("Option '--precision' must be an integer number");
})
.end()
.opt()
.name('config').title('Config file or JSON string to extend or replace default')
.long('config')
.val(function(val) {
return val || this.reject("Option '--config' must have a value.");
})
.end()
.opt()
.name('disable').title('Disable plugin by name, "--disable={PLUGIN1,PLUGIN2}" for multiple plugins (*nix)')
.long('disable')
.arr()
.val(function(val) {
return val || this.reject("Option '--disable' must have a value.");
})
.end()
.opt()
.name('enable').title('Enable plugin by name, "--enable={PLUGIN3,PLUGIN4}" for multiple plugins (*nix)')
.long('enable')
.arr()
.val(function(val) {
return val || this.reject("Option '--enable' must have a value.");
})
.end()
.opt()
.name('datauri').title('Output as Data URI string (base64, URI encoded or unencoded)')
.long('datauri')
.val(function(val) {
return val || this.reject("Option '--datauri' must have one of the following values: 'base64', 'enc' or 'unenc'");
})
.end()
.opt()
.name('multipass').title('Pass over SVGs multiple times to ensure all optimizations are applied')
.long('multipass')
.flag()
.end()
.opt()
.name('pretty').title('Make SVG pretty printed')
.long('pretty')
.flag()
.end()
.opt()
.name('indent').title('Indent number when pretty printing SVGs')
.long('indent')
.val(function(val) {
return !isNaN(val) ? val : this.reject("Option '--indent' must be an integer number");
})
.end()
.opt()
.name('recursive').title('Use with \'-f\'. Optimizes *.svg files in folders recursively.')
.short('r').long('recursive')
.flag()
.end()
.opt()
.name('quiet').title('Only output error messages, not regular status messages')
.short('q').long('quiet')
.flag()
.end()
.opt()
.name('show-plugins').title('Show available plugins and exit')
.long('show-plugins')
.flag()
.end()
.arg()
.name('input').title('Alias to --input')
.arr()
.end()
.act(function(opts, args) {
var input = opts.input || args.input,
output = opts.output,
config = {};
// --show-plugins
if (opts['show-plugins']) {
showAvailablePlugins();
return;
}
// w/o anything
if (
(!input || input[0] === '-') &&
!opts.string &&
!opts.stdin &&
!opts.folder &&
process.stdin.isTTY === true
) return this.usage();
if (typeof process == 'object' && process.versions && process.versions.node && PKG && PKG.engines.node) {
var nodeVersion = String(PKG.engines.node).match(/\d*(\.\d+)*/)[0];
if (parseFloat(process.versions.node) < parseFloat(nodeVersion)) {
return printErrorAndExit(`Error: ${PKG.name} requires Node.js version ${nodeVersion} or higher.`);
}
}
// --config
if (opts.config) {
// string
if (opts.config.charAt(0) === '{') {
try {
config = JSON.parse(opts.config);
} catch (e) {
return printErrorAndExit(`Error: Couldn't parse config JSON.\n${String(e)}`);
}
// external file
} else {
var configPath = PATH.resolve(opts.config),
configData;
try {
// require() adds some weird output on YML files
configData = FS.readFileSync(configPath, 'utf8');
config = JSON.parse(configData);
} catch (err) {
if (err.code === 'ENOENT') {
return printErrorAndExit(`Error: couldn't find config file '${opts.config}'.`);
} else if (err.code === 'EISDIR') {
return printErrorAndExit(`Error: directory '${opts.config}' is not a config file.`);
}
config = YAML.safeLoad(configData);
if (!config || Array.isArray(config)) {
return printErrorAndExit(`Error: invalid config file '${opts.config}'.`);
}
}
}
}
// --quiet
if (opts.quiet) {
config.quiet = opts.quiet;
}
// --recursive
if (opts.recursive) {
config.recursive = opts.recursive;
}
// --precision
if (opts.precision) {
var precision = Math.min(Math.max(0, parseInt(opts.precision)), 20);
if (!isNaN(precision)) {
config.floatPrecision = precision;
}
}
// --disable
if (opts.disable) {
changePluginsState(opts.disable, false, config);
}
// --enable
if (opts.enable) {
changePluginsState(opts.enable, true, config);
}
// --multipass
if (opts.multipass) {
config.multipass = true;
}
// --pretty
if (opts.pretty) {
config.js2svg = config.js2svg || {};
config.js2svg.pretty = true;
var indent;
if (opts.indent && !isNaN(indent = parseInt(opts.indent))) {
config.js2svg.indent = indent;
}
}
svgo = new SVGO(config);
// --output
if (output) {
if (input && input[0] != '-') {
if (output.length == 1 && checkIsDir(output[0])) {
var dir = output[0];
for (var i = 0; i < input.length; i++) {
output[i] = checkIsDir(input[i]) ? input[i] : PATH.resolve(dir, PATH.basename(input[i]));
}
} else if (output.length < input.length) {
output = output.concat(input.slice(output.length));
}
}
} else if (input) {
output = input;
} else if (opts.string) {
output = '-';
}
if (opts.datauri) {
config.datauri = opts.datauri;
}
// --folder
if (opts.folder) {
var ouputFolder = output && output[0] || opts.folder;
return optimizeFolder(config, opts.folder, ouputFolder).then(noop, printErrorAndExit);
}
// --input
if (input) {
// STDIN
if (input[0] === '-') {
return new Promise((resolve, reject) => {
var data = '',
file = output[0];
process.stdin
.on('data', chunk => data += chunk)
.once('end', () => processSVGData(config, {input: 'string'}, data, file).then(resolve, reject));
});
// file
} else {
return Promise.all(input.map((file, n) => optimizeFile(config, file, output[n])))
.then(noop, printErrorAndExit);
}
// --string
} else if (opts.string) {
var data = decodeSVGDatauri(opts.string);
return processSVGData(config, {input: 'string'}, data, output[0]);
}
});
/**
* Change plugins state by names array.
*
* @param {Array} names plugins names
* @param {Boolean} state active state
* @param {Object} config original config
* @return {Object} changed config
*/
function changePluginsState(names, state, config) {
names.forEach(flattenPluginsCbk);
// extend config
if (config.plugins) {
for (var name of names) {
var matched = false,
key;
for (var plugin of config.plugins) {
// get plugin name
if (typeof plugin === 'object') {
key = Object.keys(plugin)[0];
} else {
key = plugin;
}
// if there is such a plugin name
if (key === name) {
// don't replace plugin's params with true
if (typeof plugin[key] !== 'object' || !state) {
plugin[key] = state;
}
// mark it as matched
matched = true;
}
}
// if not matched and current config is not full
if (!matched && !config.full) {
// push new plugin Object
config.plugins.push({ [name]: state });
matched = true;
}
}
// just push
} else {
config.plugins = names.map(name => ({ [name]: state }));
}
return config;
}
/**
* Flatten an array of plugins by invoking this callback on each element
* whose value may be a comma separated list of plugins.
*
* @param {String} name Plugin name
* @param {Number} index Plugin index
* @param {Array} names Plugins being traversed
*/
function flattenPluginsCbk(name, index, names)
{
var split = name.split(',');
if(split.length > 1) {
names[index] = split.shift();
names.push.apply(names, split);
}
}
/**
* Optimize SVG files in a directory.
* @param {Object} config options
* @param {string} dir input directory
* @param {string} output output directory
* @return {Promise}
*/
function optimizeFolder(config, dir, output) {
if (!config.quiet) {
console.log(`Processing directory '${dir}':\n`);
}
return readdir(dir).then(files => processDirectory(config, dir, files, output));
}
/**
* Process given files, take only SVG.
* @param {Object} config options
* @param {string} dir input directory
* @param {Array} files list of file names in the directory
* @param {string} output output directory
* @return {Promise}
*/
function processDirectory(config, dir, files, output) {
// take only *.svg files, recursively if necessary
var svgFilesDescriptions = getFilesDescriptions(config, dir, files, output);
return svgFilesDescriptions.length ?
Promise.all(svgFilesDescriptions.map(fileDescription => optimizeFile(config, fileDescription.inputPath, fileDescription.outputPath))) :
Promise.reject(new Error(`No SVG files have been found in '${dir}' directory.`));
}
/**
* Get svg files descriptions
* @param {Object} config options
* @param {string} dir input directory
* @param {Array} files list of file names in the directory
* @param {string} output output directory
* @return {Array}
*/
function getFilesDescriptions(config, dir, files, output) {
const filesInThisFolder = files
.filter(name => regSVGFile.test(name))
.map(name => ({
inputPath: PATH.resolve(dir, name),
outputPath: PATH.resolve(output, name),
}));
return config.recursive ?
[].concat(
filesInThisFolder,
files
.filter(name => checkIsDir(PATH.resolve(dir, name)))
.map(subFolderName => {
const subFolderPath = PATH.resolve(dir, subFolderName);
const subFolderFiles = FS.readdirSync(subFolderPath);
const subFolderOutput = PATH.resolve(output, subFolderName);
return getFilesDescriptions(config, subFolderPath, subFolderFiles, subFolderOutput);
})
.reduce((a, b) => [].concat(a, b), [])
) :
filesInThisFolder;
}
/**
* Read SVG file and pass to processing.
* @param {Object} config options
* @param {string} file
* @param {string} output
* @return {Promise}
*/
function optimizeFile(config, file, output) {
return readFile(file, 'utf8').then(
data => processSVGData(config, {input: 'file', path: file}, data, output, file),
error => checkOptimizeFileError(config, file, output, error)
);
}
/**
* Optimize SVG data.
* @param {Object} config options
* @param {string} data SVG content to optimize
* @param {string} output where to write optimized file
* @param {string} [input] input file name (being used if output is a directory)
* @return {Promise}
*/
function processSVGData(config, info, data, output, input) {
var startTime = Date.now(),
prevFileSize = Buffer.byteLength(data, 'utf8');
return svgo.optimize(data, info).then(function(result) {
if (config.datauri) {
result.data = encodeSVGDatauri(result.data, config.datauri);
}
var resultFileSize = Buffer.byteLength(result.data, 'utf8'),
processingTime = Date.now() - startTime;
return writeOutput(input, output, result.data).then(function() {
if (!config.quiet && output != '-') {
if (input) {
console.log(`\n${PATH.basename(input)}:`);
}
printTimeInfo(processingTime);
printProfitInfo(prevFileSize, resultFileSize);
}
},
error => Promise.reject(new Error(error.code === 'ENOTDIR' ? `Error: output '${output}' is not a directory.` : error)));
});
}
/**
* Write result of an optimization.
* @param {string} input
* @param {string} output output file name. '-' for stdout
* @param {string} data data to write
* @return {Promise}
*/
function writeOutput(input, output, data) {
if (output == '-') {
console.log(data);
return Promise.resolve();
}
mkdirp.sync(PATH.dirname(output));
return writeFile(output, data, 'utf8').catch(error => checkWriteFileError(input, output, data, error));
}
/**
* Write a time taken by optimization.
* @param {number} time time in milliseconds.
*/
function printTimeInfo(time) {
console.log(`Done in ${time} ms!`);
}
/**
* Write optimizing information in human readable format.
* @param {number} inBytes size before optimization.
* @param {number} outBytes size after optimization.
*/
function printProfitInfo(inBytes, outBytes) {
var profitPercents = 100 - outBytes * 100 / inBytes;
console.log(
(Math.round((inBytes / 1024) * 1000) / 1000) + ' KiB' +
(profitPercents < 0 ? ' + ' : ' - ') +
chalk.green(Math.abs((Math.round(profitPercents * 10) / 10)) + '%') + ' = ' +
(Math.round((outBytes / 1024) * 1000) / 1000) + ' KiB'
);
}
/**
* Check for errors, if it's a dir optimize the dir.
* @param {Object} config
* @param {string} input
* @param {string} output
* @param {Error} error
* @return {Promise}
*/
function checkOptimizeFileError(config, input, output, error) {
if (error.code == 'EISDIR') {
return optimizeFolder(config, input, output);
} else if (error.code == 'ENOENT') {
return Promise.reject(new Error(`Error: no such file or directory '${error.path}'.`));
}
return Promise.reject(error);
}
/**
* Check for saving file error. If the output is a dir, then write file there.
* @param {string} input
* @param {string} output
* @param {string} data
* @param {Error} error
* @return {Promise}
*/
function checkWriteFileError(input, output, data, error) {
if (error.code == 'EISDIR' && input) {
return writeFile(PATH.resolve(output, PATH.basename(input)), data, 'utf8');
} else {
return Promise.reject(error);
}
}
/**
* Show list of available plugins with short description.
*/
function showAvailablePlugins() {
console.log('Currently available plugins:');
// Flatten an array of plugins grouped per type, sort and write output
var list = [].concat.apply([], new SVGO().config.plugins)
.sort((a, b) => a.name.localeCompare(b.name))
.map(plugin => ` [ ${chalk.green(plugin.name)} ] ${plugin.description}`)
.join('\n');
console.log(list);
}
/**
* Write an error and exit.
* @param {Error} error
* @return {Promise} a promise for running tests
*/
function printErrorAndExit(error) {
console.error(chalk.red(error));
process.exit(1);
return Promise.reject(error); // for tests
}