You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
315 lines
12 KiB
315 lines
12 KiB
#!/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. \'<div flex?="{{mode != cover}}"></div>\')', 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. <input {{#if value}}checked="checked"{{/if}}>)', 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. <?php ... ?>, {{ ... }})', 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]' : ' <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 <file>', '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 <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 <dir>', 'Specify an input directory'); |
|
program.option('--output-dir <dir>', 'Specify an output directory'); |
|
program.option('--file-ext <text>', '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); |
|
}
|
|
|