cli.js 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311
  1. #!/usr/bin/env node
  2. /**
  3. * html-minifier-terser CLI tool
  4. *
  5. * The MIT License (MIT)
  6. *
  7. * Copyright (c) 2014-2016 Zoltan Frombach
  8. *
  9. * Permission is hereby granted, free of charge, to any person obtaining a copy of
  10. * this software and associated documentation files (the "Software"), to deal in
  11. * the Software without restriction, including without limitation the rights to
  12. * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
  13. * the Software, and to permit persons to whom the Software is furnished to do so,
  14. * subject to the following conditions:
  15. *
  16. * The above copyright notice and this permission notice shall be included in all
  17. * copies or substantial portions of the Software.
  18. *
  19. * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
  20. * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
  21. * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
  22. * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
  23. * IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
  24. * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
  25. *
  26. */
  27. 'use strict';
  28. var camelCase = require('camel-case').camelCase;
  29. var fs = require('fs');
  30. var info = require('./package.json');
  31. var minify = require('./' + info.main).minify;
  32. var paramCase = require('param-case').paramCase;
  33. var path = require('path');
  34. var program = require('commander');
  35. program._name = info.name;
  36. program.version(info.version);
  37. function fatal(message) {
  38. console.error(message);
  39. process.exit(1);
  40. }
  41. /**
  42. * JSON does not support regexes, so, e.g., JSON.parse() will not create
  43. * a RegExp from the JSON value `[ "/matchString/" ]`, which is
  44. * technically just an array containing a string that begins and end with
  45. * a forward slash. To get a RegExp from a JSON string, it must be
  46. * constructed explicitly in JavaScript.
  47. *
  48. * The likelihood of actually wanting to match text that is enclosed in
  49. * forward slashes is probably quite rare, so if forward slashes were
  50. * included in an argument that requires a regex, the user most likely
  51. * thought they were part of the syntax for specifying a regex.
  52. *
  53. * In the unlikely case that forward slashes are indeed desired in the
  54. * search string, the user would need to enclose the expression in a
  55. * second set of slashes:
  56. *
  57. * --customAttrSrround "[\"//matchString//\"]"
  58. */
  59. function parseRegExp(value) {
  60. if (value) {
  61. return new RegExp(value.replace(/^\/(.*)\/$/, '$1'));
  62. }
  63. }
  64. function parseJSON(value) {
  65. if (value) {
  66. try {
  67. return JSON.parse(value);
  68. }
  69. catch (e) {
  70. if (/^{/.test(value)) {
  71. fatal('Could not parse JSON value \'' + value + '\'');
  72. }
  73. return value;
  74. }
  75. }
  76. }
  77. function parseJSONArray(value) {
  78. if (value) {
  79. value = parseJSON(value);
  80. return Array.isArray(value) ? value : [value];
  81. }
  82. }
  83. function parseJSONRegExpArray(value) {
  84. value = parseJSONArray(value);
  85. return value && value.map(parseRegExp);
  86. }
  87. function parseString(value) {
  88. return value;
  89. }
  90. var mainOptions = {
  91. caseSensitive: 'Treat attributes in case sensitive manner (useful for SVG; e.g. viewBox)',
  92. collapseBooleanAttributes: 'Omit attribute values from boolean attributes',
  93. collapseInlineTagWhitespace: 'Collapse white space around inline tag',
  94. collapseWhitespace: 'Collapse white space that contributes to text nodes in a document tree.',
  95. conservativeCollapse: 'Always collapse to 1 space (never remove it entirely)',
  96. continueOnParseError: 'Handle parse errors instead of aborting',
  97. customAttrAssign: ['Arrays of regex\'es that allow to support custom attribute assign expressions (e.g. \'<div flex?="{{mode != cover}}"></div>\')', parseJSONRegExpArray],
  98. customAttrCollapse: ['Regex that specifies custom attribute to strip newlines from (e.g. /ng-class/)', parseRegExp],
  99. customAttrSurround: ['Arrays of regex\'es that allow to support custom attribute surround expressions (e.g. <input {{#if value}}checked="checked"{{/if}}>)', parseJSONRegExpArray],
  100. customEventAttributes: ['Arrays of regex\'es that allow to support custom event attributes for minifyJS (e.g. ng-click)', parseJSONRegExpArray],
  101. decodeEntities: 'Use direct Unicode characters whenever possible',
  102. html5: 'Parse input according to HTML5 specifications',
  103. ignoreCustomComments: ['Array of regex\'es that allow to ignore certain comments, when matched', parseJSONRegExpArray],
  104. ignoreCustomFragments: ['Array of regex\'es that allow to ignore certain fragments, when matched (e.g. <?php ... ?>, {{ ... }})', parseJSONRegExpArray],
  105. includeAutoGeneratedTags: 'Insert tags generated by HTML parser',
  106. keepClosingSlash: 'Keep the trailing slash on singleton elements',
  107. maxLineLength: ['Max line length', parseInt],
  108. minifyCSS: ['Minify CSS in style elements and style attributes (uses clean-css)', parseJSON],
  109. minifyJS: ['Minify Javascript in script elements and on* attributes (uses terser)', parseJSON],
  110. minifyURLs: ['Minify URLs in various attributes (uses relateurl)', parseJSON],
  111. preserveLineBreaks: 'Always collapse to 1 line break (never remove it entirely) when whitespace between tags include a line break.',
  112. preventAttributesEscaping: 'Prevents the escaping of the values of attributes.',
  113. processConditionalComments: 'Process contents of conditional comments through minifier',
  114. processScripts: ['Array of strings corresponding to types of script elements to process through minifier (e.g. "text/ng-template", "text/x-handlebars-template", etc.)', parseJSONArray],
  115. quoteCharacter: ['Type of quote to use for attribute values (\' or ")', parseString],
  116. removeAttributeQuotes: 'Remove quotes around attributes when possible.',
  117. removeComments: 'Strip HTML comments',
  118. removeEmptyAttributes: 'Remove all attributes with whitespace-only values',
  119. removeEmptyElements: 'Remove all elements with empty contents',
  120. removeOptionalTags: 'Remove unrequired tags',
  121. removeRedundantAttributes: 'Remove attributes when value matches default.',
  122. removeScriptTypeAttributes: 'Remove type="text/javascript" from script tags. Other type attribute values are left intact.',
  123. removeStyleLinkTypeAttributes: 'Remove type="text/css" from style and link tags. Other type attribute values are left intact.',
  124. removeTagWhitespace: 'Remove space between attributes whenever possible',
  125. sortAttributes: 'Sort attributes by frequency',
  126. sortClassName: 'Sort style classes by frequency',
  127. trimCustomFragments: 'Trim white space around ignoreCustomFragments.',
  128. useShortDoctype: 'Replaces the doctype with the short (HTML5) doctype'
  129. };
  130. var mainOptionKeys = Object.keys(mainOptions);
  131. mainOptionKeys.forEach(function(key) {
  132. var option = mainOptions[key];
  133. if (Array.isArray(option)) {
  134. key = key === 'minifyURLs' ? '--minify-urls' : '--' + paramCase(key);
  135. key += option[1] === parseJSON ? ' [value]' : ' <value>';
  136. program.option(key, option[0], option[1]);
  137. }
  138. else if (~['html5', 'includeAutoGeneratedTags'].indexOf(key)) {
  139. program.option('--no-' + paramCase(key), option);
  140. }
  141. else {
  142. program.option('--' + paramCase(key), option);
  143. }
  144. });
  145. program.option('-o --output <file>', 'Specify output file (if not specified STDOUT will be used for output)');
  146. function readFile(file) {
  147. try {
  148. return fs.readFileSync(file, { encoding: 'utf8' });
  149. }
  150. catch (e) {
  151. fatal('Cannot read ' + file + '\n' + e.message);
  152. }
  153. }
  154. var config = {};
  155. program.option('-c --config-file <file>', 'Use config file', function(configPath) {
  156. var data = readFile(configPath);
  157. try {
  158. config = JSON.parse(data);
  159. }
  160. catch (je) {
  161. try {
  162. config = require(path.resolve(configPath));
  163. }
  164. catch (ne) {
  165. fatal('Cannot read the specified config file.\nAs JSON: ' + je.message + '\nAs module: ' + ne.message);
  166. }
  167. }
  168. mainOptionKeys.forEach(function(key) {
  169. if (key in config) {
  170. var option = mainOptions[key];
  171. if (Array.isArray(option)) {
  172. var value = config[key];
  173. config[key] = option[1](typeof value === 'string' ? value : JSON.stringify(value));
  174. }
  175. }
  176. });
  177. });
  178. program.option('--input-dir <dir>', 'Specify an input directory');
  179. program.option('--output-dir <dir>', 'Specify an output directory');
  180. program.option('--file-ext <text>', 'Specify an extension to be read, ex: html');
  181. var content;
  182. program.arguments('[files...]').action(function(files) {
  183. content = files.map(readFile).join('');
  184. }).parse(process.argv);
  185. function createOptions() {
  186. var options = {};
  187. mainOptionKeys.forEach(function(key) {
  188. var param = program[key === 'minifyURLs' ? 'minifyUrls' : camelCase(key)];
  189. if (typeof param !== 'undefined') {
  190. options[key] = param;
  191. }
  192. else if (key in config) {
  193. options[key] = config[key];
  194. }
  195. });
  196. return options;
  197. }
  198. function mkdir(outputDir, callback) {
  199. fs.mkdir(outputDir, function(err) {
  200. if (err) {
  201. switch (err.code) {
  202. case 'ENOENT':
  203. return mkdir(path.join(outputDir, '..'), function() {
  204. mkdir(outputDir, callback);
  205. });
  206. case 'EEXIST':
  207. break;
  208. default:
  209. fatal('Cannot create directory ' + outputDir + '\n' + err.message);
  210. }
  211. }
  212. callback();
  213. });
  214. }
  215. function processFile(inputFile, outputFile) {
  216. fs.readFile(inputFile, { encoding: 'utf8' }, function(err, data) {
  217. if (err) {
  218. fatal('Cannot read ' + inputFile + '\n' + err.message);
  219. }
  220. var minified;
  221. try {
  222. minified = minify(data, createOptions());
  223. }
  224. catch (e) {
  225. fatal('Minification error on ' + inputFile + '\n' + e.message);
  226. }
  227. fs.writeFile(outputFile, minified, { encoding: 'utf8' }, function(err) {
  228. if (err) {
  229. fatal('Cannot write ' + outputFile + '\n' + err.message);
  230. }
  231. });
  232. });
  233. }
  234. function processDirectory(inputDir, outputDir, fileExt) {
  235. fs.readdir(inputDir, function(err, files) {
  236. if (err) {
  237. fatal('Cannot read directory ' + inputDir + '\n' + err.message);
  238. }
  239. files.forEach(function(file) {
  240. var inputFile = path.join(inputDir, file);
  241. var outputFile = path.join(outputDir, file);
  242. fs.stat(inputFile, function(err, stat) {
  243. if (err) {
  244. fatal('Cannot read ' + inputFile + '\n' + err.message);
  245. }
  246. else if (stat.isDirectory()) {
  247. processDirectory(inputFile, outputFile, fileExt);
  248. }
  249. else if (!fileExt || path.extname(file) === '.' + fileExt) {
  250. mkdir(outputDir, function() {
  251. processFile(inputFile, outputFile);
  252. });
  253. }
  254. });
  255. });
  256. });
  257. }
  258. function writeMinify() {
  259. var minified;
  260. try {
  261. minified = minify(content, createOptions());
  262. }
  263. catch (e) {
  264. fatal('Minification error:\n' + e.message);
  265. }
  266. (program.output ? fs.createWriteStream(program.output).on('error', function(e) {
  267. fatal('Cannot write ' + program.output + '\n' + e.message);
  268. }) : process.stdout).write(minified);
  269. }
  270. var inputDir = program.inputDir;
  271. var outputDir = program.outputDir;
  272. var fileExt = program.fileExt;
  273. if (inputDir || outputDir) {
  274. if (!inputDir) {
  275. fatal('The option output-dir needs to be used with the option input-dir. If you are working with a single file, use -o.');
  276. }
  277. else if (!outputDir) {
  278. fatal('You need to specify where to write the output files with the option --output-dir');
  279. }
  280. processDirectory(inputDir, outputDir, fileExt);
  281. }
  282. // Minifying one or more files specified on the CMD line
  283. else if (content) {
  284. writeMinify();
  285. }
  286. // Minifying input coming from STDIN
  287. else {
  288. content = '';
  289. process.stdin.setEncoding('utf8');
  290. process.stdin.on('data', function(data) {
  291. content += data;
  292. }).on('end', writeMinify);
  293. }