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