Change from pnpm to npm, add ./link.sh shortcut for npm style package linking
[html-minifier.git] / cli.js
1 #!/usr/bin/env node
2 /**
3  * html-minifier 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
28 'use strict';
29
30 var camelCase = require('camel-case');
31 var fs = require('fs');
32 var info = require('./package.json');
33 var minify = require('./' + info.main).minify;
34 var paramCase = require('param-case');
35 var path = require('path');
36 var program = require('commander');
37
38 program._name = info.name;
39 program.version(info.version);
40
41 function fatal(message) {
42   console.error(message);
43   process.exit(1);
44 }
45
46 /**
47  * JSON does not support regexes, so, e.g., JSON.parse() will not create
48  * a RegExp from the JSON value `[ "/matchString/" ]`, which is
49  * technically just an array containing a string that begins and end with
50  * a forward slash. To get a RegExp from a JSON string, it must be
51  * constructed explicitly in JavaScript.
52  *
53  * The likelihood of actually wanting to match text that is enclosed in
54  * forward slashes is probably quite rare, so if forward slashes were
55  * included in an argument that requires a regex, the user most likely
56  * thought they were part of the syntax for specifying a regex.
57  *
58  * In the unlikely case that forward slashes are indeed desired in the
59  * search string, the user would need to enclose the expression in a
60  * second set of slashes:
61  *
62  *    --customAttrSrround "[\"//matchString//\"]"
63  */
64 function parseRegExp(value) {
65   if (value) {
66     return new RegExp(value.replace(/^\/(.*)\/$/, '$1'));
67   }
68 }
69
70 function parseJSON(value) {
71   if (value) {
72     try {
73       return JSON.parse(value);
74     }
75     catch (e) {
76       if (/^{/.test(value)) {
77         fatal('Could not parse JSON value \'' + value + '\'');
78       }
79       return value;
80     }
81   }
82 }
83
84 function parseJSONArray(value) {
85   if (value) {
86     value = parseJSON(value);
87     return Array.isArray(value) ? value : [value];
88   }
89 }
90
91 function parseJSONRegExpArray(value) {
92   value = parseJSONArray(value);
93   return value && value.map(parseRegExp);
94 }
95
96 function parseString(value) {
97   return value;
98 }
99
100 var mainOptions = {
101   caseSensitive: 'Treat attributes in case sensitive manner (useful for SVG; e.g. viewBox)',
102   collapseBooleanAttributes: 'Omit attribute values from boolean attributes',
103   collapseInlineTagWhitespace: 'Collapse white space around inline tag',
104   collapseWhitespace: 'Collapse white space that contributes to text nodes in a document tree.',
105   conservativeCollapse: 'Always collapse to 1 space (never remove it entirely)',
106   customAttrAssign: ['Arrays of regex\'es that allow to support custom attribute assign expressions (e.g. \'<div flex?="{{mode != cover}}"></div>\')', parseJSONRegExpArray],
107   customAttrCollapse: ['Regex that specifies custom attribute to strip newlines from (e.g. /ng-class/)', parseRegExp],
108   customAttrSurround: ['Arrays of regex\'es that allow to support custom attribute surround expressions (e.g. <input {{#if value}}checked="checked"{{/if}}>)', parseJSONRegExpArray],
109   customEventAttributes: ['Arrays of regex\'es that allow to support custom event attributes for minifyJS (e.g. ng-click)', parseJSONRegExpArray],
110   decodeEntities: 'Use direct Unicode characters whenever possible',
111   html5: 'Parse input according to HTML5 specifications',
112   ignoreCustomComments: ['Array of regex\'es that allow to ignore certain comments, when matched', parseJSONRegExpArray],
113   ignoreCustomFragments: ['Array of regex\'es that allow to ignore certain fragments, when matched (e.g. <?php ... ?>, {{ ... }})', parseJSONRegExpArray],
114   includeAutoGeneratedTags: 'Insert tags generated by HTML parser',
115   keepClosingSlash: 'Keep the trailing slash on singleton elements',
116   maxLineLength: ['Max line length', parseInt],
117   minifyCSS: ['Minify CSS in style elements and style attributes (uses clean-css)', parseJSON],
118   minifyJS: ['Minify Javascript in script elements and on* attributes (uses uglify-js)', parseJSON],
119   minifyURLs: ['Minify URLs in various attributes (uses relateurl)', parseJSON],
120   preserveLineBreaks: 'Always collapse to 1 line break (never remove it entirely) when whitespace between tags include a line break.',
121   preventAttributesEscaping: 'Prevents the escaping of the values of attributes.',
122   processConditionalComments: 'Process contents of conditional comments through minifier',
123   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],
124   quoteCharacter: ['Type of quote to use for attribute values (\' or ")', parseString],
125   removeAttributeQuotes: 'Remove quotes around attributes when possible.',
126   removeComments: 'Strip HTML comments',
127   removeEmptyAttributes: 'Remove all attributes with whitespace-only values',
128   removeEmptyElements: 'Remove all elements with empty contents',
129   removeOptionalTags: 'Remove unrequired tags',
130   removeRedundantAttributes: 'Remove attributes when value matches default.',
131   removeScriptTypeAttributes: 'Remove type="text/javascript" from script tags. Other type attribute values are left intact.',
132   removeStyleLinkTypeAttributes: 'Remove type="text/css" from style and link tags. Other type attribute values are left intact.',
133   removeTagWhitespace: 'Remove space between attributes whenever possible',
134   sortAttributes: 'Sort attributes by frequency',
135   sortClassName: 'Sort style classes by frequency',
136   trimCustomFragments: 'Trim white space around ignoreCustomFragments.',
137   useShortDoctype: 'Replaces the doctype with the short (HTML5) doctype'
138 };
139 var mainOptionKeys = Object.keys(mainOptions);
140 mainOptionKeys.forEach(function(key) {
141   var option = mainOptions[key];
142   if (Array.isArray(option)) {
143     key = key === 'minifyURLs' ? '--minify-urls' : '--' + paramCase(key);
144     key += option[1] === parseJSON ? ' [value]' : ' <value>';
145     program.option(key, option[0], option[1]);
146   }
147   else if (~['html5', 'includeAutoGeneratedTags'].indexOf(key)) {
148     program.option('--no-' + paramCase(key), option);
149   }
150   else {
151     program.option('--' + paramCase(key), option);
152   }
153 });
154 program.option('-o --output <file>', 'Specify output file (if not specified STDOUT will be used for output)');
155
156 function readFile(file) {
157   try {
158     return fs.readFileSync(file, { encoding: 'utf8' });
159   }
160   catch (e) {
161     fatal('Cannot read ' + file + '\n' + e.message);
162   }
163 }
164
165 var config = {};
166 program.option('-c --config-file <file>', 'Use config file', function(configPath) {
167   var data = readFile(configPath);
168   try {
169     config = JSON.parse(data);
170   }
171   catch (je) {
172     try {
173       config = require(path.resolve(configPath));
174     }
175     catch (ne) {
176       fatal('Cannot read the specified config file.\nAs JSON: ' + je.message + '\nAs module: ' + ne.message);
177     }
178   }
179   mainOptionKeys.forEach(function(key) {
180     if (key in config) {
181       var option = mainOptions[key];
182       if (Array.isArray(option)) {
183         var value = config[key];
184         config[key] = option[1](typeof value === 'string' ? value : JSON.stringify(value));
185       }
186     }
187   });
188 });
189 program.option('--input-dir <dir>', 'Specify an input directory');
190 program.option('--output-dir <dir>', 'Specify an output directory');
191 program.option('--file-ext <text>', 'Specify an extension to be read, ex: html');
192 var content;
193 program.arguments('[files...]').action(function(files) {
194   content = files.map(readFile).join('');
195 }).parse(process.argv);
196
197 function createOptions() {
198   var options = {};
199   mainOptionKeys.forEach(function(key) {
200     var param = program[key === 'minifyURLs' ? 'minifyUrls' : camelCase(key)];
201     if (typeof param !== 'undefined') {
202       options[key] = param;
203     }
204     else if (key in config) {
205       options[key] = config[key];
206     }
207   });
208   return options;
209 }
210
211 function mkdir(outputDir, callback) {
212   fs.mkdir(outputDir, function(err) {
213     if (err) {
214       switch (err.code) {
215         case 'ENOENT':
216           return mkdir(path.join(outputDir, '..'), function() {
217             mkdir(outputDir, callback);
218           });
219         case 'EEXIST':
220           break;
221         default:
222           fatal('Cannot create directory ' + outputDir + '\n' + err.message);
223       }
224     }
225     callback();
226   });
227 }
228
229 function processFile(inputFile, outputFile) {
230   fs.readFile(inputFile, { encoding: 'utf8' }, function(err, data) {
231     if (err) {
232       fatal('Cannot read ' + inputFile + '\n' + err.message);
233     }
234     var minified;
235     try {
236       minified = minify(data, createOptions());
237     }
238     catch (e) {
239       fatal('Minification error on ' + inputFile + '\n' + e.message);
240     }
241     fs.writeFile(outputFile, minified, { encoding: 'utf8' }, function(err) {
242       if (err) {
243         fatal('Cannot write ' + outputFile + '\n' + err.message);
244       }
245     });
246   });
247 }
248
249 function processDirectory(inputDir, outputDir, fileExt) {
250   fs.readdir(inputDir, function(err, files) {
251     if (err) {
252       fatal('Cannot read directory ' + inputDir + '\n' + err.message);
253     }
254     files.forEach(function(file) {
255       var inputFile = path.join(inputDir, file);
256       var outputFile = path.join(outputDir, file);
257       fs.stat(inputFile, function(err, stat) {
258         if (err) {
259           fatal('Cannot read ' + inputFile + '\n' + err.message);
260         }
261         else if (stat.isDirectory()) {
262           processDirectory(inputFile, outputFile, fileExt);
263         }
264         else if (!fileExt || path.extname(file) === '.' + fileExt) {
265           mkdir(outputDir, function() {
266             processFile(inputFile, outputFile);
267           });
268         }
269       });
270     });
271   });
272 }
273
274 function writeMinify() {
275   var minified;
276   try {
277     minified = minify(content, createOptions());
278   }
279   catch (e) {
280     fatal('Minification error:\n' + e.message);
281   }
282   (program.output ? fs.createWriteStream(program.output).on('error', function(e) {
283     fatal('Cannot write ' + program.output + '\n' + e.message);
284   }) : process.stdout).write(minified);
285 }
286
287 var inputDir = program.inputDir;
288 var outputDir = program.outputDir;
289 var fileExt = program.fileExt;
290 if (inputDir || outputDir) {
291   if (!inputDir) {
292     fatal('The option output-dir needs to be used with the option input-dir. If you are working with a single file, use -o.');
293   }
294   else if (!outputDir) {
295     fatal('You need to specify where to write the output files with the option --output-dir');
296   }
297   processDirectory(inputDir, outputDir, fileExt);
298 }
299 // Minifying one or more files specified on the CMD line
300 else if (content) {
301   writeMinify();
302 }
303 // Minifying input coming from STDIN
304 else {
305   content = '';
306   process.stdin.setEncoding('utf8');
307   process.stdin.on('data', function(data) {
308     content += data;
309   }).on('end', writeMinify);
310 }