Tim van der Lippe | 652ccb7 | 2021-05-27 17:07:12 +0100 | [diff] [blame] | 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 | continueOnParseError: 'Handle parse errors instead of aborting', |
| 107 | customAttrAssign: ['Arrays of regex\'es that allow to support custom attribute assign expressions (e.g. \'<div flex?="{{mode != cover}}"></div>\')', parseJSONRegExpArray], |
| 108 | customAttrCollapse: ['Regex that specifies custom attribute to strip newlines from (e.g. /ng-class/)', parseRegExp], |
| 109 | customAttrSurround: ['Arrays of regex\'es that allow to support custom attribute surround expressions (e.g. <input {{#if value}}checked="checked"{{/if}}>)', parseJSONRegExpArray], |
| 110 | customEventAttributes: ['Arrays of regex\'es that allow to support custom event attributes for minifyJS (e.g. ng-click)', parseJSONRegExpArray], |
| 111 | decodeEntities: 'Use direct Unicode characters whenever possible', |
| 112 | html5: 'Parse input according to HTML5 specifications', |
| 113 | ignoreCustomComments: ['Array of regex\'es that allow to ignore certain comments, when matched', parseJSONRegExpArray], |
| 114 | ignoreCustomFragments: ['Array of regex\'es that allow to ignore certain fragments, when matched (e.g. <?php ... ?>, {{ ... }})', parseJSONRegExpArray], |
| 115 | includeAutoGeneratedTags: 'Insert tags generated by HTML parser', |
| 116 | keepClosingSlash: 'Keep the trailing slash on singleton elements', |
| 117 | maxLineLength: ['Max line length', parseInt], |
| 118 | minifyCSS: ['Minify CSS in style elements and style attributes (uses clean-css)', parseJSON], |
| 119 | minifyJS: ['Minify Javascript in script elements and on* attributes (uses uglify-js)', parseJSON], |
| 120 | minifyURLs: ['Minify URLs in various attributes (uses relateurl)', parseJSON], |
| 121 | preserveLineBreaks: 'Always collapse to 1 line break (never remove it entirely) when whitespace between tags include a line break.', |
| 122 | preventAttributesEscaping: 'Prevents the escaping of the values of attributes.', |
| 123 | processConditionalComments: 'Process contents of conditional comments through minifier', |
| 124 | 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], |
| 125 | quoteCharacter: ['Type of quote to use for attribute values (\' or ")', parseString], |
| 126 | removeAttributeQuotes: 'Remove quotes around attributes when possible.', |
| 127 | removeComments: 'Strip HTML comments', |
| 128 | removeEmptyAttributes: 'Remove all attributes with whitespace-only values', |
| 129 | removeEmptyElements: 'Remove all elements with empty contents', |
| 130 | removeOptionalTags: 'Remove unrequired tags', |
| 131 | removeRedundantAttributes: 'Remove attributes when value matches default.', |
| 132 | removeScriptTypeAttributes: 'Remove type="text/javascript" from script tags. Other type attribute values are left intact.', |
| 133 | removeStyleLinkTypeAttributes: 'Remove type="text/css" from style and link tags. Other type attribute values are left intact.', |
| 134 | removeTagWhitespace: 'Remove space between attributes whenever possible', |
| 135 | sortAttributes: 'Sort attributes by frequency', |
| 136 | sortClassName: 'Sort style classes by frequency', |
| 137 | trimCustomFragments: 'Trim white space around ignoreCustomFragments.', |
| 138 | useShortDoctype: 'Replaces the doctype with the short (HTML5) doctype' |
| 139 | }; |
| 140 | var mainOptionKeys = Object.keys(mainOptions); |
| 141 | mainOptionKeys.forEach(function(key) { |
| 142 | var option = mainOptions[key]; |
| 143 | if (Array.isArray(option)) { |
| 144 | key = key === 'minifyURLs' ? '--minify-urls' : '--' + paramCase(key); |
| 145 | key += option[1] === parseJSON ? ' [value]' : ' <value>'; |
| 146 | program.option(key, option[0], option[1]); |
| 147 | } |
| 148 | else if (~['html5', 'includeAutoGeneratedTags'].indexOf(key)) { |
| 149 | program.option('--no-' + paramCase(key), option); |
| 150 | } |
| 151 | else { |
| 152 | program.option('--' + paramCase(key), option); |
| 153 | } |
| 154 | }); |
| 155 | program.option('-o --output <file>', 'Specify output file (if not specified STDOUT will be used for output)'); |
| 156 | |
| 157 | function readFile(file) { |
| 158 | try { |
| 159 | return fs.readFileSync(file, { encoding: 'utf8' }); |
| 160 | } |
| 161 | catch (e) { |
| 162 | fatal('Cannot read ' + file + '\n' + e.message); |
| 163 | } |
| 164 | } |
| 165 | |
| 166 | var config = {}; |
| 167 | program.option('-c --config-file <file>', 'Use config file', function(configPath) { |
| 168 | var data = readFile(configPath); |
| 169 | try { |
| 170 | config = JSON.parse(data); |
| 171 | } |
| 172 | catch (je) { |
| 173 | try { |
| 174 | config = require(path.resolve(configPath)); |
| 175 | } |
| 176 | catch (ne) { |
| 177 | fatal('Cannot read the specified config file.\nAs JSON: ' + je.message + '\nAs module: ' + ne.message); |
| 178 | } |
| 179 | } |
| 180 | mainOptionKeys.forEach(function(key) { |
| 181 | if (key in config) { |
| 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)); |
| 186 | } |
| 187 | } |
| 188 | }); |
| 189 | }); |
| 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'); |
| 193 | var content; |
| 194 | program.arguments('[files...]').action(function(files) { |
| 195 | content = files.map(readFile).join(''); |
| 196 | }).parse(process.argv); |
| 197 | |
| 198 | function createOptions() { |
| 199 | var options = {}; |
| 200 | mainOptionKeys.forEach(function(key) { |
| 201 | var param = program[key === 'minifyURLs' ? 'minifyUrls' : camelCase(key)]; |
| 202 | if (typeof param !== 'undefined') { |
| 203 | options[key] = param; |
| 204 | } |
| 205 | else if (key in config) { |
| 206 | options[key] = config[key]; |
| 207 | } |
| 208 | }); |
| 209 | return options; |
| 210 | } |
| 211 | |
| 212 | function mkdir(outputDir, callback) { |
| 213 | fs.mkdir(outputDir, function(err) { |
| 214 | if (err) { |
| 215 | switch (err.code) { |
| 216 | case 'ENOENT': |
| 217 | return mkdir(path.join(outputDir, '..'), function() { |
| 218 | mkdir(outputDir, callback); |
| 219 | }); |
| 220 | case 'EEXIST': |
| 221 | break; |
| 222 | default: |
| 223 | fatal('Cannot create directory ' + outputDir + '\n' + err.message); |
| 224 | } |
| 225 | } |
| 226 | callback(); |
| 227 | }); |
| 228 | } |
| 229 | |
| 230 | function processFile(inputFile, outputFile) { |
| 231 | fs.readFile(inputFile, { encoding: 'utf8' }, function(err, data) { |
| 232 | if (err) { |
| 233 | fatal('Cannot read ' + inputFile + '\n' + err.message); |
| 234 | } |
| 235 | var minified; |
| 236 | try { |
| 237 | minified = minify(data, createOptions()); |
| 238 | } |
| 239 | catch (e) { |
| 240 | fatal('Minification error on ' + inputFile + '\n' + e.message); |
| 241 | } |
| 242 | fs.writeFile(outputFile, minified, { encoding: 'utf8' }, function(err) { |
| 243 | if (err) { |
| 244 | fatal('Cannot write ' + outputFile + '\n' + err.message); |
| 245 | } |
| 246 | }); |
| 247 | }); |
| 248 | } |
| 249 | |
| 250 | function processDirectory(inputDir, outputDir, fileExt) { |
| 251 | fs.readdir(inputDir, function(err, files) { |
| 252 | if (err) { |
| 253 | fatal('Cannot read directory ' + inputDir + '\n' + err.message); |
| 254 | } |
| 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) { |
| 259 | if (err) { |
| 260 | fatal('Cannot read ' + inputFile + '\n' + err.message); |
| 261 | } |
| 262 | else if (stat.isDirectory()) { |
| 263 | processDirectory(inputFile, outputFile, fileExt); |
| 264 | } |
| 265 | else if (!fileExt || path.extname(file) === '.' + fileExt) { |
| 266 | mkdir(outputDir, function() { |
| 267 | processFile(inputFile, outputFile); |
| 268 | }); |
| 269 | } |
| 270 | }); |
| 271 | }); |
| 272 | }); |
| 273 | } |
| 274 | |
| 275 | function writeMinify() { |
| 276 | var minified; |
| 277 | try { |
| 278 | minified = minify(content, createOptions()); |
| 279 | } |
| 280 | catch (e) { |
| 281 | fatal('Minification error:\n' + e.message); |
| 282 | } |
| 283 | (program.output ? fs.createWriteStream(program.output).on('error', function(e) { |
| 284 | fatal('Cannot write ' + program.output + '\n' + e.message); |
| 285 | }) : process.stdout).write(minified); |
| 286 | } |
| 287 | |
| 288 | var inputDir = program.inputDir; |
| 289 | var outputDir = program.outputDir; |
| 290 | var fileExt = program.fileExt; |
| 291 | if (inputDir || outputDir) { |
| 292 | if (!inputDir) { |
| 293 | fatal('The option output-dir needs to be used with the option input-dir. If you are working with a single file, use -o.'); |
| 294 | } |
| 295 | else if (!outputDir) { |
| 296 | fatal('You need to specify where to write the output files with the option --output-dir'); |
| 297 | } |
| 298 | processDirectory(inputDir, outputDir, fileExt); |
| 299 | } |
| 300 | // Minifying one or more files specified on the CMD line |
| 301 | else if (content) { |
| 302 | writeMinify(); |
| 303 | } |
| 304 | // Minifying input coming from STDIN |
| 305 | else { |
| 306 | content = ''; |
| 307 | process.stdin.setEncoding('utf8'); |
| 308 | process.stdin.on('data', function(data) { |
| 309 | content += data; |
| 310 | }).on('end', writeMinify); |
| 311 | } |