/* 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('Enable multipass') .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 }