UNPKG

html-minifier-next

Version:

Highly configurable, well-tested, JavaScript-based HTML minifier.

343 lines (306 loc) 13.8 kB
#!/usr/bin/env node /** * html-minifier-next 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. * */ import fs from 'fs'; import path from 'path'; import { createRequire } from 'module'; import { camelCase, paramCase } from 'change-case'; import { Command } from 'commander'; import { minify } from './src/htmlminifier.js'; const require = createRequire(import.meta.url); const pkg = require('./package.json'); const program = new Command(); program.name(pkg.name); program.version(pkg.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 { 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); } const parseString = value => value; const mainOptions = { caseSensitive: 'Treat attributes in case-sensitive manner (useful for custom HTML elements)', collapseBooleanAttributes: 'Omit attribute values from boolean attributes', customFragmentQuantifierLimit: ['Set maximum quantifier limit for custom fragments to prevent ReDoS attacks (default: 200)', parseInt], collapseInlineTagWhitespace: 'Don’t leave any spaces between “display: inline;” elements when collapsing—use with “collapseWhitespace=true”', collapseWhitespace: 'Collapse whitespace that contributes to text nodes in a document tree', conservativeCollapse: 'Always collapse to 1 space (never remove it entirely)—use with “collapseWhitespace=true”', continueOnParseError: 'Handle parse errors instead of aborting', customAttrAssign: ['Arrays of regexes 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 regexes that allow to support custom attribute surround expressions (e.g., “<input {{#if value}}checked="checked"{{/if}}>”)', parseJSONRegExpArray], customEventAttributes: ['Arrays of regexes 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 the HTML specification', ignoreCustomComments: ['Array of regexes that allow to ignore certain comments, when matched', parseJSONRegExpArray], ignoreCustomFragments: ['Array of regexes that allow to ignore certain fragments, when matched (e.g., “<?php … ?>”, “{{ … }}”)', parseJSONRegExpArray], includeAutoGeneratedTags: 'Insert elements generated by HTML parser', inlineCustomElements: ['Array of names of custom elements which are inline', parseJSONArray], keepClosingSlash: 'Keep the trailing slash on void elements', maxInputLength: ['Maximum input length to prevent ReDoS attacks', parseInt], maxLineLength: ['Specify a maximum line length; compressed output will be split by newlines at valid HTML split-points', parseInt], minifyCSS: ['Minify CSS in “style” elements and “style” attributes (uses clean-css)', parseJSON], minifyJS: ['Minify JavaScript in “script” elements and event 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 includes a line break—use with “collapseWhitespace=true”', 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: 'Remove “type="text/javascript"” from “script” elements; other “type” attribute values are left intact', removeStyleLinkTypeAttributes: 'Remove “type="text/css"” from “style” and “link” elements; other “type” attribute values are left intact', removeTagWhitespace: 'Remove space between attributes whenever possible; note that this will result in invalid HTML', sortAttributes: 'Sort attributes by frequency', sortClassName: 'Sort style classes by frequency', trimCustomFragments: 'Trim whitespace around “ignoreCustomFragments”', useShortDoctype: 'Replaces the doctype with the short (HTML) doctype' }; // Configure command line flags const mainOptionKeys = Object.keys(mainOptions); mainOptionKeys.forEach(function (key) { const 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); } } let config = {}; program.option('-c --config-file <file>', 'Use config file', function (configPath) { const 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) { const option = mainOptions[key]; if (Array.isArray(option)) { const value = config[key]; config[key] = option[1](typeof value === 'string' ? value : JSON.stringify(value)); } } }); // Handle fileExt in config file if ('fileExt' in config) { // Support both string (`html,htm`) and array (`["html", "htm"]`) formats if (Array.isArray(config.fileExt)) { config.fileExt = config.fileExt.join(','); } } }); program.option('--input-dir <dir>', 'Specify an input directory'); program.option('--output-dir <dir>', 'Specify an output directory'); program.option('--file-ext <extensions>', 'Specify file extension(s) to process (comma-separated), e.g., “html” or “html,htm,php”'); let content; program.arguments('[files...]').action(function (files) { content = files.map(readFile).join(''); }).parse(process.argv); const programOptions = program.opts(); function createOptions() { const options = {}; mainOptionKeys.forEach(function (key) { const 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, { recursive: true }, function (err) { if (err) { 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); } let 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 parseFileExtensions(fileExt) { if (!fileExt) return []; const list = fileExt .split(',') .map(ext => ext.trim().replace(/^\.+/, '').toLowerCase()) .filter(ext => ext.length > 0); return [...new Set(list)]; } function shouldProcessFile(filename, fileExtensions) { if (!fileExtensions || fileExtensions.length === 0) { return true; // No extensions specified, process all files } const fileExt = path.extname(filename).replace(/^\.+/, '').toLowerCase(); return fileExtensions.includes(fileExt); } function processDirectory(inputDir, outputDir, extensions) { // If first call provided a string, normalize once; otherwise assume pre-parsed array if (typeof extensions === 'string') { extensions = parseFileExtensions(extensions); } fs.readdir(inputDir, function (err, files) { if (err) { fatal('Cannot read directory ' + inputDir + '\n' + err.message); } files.forEach(function (file) { const inputFile = path.join(inputDir, file); const 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, extensions); } else if (shouldProcessFile(file, extensions)) { mkdir(outputDir, function () { processFile(inputFile, outputFile); }); } }); }); }); } const writeMinify = async () => { const minifierOptions = createOptions(); let minified; try { minified = await minify(content, minifierOptions); } catch (e) { fatal('Minification error:\n' + e.message); } let stream = process.stdout; if (programOptions.output) { stream = fs.createWriteStream(programOptions.output) .on('error', (e) => { fatal('Cannot write ' + programOptions.output + '\n' + e.message); }); } stream.write(minified); }; const { inputDir, outputDir, fileExt } = programOptions; // Resolve file extensions: CLI argument takes priority over config file, even if empty string const hasCliFileExt = program.getOptionValueSource('fileExt') === 'cli'; const resolvedFileExt = hasCliFileExt ? fileExt : config.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, resolvedFileExt); } else if (content) { // Minifying one or more files specified on the CMD line writeMinify(); } else { // Minifying input coming from STDIN content = ''; process.stdin.setEncoding('utf8'); process.stdin.on('data', function (data) { content += data; }).on('end', writeMinify); }