html-minifier 3.5.21 → 5.0.0

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.
Files changed (55) hide show
  1. package/LICENSE.txt +23 -0
  2. package/README.md +851 -109
  3. package/cli.js +991 -206
  4. package/dist/types/htmlminifier.d.ts +629 -0
  5. package/dist/types/htmlminifier.d.ts.map +1 -0
  6. package/dist/types/htmlparser.d.ts +28 -0
  7. package/dist/types/htmlparser.d.ts.map +1 -0
  8. package/dist/types/lib/attributes.d.ts +87 -0
  9. package/dist/types/lib/attributes.d.ts.map +1 -0
  10. package/dist/types/lib/constants.d.ts +421 -0
  11. package/dist/types/lib/constants.d.ts.map +1 -0
  12. package/dist/types/lib/content.d.ts +44 -0
  13. package/dist/types/lib/content.d.ts.map +1 -0
  14. package/dist/types/lib/elements.d.ts +48 -0
  15. package/dist/types/lib/elements.d.ts.map +1 -0
  16. package/dist/types/lib/fragments.d.ts +47 -0
  17. package/dist/types/lib/fragments.d.ts.map +1 -0
  18. package/dist/types/lib/option-definitions.d.ts +33 -0
  19. package/dist/types/lib/option-definitions.d.ts.map +1 -0
  20. package/dist/types/lib/options.d.ts +93 -0
  21. package/dist/types/lib/options.d.ts.map +1 -0
  22. package/dist/types/lib/unused-css.d.ts +76 -0
  23. package/dist/types/lib/unused-css.d.ts.map +1 -0
  24. package/dist/types/lib/urls.d.ts +15 -0
  25. package/dist/types/lib/urls.d.ts.map +1 -0
  26. package/dist/types/lib/utils.d.ts +132 -0
  27. package/dist/types/lib/utils.d.ts.map +1 -0
  28. package/dist/types/lib/whitespace.d.ts +97 -0
  29. package/dist/types/lib/whitespace.d.ts.map +1 -0
  30. package/dist/types/presets.d.ts +50 -0
  31. package/dist/types/presets.d.ts.map +1 -0
  32. package/dist/types/tokenchain.d.ts +36 -0
  33. package/dist/types/tokenchain.d.ts.map +1 -0
  34. package/html-minifier.schema.json +322 -0
  35. package/package.json +70 -58
  36. package/src/htmlminifier.js +2848 -1095
  37. package/src/htmlparser.js +1165 -450
  38. package/src/lib/attributes.js +1196 -0
  39. package/src/lib/constants.js +521 -0
  40. package/src/lib/content.js +237 -0
  41. package/src/lib/elements.js +268 -0
  42. package/src/lib/file-pool.js +193 -0
  43. package/src/lib/file-write.js +87 -0
  44. package/src/lib/fragments.js +340 -0
  45. package/src/lib/option-definitions.js +246 -0
  46. package/src/lib/options.js +841 -0
  47. package/src/lib/unused-css.js +499 -0
  48. package/src/lib/urls.js +146 -0
  49. package/src/lib/utils.js +1026 -0
  50. package/src/lib/whitespace.js +431 -0
  51. package/src/presets.js +58 -0
  52. package/src/tokenchain.js +142 -56
  53. package/LICENSE +0 -22
  54. package/sample-cli-config-file.conf +0 -39
  55. 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
- * The MIT License (MIT)
6
+ * MIT License
6
7
  *
