[6a3a178] | 1 | 'use strict';
|
---|
| 2 |
|
---|
| 3 | const FS = require('fs');
|
---|
| 4 | const PATH = require('path');
|
---|
| 5 | const { green, red } = require('nanocolors');
|
---|
| 6 | const { loadConfig, optimize } = require('../svgo-node.js');
|
---|
| 7 | const pluginsMap = require('../../plugins/plugins.js');
|
---|
| 8 | const PKG = require('../../package.json');
|
---|
| 9 | const { encodeSVGDatauri, decodeSVGDatauri } = require('./tools.js');
|
---|
| 10 |
|
---|
| 11 | const regSVGFile = /\.svg$/i;
|
---|
| 12 |
|
---|
| 13 | /**
|
---|
| 14 | * Synchronously check if path is a directory. Tolerant to errors like ENOENT.
|
---|
| 15 | * @param {string} path
|
---|
| 16 | */
|
---|
| 17 | function checkIsDir(path) {
|
---|
| 18 | try {
|
---|
| 19 | return FS.lstatSync(path).isDirectory();
|
---|
| 20 | } catch (e) {
|
---|
| 21 | return false;
|
---|
| 22 | }
|
---|
| 23 | }
|
---|
| 24 |
|
---|
| 25 | module.exports = function makeProgram(program) {
|
---|
| 26 | program
|
---|
| 27 | .name(PKG.name)
|
---|
| 28 | .description(PKG.description, {
|
---|
| 29 | INPUT: 'Alias to --input',
|
---|
| 30 | })
|
---|
| 31 | .version(PKG.version, '-v, --version')
|
---|
| 32 | .arguments('[INPUT...]')
|
---|
| 33 | .option('-i, --input <INPUT...>', 'Input files, "-" for STDIN')
|
---|
| 34 | .option('-s, --string <STRING>', 'Input SVG data string')
|
---|
| 35 | .option(
|
---|
| 36 | '-f, --folder <FOLDER>',
|
---|
| 37 | 'Input folder, optimize and rewrite all *.svg files'
|
---|
| 38 | )
|
---|
| 39 | .option(
|
---|
| 40 | '-o, --output <OUTPUT...>',
|
---|
| 41 | 'Output file or folder (by default the same as the input), "-" for STDOUT'
|
---|
| 42 | )
|
---|
| 43 | .option(
|
---|
| 44 | '-p, --precision <INTEGER>',
|
---|
| 45 | 'Set number of digits in the fractional part, overrides plugins params'
|
---|
| 46 | )
|
---|
| 47 | .option('--config <CONFIG>', 'Custom config file, only .js is supported')
|
---|
| 48 | .option(
|
---|
| 49 | '--datauri <FORMAT>',
|
---|
| 50 | 'Output as Data URI string (base64), URI encoded (enc) or unencoded (unenc)'
|
---|
| 51 | )
|
---|
| 52 | .option(
|
---|
| 53 | '--multipass',
|
---|
| 54 | 'Pass over SVGs multiple times to ensure all optimizations are applied'
|
---|
| 55 | )
|
---|
| 56 | .option('--pretty', 'Make SVG pretty printed')
|
---|
| 57 | .option('--indent <INTEGER>', 'Indent number when pretty printing SVGs')
|
---|
| 58 | .option(
|
---|
| 59 | '--eol <EOL>',
|
---|
| 60 | 'Line break to use when outputting SVG: lf, crlf. If unspecified, uses platform default.'
|
---|
| 61 | )
|
---|
| 62 | .option('--final-newline', 'Ensure SVG ends with a line break')
|
---|
| 63 | .option(
|
---|
| 64 | '-r, --recursive',
|
---|
| 65 | "Use with '--folder'. Optimizes *.svg files in folders recursively."
|
---|
| 66 | )
|
---|
| 67 | .option(
|
---|
| 68 | '--exclude <PATTERN...>',
|
---|
| 69 | "Use with '--folder'. Exclude files matching regular expression pattern."
|
---|
| 70 | )
|
---|
| 71 | .option(
|
---|
| 72 | '-q, --quiet',
|
---|
| 73 | 'Only output error messages, not regular status messages'
|
---|
| 74 | )
|
---|
| 75 | .option('--show-plugins', 'Show available plugins and exit')
|
---|
| 76 | .action(action);
|
---|
| 77 | };
|
---|
| 78 |
|
---|
| 79 | async function action(args, opts, command) {
|
---|
| 80 | var input = opts.input || args;
|
---|
| 81 | var output = opts.output;
|
---|
| 82 | var config = {};
|
---|
| 83 |
|
---|
| 84 | if (opts.precision != null) {
|
---|
| 85 | const number = Number.parseInt(opts.precision, 10);
|
---|
| 86 | if (Number.isNaN(number)) {
|
---|
| 87 | console.error(
|
---|
| 88 | "error: option '-p, --precision' argument must be an integer number"
|
---|
| 89 | );
|
---|
| 90 | process.exit(1);
|
---|
| 91 | } else {
|
---|
| 92 | opts.precision = number;
|
---|
| 93 | }
|
---|
| 94 | }
|
---|
| 95 |
|
---|
| 96 | if (opts.datauri != null) {
|
---|
| 97 | if (
|
---|
| 98 | opts.datauri !== 'base64' &&
|
---|
| 99 | opts.datauri !== 'enc' &&
|
---|
| 100 | opts.datauri !== 'unenc'
|
---|
| 101 | ) {
|
---|
| 102 | console.error(
|
---|
| 103 | "error: option '--datauri' must have one of the following values: 'base64', 'enc' or 'unenc'"
|
---|
| 104 | );
|
---|
| 105 | process.exit(1);
|
---|
| 106 | }
|
---|
| 107 | }
|
---|
| 108 |
|
---|
| 109 | if (opts.indent != null) {
|
---|
| 110 | const number = Number.parseInt(opts.indent, 10);
|
---|
| 111 | if (Number.isNaN(number)) {
|
---|
| 112 | console.error(
|
---|
| 113 | "error: option '--indent' argument must be an integer number"
|
---|
| 114 | );
|
---|
| 115 | process.exit(1);
|
---|
| 116 | } else {
|
---|
| 117 | opts.indent = number;
|
---|
| 118 | }
|
---|
| 119 | }
|
---|
| 120 |
|
---|
| 121 | if (opts.eol != null && opts.eol !== 'lf' && opts.eol !== 'crlf') {
|
---|
| 122 | console.error(
|
---|
| 123 | "error: option '--eol' must have one of the following values: 'lf' or 'crlf'"
|
---|
| 124 | );
|
---|
| 125 | process.exit(1);
|
---|
| 126 | }
|
---|
| 127 |
|
---|
| 128 | // --show-plugins
|
---|
| 129 | if (opts.showPlugins) {
|
---|
| 130 | showAvailablePlugins();
|
---|
| 131 | return;
|
---|
| 132 | }
|
---|
| 133 |
|
---|
| 134 | // w/o anything
|
---|
| 135 | if (
|
---|
| 136 | (input.length === 0 || input[0] === '-') &&
|
---|
| 137 | !opts.string &&
|
---|
| 138 | !opts.stdin &&
|
---|
| 139 | !opts.folder &&
|
---|
| 140 | process.stdin.isTTY === true
|
---|
| 141 | ) {
|
---|
| 142 | return command.help();
|
---|
| 143 | }
|
---|
| 144 |
|
---|
| 145 | if (
|
---|
| 146 | typeof process == 'object' &&
|
---|
| 147 | process.versions &&
|
---|
| 148 | process.versions.node &&
|
---|
| 149 | PKG &&
|
---|
| 150 | PKG.engines.node
|
---|
| 151 | ) {
|
---|
| 152 | var nodeVersion = String(PKG.engines.node).match(/\d*(\.\d+)*/)[0];
|
---|
| 153 | if (parseFloat(process.versions.node) < parseFloat(nodeVersion)) {
|
---|
| 154 | throw Error(
|
---|
| 155 | `${PKG.name} requires Node.js version ${nodeVersion} or higher.`
|
---|
| 156 | );
|
---|
| 157 | }
|
---|
| 158 | }
|
---|
| 159 |
|
---|
| 160 | // --config
|
---|
| 161 | const loadedConfig = await loadConfig(opts.config);
|
---|
| 162 | if (loadedConfig != null) {
|
---|
| 163 | config = loadedConfig;
|
---|
| 164 | }
|
---|
| 165 |
|
---|
| 166 | // --quiet
|
---|
| 167 | if (opts.quiet) {
|
---|
| 168 | config.quiet = opts.quiet;
|
---|
| 169 | }
|
---|
| 170 |
|
---|
| 171 | // --recursive
|
---|
| 172 | if (opts.recursive) {
|
---|
| 173 | config.recursive = opts.recursive;
|
---|
| 174 | }
|
---|
| 175 |
|
---|
| 176 | // --exclude
|
---|
| 177 | config.exclude = opts.exclude
|
---|
| 178 | ? opts.exclude.map((pattern) => RegExp(pattern))
|
---|
| 179 | : [];
|
---|
| 180 |
|
---|
| 181 | // --precision
|
---|
| 182 | if (opts.precision != null) {
|
---|
| 183 | var precision = Math.min(Math.max(0, opts.precision), 20);
|
---|
| 184 | config.floatPrecision = precision;
|
---|
| 185 | }
|
---|
| 186 |
|
---|
| 187 | // --multipass
|
---|
| 188 | if (opts.multipass) {
|
---|
| 189 | config.multipass = true;
|
---|
| 190 | }
|
---|
| 191 |
|
---|
| 192 | // --pretty
|
---|
| 193 | if (opts.pretty) {
|
---|
| 194 | config.js2svg = config.js2svg || {};
|
---|
| 195 | config.js2svg.pretty = true;
|
---|
| 196 | if (opts.indent != null) {
|
---|
| 197 | config.js2svg.indent = opts.indent;
|
---|
| 198 | }
|
---|
| 199 | }
|
---|
| 200 |
|
---|
| 201 | // --eol
|
---|
| 202 | if (opts.eol) {
|
---|
| 203 | config.js2svg = config.js2svg || {};
|
---|
| 204 | config.js2svg.eol = opts.eol;
|
---|
| 205 | }
|
---|
| 206 |
|
---|
| 207 | // --final-newline
|
---|
| 208 | if (opts.finalNewline) {
|
---|
| 209 | config.js2svg = config.js2svg || {};
|
---|
| 210 | config.js2svg.finalNewline = true;
|
---|
| 211 | }
|
---|
| 212 |
|
---|
| 213 | // --output
|
---|
| 214 | if (output) {
|
---|
| 215 | if (input.length && input[0] != '-') {
|
---|
| 216 | if (output.length == 1 && checkIsDir(output[0])) {
|
---|
| 217 | var dir = output[0];
|
---|
| 218 | for (var i = 0; i < input.length; i++) {
|
---|
| 219 | output[i] = checkIsDir(input[i])
|
---|
| 220 | ? input[i]
|
---|
| 221 | : PATH.resolve(dir, PATH.basename(input[i]));
|
---|
| 222 | }
|
---|
| 223 | } else if (output.length < input.length) {
|
---|
| 224 | output = output.concat(input.slice(output.length));
|
---|
| 225 | }
|
---|
| 226 | }
|
---|
| 227 | } else if (input.length) {
|
---|
| 228 | output = input;
|
---|
| 229 | } else if (opts.string) {
|
---|
| 230 | output = '-';
|
---|
| 231 | }
|
---|
| 232 |
|
---|
| 233 | if (opts.datauri) {
|
---|
| 234 | config.datauri = opts.datauri;
|
---|
| 235 | }
|
---|
| 236 |
|
---|
| 237 | // --folder
|
---|
| 238 | if (opts.folder) {
|
---|
| 239 | var ouputFolder = (output && output[0]) || opts.folder;
|
---|
| 240 | await optimizeFolder(config, opts.folder, ouputFolder);
|
---|
| 241 | }
|
---|
| 242 |
|
---|
| 243 | // --input
|
---|
| 244 | if (input.length !== 0) {
|
---|
| 245 | // STDIN
|
---|
| 246 | if (input[0] === '-') {
|
---|
| 247 | return new Promise((resolve, reject) => {
|
---|
| 248 | var data = '',
|
---|
| 249 | file = output[0];
|
---|
| 250 |
|
---|
| 251 | process.stdin
|
---|
| 252 | .on('data', (chunk) => (data += chunk))
|
---|
| 253 | .once('end', () =>
|
---|
| 254 | processSVGData(config, { input: 'string' }, data, file).then(
|
---|
| 255 | resolve,
|
---|
| 256 | reject
|
---|
| 257 | )
|
---|
| 258 | );
|
---|
| 259 | });
|
---|
| 260 | // file
|
---|
| 261 | } else {
|
---|
| 262 | await Promise.all(
|
---|
| 263 | input.map((file, n) => optimizeFile(config, file, output[n]))
|
---|
| 264 | );
|
---|
| 265 | }
|
---|
| 266 |
|
---|
| 267 | // --string
|
---|
| 268 | } else if (opts.string) {
|
---|
| 269 | var data = decodeSVGDatauri(opts.string);
|
---|
| 270 |
|
---|
| 271 | return processSVGData(config, { input: 'string' }, data, output[0]);
|
---|
| 272 | }
|
---|
| 273 | }
|
---|
| 274 |
|
---|
| 275 | /**
|
---|
| 276 | * Optimize SVG files in a directory.
|
---|
| 277 | * @param {Object} config options
|
---|
| 278 | * @param {string} dir input directory
|
---|
| 279 | * @param {string} output output directory
|
---|
| 280 | * @return {Promise}
|
---|
| 281 | */
|
---|
| 282 | function optimizeFolder(config, dir, output) {
|
---|
| 283 | if (!config.quiet) {
|
---|
| 284 | console.log(`Processing directory '${dir}':\n`);
|
---|
| 285 | }
|
---|
| 286 | return FS.promises
|
---|
| 287 | .readdir(dir)
|
---|
| 288 | .then((files) => processDirectory(config, dir, files, output));
|
---|
| 289 | }
|
---|
| 290 |
|
---|
| 291 | /**
|
---|
| 292 | * Process given files, take only SVG.
|
---|
| 293 | * @param {Object} config options
|
---|
| 294 | * @param {string} dir input directory
|
---|
| 295 | * @param {Array} files list of file names in the directory
|
---|
| 296 | * @param {string} output output directory
|
---|
| 297 | * @return {Promise}
|
---|
| 298 | */
|
---|
| 299 | function processDirectory(config, dir, files, output) {
|
---|
| 300 | // take only *.svg files, recursively if necessary
|
---|
| 301 | var svgFilesDescriptions = getFilesDescriptions(config, dir, files, output);
|
---|
| 302 |
|
---|
| 303 | return svgFilesDescriptions.length
|
---|
| 304 | ? Promise.all(
|
---|
| 305 | svgFilesDescriptions.map((fileDescription) =>
|
---|
| 306 | optimizeFile(
|
---|
| 307 | config,
|
---|
| 308 | fileDescription.inputPath,
|
---|
| 309 | fileDescription.outputPath
|
---|
| 310 | )
|
---|
| 311 | )
|
---|
| 312 | )
|
---|
| 313 | : Promise.reject(
|
---|
| 314 | new Error(`No SVG files have been found in '${dir}' directory.`)
|
---|
| 315 | );
|
---|
| 316 | }
|
---|
| 317 |
|
---|
| 318 | /**
|
---|
| 319 | * Get svg files descriptions
|
---|
| 320 | * @param {Object} config options
|
---|
| 321 | * @param {string} dir input directory
|
---|
| 322 | * @param {Array} files list of file names in the directory
|
---|
| 323 | * @param {string} output output directory
|
---|
| 324 | * @return {Array}
|
---|
| 325 | */
|
---|
| 326 | function getFilesDescriptions(config, dir, files, output) {
|
---|
| 327 | const filesInThisFolder = files
|
---|
| 328 | .filter(
|
---|
| 329 | (name) =>
|
---|
| 330 | regSVGFile.test(name) &&
|
---|
| 331 | !config.exclude.some((regExclude) => regExclude.test(name))
|
---|
| 332 | )
|
---|
| 333 | .map((name) => ({
|
---|
| 334 | inputPath: PATH.resolve(dir, name),
|
---|
| 335 | outputPath: PATH.resolve(output, name),
|
---|
| 336 | }));
|
---|
| 337 |
|
---|
| 338 | return config.recursive
|
---|
| 339 | ? [].concat(
|
---|
| 340 | filesInThisFolder,
|
---|
| 341 | files
|
---|
| 342 | .filter((name) => checkIsDir(PATH.resolve(dir, name)))
|
---|
| 343 | .map((subFolderName) => {
|
---|
| 344 | const subFolderPath = PATH.resolve(dir, subFolderName);
|
---|
| 345 | const subFolderFiles = FS.readdirSync(subFolderPath);
|
---|
| 346 | const subFolderOutput = PATH.resolve(output, subFolderName);
|
---|
| 347 | return getFilesDescriptions(
|
---|
| 348 | config,
|
---|
| 349 | subFolderPath,
|
---|
| 350 | subFolderFiles,
|
---|
| 351 | subFolderOutput
|
---|
| 352 | );
|
---|
| 353 | })
|
---|
| 354 | .reduce((a, b) => [].concat(a, b), [])
|
---|
| 355 | )
|
---|
| 356 | : filesInThisFolder;
|
---|
| 357 | }
|
---|
| 358 |
|
---|
| 359 | /**
|
---|
| 360 | * Read SVG file and pass to processing.
|
---|
| 361 | * @param {Object} config options
|
---|
| 362 | * @param {string} file
|
---|
| 363 | * @param {string} output
|
---|
| 364 | * @return {Promise}
|
---|
| 365 | */
|
---|
| 366 | function optimizeFile(config, file, output) {
|
---|
| 367 | return FS.promises.readFile(file, 'utf8').then(
|
---|
| 368 | (data) =>
|
---|
| 369 | processSVGData(config, { input: 'file', path: file }, data, output, file),
|
---|
| 370 | (error) => checkOptimizeFileError(config, file, output, error)
|
---|
| 371 | );
|
---|
| 372 | }
|
---|
| 373 |
|
---|
| 374 | /**
|
---|
| 375 | * Optimize SVG data.
|
---|
| 376 | * @param {Object} config options
|
---|
| 377 | * @param {string} data SVG content to optimize
|
---|
| 378 | * @param {string} output where to write optimized file
|
---|
| 379 | * @param {string} [input] input file name (being used if output is a directory)
|
---|
| 380 | * @return {Promise}
|
---|
| 381 | */
|
---|
| 382 | function processSVGData(config, info, data, output, input) {
|
---|
| 383 | var startTime = Date.now(),
|
---|
| 384 | prevFileSize = Buffer.byteLength(data, 'utf8');
|
---|
| 385 |
|
---|
| 386 | const result = optimize(data, { ...config, ...info });
|
---|
| 387 | if (result.modernError) {
|
---|
| 388 | console.error(red(result.modernError.toString()));
|
---|
| 389 | process.exit(1);
|
---|
| 390 | }
|
---|
| 391 | if (config.datauri) {
|
---|
| 392 | result.data = encodeSVGDatauri(result.data, config.datauri);
|
---|
| 393 | }
|
---|
| 394 | var resultFileSize = Buffer.byteLength(result.data, 'utf8'),
|
---|
| 395 | processingTime = Date.now() - startTime;
|
---|
| 396 |
|
---|
| 397 | return writeOutput(input, output, result.data).then(
|
---|
| 398 | function () {
|
---|
| 399 | if (!config.quiet && output != '-') {
|
---|
| 400 | if (input) {
|
---|
| 401 | console.log(`\n${PATH.basename(input)}:`);
|
---|
| 402 | }
|
---|
| 403 | printTimeInfo(processingTime);
|
---|
| 404 | printProfitInfo(prevFileSize, resultFileSize);
|
---|
| 405 | }
|
---|
| 406 | },
|
---|
| 407 | (error) =>
|
---|
| 408 | Promise.reject(
|
---|
| 409 | new Error(
|
---|
| 410 | error.code === 'ENOTDIR'
|
---|
| 411 | ? `Error: output '${output}' is not a directory.`
|
---|
| 412 | : error
|
---|
| 413 | )
|
---|
| 414 | )
|
---|
| 415 | );
|
---|
| 416 | }
|
---|
| 417 |
|
---|
| 418 | /**
|
---|
| 419 | * Write result of an optimization.
|
---|
| 420 | * @param {string} input
|
---|
| 421 | * @param {string} output output file name. '-' for stdout
|
---|
| 422 | * @param {string} data data to write
|
---|
| 423 | * @return {Promise}
|
---|
| 424 | */
|
---|
| 425 | function writeOutput(input, output, data) {
|
---|
| 426 | if (output == '-') {
|
---|
| 427 | console.log(data);
|
---|
| 428 | return Promise.resolve();
|
---|
| 429 | }
|
---|
| 430 |
|
---|
| 431 | FS.mkdirSync(PATH.dirname(output), { recursive: true });
|
---|
| 432 |
|
---|
| 433 | return FS.promises
|
---|
| 434 | .writeFile(output, data, 'utf8')
|
---|
| 435 | .catch((error) => checkWriteFileError(input, output, data, error));
|
---|
| 436 | }
|
---|
| 437 |
|
---|
| 438 | /**
|
---|
| 439 | * Write a time taken by optimization.
|
---|
| 440 | * @param {number} time time in milliseconds.
|
---|
| 441 | */
|
---|
| 442 | function printTimeInfo(time) {
|
---|
| 443 | console.log(`Done in ${time} ms!`);
|
---|
| 444 | }
|
---|
| 445 |
|
---|
| 446 | /**
|
---|
| 447 | * Write optimizing information in human readable format.
|
---|
| 448 | * @param {number} inBytes size before optimization.
|
---|
| 449 | * @param {number} outBytes size after optimization.
|
---|
| 450 | */
|
---|
| 451 | function printProfitInfo(inBytes, outBytes) {
|
---|
| 452 | var profitPercents = 100 - (outBytes * 100) / inBytes;
|
---|
| 453 |
|
---|
| 454 | console.log(
|
---|
| 455 | Math.round((inBytes / 1024) * 1000) / 1000 +
|
---|
| 456 | ' KiB' +
|
---|
| 457 | (profitPercents < 0 ? ' + ' : ' - ') +
|
---|
| 458 | green(Math.abs(Math.round(profitPercents * 10) / 10) + '%') +
|
---|
| 459 | ' = ' +
|
---|
| 460 | Math.round((outBytes / 1024) * 1000) / 1000 +
|
---|
| 461 | ' KiB'
|
---|
| 462 | );
|
---|
| 463 | }
|
---|
| 464 |
|
---|
| 465 | /**
|
---|
| 466 | * Check for errors, if it's a dir optimize the dir.
|
---|
| 467 | * @param {Object} config
|
---|
| 468 | * @param {string} input
|
---|
| 469 | * @param {string} output
|
---|
| 470 | * @param {Error} error
|
---|
| 471 | * @return {Promise}
|
---|
| 472 | */
|
---|
| 473 | function checkOptimizeFileError(config, input, output, error) {
|
---|
| 474 | if (error.code == 'EISDIR') {
|
---|
| 475 | return optimizeFolder(config, input, output);
|
---|
| 476 | } else if (error.code == 'ENOENT') {
|
---|
| 477 | return Promise.reject(
|
---|
| 478 | new Error(`Error: no such file or directory '${error.path}'.`)
|
---|
| 479 | );
|
---|
| 480 | }
|
---|
| 481 | return Promise.reject(error);
|
---|
| 482 | }
|
---|
| 483 |
|
---|
| 484 | /**
|
---|
| 485 | * Check for saving file error. If the output is a dir, then write file there.
|
---|
| 486 | * @param {string} input
|
---|
| 487 | * @param {string} output
|
---|
| 488 | * @param {string} data
|
---|
| 489 | * @param {Error} error
|
---|
| 490 | * @return {Promise}
|
---|
| 491 | */
|
---|
| 492 | function checkWriteFileError(input, output, data, error) {
|
---|
| 493 | if (error.code == 'EISDIR' && input) {
|
---|
| 494 | return FS.promises.writeFile(
|
---|
| 495 | PATH.resolve(output, PATH.basename(input)),
|
---|
| 496 | data,
|
---|
| 497 | 'utf8'
|
---|
| 498 | );
|
---|
| 499 | } else {
|
---|
| 500 | return Promise.reject(error);
|
---|
| 501 | }
|
---|
| 502 | }
|
---|
| 503 |
|
---|
| 504 | /**
|
---|
| 505 | * Show list of available plugins with short description.
|
---|
| 506 | */
|
---|
| 507 | function showAvailablePlugins() {
|
---|
| 508 | const list = Object.entries(pluginsMap)
|
---|
| 509 | .sort(([a], [b]) => a.localeCompare(b))
|
---|
| 510 | .map(([name, plugin]) => ` [ ${green(name)} ] ${plugin.description}`)
|
---|
| 511 | .join('\n');
|
---|
| 512 | console.log('Currently available plugins:\n' + list);
|
---|
| 513 | }
|
---|
| 514 |
|
---|
| 515 | module.exports.checkIsDir = checkIsDir;
|
---|