3 * html-minifier CLI tool
5 * The MIT License (MIT)
7 * Copyright (c) 2014-2016 Zoltan Frombach
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:
16 * The above copyright notice and this permission notice shall be included in all
17 * copies or substantial portions of the Software.
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.
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');
38 program._name = info.name;
39 program.version(info.version);
41 function fatal(message) {
42 console.error(message);
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.
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.
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:
62 * --customAttrSrround "[\"//matchString//\"]"
64 function parseRegExp(value) {
66 return new RegExp(value.replace(/^\/(.*)\/$/, '$1'));
70 function parseJSON(value) {
73 return JSON.parse(value);
76 if (/^{/.test(value)) {
77 fatal('Could not parse JSON value \'' + value + '\'');
84 function parseJSONArray(value) {
86 value = parseJSON(value);
87 return Array.isArray(value) ? value : [value];
91 function parseJSONRegExpArray(value) {
92 value = parseJSONArray(value);
93 return value && value.map(parseRegExp);
96 function parseString(value) {
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'
139 var mainOptionKeys = Object.keys(mainOptions);
140 mainOptionKeys.forEach(function(key) {
141 var option = mainOptions[key];
142 key = '--' + paramCase(key);
143 if (Array.isArray(option)) {
144 var optional = option[1] === parseJSON;
145 program.option(key + (optional ? ' [value]' : ' <value>'), option[0], option[1]);
148 program.option(key, option);
151 program.option('-o --output <file>', 'Specify output file (if not specified STDOUT will be used for output)', function(outputPath) {
152 return fs.createWriteStream(outputPath).on('error', function(e) {
153 fatal('Cannot write ' + outputPath + '\n' + e.message);
157 function readFile(file) {
159 return fs.readFileSync(file, { encoding: 'utf8' });
162 fatal('Cannot read ' + file + '\n' + e.message);
167 program.option('-c --config-file <file>', 'Use config file', function(configPath) {
168 var data = readFile(configPath);
170 config = JSON.parse(data);
174 config = require(path.resolve(configPath));
177 fatal('Cannot read the specified config file.\nAs JSON: ' + je.message + '\nAs module: ' + ne.message);
180 mainOptionKeys.forEach(function(key) {
182 var option = mainOptions[key];
183 if (Array.isArray(option)) {
184 var value = config[key];
185 config[key] = option[1](typeof value === 'string' ? value : JSON.stringify(value));
190 program.option('--input-dir <dir>', 'Specify an input directory');
191 program.option('--output-dir <dir>', 'Specify an output directory');
192 program.option('--file-ext <text>', 'Specify an extension to be read, ex: html');
194 program.arguments('[files...]').action(function(files) {
195 content = files.map(readFile).join('');
196 }).parse(process.argv);
198 function createOptions() {
200 mainOptionKeys.forEach(function(key) {
201 var param = program[camelCase(key)];
202 if (typeof param !== 'undefined') {
203 options[key] = param;
205 else if (key in config) {
206 options[key] = config[key];
212 function mkdir(outputDir, callback) {
213 fs.mkdir(outputDir, function(err) {
217 return mkdir(path.join(outputDir, '..'), function() {
218 mkdir(outputDir, callback);
223 fatal('Cannot create directory ' + outputDir + '\n' + err.message);
230 function processFile(inputFile, outputFile) {
231 fs.readFile(inputFile, { encoding: 'utf8' }, function(err, data) {
233 fatal('Cannot read ' + inputFile + '\n' + err.message);
237 minified = minify(data, createOptions());
240 fatal('Minification error on ' + inputFile + '\n' + e.message);
242 fs.writeFile(outputFile, minified, { encoding: 'utf8' }, function(err) {
244 fatal('Cannot write ' + outputFile + '\n' + err.message);
250 function processDirectory(inputDir, outputDir, fileExt) {
251 fs.readdir(inputDir, function(err, files) {
253 fatal('Cannot read directory ' + inputDir + '\n' + err.message);
255 files.forEach(function(file) {
256 var inputFile = path.join(inputDir, file);
257 var outputFile = path.join(outputDir, file);
258 fs.stat(inputFile, function(err, stat) {
260 fatal('Cannot read ' + inputFile + '\n' + err.message);
262 else if (stat.isDirectory()) {
263 processDirectory(inputFile, outputFile, fileExt);
265 else if (!fileExt || path.extname(file) === '.' + fileExt) {
266 mkdir(outputDir, function() {
267 processFile(inputFile, outputFile);
275 function writeMinify() {
278 minified = minify(content, createOptions());
281 fatal('Minification error:\n' + e.message);
283 program.output.write(minified);
286 var inputDir = program.inputDir;
287 var outputDir = program.outputDir;
288 var fileExt = program.fileExt;
289 if (inputDir || outputDir) {
291 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 else if (!outputDir) {
294 fatal('You need to specify where to write the output files with the option --output-dir');
296 processDirectory(inputDir, outputDir, fileExt);
298 // Minifying one or more files specified on the CMD line
299 else if (typeof content === 'string') {
302 // Minifying input coming from STDIN
305 process.stdin.setEncoding('utf8');
306 process.stdin.on('data', function(data) {
308 }).on('end', writeMinify);