html-minifier 4.0.0 → 5.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE.txt +23 -0
- package/README.md +851 -110
- package/cli.js +991 -207
- package/dist/types/htmlminifier.d.ts +629 -0
- package/dist/types/htmlminifier.d.ts.map +1 -0
- package/dist/types/htmlparser.d.ts +28 -0
- package/dist/types/htmlparser.d.ts.map +1 -0
- package/dist/types/lib/attributes.d.ts +87 -0
- package/dist/types/lib/attributes.d.ts.map +1 -0
- package/dist/types/lib/constants.d.ts +421 -0
- package/dist/types/lib/constants.d.ts.map +1 -0
- package/dist/types/lib/content.d.ts +44 -0
- package/dist/types/lib/content.d.ts.map +1 -0
- package/dist/types/lib/elements.d.ts +48 -0
- package/dist/types/lib/elements.d.ts.map +1 -0
- package/dist/types/lib/fragments.d.ts +47 -0
- package/dist/types/lib/fragments.d.ts.map +1 -0
- package/dist/types/lib/option-definitions.d.ts +33 -0
- package/dist/types/lib/option-definitions.d.ts.map +1 -0
- package/dist/types/lib/options.d.ts +93 -0
- package/dist/types/lib/options.d.ts.map +1 -0
- package/dist/types/lib/unused-css.d.ts +76 -0
- package/dist/types/lib/unused-css.d.ts.map +1 -0
- package/dist/types/lib/urls.d.ts +15 -0
- package/dist/types/lib/urls.d.ts.map +1 -0
- package/dist/types/lib/utils.d.ts +132 -0
- package/dist/types/lib/utils.d.ts.map +1 -0
- package/dist/types/lib/whitespace.d.ts +97 -0
- package/dist/types/lib/whitespace.d.ts.map +1 -0
- package/dist/types/presets.d.ts +50 -0
- package/dist/types/presets.d.ts.map +1 -0
- package/dist/types/tokenchain.d.ts +36 -0
- package/dist/types/tokenchain.d.ts.map +1 -0
- package/html-minifier.schema.json +322 -0
- package/package.json +71 -60
- package/src/htmlminifier.js +2862 -1106
- package/src/htmlparser.js +1163 -452
- package/src/lib/attributes.js +1196 -0
- package/src/lib/constants.js +521 -0
- package/src/lib/content.js +237 -0
- package/src/lib/elements.js +268 -0
- package/src/lib/file-pool.js +193 -0
- package/src/lib/file-write.js +87 -0
- package/src/lib/fragments.js +340 -0
- package/src/lib/option-definitions.js +246 -0
- package/src/lib/options.js +841 -0
- package/src/lib/unused-css.js +499 -0
- package/src/lib/urls.js +146 -0
- package/src/lib/utils.js +1026 -0
- package/src/lib/whitespace.js +431 -0
- package/src/presets.js +58 -0
- package/src/tokenchain.js +142 -56
- package/LICENSE +0 -22
- package/sample-cli-config-file.conf +0 -40
- package/src/utils.js +0 -18
package/cli.js
CHANGED
|
@@ -1,10 +1,13 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
+
|
|
2
3
|
/**
|
|
3
4
|
* html-minifier CLI tool
|
|
4
5
|
*
|
|
5
|
-
*
|
|
6
|
+
* MIT License
|
|
6
7
|
*
|
|
7
|
-
* Copyright
|
|
8
|
+
* Copyright 2014–2016 Zoltan Frombach
|
|
9
|
+
* Copyright Juriy “kangax” Zaytsev
|
|
10
|
+
* Copyright 2025 Jens Oliver Meiert
|
|
8
11
|
*
|
|
9
12
|
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
|
10
13
|
* this software and associated documentation files (the "Software"), to deal in
|
|
@@ -25,26 +28,81 @@
|
|
|
25
28
|
*
|
|
26
29
|
*/
|
|
27
30
|
|
|
28
|
-
|
|
31
|
+
import fs from 'fs';
|
|
32
|
+
import path from 'path';
|
|
33
|
+
import { pathToFileURL } from 'url';
|
|
34
|
+
import os from 'os';
|
|
35
|
+
import readline from 'readline';
|
|
36
|
+
import { Command, Option } from 'commander';
|
|
37
|
+
|
|
38
|
+
/** @param {string} str */
|
|
39
|
+
const camelCase = (str) => paramCase(str).replace(/-([a-z])/g, (_, c) => c.toUpperCase());
|
|
40
|
+
|
|
41
|
+
// Commander derives its option key by applying paramCase then camelCase to the flag name, after
|
|
42
|
+
// stripping a leading `no-` for negated flags (`--no-foo-bar` → `fooBar`); as option definition
|
|
43
|
+
// keys may not survive that round-trip (`minifyURLs` → `minifyUrls`, `noNewlinesBeforeTagClose` →
|
|
44
|
+
// `newlinesBeforeTagClose`), `commanderOptionKey` takes the same path to get Commander’s key
|
|
45
|
+
/** @param {string} key */
|
|
46
|
+
const commanderOptionKey = (key) => {
|
|
47
|
+
const pc = paramCase(key);
|
|
48
|
+
return pc.startsWith('no-') ? camelCase(pc.slice(3)) : camelCase(pc);
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
// Lazy-load HM to reduce CLI cold-start overhead
|
|
52
|
+
import { getPreset, getPresetNames } from './src/presets.js';
|
|
53
|
+
import { paramCase, parseRegExp } from './src/lib/utils.js';
|
|
54
|
+
import { optionDefinitions } from './src/lib/option-definitions.js';
|
|
55
|
+
import { writeFileAtomic } from './src/lib/file-write.js';
|
|
56
|
+
|
|
57
|
+
const pkg = JSON.parse(fs.readFileSync(new URL('./package.json', import.meta.url), 'utf8'));
|
|
58
|
+
|
|
59
|
+
const EXTENSIONS_DEFAULT = ['html', 'htm', 'shtml', 'shtm'];
|
|
60
|
+
const EXTENSIONS_NON_HTML = new Set(['css', 'js', 'mjs', 'cjs', 'jsx', 'ts', 'tsx', 'svg']);
|
|
61
|
+
|
|
62
|
+
const CONCURRENCY_IN_PROCESS = Math.max(1, Math.min(os.cpus().length || 4, 8));
|
|
29
63
|
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
64
|
+
// A run with many files earns back each worker’s isolate/JIT startup cost. A smaller
|
|
65
|
+
// run can do so too when it carries enough bytes to share out, which a file count
|
|
66
|
+
// alone cannot see.
|
|
67
|
+
const FILES_PER_RUN_MIN = 128;
|
|
68
|
+
const PARALLEL_BYTES_MIN = 2 * 1024 * 1024;
|
|
69
|
+
// Each worker warms up its own JIT, so the useful count tops out well short of the cores
|
|
70
|
+
const WORKERS_MAX = 6;
|
|
37
71
|
|
|
38
|
-
|
|
39
|
-
|
|
72
|
+
/** @type {WeakMap<string[], Promise<number[]>>} */
|
|
73
|
+
const fileSizesByList = new WeakMap();
|
|
40
74
|
|
|
75
|
+
const MARK_ERROR = process.stderr.isTTY ? '\x1b[31m' : '';
|
|
76
|
+
const MARK_SUCCESS = process.stderr.isTTY ? '\x1b[32m' : '';
|
|
77
|
+
const MARK_WARNING = process.stderr.isTTY ? '\x1b[33m' : '';
|
|
78
|
+
const MARK_RESET = process.stderr.isTTY ? '\x1b[0m' : '';
|
|
79
|
+
|
|
80
|
+
const program = new Command();
|
|
81
|
+
program.name(pkg.name);
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* @param {string} message
|
|
85
|
+
* @returns {never}
|
|
86
|
+
*/
|
|
41
87
|
function fatal(message) {
|
|
42
|
-
console.error(message);
|
|
88
|
+
console.error(`${MARK_ERROR}${message}${MARK_RESET}`);
|
|
43
89
|
process.exit(1);
|
|
44
90
|
}
|
|
45
91
|
|
|
92
|
+
// `catch` bindings are `unknown`, and throw values need not be `Error`s
|
|
93
|
+
/** @param {unknown} err */
|
|
94
|
+
const errorMessage = (err) => err instanceof Error ? err.message : String(err);
|
|
95
|
+
|
|
96
|
+
// Handle broken pipe (e.g., when piping to `head`)
|
|
97
|
+
process.stdout.on('error', (/** @type {NodeJS.ErrnoException} */ err) => {
|
|
98
|
+
if (err && err.code === 'EPIPE') {
|
|
99
|
+
process.exit(0);
|
|
100
|
+
}
|
|
101
|
+
fatal('STDOUT error\n' + (err && err.message ? err.message : String(err)));
|
|
102
|
+
});
|
|
103
|
+
|
|
46
104
|
/**
|
|
47
|
-
* JSON does not support regexes, so, e.g., JSON.parse() will not create
|
|
105
|
+
* JSON does not support regexes, so, e.g., `JSON.parse()` will not create
|
|
48
106
|
* a RegExp from the JSON value `[ "/matchString/" ]`, which is
|
|
49
107
|
* technically just an array containing a string that begins and end with
|
|
50
108
|
* a forward slash. To get a RegExp from a JSON string, it must be
|
|
@@ -59,253 +117,979 @@ function fatal(message) {
|
|
|
59
117
|
* search string, the user would need to enclose the expression in a
|
|
60
118
|
* second set of slashes:
|
|
61
119
|
*
|
|
62
|
-
* --
|
|
120
|
+
* --customAttrSurround "[\"//matchString//\"]"
|
|
63
121
|
*/
|
|
64
|
-
function parseRegExp(value) {
|
|
65
|
-
if (value) {
|
|
66
|
-
return new RegExp(value.replace(/^\/(.*)\/$/, '$1'));
|
|
67
|
-
}
|
|
68
|
-
}
|
|
69
122
|
|
|
123
|
+
/** @param {string} value */
|
|
70
124
|
function parseJSON(value) {
|
|
71
125
|
if (value) {
|
|
72
126
|
try {
|
|
73
127
|
return JSON.parse(value);
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
fatal('Could not parse JSON value \'' + value + '\'');
|
|
128
|
+
} catch {
|
|
129
|
+
if (/^\s*[{[]/.test(value)) {
|
|
130
|
+
fatal('Could not parse JSON value `' + value + '`');
|
|
78
131
|
}
|
|
79
132
|
return value;
|
|
80
133
|
}
|
|
81
134
|
}
|
|
82
135
|
}
|
|
83
136
|
|
|
137
|
+
/** @param {string} value */
|
|
84
138
|
function parseJSONArray(value) {
|
|
85
|
-
if (value)
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
}
|
|
139
|
+
if (!value) return undefined;
|
|
140
|
+
const parsed = parseJSON(value);
|
|
141
|
+
return Array.isArray(parsed) ? parsed : [parsed];
|
|
89
142
|
}
|
|
90
143
|
|
|
144
|
+
/** @param {string} value */
|
|
91
145
|
function parseJSONRegExpArray(value) {
|
|
92
|
-
|
|
93
|
-
return
|
|
146
|
+
const values = parseJSONArray(value);
|
|
147
|
+
return values && values.map(parseRegExp);
|
|
94
148
|
}
|
|
95
149
|
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
}
|
|
150
|
+
/** @param {string} value */
|
|
151
|
+
const parseString = value => value;
|
|
99
152
|
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
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]);
|
|
153
|
+
/** @param {string} optionName */
|
|
154
|
+
const parseValidInt = (optionName) => (/** @type {string} */ value) => {
|
|
155
|
+
const s = String(value).trim();
|
|
156
|
+
// Accept only non-negative whole integers
|
|
157
|
+
if (!/^\d+$/.test(s)) {
|
|
158
|
+
fatal(`Invalid number for \`--${paramCase(optionName)}: "${value}"\``);
|
|
147
159
|
}
|
|
148
|
-
|
|
149
|
-
|
|
160
|
+
const num = Number(s);
|
|
161
|
+
return num;
|
|
162
|
+
};
|
|
163
|
+
|
|
164
|
+
// Map option types to CLI parsers—`int` is resolved by `getParser`, as its
|
|
165
|
+
// parser closes over the option name for error messages
|
|
166
|
+
/** @type {Record<string, (value: string) => any>} */
|
|
167
|
+
const typeParsers = {
|
|
168
|
+
regexp: parseRegExp,
|
|
169
|
+
regexpArray: parseJSONRegExpArray,
|
|
170
|
+
json: parseJSON,
|
|
171
|
+
jsonObject: parseJSON,
|
|
172
|
+
jsonArray: parseJSONArray,
|
|
173
|
+
string: parseString
|
|
174
|
+
};
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* @param {string} key Option definition key, for the `int` parser’s error messages
|
|
178
|
+
* @param {string} type Option definition type
|
|
179
|
+
* @returns {(value: string) => any}
|
|
180
|
+
*/
|
|
181
|
+
function getParser(key, type) {
|
|
182
|
+
const parser = type === 'int' ? parseValidInt(key) : typeParsers[type];
|
|
183
|
+
if (!parser) {
|
|
184
|
+
fatal(`No CLI parser for option \`${key}\` (type \`${type}\`)—add the type to \`typeParsers\` in cli.js`);
|
|
150
185
|
}
|
|
151
|
-
|
|
152
|
-
|
|
186
|
+
return parser;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// Configure command-line flags from shared option definitions
|
|
190
|
+
const mainOptionKeys = Object.keys(optionDefinitions);
|
|
191
|
+
Object.entries(optionDefinitions).forEach(function ([key, { description, descriptionAffirmative, type }]) {
|
|
192
|
+
const flag = paramCase(key);
|
|
193
|
+
if (type === 'invertedBoolean') {
|
|
194
|
+
// The positive form (to re-enable after a preset/config disables it) is hidden from
|
|
195
|
+
// help—the footer note covers the convention; the negative form is the primary use case
|
|
196
|
+
program.addOption(new Option('--' + flag, descriptionAffirmative ?? 'Enable --' + flag).hideHelp());
|
|
197
|
+
program.option('--no-' + flag, description);
|
|
198
|
+
} else if (type === 'boolean') {
|
|
199
|
+
program.option('--' + flag, description);
|
|
200
|
+
// The negation form is hidden from help—the footer note covers the convention;
|
|
201
|
+
// skip options whose flag already starts with `no-` (currently only
|
|
202
|
+
// `noNewlinesBeforeTagClose`), as `--no-no-X` is not usable
|
|
203
|
+
if (!flag.startsWith('no-')) {
|
|
204
|
+
program.addOption(new Option('--no-' + flag, 'Disable --' + flag).hideHelp());
|
|
205
|
+
}
|
|
206
|
+
} else {
|
|
207
|
+
const cliFlag = '--' + flag + (type === 'json' || type === 'jsonObject' ? ' [value]' : ' <value>');
|
|
208
|
+
program.option(cliFlag, description, getParser(key, type));
|
|
153
209
|
}
|
|
154
210
|
});
|
|
155
|
-
program.option('-
|
|
211
|
+
program.option('-i --input <file>', 'Specify input file (alternative to positional argument; pair with `--output` for output)');
|
|
212
|
+
program.option('-o --output <file>', 'Specify output file (reads from `--input` file argument or STDIN; outputs to STDOUT if not specified)');
|
|
213
|
+
program.option('-v --verbose', 'Show detailed processing information (active options, worker thread count, file statistics, and minifier warnings)');
|
|
214
|
+
program.option('-d --dry', 'Dry run: Process and report statistics without writing output');
|
|
215
|
+
program.addHelpText('after', '\nBoolean options support a `--no-<flag>` form to disable them, overriding a preset or config file (e.g., `--preset=comprehensive --no-collapse-whitespace`).\n\nHTML Minifier documentation: https://meiert.com/hm');
|
|
216
|
+
|
|
217
|
+
// Lazy import wrapper for HM
|
|
218
|
+
/** @type {Promise<typeof import('./src/htmlminifier.js').minify> | undefined} */
|
|
219
|
+
let minifyFnPromise;
|
|
220
|
+
async function getMinify() {
|
|
221
|
+
if (!minifyFnPromise) {
|
|
222
|
+
minifyFnPromise = import('./src/htmlminifier.js').then(m => m.minify);
|
|
223
|
+
}
|
|
224
|
+
return minifyFnPromise;
|
|
225
|
+
}
|
|
156
226
|
|
|
227
|
+
/** @param {string} file */
|
|
157
228
|
function readFile(file) {
|
|
158
229
|
try {
|
|
159
230
|
return fs.readFileSync(file, { encoding: 'utf8' });
|
|
231
|
+
} catch (err) {
|
|
232
|
+
fatal('Cannot read ' + file + '\n' + errorMessage(err));
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* Load config from a file path. Extensions .json, .js, and .mjs are handled
|
|
238
|
+
* directly; for unknown extensions, JSON is tried first, then module import.
|
|
239
|
+
* @param {string} configPath - Path to config file
|
|
240
|
+
* @returns {Promise<Record<string, any>>} Loaded config object
|
|
241
|
+
*/
|
|
242
|
+
async function loadConfigFromPath(configPath) {
|
|
243
|
+
const abs = path.resolve(configPath);
|
|
244
|
+
const ext = path.extname(configPath).toLowerCase();
|
|
245
|
+
|
|
246
|
+
if (ext === '.json') {
|
|
247
|
+
try { return JSON.parse(readFile(abs).replace(/^\uFEFF/, '')); }
|
|
248
|
+
catch (err) { fatal(`Cannot parse config file as JSON: ${errorMessage(err)}`); }
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
if (ext === '.js' || ext === '.mjs') {
|
|
252
|
+
// `import()` handles both ESM and CJS .js files—Node resolves the type via the
|
|
253
|
+
// nearest package.json `type` field, same as it does for regular module loading
|
|
254
|
+
try { const mod = await import(pathToFileURL(abs).href); return 'default' in mod ? mod.default : mod; }
|
|
255
|
+
catch (err) { fatal(`Cannot load config file: ${errorMessage(err)}`); }
|
|
160
256
|
}
|
|
161
|
-
|
|
162
|
-
|
|
257
|
+
|
|
258
|
+
/** @type {unknown} */
|
|
259
|
+
let jsonErr;
|
|
260
|
+
try { return JSON.parse(readFile(abs).replace(/^\uFEFF/, '')); }
|
|
261
|
+
catch (err) { jsonErr = err; }
|
|
262
|
+
|
|
263
|
+
try { const mod = await import(pathToFileURL(abs).href); return 'default' in mod ? mod.default : mod; }
|
|
264
|
+
catch (esmErr) {
|
|
265
|
+
fatal(`Cannot read the specified config file.\nAs JSON: ${errorMessage(jsonErr)}\nAs module: ${errorMessage(esmErr)}`);
|
|
163
266
|
}
|
|
164
267
|
}
|
|
165
268
|
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
269
|
+
// Config keys the CLI handles itself, beyond the options in `optionDefinitions`
|
|
270
|
+
const CONFIG_KEYS_EXTRA = new Set(['$schema', 'preset', 'fileExt', 'ignoreDir']);
|
|
271
|
+
|
|
272
|
+
// Default config file, looked up in the working directory when `--config-file` isn’t specified
|
|
273
|
+
const CONFIG_FILE_DEFAULT = 'html-minifier.config.json';
|
|
274
|
+
|
|
275
|
+
/**
|
|
276
|
+
* Parse a config value—strings as on the command line, anything else as it is, since
|
|
277
|
+
* a JSON round trip would drop what only a module config can hold (functions, regexes)
|
|
278
|
+
* @param {string} key Option definition key
|
|
279
|
+
* @param {string} type Option definition type
|
|
280
|
+
* @param {unknown} value
|
|
281
|
+
*/
|
|
282
|
+
function parseConfigValue(key, type, value) {
|
|
283
|
+
if (typeof value === 'string') return getParser(key, type)(value);
|
|
284
|
+
switch (type) {
|
|
285
|
+
case 'int':
|
|
286
|
+
return getParser(key, type)(String(value));
|
|
287
|
+
// A single value is wrapped as its string form is; `null` and `undefined` leave it unset
|
|
288
|
+
case 'regexpArray':
|
|
289
|
+
return value == null ? undefined : (Array.isArray(value) ? value : [value]).map(parseRegExp);
|
|
290
|
+
case 'jsonArray':
|
|
291
|
+
return value == null ? undefined : (Array.isArray(value) ? value : [value]);
|
|
292
|
+
default:
|
|
293
|
+
return value;
|
|
171
294
|
}
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/**
|
|
298
|
+
* Normalize and validate config object by applying parsers and transforming values.
|
|
299
|
+
* @param {Record<string, any>} config - Raw config object
|
|
300
|
+
* @returns {Record<string, any>} Normalized config object
|
|
301
|
+
*/
|
|
302
|
+
function normalizeConfig(config) {
|
|
303
|
+
/** @type {Record<string, any>} */
|
|
304
|
+
const normalized = { ...config };
|
|
305
|
+
|
|
306
|
+
// Warn about unrecognized config keys—catches typos and options removed in earlier versions
|
|
307
|
+
Object.keys(normalized).forEach(function (key) {
|
|
308
|
+
if (!Object.hasOwn(optionDefinitions, key) && !CONFIG_KEYS_EXTRA.has(key)) {
|
|
309
|
+
console.error(`Ignoring unknown or deprecated config option \`${key}\` (see \`--help\` or README for available options)`);
|
|
178
310
|
}
|
|
179
|
-
}
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
// Apply parsers to main options
|
|
314
|
+
Object.entries(optionDefinitions).forEach(function ([key, { type }]) {
|
|
315
|
+
if (key in normalized) {
|
|
316
|
+
if (type !== 'boolean' && type !== 'invertedBoolean') {
|
|
317
|
+
normalized[key] = parseConfigValue(key, type, normalized[key]);
|
|
186
318
|
}
|
|
187
319
|
}
|
|
188
320
|
});
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
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];
|
|
321
|
+
|
|
322
|
+
if ('fileExt' in normalized) {
|
|
323
|
+
// Support both string (`html,htm`) and array (`["html", "htm"]`) formats
|
|
324
|
+
if (Array.isArray(normalized.fileExt)) {
|
|
325
|
+
normalized.fileExt = normalized.fileExt.join(',');
|
|
207
326
|
}
|
|
208
|
-
}
|
|
209
|
-
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
if ('ignoreDir' in normalized) {
|
|
330
|
+
// Support both string (`libs,vendor`) and array (`["libs", "vendor"]`) formats
|
|
331
|
+
if (Array.isArray(normalized.ignoreDir)) {
|
|
332
|
+
normalized.ignoreDir = normalized.ignoreDir.join(',');
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
return normalized;
|
|
210
337
|
}
|
|
211
338
|
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
339
|
+
/** @type {Record<string, any>} */
|
|
340
|
+
let config = {};
|
|
341
|
+
program.option('-z, --zero', 'Minify all HTML files in the current folder and its subfolders in place (except node_modules), using comprehensive settings (standalone—the flag is ignored when combined with other options)');
|
|
342
|
+
program.option('-I --input-dir <dir>', 'Specify an input directory');
|
|
343
|
+
program.option('-X --ignore-dir <patterns>', 'Exclude directories—relative to input directory—from processing (comma-separated, overrides config file setting)');
|
|
344
|
+
program.option('-O --output-dir <dir>', 'Specify an output directory');
|
|
345
|
+
program.option('-w --workers <n>', 'Number of worker threads for multi-file runs; defaults to half the available cores (at most 6) for runs large enough to justify them', parseValidInt('workers'));
|
|
346
|
+
program.option('-f --file-ext <extensions>', 'Specify file extension(s) to process (comma-separated, overrides config file setting); defaults to `html,htm,shtml,shtm`; use `*` for all files');
|
|
347
|
+
program.option('-p --preset <name>', `Use a preset configuration (${getPresetNames().join(', ')})`);
|
|
348
|
+
program.option('-c --config-file <file>', 'Use a configuration file (defaults to html-minifier.config.json in the working directory, if present)');
|
|
349
|
+
program.version(pkg.version, '-V, --version', 'Output the version number');
|
|
350
|
+
program.helpOption('-h, --help', 'Display help for command');
|
|
351
|
+
|
|
352
|
+
(async () => {
|
|
353
|
+
/** @type {string} */
|
|
354
|
+
let content;
|
|
355
|
+
let filesProvided = false;
|
|
356
|
+
/** @type {string[]} */
|
|
357
|
+
let capturedFiles = [];
|
|
358
|
+
await program.arguments('[files...]').action(function (files) {
|
|
359
|
+
capturedFiles = files;
|
|
360
|
+
filesProvided = files.length > 0;
|
|
361
|
+
// Defer reading files until after check for consumed filenames
|
|
362
|
+
}).parseAsync(process.argv);
|
|
363
|
+
|
|
364
|
+
const programOptions = program.opts();
|
|
365
|
+
|
|
366
|
+
// A `parseJSON` option that consumed a filename as its value is `true`, and the
|
|
367
|
+
// file goes back on the list
|
|
368
|
+
const jsonOptionKeys = ['minifyCss', 'minifyJs', 'minifyUrls'];
|
|
369
|
+
for (const key of jsonOptionKeys) {
|
|
370
|
+
const value = programOptions[key];
|
|
371
|
+
if (typeof value === 'string' && /\.(html?|shtml?|xhtml?|php|xml|svg|jsx|tsx|vue|ejs|hbs|mustache|twig)$/i.test(value)) {
|
|
372
|
+
programOptions[key] = true;
|
|
373
|
+
capturedFiles.push(value);
|
|
374
|
+
filesProvided = true;
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
if (programOptions.input) {
|
|
379
|
+
capturedFiles.unshift(programOptions.input);
|
|
380
|
+
filesProvided = true;
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
// Handle zero config mode (standalone in-place minification of the current folder)
|
|
384
|
+
if (programOptions.zero) {
|
|
385
|
+
const hasOtherArgs = process.argv.slice(2).some(arg => arg !== '--zero' && arg !== '-z');
|
|
386
|
+
if (hasOtherArgs) {
|
|
387
|
+
console.error('Note: `--zero` was ignored—it can only be used on its own, to minify the current folder at comprehensive settings.');
|
|
388
|
+
} else {
|
|
389
|
+
const cwd = process.cwd();
|
|
390
|
+
const [execPath = '', scriptPath = ''] = process.argv;
|
|
391
|
+
const commandName = process.env.npm_command === 'exec'
|
|
392
|
+
? 'npx html-minifier'
|
|
393
|
+
: scriptPath.endsWith('.js')
|
|
394
|
+
? `${path.basename(execPath)} ${scriptPath}`
|
|
395
|
+
: path.basename(scriptPath);
|
|
396
|
+
|
|
397
|
+
process.stderr.write(
|
|
398
|
+
`${MARK_WARNING}Zero-config mode minifies all HTML files in the current folder and its subfolders (${cwd}) in place, using comprehensive settings. If you want to compare results and be able to revert, do this under version control.${MARK_RESET}\n` +
|
|
399
|
+
`Equivalent to: ${commandName} --input-dir=. --output-dir=. --ignore-dir=node_modules --preset=comprehensive\n\n` +
|
|
400
|
+
`Do you want to continue? [y/N] `
|
|
401
|
+
);
|
|
402
|
+
|
|
403
|
+
const answer = await new Promise((resolve) => {
|
|
404
|
+
const rl = readline.createInterface({ input: process.stdin });
|
|
405
|
+
rl.once('line', (line) => {
|
|
406
|
+
resolve(line.trim().toLowerCase());
|
|
407
|
+
rl.close();
|
|
408
|
+
});
|
|
409
|
+
rl.once('close', () => resolve(''));
|
|
410
|
+
});
|
|
411
|
+
|
|
412
|
+
if (answer !== 'y') {
|
|
413
|
+
process.stderr.write(`${MARK_ERROR}In-place minification aborted.${MARK_RESET}\n`);
|
|
414
|
+
process.exit(0);
|
|
224
415
|
}
|
|
416
|
+
|
|
417
|
+
programOptions.preset = 'comprehensive';
|
|
418
|
+
|
|
419
|
+
const inputDirResolved = await fs.promises.realpath(cwd).catch(() => cwd);
|
|
420
|
+
const stats = await processDirectory(cwd, cwd, EXTENSIONS_DEFAULT, false, false, undefined, Boolean(process.stderr.isTTY), ['node_modules'], inputDirResolved);
|
|
421
|
+
reportProcessed(stats.length);
|
|
422
|
+
|
|
423
|
+
process.exit(0);
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
// Load and normalize config if `--config-file` was specified; otherwise look
|
|
428
|
+
// for a default config file in the working directory
|
|
429
|
+
if (programOptions.configFile) {
|
|
430
|
+
config = await loadConfigFromPath(programOptions.configFile);
|
|
431
|
+
config = normalizeConfig(config);
|
|
432
|
+
} else if (fs.existsSync(path.resolve(CONFIG_FILE_DEFAULT))) {
|
|
433
|
+
console.error(`Using config file ${CONFIG_FILE_DEFAULT}`);
|
|
434
|
+
config = normalizeConfig(await loadConfigFromPath(CONFIG_FILE_DEFAULT));
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
function createOptions() {
|
|
438
|
+
/** @type {Record<string, any>} */
|
|
439
|
+
const options = {};
|
|
440
|
+
|
|
441
|
+
// Priority order: preset < config < CLI
|
|
442
|
+
// 1. Apply preset if specified (CLI `--preset` takes priority over config.preset)
|
|
443
|
+
const presetName = programOptions.preset || config.preset;
|
|
444
|
+
if (presetName) {
|
|
445
|
+
const preset = getPreset(presetName);
|
|
446
|
+
if (!preset) {
|
|
447
|
+
fatal(`Unknown preset “${presetName}”. Available presets: ${getPresetNames().join(', ')}`);
|
|
448
|
+
}
|
|
449
|
+
Object.assign(options, preset);
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
// 2. Apply config file options (overrides preset)
|
|
453
|
+
mainOptionKeys.forEach(function (key) {
|
|
454
|
+
if (key in config) {
|
|
455
|
+
options[key] = config[key];
|
|
456
|
+
}
|
|
457
|
+
});
|
|
458
|
+
|
|
459
|
+
// 3. Apply CLI options (overrides config and preset)
|
|
460
|
+
Object.entries(optionDefinitions).forEach(function ([key, { type }]) {
|
|
461
|
+
const ck = commanderOptionKey(key);
|
|
462
|
+
if (program.getOptionValueSource(ck) === 'cli') {
|
|
463
|
+
const val = programOptions[ck];
|
|
464
|
+
// For boolean options whose param-case name starts with `no-`, Commander treats
|
|
465
|
+
// the flag as a negation and stores the inverted value under the stripped key;
|
|
466
|
+
// invert back so the option definition key gets the intended value
|
|
467
|
+
options[key] = (type === 'boolean' && paramCase(key).startsWith('no-')) ? !val : val;
|
|
468
|
+
}
|
|
469
|
+
});
|
|
470
|
+
|
|
471
|
+
// 4. Surface minifier diagnostics when verbose
|
|
472
|
+
if (programOptions.verbose || programOptions.dry) {
|
|
473
|
+
options.log = createLog();
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
return options;
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
/**
|
|
480
|
+
* Diagnostics name no file of their own, so a run over a directory—where several are in
|
|
481
|
+
* flight at once and lines interleave—says which file each came from
|
|
482
|
+
* @param {string} [fileLabel]
|
|
483
|
+
*/
|
|
484
|
+
function createLog(fileLabel) {
|
|
485
|
+
const where = fileLabel ? ` (${fileLabel})` : '';
|
|
486
|
+
return (/** @type {unknown} */ message) => {
|
|
487
|
+
// The hook carries the minifier’s per-call timing as well, which the run's own
|
|
488
|
+
// per-file statistics already cover
|
|
489
|
+
if (typeof message === 'string' && message.startsWith('minified in: ')) {
|
|
490
|
+
return;
|
|
491
|
+
}
|
|
492
|
+
// Only `continueOnMinifyError` passes `Error` objects, always after leaving the
|
|
493
|
+
// offending content unminified—so say that
|
|
494
|
+
if (message instanceof Error) {
|
|
495
|
+
console.error(` ${MARK_WARNING}Warning: Minification failed, content left as-is: ${message.message || message}${where}${MARK_RESET}`);
|
|
496
|
+
} else if (String(message).startsWith('Warning: ') || String(message).startsWith('HTML Minifier: ')) {
|
|
497
|
+
console.error(` ${MARK_WARNING}${message}${where}${MARK_RESET}`);
|
|
498
|
+
} else {
|
|
499
|
+
console.error(` ${message}${where}`);
|
|
500
|
+
}
|
|
501
|
+
};
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
/** @param {Record<string, unknown>} minifierOptions */
|
|
505
|
+
function getActiveOptionsDisplay(minifierOptions) {
|
|
506
|
+
const presetName = programOptions.preset || config.preset;
|
|
507
|
+
if (presetName) {
|
|
508
|
+
console.error(`Using preset: ${presetName}`);
|
|
509
|
+
}
|
|
510
|
+
const activeOptions = Object.entries(minifierOptions)
|
|
511
|
+
.filter(([k]) => program.getOptionValueSource(commanderOptionKey(k)) === 'cli')
|
|
512
|
+
.map(([k, v]) => (typeof v === 'boolean' ? (v ? k : `no-${k}`) : k));
|
|
513
|
+
if (activeOptions.length > 0) {
|
|
514
|
+
console.error('CLI options: ' + activeOptions.join(', '));
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
/**
|
|
519
|
+
* @param {string} original
|
|
520
|
+
* @param {string} minified
|
|
521
|
+
*/
|
|
522
|
+
function calculateStats(original, minified) {
|
|
523
|
+
return calculateSizeStats(Buffer.byteLength(original, 'utf8'), Buffer.byteLength(minified, 'utf8'));
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
/**
|
|
527
|
+
* @param {number} originalSize
|
|
528
|
+
* @param {number} minifiedSize
|
|
529
|
+
*/
|
|
530
|
+
function calculateSizeStats(originalSize, minifiedSize) {
|
|
531
|
+
const saved = originalSize - minifiedSize;
|
|
532
|
+
const sign = saved >= 0 ? '-' : '+';
|
|
533
|
+
const percentage = originalSize ? ((Math.abs(saved) / originalSize) * 100).toFixed(1) : '0.0';
|
|
534
|
+
return { originalSize, minifiedSize, saved, sign, percentage };
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
/** @param {ReturnType<typeof calculateSizeStats>} stats */
|
|
538
|
+
function formatSizeStats(stats) {
|
|
539
|
+
return `${stats.originalSize.toLocaleString()} → ${stats.minifiedSize.toLocaleString()} bytes (${stats.sign}${Math.abs(stats.saved).toLocaleString()}, ${stats.percentage}%)`;
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
/**
|
|
543
|
+
* @param {string} label
|
|
544
|
+
* @param {ReturnType<typeof calculateSizeStats>} stats
|
|
545
|
+
*/
|
|
546
|
+
function reportFileStats(label, stats) {
|
|
547
|
+
console.error(` ${MARK_SUCCESS}✓${MARK_RESET} ${label}: ${formatSizeStats(stats)}`);
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
/** @param {number} count */
|
|
551
|
+
function reportProcessed(count) {
|
|
552
|
+
console.error(`${MARK_SUCCESS}Processed ${count.toLocaleString()} file${count === 1 ? '' : 's'}.${MARK_RESET}`);
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
/**
|
|
556
|
+
* Reports a single-output run and writes its result to `--output` or STDOUT; a dry
|
|
557
|
+
* run reports only
|
|
558
|
+
* @param {string} inputSource
|
|
559
|
+
* @param {string} original
|
|
560
|
+
* @param {string} minified
|
|
561
|
+
*/
|
|
562
|
+
async function reportAndWrite(inputSource, original, minified) {
|
|
563
|
+
const stats = calculateStats(original, minified);
|
|
564
|
+
|
|
565
|
+
if (programOptions.dry) {
|
|
566
|
+
console.error(`[DRY RUN] Would minify: ${inputSource} → ${programOptions.output || 'STDOUT'}`);
|
|
567
|
+
console.error(` Original: ${stats.originalSize.toLocaleString()} bytes`);
|
|
568
|
+
console.error(` Minified: ${stats.minifiedSize.toLocaleString()} bytes`);
|
|
569
|
+
console.error(` Saved: ${stats.sign}${Math.abs(stats.saved).toLocaleString()} bytes (${stats.percentage}%)`);
|
|
570
|
+
await printCacheStats();
|
|
571
|
+
return;
|
|
225
572
|
}
|
|
226
|
-
callback();
|
|
227
|
-
});
|
|
228
|
-
}
|
|
229
573
|
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
fatal('Cannot read ' + inputFile + '\n' + err.message);
|
|
574
|
+
if (programOptions.verbose) {
|
|
575
|
+
reportFileStats(inputSource, stats);
|
|
576
|
+
await printCacheStats();
|
|
234
577
|
}
|
|
235
|
-
|
|
578
|
+
|
|
579
|
+
if (programOptions.output) {
|
|
580
|
+
try {
|
|
581
|
+
await fs.promises.mkdir(path.dirname(programOptions.output), { recursive: true });
|
|
582
|
+
await writeFileAtomic(programOptions.output, minified);
|
|
583
|
+
} catch (err) {
|
|
584
|
+
fatal('Cannot write ' + programOptions.output + '\n' + errorMessage(err));
|
|
585
|
+
}
|
|
586
|
+
return;
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
process.stdout.write(minified);
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
/**
|
|
593
|
+
* Byte size per file, read once per list whichever step asks first; a failed stat counts
|
|
594
|
+
* as 0, leaving normal file processing to report the read error with its usual wording
|
|
595
|
+
* @param {string[]} files
|
|
596
|
+
*/
|
|
597
|
+
function getFileSizes(files) {
|
|
598
|
+
let sizes = fileSizesByList.get(files);
|
|
599
|
+
if (!sizes) {
|
|
600
|
+
sizes = Promise.all(files.map(file => fs.promises.stat(file).then(stat => stat.size, () => 0)));
|
|
601
|
+
fileSizesByList.set(files, sizes);
|
|
602
|
+
}
|
|
603
|
+
return sizes;
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
/**
|
|
607
|
+
* A pool for this run, or `null` where one wouldn’t pay or wouldn’t work—in which case
|
|
608
|
+
* the caller minifies in-process exactly as before
|
|
609
|
+
* @param {string[]} files
|
|
610
|
+
*/
|
|
611
|
+
async function createPool(files) {
|
|
612
|
+
const fileCount = files.length;
|
|
613
|
+
const requested = programOptions.workers;
|
|
614
|
+
const isVerbose = Boolean(programOptions.verbose || programOptions.dry);
|
|
615
|
+
// Whether the run went wide is worth saying, since the default weighs the file
|
|
616
|
+
// count and the bytes there are to share out, neither of which the caller sees
|
|
617
|
+
const inProcess = () => {
|
|
618
|
+
if (isVerbose) console.error('Worker threads: none (minifying in-process)');
|
|
619
|
+
return null;
|
|
620
|
+
};
|
|
621
|
+
// `availableParallelism` reports what this process may actually use, which under a
|
|
622
|
+
// CPU affinity mask—a container, commonly—is less than the machine’s core count
|
|
623
|
+
const isDefault = requested === undefined;
|
|
624
|
+
const size = isDefault
|
|
625
|
+
? Math.max(1, Math.min(Math.floor(os.availableParallelism() / 2), WORKERS_MAX))
|
|
626
|
+
: requested;
|
|
627
|
+
|
|
628
|
+
// The file count only decides the default; asking for workers outright is the
|
|
629
|
+
// caller’s judgment to make, whatever the run looks like
|
|
630
|
+
if (size <= 1) {
|
|
631
|
+
return inProcess();
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
if (isDefault && fileCount < FILES_PER_RUN_MIN) {
|
|
635
|
+
// `stat()` is cheaper than starting workers, and a pooled run reuses the sizes
|
|
636
|
+
const sizes = await getFileSizes(files);
|
|
637
|
+
// No single file is split across workers, so the largest one sets a floor no
|
|
638
|
+
// pool can beat; what the rest carry is the work there is to share out
|
|
639
|
+
const parallelBytes = sizes.reduce((sum, byteSize) => sum + byteSize, 0) - Math.max(0, ...sizes);
|
|
640
|
+
if (parallelBytes < PARALLEL_BYTES_MIN) {
|
|
641
|
+
return inProcess();
|
|
642
|
+
}
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
// `log` is a closure over this process’s console and is rebuilt inside the worker;
|
|
646
|
+
// anything else unclonable (such as a function from a module config) stays in-process
|
|
647
|
+
const { log, ...options } = createOptions();
|
|
648
|
+
try {
|
|
649
|
+
structuredClone(options);
|
|
650
|
+
} catch {
|
|
651
|
+
return inProcess();
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
// A pool this small has nothing to share out and still pays for its workers, so an
|
|
655
|
+
// explicit request for them lands in-process as `--workers=1` does
|
|
656
|
+
const poolSize = Math.min(size, fileCount);
|
|
657
|
+
if (poolSize <= 1) {
|
|
658
|
+
return inProcess();
|
|
659
|
+
}
|
|
660
|
+
|
|
236
661
|
try {
|
|
237
|
-
|
|
662
|
+
const { createFilePool } = await import('./src/lib/file-pool.js');
|
|
663
|
+
const pool = createFilePool({
|
|
664
|
+
options,
|
|
665
|
+
size: poolSize,
|
|
666
|
+
// `log` is only set when the run asks for diagnostics
|
|
667
|
+
onLog: log
|
|
668
|
+
? (/** @type {string} */ message, /** @type {boolean} */ isError, /** @type {string} */ file) =>
|
|
669
|
+
createLog(path.relative(process.cwd(), file))(isError ? new Error(message) : message)
|
|
670
|
+
: undefined
|
|
671
|
+
});
|
|
672
|
+
if (isVerbose) console.error(`Worker threads: ${poolSize}`);
|
|
673
|
+
return pool;
|
|
674
|
+
} catch {
|
|
675
|
+
return inProcess();
|
|
238
676
|
}
|
|
239
|
-
|
|
240
|
-
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
async function printCacheStats() {
|
|
680
|
+
const { getCacheStats } = await import('./src/htmlminifier.js');
|
|
681
|
+
const cacheStats = getCacheStats();
|
|
682
|
+
/** @type {Record<string, string>} */
|
|
683
|
+
const labels = { css: 'CSS', js: 'JS', svg: 'SVG' };
|
|
684
|
+
|
|
685
|
+
/** @type {string[]} */
|
|
686
|
+
const lines = [];
|
|
687
|
+
for (const [key, { gets, hits, size, limit }] of Object.entries(cacheStats)) {
|
|
688
|
+
// Untouched caches (e.g., with their minifier disabled) are left out
|
|
689
|
+
if (gets === 0) continue;
|
|
690
|
+
const misses = gets - hits;
|
|
691
|
+
lines.push(` ${labels[key]} cache: ${hits.toLocaleString()} hit${hits === 1 ? '' : 's'}, ${misses.toLocaleString()} miss${misses === 1 ? '' : 'es'}, ${size.toLocaleString()}/${limit.toLocaleString()} entries`);
|
|
241
692
|
}
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
693
|
+
|
|
694
|
+
if (lines.length === 0) return;
|
|
695
|
+
console.error('Cache stats:');
|
|
696
|
+
lines.forEach(line => console.error(line));
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
/**
|
|
700
|
+
* @param {string} inputFile
|
|
701
|
+
* @param {string} outputFile
|
|
702
|
+
* @param {boolean} [isDryRun]
|
|
703
|
+
* @param {boolean} [isVerbose]
|
|
704
|
+
*/
|
|
705
|
+
async function processFile(inputFile, outputFile, isDryRun = false, isVerbose = false) {
|
|
706
|
+
const data = await fs.promises.readFile(inputFile, { encoding: 'utf8' }).catch(err => {
|
|
707
|
+
fatal('Cannot read ' + inputFile + '\n' + errorMessage(err));
|
|
708
|
+
});
|
|
709
|
+
|
|
710
|
+
let minified;
|
|
711
|
+
try {
|
|
712
|
+
const minify = await getMinify();
|
|
713
|
+
const fileOptions = createOptions();
|
|
714
|
+
if (fileOptions.log) {
|
|
715
|
+
fileOptions.log = createLog(path.relative(process.cwd(), inputFile));
|
|
245
716
|
}
|
|
717
|
+
minified = await minify(data, fileOptions);
|
|
718
|
+
} catch (err) {
|
|
719
|
+
fatal('Minification error on ' + inputFile + '\n' + errorMessage(err));
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
const stats = calculateStats(data, minified);
|
|
723
|
+
|
|
724
|
+
if (isDryRun || isVerbose) {
|
|
725
|
+
reportFileStats(path.relative(process.cwd(), inputFile), stats);
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
if (!isDryRun) {
|
|
729
|
+
await writeFileAtomic(outputFile, minified).catch(err => {
|
|
730
|
+
fatal('Cannot write ' + outputFile + '\n' + errorMessage(err));
|
|
731
|
+
});
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
return { originalSize: stats.originalSize, minifiedSize: stats.minifiedSize, saved: stats.saved };
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
/**
|
|
738
|
+
* @param {string} fileExt
|
|
739
|
+
* @returns {string[]}
|
|
740
|
+
*/
|
|
741
|
+
function parseFileExtensions(fileExt) {
|
|
742
|
+
if (!fileExt) return [];
|
|
743
|
+
if (fileExt.trim() === '*') return ['*'];
|
|
744
|
+
const list = fileExt
|
|
745
|
+
.split(',')
|
|
746
|
+
.map(ext => ext.trim().replace(/^\.+/, '').toLowerCase())
|
|
747
|
+
.filter(ext => ext.length > 0);
|
|
748
|
+
return [...new Set(list)];
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
/**
|
|
752
|
+
* @param {string} filename
|
|
753
|
+
* @param {string[]} fileExtensions
|
|
754
|
+
*/
|
|
755
|
+
function shouldProcessFile(filename, fileExtensions) {
|
|
756
|
+
if (fileExtensions.includes('*')) {
|
|
757
|
+
return true;
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
const fileExt = path.extname(filename).replace(/^\.+/, '').toLowerCase();
|
|
761
|
+
return fileExtensions.includes(fileExt);
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
/**
|
|
765
|
+
* @param {string} patterns - Comma-separated directory patterns (e.g., "libs,vendor")
|
|
766
|
+
* @returns {string[]} Array of trimmed pattern strings with normalized separators
|
|
767
|
+
*/
|
|
768
|
+
function parseIgnorePatterns(patterns) {
|
|
769
|
+
if (!patterns) return [];
|
|
770
|
+
return patterns
|
|
771
|
+
.split(',')
|
|
772
|
+
.map(p => p.trim().replace(/\\/g, '/').replace(/\/+$/, ''))
|
|
773
|
+
.filter(p => p.length > 0);
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
/**
|
|
777
|
+
* Check if a directory should be ignored based on ignore patterns.
|
|
778
|
+
* Supports matching by directory name or relative path.
|
|
779
|
+
* @param {string} dirPath - Absolute path to the directory
|
|
780
|
+
* @param {string[]} ignorePatterns - Array of patterns to match against (with forward slashes)
|
|
781
|
+
* @param {string} baseDir - Base directory for relative path calculation
|
|
782
|
+
* @returns {boolean} True if directory should be ignored
|
|
783
|
+
*/
|
|
784
|
+
function shouldIgnoreDirectory(dirPath, ignorePatterns, baseDir) {
|
|
785
|
+
if (!ignorePatterns || ignorePatterns.length === 0) return false;
|
|
786
|
+
|
|
787
|
+
// Normalize to forward slashes for cross-platform comparison
|
|
788
|
+
const relativePath = path.relative(baseDir, dirPath).replace(/\\/g, '/');
|
|
789
|
+
const dirName = path.basename(dirPath);
|
|
790
|
+
|
|
791
|
+
return ignorePatterns.some(pattern => {
|
|
792
|
+
// Support both exact directory names and relative paths
|
|
793
|
+
return dirName === pattern || relativePath === pattern || relativePath.startsWith(pattern + '/');
|
|
246
794
|
});
|
|
247
|
-
}
|
|
248
|
-
}
|
|
795
|
+
}
|
|
249
796
|
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
797
|
+
/**
|
|
798
|
+
* @param {number} current
|
|
799
|
+
* @param {number} total
|
|
800
|
+
*/
|
|
801
|
+
function updateProgress(current, total) {
|
|
802
|
+
// Clear the line first, then write simple progress
|
|
803
|
+
process.stderr.write(`\r\x1b[K`);
|
|
804
|
+
const percentage = ((current / total) * 100).toFixed(1);
|
|
805
|
+
process.stderr.write(`Processing ${current.toLocaleString()}/${total.toLocaleString()} (${percentage}%)`);
|
|
806
|
+
}
|
|
807
|
+
|
|
808
|
+
function clearProgress() {
|
|
809
|
+
process.stderr.write('\r\x1b[K'); // Clear the line
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
/**
|
|
813
|
+
* @template T, R
|
|
814
|
+
* @param {T[]} items
|
|
815
|
+
* @param {number} limit
|
|
816
|
+
* @param {(item: T, index: number) => Promise<R>} worker
|
|
817
|
+
* @returns {Promise<R[]>}
|
|
818
|
+
*/
|
|
819
|
+
async function runWithConcurrency(items, limit, worker) {
|
|
820
|
+
/** @type {R[]} */
|
|
821
|
+
const results = new Array(items.length);
|
|
822
|
+
let next = 0;
|
|
823
|
+
let active = 0;
|
|
824
|
+
return new Promise((resolve, reject) => {
|
|
825
|
+
const launch = () => {
|
|
826
|
+
while (active < limit && next < items.length) {
|
|
827
|
+
const current = next++;
|
|
828
|
+
const item = /** @type {T} */ (items[current]);
|
|
829
|
+
active++;
|
|
830
|
+
Promise.resolve(worker(item, current))
|
|
831
|
+
.then((res) => {
|
|
832
|
+
results[current] = res;
|
|
833
|
+
active--;
|
|
834
|
+
launch();
|
|
835
|
+
})
|
|
836
|
+
.catch(reject);
|
|
264
837
|
}
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
processFile(inputFile, outputFile);
|
|
268
|
-
});
|
|
838
|
+
if (next >= items.length && active === 0) {
|
|
839
|
+
resolve(results);
|
|
269
840
|
}
|
|
270
|
-
}
|
|
841
|
+
};
|
|
842
|
+
launch();
|
|
271
843
|
});
|
|
272
|
-
});
|
|
273
|
-
}
|
|
274
|
-
|
|
275
|
-
function writeMinify() {
|
|
276
|
-
var minified;
|
|
277
|
-
try {
|
|
278
|
-
minified = minify(content, createOptions());
|
|
279
844
|
}
|
|
280
|
-
|
|
281
|
-
|
|
845
|
+
|
|
846
|
+
/**
|
|
847
|
+
* @param {string} dir
|
|
848
|
+
* @param {string[]} extensions
|
|
849
|
+
* @param {string | undefined} skipRootAbs
|
|
850
|
+
* @param {string[]} ignorePatterns
|
|
851
|
+
* @param {string} baseDir
|
|
852
|
+
* @returns {Promise<string[]>}
|
|
853
|
+
*/
|
|
854
|
+
async function collectFiles(dir, extensions, skipRootAbs, ignorePatterns, baseDir) {
|
|
855
|
+
/** @type {string[]} */
|
|
856
|
+
const out = [];
|
|
857
|
+
const entries = await fs.promises.readdir(dir).catch(() => []);
|
|
858
|
+
for (const name of entries) {
|
|
859
|
+
const filePath = path.join(dir, name);
|
|
860
|
+
if (skipRootAbs) {
|
|
861
|
+
const real = await fs.promises.realpath(filePath).catch(() => undefined);
|
|
862
|
+
if (real && (real === skipRootAbs || real.startsWith(skipRootAbs + path.sep))) continue;
|
|
863
|
+
}
|
|
864
|
+
const lst = await fs.promises.lstat(filePath).catch(() => null);
|
|
865
|
+
if (!lst || lst.isSymbolicLink()) continue;
|
|
866
|
+
if (lst.isDirectory()) {
|
|
867
|
+
if (shouldIgnoreDirectory(filePath, ignorePatterns, baseDir)) continue;
|
|
868
|
+
const sub = await collectFiles(filePath, extensions, skipRootAbs, ignorePatterns, baseDir);
|
|
869
|
+
out.push(...sub);
|
|
870
|
+
} else if (shouldProcessFile(name, extensions)) {
|
|
871
|
+
out.push(filePath);
|
|
872
|
+
}
|
|
873
|
+
}
|
|
874
|
+
return out;
|
|
282
875
|
}
|
|
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
876
|
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
877
|
+
/**
|
|
878
|
+
* @param {string} inputDir
|
|
879
|
+
* @param {string} outputDir
|
|
880
|
+
* @param {string[]} extensions
|
|
881
|
+
* @param {boolean} isDryRun
|
|
882
|
+
* @param {boolean} isVerbose
|
|
883
|
+
* @param {string | undefined} skipRootAbs
|
|
884
|
+
* @param {boolean} showProgress
|
|
885
|
+
* @param {string[]} ignorePatterns
|
|
886
|
+
* @param {string} baseDir
|
|
887
|
+
*/
|
|
888
|
+
async function processDirectory(inputDir, outputDir, extensions, isDryRun, isVerbose, skipRootAbs, showProgress, ignorePatterns, baseDir) {
|
|
889
|
+
// Collect all files first for bounded parallel processing
|
|
890
|
+
const list = await collectFiles(inputDir, extensions, skipRootAbs, ignorePatterns, baseDir);
|
|
891
|
+
const allStats = new Array(list.length);
|
|
892
|
+
|
|
893
|
+
const prepareOutDir = async (/** @type {string} */ inputFile) => {
|
|
894
|
+
const outFile = path.join(outputDir, path.relative(inputDir, inputFile));
|
|
895
|
+
if (!isDryRun) {
|
|
896
|
+
await fs.promises.mkdir(path.dirname(outFile), { recursive: true }).catch(err => {
|
|
897
|
+
fatal('Cannot create directory ' + path.dirname(outFile) + '\n' + errorMessage(err));
|
|
898
|
+
});
|
|
899
|
+
}
|
|
900
|
+
return outFile;
|
|
901
|
+
};
|
|
902
|
+
|
|
903
|
+
let done = 0;
|
|
904
|
+
const advanceProgress = () => {
|
|
905
|
+
if (showProgress) updateProgress(++done, list.length);
|
|
906
|
+
};
|
|
907
|
+
|
|
908
|
+
const pool = await createPool(list);
|
|
909
|
+
if (pool) {
|
|
910
|
+
try {
|
|
911
|
+
const { orderLargestFirst } = await import('./src/lib/file-pool.js');
|
|
912
|
+
const [fileSizes, tasks] = await Promise.all([
|
|
913
|
+
getFileSizes(list),
|
|
914
|
+
Promise.all(list.map(async (inputFile, idx) => ({ inputFile, outFile: await prepareOutDir(inputFile), idx })))
|
|
915
|
+
]);
|
|
916
|
+
// `run()` queues synchronously, so the files reach the pool in exactly this order
|
|
917
|
+
await Promise.all(orderLargestFirst(tasks, fileSizes).map(async ({ inputFile, outFile, idx }) => {
|
|
918
|
+
const sizes = await pool.run({ inputFile, outputFile: outFile, dryRun: isDryRun })
|
|
919
|
+
.catch(err => {
|
|
920
|
+
// A worker names the step it failed at, so the wording matches what this
|
|
921
|
+
// file would have got had it been minified in this process
|
|
922
|
+
if (err?.stage === 'read') fatal('Cannot read ' + inputFile + '\n' + errorMessage(err));
|
|
923
|
+
if (err?.stage === 'write') fatal('Cannot write ' + outFile + '\n' + errorMessage(err));
|
|
924
|
+
fatal('Minification error on ' + inputFile + '\n' + errorMessage(err));
|
|
925
|
+
});
|
|
926
|
+
const stats = calculateSizeStats(sizes.originalSize, sizes.minifiedSize);
|
|
927
|
+
if (isDryRun || isVerbose) {
|
|
928
|
+
reportFileStats(path.relative(process.cwd(), inputFile), stats);
|
|
929
|
+
}
|
|
930
|
+
allStats[idx] = { originalSize: stats.originalSize, minifiedSize: stats.minifiedSize, saved: stats.saved };
|
|
931
|
+
advanceProgress();
|
|
932
|
+
}));
|
|
933
|
+
} finally {
|
|
934
|
+
await pool.close();
|
|
935
|
+
}
|
|
936
|
+
} else {
|
|
937
|
+
await runWithConcurrency(list, CONCURRENCY_IN_PROCESS, async (inputFile, idx) => {
|
|
938
|
+
const outFile = await prepareOutDir(inputFile);
|
|
939
|
+
allStats[idx] = await processFile(inputFile, outFile, isDryRun, isVerbose);
|
|
940
|
+
advanceProgress();
|
|
941
|
+
});
|
|
942
|
+
}
|
|
943
|
+
if (showProgress) clearProgress();
|
|
944
|
+
return allStats.filter(Boolean);
|
|
294
945
|
}
|
|
295
|
-
|
|
296
|
-
|
|
946
|
+
|
|
947
|
+
const writeMinify = async () => {
|
|
948
|
+
const minifierOptions = createOptions();
|
|
949
|
+
|
|
950
|
+
if (programOptions.verbose || programOptions.dry) {
|
|
951
|
+
getActiveOptionsDisplay(minifierOptions);
|
|
952
|
+
}
|
|
953
|
+
|
|
954
|
+
let minified;
|
|
955
|
+
|
|
956
|
+
try {
|
|
957
|
+
const minify = await getMinify();
|
|
958
|
+
minified = await minify(content, minifierOptions);
|
|
959
|
+
} catch (err) {
|
|
960
|
+
fatal('Minification error:\n' + errorMessage(err));
|
|
961
|
+
}
|
|
962
|
+
|
|
963
|
+
await reportAndWrite(program.args.length > 0 ? program.args.join(', ') : 'STDIN', content, minified);
|
|
964
|
+
};
|
|
965
|
+
|
|
966
|
+
const { inputDir, outputDir, fileExt, ignoreDir } = programOptions;
|
|
967
|
+
|
|
968
|
+
// Resolve file extensions: CLI argument > config file > defaults
|
|
969
|
+
const hasCliFileExt = program.getOptionValueSource('fileExt') === 'cli';
|
|
970
|
+
const resolvedFileExt = hasCliFileExt ? (fileExt || '*') : (config.fileExt || EXTENSIONS_DEFAULT);
|
|
971
|
+
|
|
972
|
+
// Resolve ignore patterns: CLI argument takes priority over config file
|
|
973
|
+
const hasCliIgnoreDir = program.getOptionValueSource('ignoreDir') === 'cli';
|
|
974
|
+
const resolvedIgnoreDir = hasCliIgnoreDir ? ignoreDir : config.ignoreDir;
|
|
975
|
+
|
|
976
|
+
if (inputDir || outputDir) {
|
|
977
|
+
if (!inputDir) {
|
|
978
|
+
fatal('The option `output-dir` needs to be used with the option `input-dir`—if you are working with a single file, use `--input`/`--output`');
|
|
979
|
+
} else if (!outputDir) {
|
|
980
|
+
fatal('You need to specify where to write the output files with the option `--output-dir`');
|
|
981
|
+
}
|
|
982
|
+
|
|
983
|
+
const extList = Array.isArray(resolvedFileExt) ? resolvedFileExt : parseFileExtensions(String(resolvedFileExt || ''));
|
|
984
|
+
{
|
|
985
|
+
const isWildcard = extList.includes('*');
|
|
986
|
+
const nonHtmlExts = isWildcard ? [] : extList.filter(e => EXTENSIONS_NON_HTML.has(e));
|
|
987
|
+
if (isWildcard || nonHtmlExts.length > 0) {
|
|
988
|
+
const label = isWildcard ? 'all file types' : nonHtmlExts.map(e => `.${e}`).join(', ');
|
|
989
|
+
console.error(`${MARK_WARNING}Warning: Processing ${label}—HTML Minifier processes CSS, JavaScript, and SVG only when embedded in HTML. Non-HTML files may produce incomplete or broken output.${MARK_RESET}`);
|
|
990
|
+
}
|
|
991
|
+
}
|
|
992
|
+
|
|
993
|
+
await (async () => {
|
|
994
|
+
// `--dry` automatically enables verbose mode
|
|
995
|
+
const isVerbose = programOptions.verbose || programOptions.dry;
|
|
996
|
+
|
|
997
|
+
if (isVerbose) {
|
|
998
|
+
const minifierOptions = createOptions();
|
|
999
|
+
getActiveOptionsDisplay(minifierOptions);
|
|
1000
|
+
}
|
|
1001
|
+
|
|
1002
|
+
// Prevent traversing into the output directory when it is inside the input directory
|
|
1003
|
+
let outputReal;
|
|
1004
|
+
const inputReal = await fs.promises.realpath(inputDir).catch(() => undefined);
|
|
1005
|
+
try {
|
|
1006
|
+
outputReal = await fs.promises.realpath(outputDir);
|
|
1007
|
+
} catch {
|
|
1008
|
+
outputReal = path.resolve(outputDir);
|
|
1009
|
+
}
|
|
1010
|
+
let skipRootAbs;
|
|
1011
|
+
if (inputReal && outputReal && outputReal !== inputReal && outputReal.startsWith(inputReal + path.sep)) {
|
|
1012
|
+
skipRootAbs = outputReal;
|
|
1013
|
+
}
|
|
1014
|
+
|
|
1015
|
+
if (programOptions.dry) {
|
|
1016
|
+
console.error(`[DRY RUN] Would process directory: ${inputDir} → ${outputDir}`);
|
|
1017
|
+
}
|
|
1018
|
+
|
|
1019
|
+
const showProgress = Boolean(process.stderr.isTTY) && !isVerbose;
|
|
1020
|
+
const ignorePatterns = parseIgnorePatterns(resolvedIgnoreDir);
|
|
1021
|
+
|
|
1022
|
+
try {
|
|
1023
|
+
const stat = await fs.promises.stat(inputDir);
|
|
1024
|
+
if (!stat.isDirectory()) {
|
|
1025
|
+
fatal(`${inputDir} is not a directory—to minify a single file, use \`--input\`/\`--output\`: html-minifier [options] -i ${inputDir} -o <output-file>`);
|
|
1026
|
+
}
|
|
1027
|
+
} catch (err) {
|
|
1028
|
+
fatal('Cannot read directory ' + inputDir + '\n' + errorMessage(err));
|
|
1029
|
+
}
|
|
1030
|
+
|
|
1031
|
+
// Resolve base directory for consistent path comparisons
|
|
1032
|
+
const inputDirResolved = inputReal || inputDir;
|
|
1033
|
+
|
|
1034
|
+
const stats = await processDirectory(inputDir, outputDir, extList, Boolean(programOptions.dry), Boolean(isVerbose), skipRootAbs, showProgress, ignorePatterns, inputDirResolved);
|
|
1035
|
+
|
|
1036
|
+
if (showProgress) {
|
|
1037
|
+
reportProcessed(stats.length);
|
|
1038
|
+
}
|
|
1039
|
+
|
|
1040
|
+
if (isVerbose && stats.length > 0) {
|
|
1041
|
+
const totalOriginal = stats.reduce((sum, s) => sum + s.originalSize, 0);
|
|
1042
|
+
const totalMinified = stats.reduce((sum, s) => sum + s.minifiedSize, 0);
|
|
1043
|
+
console.error('---');
|
|
1044
|
+
console.error(`Total: ${formatSizeStats(calculateSizeStats(totalOriginal, totalMinified))}`);
|
|
1045
|
+
}
|
|
1046
|
+
|
|
1047
|
+
if (isVerbose) {
|
|
1048
|
+
await printCacheStats();
|
|
1049
|
+
}
|
|
1050
|
+
})();
|
|
1051
|
+
} else if (filesProvided) { // Minifying one or more files specified on the command line
|
|
1052
|
+
// Process each file independently, then concatenate outputs
|
|
1053
|
+
const minifierOptions = createOptions();
|
|
1054
|
+
if (programOptions.verbose || programOptions.dry) {
|
|
1055
|
+
getActiveOptionsDisplay(minifierOptions);
|
|
1056
|
+
}
|
|
1057
|
+
|
|
1058
|
+
for (const file of capturedFiles) {
|
|
1059
|
+
const ext = path.extname(file).replace(/^\./, '').toLowerCase();
|
|
1060
|
+
if (EXTENSIONS_NON_HTML.has(ext)) {
|
|
1061
|
+
console.error(`${MARK_WARNING}Warning: ${path.basename(file)} does not appear to be an HTML file—HTML Minifier processes CSS, JavaScript, and SVG only when embedded in HTML. The output may be incomplete or broken.${MARK_RESET}`);
|
|
1062
|
+
}
|
|
1063
|
+
}
|
|
1064
|
+
|
|
1065
|
+
const inputs = capturedFiles.slice();
|
|
1066
|
+
|
|
1067
|
+
const originals = new Array(inputs.length);
|
|
1068
|
+
const outputs = new Array(inputs.length);
|
|
1069
|
+
|
|
1070
|
+
await runWithConcurrency(inputs, CONCURRENCY_IN_PROCESS, async (file, idx) => {
|
|
1071
|
+
const data = await fs.promises.readFile(file, 'utf8').catch(err => fatal('Cannot read ' + file + '\n' + errorMessage(err)));
|
|
1072
|
+
const minify = await getMinify();
|
|
1073
|
+
let out;
|
|
1074
|
+
try {
|
|
1075
|
+
out = await minify(data, minifierOptions);
|
|
1076
|
+
} catch (err) {
|
|
1077
|
+
fatal('Minification error on ' + file + '\n' + errorMessage(err));
|
|
1078
|
+
}
|
|
1079
|
+
originals[idx] = data;
|
|
1080
|
+
outputs[idx] = out;
|
|
1081
|
+
});
|
|
1082
|
+
|
|
1083
|
+
await reportAndWrite(capturedFiles.join(', '), originals.join(''), outputs.join(''));
|
|
1084
|
+
process.exit(0);
|
|
1085
|
+
} else { // Minifying input coming from STDIN
|
|
1086
|
+
content = '';
|
|
1087
|
+
process.stdin.setEncoding('utf8');
|
|
1088
|
+
process.stdin.on('data', function (/** @type {string} */ data) {
|
|
1089
|
+
content += data;
|
|
1090
|
+
}).on('end', async function() {
|
|
1091
|
+
await writeMinify();
|
|
1092
|
+
process.exit(0);
|
|
1093
|
+
});
|
|
297
1094
|
}
|
|
298
|
-
|
|
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
|
-
}
|
|
1095
|
+
})();
|