7
- * Copyright (c) 2014-2016 Zoltan Frombach
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
- 'use strict';
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
- var camelCase = require('camel-case');
31
- var fs = require('fs');
32
- var info = require('./package.json');
33
- var minify = require('./' + info.main).minify;
34
- var paramCase = require('param-case');
35
- var path = require('path');
36
- var program = require('commander');
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
- program._name = info.name;
39
- program.version(info.version);
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,252 +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
- * --customAttrSrround "[\"//matchString//\"]"
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
- catch (e) {
76
- if (/^{/.test(value)) {
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
- value = parseJSON(value);
87
- return Array.isArray(value) ? value : [value];
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
- value = parseJSONArray(value);
93
- return value && value.map(parseRegExp);
146
+ const values = parseJSONArray(value);
147
+ return values && values.map(parseRegExp);
94
148
  }
95
149
 
96
- function parseString(value) {
97
- return value;
98
- }
150
+ /** @param {string} value */
151
+ const parseString = value => value;
99
152
 
100
- var mainOptions = {
101
- caseSensitive: 'Treat attributes in case sensitive manner (useful for SVG; e.g. viewBox)',
102
- collapseBooleanAttributes: 'Omit attribute values from boolean attributes',
103
- collapseInlineTagWhitespace: 'Collapse white space around inline tag',
104
- collapseWhitespace: 'Collapse white space that contributes to text nodes in a document tree.',
105
- conservativeCollapse: 'Always collapse to 1 space (never remove it entirely)',
106
- customAttrAssign: ['Arrays of regex\'es that allow to support custom attribute assign expressions (e.g. \'<div flex?="{{mode != cover}}"></div>\')', parseJSONRegExpArray],
107
- customAttrCollapse: ['Regex that specifies custom attribute to strip newlines from (e.g. /ng-class/)', parseRegExp],
108
- customAttrSurround: ['Arrays of regex\'es that allow to support custom attribute surround expressions (e.g. <input {{#if value}}checked="checked"{{/if}}>)', parseJSONRegExpArray],
109
- customEventAttributes: ['Arrays of regex\'es that allow to support custom event attributes for minifyJS (e.g. ng-click)', parseJSONRegExpArray],
110
- decodeEntities: 'Use direct Unicode characters whenever possible',
111
- html5: 'Parse input according to HTML5 specifications',
112
- ignoreCustomComments: ['Array of regex\'es that allow to ignore certain comments, when matched', parseJSONRegExpArray],
113
- ignoreCustomFragments: ['Array of regex\'es that allow to ignore certain fragments, when matched (e.g. <?php ... ?>, {{ ... }})', parseJSONRegExpArray],
114
- includeAutoGeneratedTags: 'Insert tags generated by HTML parser',
115
- keepClosingSlash: 'Keep the trailing slash on singleton elements',
116
- maxLineLength: ['Max line length', parseInt],
117
- minifyCSS: ['Minify CSS in style elements and style attributes (uses clean-css)', parseJSON],
118
- minifyJS: ['Minify Javascript in script elements and on* attributes (uses uglify-js)', parseJSON],
119
- minifyURLs: ['Minify URLs in various attributes (uses relateurl)', parseJSON],
120
- preserveLineBreaks: 'Always collapse to 1 line break (never remove it entirely) when whitespace between tags include a line break.',
121
- preventAttributesEscaping: 'Prevents the escaping of the values of attributes.',
122
- processConditionalComments: 'Process contents of conditional comments through minifier',
123
- 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],
124
- quoteCharacter: ['Type of quote to use for attribute values (\' or ")', parseString],
125
- removeAttributeQuotes: 'Remove quotes around attributes when possible.',
126
- removeComments: 'Strip HTML comments',
127
- removeEmptyAttributes: 'Remove all attributes with whitespace-only values',
128
- removeEmptyElements: 'Remove all elements with empty contents',
129
- removeOptionalTags: 'Remove unrequired tags',
130
- removeRedundantAttributes: 'Remove attributes when value matches default.',
131
- removeScriptTypeAttributes: 'Remove type="text/javascript" from script tags. Other type attribute values are left intact.',
132
- removeStyleLinkTypeAttributes: 'Remove type="text/css" from style and link tags. Other type attribute values are left intact.',
133
- removeTagWhitespace: 'Remove space between attributes whenever possible',
134
- sortAttributes: 'Sort attributes by frequency',
135
- sortClassName: 'Sort style classes by frequency',
136
- trimCustomFragments: 'Trim white space around ignoreCustomFragments.',
137
- useShortDoctype: 'Replaces the doctype with the short (HTML5) doctype'
138
- };
139
- var mainOptionKeys = Object.keys(mainOptions);
140
- mainOptionKeys.forEach(function(key) {
141
- var option = mainOptions[key];
142
- if (Array.isArray(option)) {
143
- key = key === 'minifyURLs' ? '--minify-urls' : '--' + paramCase(key);
144
- key += option[1] === parseJSON ? ' [value]' : ' <value>';
145
- 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}"\``);
146
159
  }
147
- else if (~['html5', 'includeAutoGeneratedTags'].indexOf(key)) {
148
- program.option('--no-' + paramCase(key), option);
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`);
149
185
  }
150
- else {
151
- program.option('--' + paramCase(key), option);
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));
152
209
  }
153
210
  });
154
- program.option('-o --output <file>', 'Specify output file (if not specified STDOUT will be used for output)');
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
+ }
155
226
 
227
+ /** @param {string} file */
156
228
  function readFile(file) {
157
229
  try {
158
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)}`); }
159
256
  }
160
- catch (e) {
161
- fatal('Cannot read ' + file + '\n' + e.message);
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)}`);
162
266
  }
163
267
  }
164
268
 
165
- var config = {};
166
- program.option('-c --config-file <file>', 'Use config file', function(configPath) {
167
- var data = readFile(configPath);
168
- try {
169
- config = JSON.parse(data);
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;
170
294
  }
171
- catch (je) {
172
- try {
173
- config = require(path.resolve(configPath));
174
- }
175
- catch (ne) {
176
- fatal('Cannot read the specified config file.\nAs JSON: ' + je.message + '\nAs module: ' + ne.message);
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)`);
177
310
  }
178
- }
179
- mainOptionKeys.forEach(function(key) {
180
- if (key in config) {
181
- var option = mainOptions[key];
182
- if (Array.isArray(option)) {
183
- var value = config[key];
184
- config[key] = option[1](typeof value === 'string' ? value : JSON.stringify(value));
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]);
185
318
  }
186
319
  }
