#!/usr/bin/env node /** * html-minifier-terser CLI tool * * The MIT License (MIT) * * Copyright (c) 2014-2016 Zoltan Frombach * * Permission is hereby granted, free of charge, to any person obtaining a copy of * this software and associated documentation files (the "Software"), to deal in * the Software without restriction, including without limitation the rights to * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of * the Software, and to permit persons to whom the Software is furnished to do so, * subject to the following conditions: * * The above copyright notice and this permission notice shall be included in all * copies or substantial portions of the Software. * * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER * IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. * */ 'use strict'; var camelCase = require('camel-case').camelCase; var fs = require('fs'); var info = require('./package.json'); var minify = require('./' + info.main).minify; var paramCase = require('param-case').paramCase; var path = require('path'); var { Command } = require('commander'); const program = new Command(); program.name(info.name); program.version(info.version); function fatal(message) { console.error(message); process.exit(1); } /** * JSON does not support regexes, so, e.g., JSON.parse() will not create * a RegExp from the JSON value `[ "/matchString/" ]`, which is * technically just an array containing a string that begins and end with * a forward slash. To get a RegExp from a JSON string, it must be * constructed explicitly in JavaScript. * * The likelihood of actually wanting to match text that is enclosed in * forward slashes is probably quite rare, so if forward slashes were * included in an argument that requires a regex, the user most likely * thought they were part of the syntax for specifying a regex. * * In the unlikely case that forward slashes are indeed desired in the * search string, the user would need to enclose the expression in a * second set of slashes: * * --customAttrSrround "[\"//matchString//\"]" */ function parseRegExp(value) { if (value) { return new RegExp(value.replace(/^\/(.*)\/$/, '$1')); } } function parseJSON(value) { if (value) { try { return JSON.parse(value); } catch (e) { if (/^{/.test(value)) { fatal('Could not parse JSON value \'' + value + '\''); } return value; } } } function parseJSONArray(value) { if (value) { value = parseJSON(value); return Array.isArray(value) ? value : [value]; } } function parseJSONRegExpArray(value) { value = parseJSONArray(value); return value && value.map(parseRegExp); } function parseString(value) { return value; } var mainOptions = { caseSensitive: 'Treat attributes in case sensitive manner (useful for SVG; e.g. viewBox)', collapseBooleanAttributes: 'Omit attribute values from boolean attributes', collapseInlineTagWhitespace: 'Collapse white space around inline tag', collapseWhitespace: 'Collapse white space that contributes to text nodes in a document tree.', conservativeCollapse: 'Always collapse to 1 space (never remove it entirely)', continueOnParseError: 'Handle parse errors instead of aborting', customAttrAssign: ['Arrays of regex\'es that allow to support custom attribute assign expressions (e.g. \'
\')', parseJSONRegExpArray], customAttrCollapse: ['Regex that specifies custom attribute to strip newlines from (e.g. /ng-class/)', parseRegExp], customAttrSurround: ['Arrays of regex\'es that allow to support custom attribute surround expressions (e.g. )', parseJSONRegExpArray], customEventAttributes: ['Arrays of regex\'es that allow to support custom event attributes for minifyJS (e.g. ng-click)', parseJSONRegExpArray], decodeEntities: 'Use direct Unicode characters whenever possible', html5: 'Parse input according to HTML5 specifications', ignoreCustomComments: ['Array of regex\'es that allow to ignore certain comments, when matched', parseJSONRegExpArray], ignoreCustomFragments: ['Array of regex\'es that allow to ignore certain fragments, when matched (e.g. , {{ ... }})', parseJSONRegExpArray], includeAutoGeneratedTags: 'Insert tags generated by HTML parser', keepClosingSlash: 'Keep the trailing slash on singleton elements', maxLineLength: ['Max line length', parseInt], minifyCSS: ['Minify CSS in style elements and style attributes (uses clean-css)', parseJSON], minifyJS: ['Minify Javascript in script elements and on* attributes (uses terser)', parseJSON], minifyURLs: ['Minify URLs in various attributes (uses relateurl)', parseJSON], noNewlinesBeforeTagClose: 'Never add a newline before a tag that closes an element', preserveLineBreaks: 'Always collapse to 1 line break (never remove it entirely) when whitespace between tags include a line break.', preventAttributesEscaping: 'Prevents the escaping of the values of attributes.', processConditionalComments: 'Process contents of conditional comments through minifier', 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], quoteCharacter: ['Type of quote to use for attribute values (\' or ")', parseString], removeAttributeQuotes: 'Remove quotes around attributes when possible.', removeComments: 'Strip HTML comments', removeEmptyAttributes: 'Remove all attributes with whitespace-only values', removeEmptyElements: 'Remove all elements with empty contents', removeOptionalTags: 'Remove unrequired tags', removeRedundantAttributes: 'Remove attributes when value matches default.', 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', removeStyleLinkTypeAttributes: 'Remove type="text/css" from style and link tags. Other type attribute values are left intact.', removeTagWhitespace: 'Remove space between attributes whenever possible', sortAttributes: 'Sort attributes by frequency', sortClassName: 'Sort style classes by frequency', trimCustomFragments: 'Trim white space around ignoreCustomFragments.', useShortDoctype: 'Replaces the doctype with the short (HTML5) doctype' }; var mainOptionKeys = Object.keys(mainOptions); mainOptionKeys.forEach(function(key) { var option = mainOptions[key]; if (Array.isArray(option)) { key = key === 'minifyURLs' ? '--minify-urls' : '--' + paramCase(key); key += option[1] === parseJSON ? ' [value]' : ' '; program.option(key, option[0], option[1]); } else if (~['html5', 'includeAutoGeneratedTags'].indexOf(key)) { program.option('--no-' + paramCase(key), option); } else { program.option('--' + paramCase(key), option); } }); program.option('-o --output ', 'Specify output file (if not specified STDOUT will be used for output)'); function readFile(file) { try { return fs.readFileSync(file, { encoding: 'utf8' }); } catch (e) { fatal('Cannot read ' + file + '\n' + e.message); } } var config = {}; program.option('-c --config-file ', 'Use config file', function(configPath) { var data = readFile(configPath); try { config = JSON.parse(data); } catch (je) { try { config = require(path.resolve(configPath)); } catch (ne) { fatal('Cannot read the specified config file.\nAs JSON: ' + je.message + '\nAs module: ' + ne.message); } } mainOptionKeys.forEach(function(key) { if (key in config) { var option = mainOptions[key]; if (Array.isArray(option)) { var value = config[key]; config[key] = option[1](typeof value === 'string' ? value : JSON.stringify(value)); } } }); }); program.option('--input-dir ', 'Specify an input directory'); program.option('--output-dir ', 'Specify an output directory'); program.option('--file-ext ', 'Specify an extension to be read, ex: html'); var content; program.arguments('[files...]').action(function(files) { content = files.map(readFile).join(''); }).parse(process.argv); const programOptions = program.opts(); function createOptions() { var options = {}; mainOptionKeys.forEach(function(key) { var param = programOptions[key === 'minifyURLs' ? 'minifyUrls' : camelCase(key)]; if (typeof param !== 'undefined') { options[key] = param; } else if (key in config) { options[key] = config[key]; } }); return options; } function mkdir(outputDir, callback) { fs.mkdir(outputDir, function(err) { if (err) { switch (err.code) { case 'ENOENT': return mkdir(path.join(outputDir, '..'), function() { mkdir(outputDir, callback); }); case 'EEXIST': break; default: fatal('Cannot create directory ' + outputDir + '\n' + err.message); } } callback(); }); } function processFile(inputFile, outputFile) { fs.readFile(inputFile, { encoding: 'utf8' }, async function(err, data) { if (err) { fatal('Cannot read ' + inputFile + '\n' + err.message); } var minified; try { minified = await minify(data, createOptions()); } catch (e) { fatal('Minification error on ' + inputFile + '\n' + e.message); } fs.writeFile(outputFile, minified, { encoding: 'utf8' }, function(err) { if (err) { fatal('Cannot write ' + outputFile + '\n' + err.message); } }); }); } function processDirectory(inputDir, outputDir, fileExt) { fs.readdir(inputDir, function(err, files) { if (err) { fatal('Cannot read directory ' + inputDir + '\n' + err.message); } files.forEach(function(file) { var inputFile = path.join(inputDir, file); var outputFile = path.join(outputDir, file); fs.stat(inputFile, function(err, stat) { if (err) { fatal('Cannot read ' + inputFile + '\n' + err.message); } else if (stat.isDirectory()) { processDirectory(inputFile, outputFile, fileExt); } else if (!fileExt || path.extname(file) === '.' + fileExt) { mkdir(outputDir, function() { processFile(inputFile, outputFile); }); } }); }); }); } async function writeMinify() { var minified; try { minified = await minify(content, createOptions()); } catch (e) { fatal('Minification error:\n' + e.message); } (programOptions.output ? fs.createWriteStream(programOptions.output).on('error', function(e) { fatal('Cannot write ' + programOptions.output + '\n' + e.message); }) : process.stdout).write(minified); } var inputDir = programOptions.inputDir; var outputDir = programOptions.outputDir; var fileExt = programOptions.fileExt; if (inputDir || outputDir) { if (!inputDir) { fatal('The option output-dir needs to be used with the option input-dir. If you are working with a single file, use -o.'); } else if (!outputDir) { fatal('You need to specify where to write the output files with the option --output-dir'); } processDirectory(inputDir, outputDir, fileExt); } // Minifying one or more files specified on the CMD line else if (content) { writeMinify(); } // Minifying input coming from STDIN else { content = ''; process.stdin.setEncoding('utf8'); process.stdin.on('data', function(data) { content += data; }).on('end', writeMinify); }