187
320
  });
188
- });
189
- program.option('--input-dir <dir>', 'Specify an input directory');
190
- program.option('--output-dir <dir>', 'Specify an output directory');
191
- program.option('--file-ext <text>', 'Specify an extension to be read, ex: html');
192
- var content;
193
- program.arguments('[files...]').action(function(files) {
194
- content = files.map(readFile).join('');
195
- }).parse(process.argv);
196
-
197
- function createOptions() {
198
- var options = {};
199
- mainOptionKeys.forEach(function(key) {
200
- var param = program[key === 'minifyURLs' ? 'minifyUrls' : camelCase(key)];
201
- if (typeof param !== 'undefined') {
202
- options[key] = param;
203
- }
204
- else if (key in config) {
205
- 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(',');
206
326
  }
207
- });
208
- return options;
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;
209
337
  }
210
338
 
211
- function mkdir(outputDir, callback) {
212
- fs.mkdir(outputDir, function(err) {
213
- if (err) {
214
- switch (err.code) {
215
- case 'ENOENT':
216
- return mkdir(path.join(outputDir, '..'), function() {
217
- mkdir(outputDir, callback);
218
- });
219
- case 'EEXIST':
220
- break;
221
- default:
222
- fatal('Cannot create directory ' + outputDir + '\n' + err.message);
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);
223
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;
224
572
  }
225
- callback();
226
- });
227
- }
228
573
 
229
- function processFile(inputFile, outputFile) {
230
- fs.readFile(inputFile, { encoding: 'utf8' }, function(err, data) {
231
- if (err) {
232
- fatal('Cannot read ' + inputFile + '\n' + err.message);
574
+ if (programOptions.verbose) {
575
+ reportFileStats(inputSource, stats);
576
+ await printCacheStats();
233
577
  }
234
- var minified;
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
+
235
661
  try {
236
- minified = minify(data, createOptions());
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();
237
676
  }
238
- catch (e) {
239
- fatal('Minification error on ' + inputFile + '\n' + e.message);
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`);
240
692
  }
241
- fs.writeFile(outputFile, minified, { encoding: 'utf8' }, function(err) {
242
- if (err) {
243
- fatal('Cannot write ' + outputFile + '\n' + err.message);
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));
244
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 + '/');
245
794
  });
246
- });
247
- }
795
+ }
248
796
 
249
- function processDirectory(inputDir, outputDir, fileExt) {
250
- fs.readdir(inputDir, function(err, files) {
251
- if (err) {
252
- fatal('Cannot read directory ' + inputDir + '\n' + err.message);
253
- }
254
- files.forEach(function(file) {
255
- var inputFile = path.join(inputDir, file);
256
- var outputFile = path.join(outputDir, file);
257
- fs.stat(inputFile, function(err, stat) {
258
- if (err) {
259
- fatal('Cannot read ' + inputFile + '\n' + err.message);
260
- }
261
- else if (stat.isDirectory()) {
262
- processDirectory(inputFile, outputFile, fileExt);
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);
263
837
  }
264
- else if (!fileExt || path.extname(file) === '.' + fileExt) {
265
- mkdir(outputDir, function() {
266
- processFile(inputFile, outputFile);
267
- });
838
+ if (next >= items.length && active === 0) {
839
+ resolve(results);
268
840
  }
269
- });
841
+ };
842
+ launch();
270
843
  });
271
- });
272
- }
273
-
274
- function writeMinify() {
275
- var minified;
276
- try {
277
- minified = minify(content, createOptions());
278
844
  }
279
- catch (e) {
280
- fatal('Minification error:\n' + e.message);
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;
281
875
  }
282
- (program.output ? fs.createWriteStream(program.output).on('error', function(e) {
283
- fatal('Cannot write ' + program.output + '\n' + e.message);
284
- }) : process.stdout).write(minified);
285
- }
286
876
 
287
- var inputDir = program.inputDir;
288
- var outputDir = program.outputDir;
289
- var fileExt = program.fileExt;
290
- if (inputDir || outputDir) {
291
- if (!inputDir) {
292
- fatal('The option output-dir needs to be used with the option input-dir. If you are working with a single file, use -o.');
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);
293
945
  }
294
- else if (!outputDir) {
295
- fatal('You need to specify where to write the output files with the option --output-dir');
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
+ });
296
1094
  }
297
- processDirectory(inputDir, outputDir, fileExt);
298
- }
299
- // Minifying one or more files specified on the CMD line
300
- else if (content) {
301
- writeMinify();
302
- }
303
- // Minifying input coming from STDIN
304
- else {
305
- content = '';
306
- process.stdin.setEncoding('utf8');
307
- process.stdin.on('data', function(data) {
308
- content += data;
309
- }).on('end', writeMinify);
310
- }
1095
+ })();