html-minifier-next 3.0.0 → 3.2.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 (3) hide show
  1. package/README.md +121 -83
  2. package/cli.js +381 -120
  3. package/package.json +6 -6
package/README.md CHANGED
@@ -1,7 +1,6 @@
1
1
  # HTML Minifier Next
2
2
 
3
- [![npm version](https://img.shields.io/npm/v/html-minifier-next.svg)](https://www.npmjs.com/package/html-minifier-next)
4
- [![Build status](https://github.com/j9t/html-minifier-next/workflows/Tests/badge.svg)](https://github.com/j9t/html-minifier-next/actions)
3
+ [![npm version](https://img.shields.io/npm/v/html-minifier-next.svg)](https://www.npmjs.com/package/html-minifier-next) [![Build status](https://github.com/j9t/html-minifier-next/workflows/Tests/badge.svg)](https://github.com/j9t/html-minifier-next/actions)
5
4
 
6
5
  HTML Minifier Next (HMN) is a highly **configurable, well-tested, JavaScript-based HTML minifier**.
7
6
 
@@ -21,35 +20,9 @@ From npm for programmatic use:
21
20
  npm i html-minifier-next
22
21
  ```
23
22
 
24
- ## Usage
23
+ ## General usage
25
24
 
26
- **Note** that almost all options are disabled by default. Experiment and find what works best for you and your project.
27
-
28
- **Sample command line:**
29
-
30
- ```bash
31
- html-minifier-next --collapse-whitespace --remove-comments --minify-js true --input-dir=. --output-dir=example
32
- ```
33
-
34
- **Process specific file extensions:**
35
-
36
- ```bash
37
- # Process only HTML files (CLI method)
38
- html-minifier-next --collapse-whitespace --input-dir=src --output-dir=dist --file-ext=html
39
-
40
- # Process multiple file extensions (CLI method)
41
- html-minifier-next --collapse-whitespace --input-dir=src --output-dir=dist --file-ext=html,htm,php
42
-
43
- # Using configuration file that sets `fileExt` (e.g., `"fileExt": "html,htm"`)
44
- html-minifier-next --config-file=html-minifier.json --input-dir=src --output-dir=dist
45
-
46
- # Process all files (default behavior)
47
- html-minifier-next --collapse-whitespace --input-dir=src --output-dir=dist
48
- # Note: When processing all files, non-HTML files will also be read as UTF‑8 and passed to the minifier.
49
- # Consider restricting with “--file-ext” to avoid touching binaries (e.g., images, archives).
50
- ```
51
-
52
- ### CLI options
25
+ ### CLI
53
26
 
54
27
  Use `html-minifier-next --help` to check all available options:
55
28
 
@@ -58,8 +31,11 @@ Use `html-minifier-next --help` to check all available options:
58
31
  | `--input-dir <dir>` | Specify an input directory | `--input-dir=src` |
59
32
  | `--output-dir <dir>` | Specify an output directory | `--output-dir=dist` |
60
33
  | `--file-ext <extensions>` | Specify file extension(s) to process (overrides config file setting) | `--file-ext=html`, `--file-ext=html,htm,php`, `--file-ext="html, htm, php"` |
61
- | `-o --output <file>` | Specify output file (single file mode) | `-o minified.html` |
62
- | `-c --config-file <file>` | Use a configuration file | `--config-file=html-minifier.json` |
34
+ | `-o <file>`, `--output <file>` | Specify output file (reads from file arguments or STDIN) | File to file: `html-minifier-next input.html -o output.html`<br>Pipe to file: `cat input.html \| html-minifier-next -o output.html`<br>File to STDOUT: `html-minifier-next input.html` |
35
+ | `-c <file>`, `--config-file <file>` | Use a configuration file | `--config-file=html-minifier.json` |
36
+ | `-v`, `--verbose` | Show detailed processing information (active options, file statistics) | `html-minifier-next --input-dir=src --output-dir=dist --verbose --collapse-whitespace` |
37
+ | `-d`, `--dry` | Dry run: Process and report statistics without writing output | `html-minifier-next input.html --dry --collapse-whitespace` |
38
+ | `-V`, `--version` | Output the version number | `html-minifier-next --version` |
63
39
 
64
40
  ### Configuration file
65
41
 
@@ -123,7 +99,62 @@ const { minify } = require('html-minifier-next');
123
99
 
124
100
  See [the original blog post](http://perfectionkills.com/experimenting-with-html-minifier) for details of [how it works](http://perfectionkills.com/experimenting-with-html-minifier#how_it_works), [description of each option](http://perfectionkills.com/experimenting-with-html-minifier#options), [testing results](http://perfectionkills.com/experimenting-with-html-minifier#field_testing), and [conclusions](http://perfectionkills.com/experimenting-with-html-minifier#cost_and_benefits).
125
101
 
126
- For lint-like capabilities take a look at [HTMLLint](https://github.com/kangax/html-lint).
102
+ For lint-like capabilities, take a look at [HTMLLint](https://github.com/kangax/html-lint).
103
+
104
+ ## Options quick reference
105
+
106
+ Most of the options are disabled by default. Experiment and find what works best for you and your project.
107
+
108
+ Options can be used in config files (camelCase) or via CLI flags (kebab-case with `--` prefix). Options that default to `true` use `--no-` prefix in CLI to disable them.
109
+
110
+ | Option (config/CLI) | Description | Default |
111
+ | --- | --- | --- |
112
+ | `caseSensitive`<br>`--case-sensitive` | Treat attributes in case-sensitive manner (useful for custom HTML elements) | `false` |
113
+ | `collapseBooleanAttributes`<br>`--collapse-boolean-attributes` | [Omit attribute values from boolean attributes](http://perfectionkills.com/experimenting-with-html-minifier#collapse_boolean_attributes) | `false` |
114
+ | `collapseInlineTagWhitespace`<br>`--collapse-inline-tag-whitespace` | Don’t leave any spaces between `display: inline;` elements when collapsing—use with `collapseWhitespace=true` | `false` |
115
+ | `collapseWhitespace`<br>`--collapse-whitespace` | [Collapse whitespace that contributes to text nodes in a document tree](http://perfectionkills.com/experimenting-with-html-minifier#collapse_whitespace) | `false` |
116
+ | `conservativeCollapse`<br>`--conservative-collapse` | Always collapse to 1 space (never remove it entirely)—use with `collapseWhitespace=true` | `false` |
117
+ | `continueOnParseError`<br>`--continue-on-parse-error` | [Handle parse errors](https://html.spec.whatwg.org/multipage/parsing.html#parse-errors) instead of aborting | `false` |
118
+ | `customAttrAssign`<br>`--custom-attr-assign` | Arrays of regexes that allow to support custom attribute assign expressions (e.g., `<div flex?="{{mode != cover}}"></div>`) | `[]` |
119
+ | `customAttrCollapse`<br>`--custom-attr-collapse` | Regex that specifies custom attribute to strip newlines from (e.g., `/ng-class/`) | |
120
+ | `customAttrSurround`<br>`--custom-attr-surround` | Arrays of regexes that allow to support custom attribute surround expressions (e.g., `<input {{#if value}}checked="checked"{{/if}}>`) | `[]` |
121
+ | `customEventAttributes`<br>`--custom-event-attributes` | Arrays of regexes that allow to support custom event attributes for `minifyJS` (e.g., `ng-click`) | `[ /^on[a-z]{3,}$/ ]` |
122
+ | `customFragmentQuantifierLimit`<br>`--custom-fragment-quantifier-limit` | Set maximum quantifier limit for custom fragments to prevent ReDoS attacks | `200` |
123
+ | `decodeEntities`<br>`--decode-entities` | Use direct Unicode characters whenever possible | `false` |
124
+ | `html5`<br>`--no-html5` | Parse input according to the HTML specification | `true` |
125
+ | `ignoreCustomComments`<br>`--ignore-custom-comments` | Array of regexes that allow to ignore certain comments, when matched | `[ /^!/, /^\s*#/ ]` |
126
+ | `ignoreCustomFragments`<br>`--ignore-custom-fragments` | Array of regexes that allow to ignore certain fragments, when matched (e.g., `<?php … ?>`, `{{ … }}`, etc.) | `[ /<%[\s\S]*?%>/, /<\?[\s\S]*?\?>/ ]` |
127
+ | `includeAutoGeneratedTags`<br>`--no-include-auto-generated-tags` | Insert elements generated by HTML parser | `true` |
128
+ | `inlineCustomElements`<br>`--inline-custom-elements` | Array of names of custom elements which are inline | `[]` |
129
+ | `keepClosingSlash`<br>`--keep-closing-slash` | Keep the trailing slash on void elements | `false` |
130
+ | `maxInputLength`<br>`--max-input-length` | Maximum input length to prevent ReDoS attacks (disabled by default) | `undefined` |
131
+ | `maxLineLength`<br>`--max-line-length` | Specify a maximum line length; compressed output will be split by newlines at valid HTML split-points | |
132
+ | `minifyCSS`<br>`--minify-css` | Minify CSS in `style` elements and `style` attributes (uses [clean-css](https://github.com/jakubpawlowicz/clean-css)) | `false` (could be `true`, `Object`, `Function(text, type)`) |
133
+ | `minifyJS`<br>`--minify-js` | Minify JavaScript in `script` elements and event attributes (uses [Terser](https://github.com/terser/terser)) | `false` (could be `true`, `Object`, `Function(text, inline)`) |
134
+ | `minifyURLs`<br>`--minify-urls` | Minify URLs in various attributes (uses [relateurl](https://github.com/stevenvachon/relateurl)) | `false` (could be `String`, `Object`, `Function(text)`, `async Function(text)`) |
135
+ | `noNewlinesBeforeTagClose`<br>`--no-newlines-before-tag-close` | Never add a newline before a tag that closes an element | `false` |
136
+ | `preserveLineBreaks`<br>`--preserve-line-breaks` | Always collapse to 1 line break (never remove it entirely) when whitespace between tags includes a line break—use with `collapseWhitespace=true` | `false` |
137
+ | `preventAttributesEscaping`<br>`--prevent-attributes-escaping` | Prevents the escaping of the values of attributes | `false` |
138
+ | `processConditionalComments`<br>`--process-conditional-comments` | Process contents of conditional comments through minifier | `false` |
139
+ | `processScripts`<br>`--process-scripts` | Array of strings corresponding to types of `script` elements to process through minifier (e.g., `text/ng-template`, `text/x-handlebars-template`, etc.) | `[]` |
140
+ | `quoteCharacter`<br>`--quote-character` | Type of quote to use for attribute values (`'` or `"`) | |
141
+ | `removeAttributeQuotes`<br>`--remove-attribute-quotes` | [Remove quotes around attributes when possible](http://perfectionkills.com/experimenting-with-html-minifier#remove_attribute_quotes) | `false` |
142
+ | `removeComments`<br>`--remove-comments` | [Strip HTML comments](http://perfectionkills.com/experimenting-with-html-minifier#remove_comments) | `false` |
143
+ | `removeEmptyAttributes`<br>`--remove-empty-attributes` | [Remove all attributes with whitespace-only values](http://perfectionkills.com/experimenting-with-html-minifier#remove_empty_or_blank_attributes) | `false` (could be `true`, `Function(attrName, tag)`) |
144
+ | `removeEmptyElements`<br>`--remove-empty-elements` | [Remove all elements with empty contents](http://perfectionkills.com/experimenting-with-html-minifier#remove_empty_elements) | `false` |
145
+ | `removeOptionalTags`<br>`--remove-optional-tags` | [Remove optional tags](http://perfectionkills.com/experimenting-with-html-minifier#remove_optional_tags) | `false` |
146
+ | `removeRedundantAttributes`<br>`--remove-redundant-attributes` | [Remove attributes when value matches default](https://meiert.com/blog/optional-html/#toc-attribute-values) | `false` |
147
+ | `removeScriptTypeAttributes`<br>`--remove-script-type-attributes` | Remove `type="text/javascript"` from `script` elements; other `type` attribute values are left intact | `false` |
148
+ | `removeStyleLinkTypeAttributes`<br>`--remove-style-link-type-attributes` | Remove `type="text/css"` from `style` and `link` elements; other `type` attribute values are left intact | `false` |
149
+ | `removeTagWhitespace`<br>`--remove-tag-whitespace` | Remove space between attributes whenever possible; **note that this will result in invalid HTML** | `false` |
150
+ | `sortAttributes`<br>`--sort-attributes` | [Sort attributes by frequency](#sorting-attributes-and-style-classes) | `false` |
151
+ | `sortClassName`<br>`--sort-class-name` | [Sort style classes by frequency](#sorting-attributes-and-style-classes) | `false` |
152
+ | `trimCustomFragments`<br>`--trim-custom-fragments` | Trim whitespace around `ignoreCustomFragments` | `false` |
153
+ | `useShortDoctype`<br>`--use-short-doctype` | [Replaces the doctype with the short (HTML) doctype](http://perfectionkills.com/experimenting-with-html-minifier#use_short_doctype) | `false` |
154
+
155
+ ### Sorting attributes and style classes
156
+
157
+ Minifier options like `sortAttributes` and `sortClassName` won’t impact the plain‑text size of the output. However, they form long, repetitive character chains that should improve the compression ratio of gzip used for HTTP.
127
158
 
128
159
  ## Minification comparison
129
160
 
@@ -151,58 +182,63 @@ How does HTML Minifier Next compare to other solutions, like [minimize](https://
151
182
  | [United Nations](https://www.un.org/en/) | 151 | **114** | 130 | 123 | 121 | 124 |
152
183
  | [W3C](https://www.w3.org/) | 50 | **36** | 41 | 39 | 39 | 39 |
153
184
 
154
- ## Options quick reference
185
+ ## Examples
155
186
 
156
- Most of the options are disabled by default.
187
+ ### CLI
157
188
 
158
- | Option | Description | Default |
159
- | --- | --- | --- |
160
- | `caseSensitive` | Treat attributes in case-sensitive manner (useful for custom HTML elements) | `false` |
161
- | `collapseBooleanAttributes` | [Omit attribute values from boolean attributes](http://perfectionkills.com/experimenting-with-html-minifier#collapse_boolean_attributes) | `false` |
162
- | `customFragmentQuantifierLimit` | Set maximum quantifier limit for custom fragments to prevent ReDoS attacks | `200` |
163
- | `collapseInlineTagWhitespace` | Don’t leave any spaces between `display: inline;` elements when collapsing—use with `collapseWhitespace=true` | `false` |
164
- | `collapseWhitespace` | [Collapse whitespace that contributes to text nodes in a document tree](http://perfectionkills.com/experimenting-with-html-minifier#collapse_whitespace) | `false` |
165
- | `conservativeCollapse` | Always collapse to 1 space (never remove it entirely)—use with `collapseWhitespace=true` | `false` |
166
- | `continueOnParseError` | [Handle parse errors](https://html.spec.whatwg.org/multipage/parsing.html#parse-errors) instead of aborting | `false` |
167
- | `customAttrAssign` | Arrays of regexes that allow to support custom attribute assign expressions (e.g., `'<div flex?="{{mode != cover}}"></div>'`) | `[]` |
168
- | `customAttrCollapse` | Regex that specifies custom attribute to strip newlines from (e.g., `/ng-class/`) | |
169
- | `customAttrSurround` | Arrays of regexes that allow to support custom attribute surround expressions (e.g., `<input {{#if value}}checked="checked"{{/if}}>`) | `[]` |
170
- | `customEventAttributes` | Arrays of regexes that allow to support custom event attributes for `minifyJS` (e.g., `ng-click`) | `[ /^on[a-z]{3,}$/ ]` |
171
- | `decodeEntities` | Use direct Unicode characters whenever possible | `false` |
172
- | `html5` | Parse input according to the HTML specification | `true` |
173
- | `ignoreCustomComments` | Array of regexes that allow to ignore certain comments, when matched | `[ /^!/, /^\s*#/ ]` |
174
- | `ignoreCustomFragments` | Array of regexes that allow to ignore certain fragments, when matched (e.g., `<?php … ?>`, `{{ … }}`, etc.) | `[ /<%[\s\S]*?%>/, /<\?[\s\S]*?\?>/ ]` |
175
- | `includeAutoGeneratedTags` | Insert elements generated by HTML parser | `true` |
176
- | `inlineCustomElements` | Array of names of custom elements which are inline | `[]` |
177
- | `keepClosingSlash` | Keep the trailing slash on void elements | `false` |
178
- | `maxInputLength` | Maximum input length to prevent ReDoS attacks (disabled by default) | `undefined` |
179
- | `maxLineLength` | Specify a maximum line length; compressed output will be split by newlines at valid HTML split-points | |
180
- | `minifyCSS` | Minify CSS in `style` elements and `style` attributes (uses [clean-css](https://github.com/jakubpawlowicz/clean-css)) | `false` (could be `true`, `Object`, `Function(text, type)`) |
181
- | `minifyJS` | Minify JavaScript in `script` elements and event attributes (uses [Terser](https://github.com/terser/terser)) | `false` (could be `true`, `Object`, `Function(text, inline)`) |
182
- | `minifyURLs` | Minify URLs in various attributes (uses [relateurl](https://github.com/stevenvachon/relateurl)) | `false` (could be `String`, `Object`, `Function(text)`, `async Function(text)`) |
183
- | `noNewlinesBeforeTagClose` | Never add a newline before a tag that closes an element | `false` |
184
- | `preserveLineBreaks` | Always collapse to 1 line break (never remove it entirely) when whitespace between tags includes a line break—use with `collapseWhitespace=true` | `false` |
185
- | `preventAttributesEscaping` | Prevents the escaping of the values of attributes | `false` |
186
- | `processConditionalComments` | Process contents of conditional comments through minifier | `false` |
187
- | `processScripts` | Array of strings corresponding to types of `script` elements to process through minifier (e.g., `text/ng-template`, `text/x-handlebars-template`, etc.) | `[]` |
188
- | `quoteCharacter` | Type of quote to use for attribute values (`'` or `"`) | |
189
- | `removeAttributeQuotes` | [Remove quotes around attributes when possible](http://perfectionkills.com/experimenting-with-html-minifier#remove_attribute_quotes) | `false` |
190
- | `removeComments` | [Strip HTML comments](http://perfectionkills.com/experimenting-with-html-minifier#remove_comments) | `false` |
191
- | `removeEmptyAttributes` | [Remove all attributes with whitespace-only values](http://perfectionkills.com/experimenting-with-html-minifier#remove_empty_or_blank_attributes) | `false` (could be `true`, `Function(attrName, tag)`) |
192
- | `removeEmptyElements` | [Remove all elements with empty contents](http://perfectionkills.com/experimenting-with-html-minifier#remove_empty_elements) | `false` |
193
- | `removeOptionalTags` | [Remove optional tags](http://perfectionkills.com/experimenting-with-html-minifier#remove_optional_tags) | `false` |
194
- | `removeRedundantAttributes` | [Remove attributes when value matches default](https://meiert.com/blog/optional-html/#toc-attribute-values) | `false` |
195
- | `removeScriptTypeAttributes` | Remove `type="text/javascript"` from `script` elements; other `type` attribute values are left intact | `false` |
196
- | `removeStyleLinkTypeAttributes`| Remove `type="text/css"` from `style` and `link` elements; other `type` attribute values are left intact | `false` |
197
- | `removeTagWhitespace` | Remove space between attributes whenever possible; **note that this will result in invalid HTML** | `false` |
198
- | `sortAttributes` | [Sort attributes by frequency](#sorting-attributes-and-style-classes) | `false` |
199
- | `sortClassName` | [Sort style classes by frequency](#sorting-attributes-and-style-classes) | `false` |
200
- | `trimCustomFragments` | Trim whitespace around `ignoreCustomFragments` | `false` |
201
- | `useShortDoctype` | [Replaces the doctype with the short (HTML) doctype](http://perfectionkills.com/experimenting-with-html-minifier#use_short_doctype) | `false` |
189
+ **Sample command line:**
202
190
 
203
- ### Sorting attributes and style classes
191
+ ```bash
192
+ html-minifier-next --collapse-whitespace --remove-comments --minify-js true --input-dir=. --output-dir=example
193
+ ```
204
194
 
205
- Minifier options like `sortAttributes` and `sortClassName` won’t impact the plain‑text size of the output. However, they form long, repetitive character chains that should improve the compression ratio of gzip used for HTTP.
195
+ **Process specific file extensions:**
196
+
197
+ ```bash
198
+ # Process only HTML files
199
+ html-minifier-next --collapse-whitespace --input-dir=src --output-dir=dist --file-ext=html
200
+
201
+ # Process multiple file extensions
202
+ html-minifier-next --collapse-whitespace --input-dir=src --output-dir=dist --file-ext=html,htm,php
203
+
204
+ # Using configuration file that sets `fileExt` (e.g., `"fileExt": "html,htm"`)
205
+ html-minifier-next --config-file=html-minifier.json --input-dir=src --output-dir=dist
206
+
207
+ # Process all files (default behavior)
208
+ html-minifier-next --collapse-whitespace --input-dir=src --output-dir=dist
209
+ # Note: When processing all files, non-HTML files will also be read as UTF‑8 and passed to the minifier
210
+ # Consider restricting with `--file-ext` to avoid touching binaries (e.g., images, archives)
211
+ ```
212
+
213
+ **Dry run mode (preview outcome without writing files):**
214
+
215
+ ```bash
216
+ # Preview with output file
217
+ html-minifier-next input.html -o output.html --dry --collapse-whitespace
218
+
219
+ # Preview directory processing with statistics per file and total
220
+ html-minifier-next --input-dir=src --output-dir=dist --dry --collapse-whitespace
221
+ # Output: [DRY RUN] Would process directory: src → dist
222
+ # index.html: 1,234 → 892 bytes (-342, 27.7%)
223
+ # about.html: 2,100 → 1,654 bytes (-446, 21.2%)
224
+ # ---
225
+ # Total: 3,334 → 2,546 bytes (-788, 23.6%)
226
+ ```
227
+
228
+ **Verbose mode (show detailed processing information):**
229
+
230
+ ```bash
231
+ # Show processing details while minifying
232
+ html-minifier-next --input-dir=src --output-dir=dist --verbose --collapse-whitespace
233
+ # Output: Options: collapseWhitespace, html5, includeAutoGeneratedTags
234
+ # ✓ src/index.html: 1,234 → 892 bytes (-342, 27.7%)
235
+ # ✓ src/about.html: 2,100 → 1,654 bytes (-446, 21.2%)
236
+ # ---
237
+ # Total: 3,334 → 2,546 bytes (-788, 23.6%)
238
+
239
+ # Note: `--dry` automatically enables verbose output
240
+ html-minifier-next --input-dir=src --output-dir=dist --dry --collapse-whitespace
241
+ ```
206
242
 
207
243
  ## Special cases
208
244
 
@@ -283,7 +319,9 @@ ignoreCustomFragments: [/\{\{[\s\S]{0,500}?\}\}/]
283
319
 
284
320
  **Important:** When using custom `ignoreCustomFragments`, the minifier automatically applies bounded quantifiers to prevent ReDoS attacks, but you can also write safer patterns yourself using explicit bounds.
285
321
 
286
- ## Running benchmarks
322
+ ## Running HTML Minifier Next
323
+
324
+ ### Benchmarks
287
325
 
288
326
  Benchmarks for minified HTML:
289
327
 
@@ -293,7 +331,7 @@ npm install
293
331
  npm run benchmarks
294
332
  ```
295
333
 
296
- ## Running local server
334
+ ## Local server
297
335
 
298
336
  ```shell
299
337
  npm run serve
package/cli.js CHANGED
@@ -27,6 +27,7 @@
27
27
 
28
28
  import fs from 'fs';
29
29
  import path from 'path';
30
+ import { pathToFileURL } from 'url';
30
31
  import { createRequire } from 'module';
31
32
  import { camelCase, paramCase } from 'change-case';
32
33
  import { Command } from 'commander';
@@ -44,6 +45,14 @@ function fatal(message) {
44
45
  process.exit(1);
45
46
  }
46
47
 
48
+ // Handle broken pipe (e.g., when piping to `head`)
49
+ process.stdout.on('error', (err) => {
50
+ if (err && err.code === 'EPIPE') {
51
+ process.exit(0);
52
+ }
53
+ fatal('STDOUT error\n' + (err && err.message ? err.message : String(err)));
54
+ });
55
+
47
56
  /**
48
57
  * JSON does not support regexes, so, e.g., JSON.parse() will not create
49
58
  * a RegExp from the JSON value `[ "/matchString/" ]`, which is
@@ -60,7 +69,7 @@ function fatal(message) {
60
69
  * search string, the user would need to enclose the expression in a
61
70
  * second set of slashes:
62
71
  *
63
- * --customAttrSrround "[\"//matchString//\"]"
72
+ * --customAttrSurround "[\"//matchString//\"]"
64
73
  */
65
74
  function parseRegExp(value) {
66
75
  if (value) {
@@ -73,8 +82,8 @@ function parseJSON(value) {
73
82
  try {
74
83
  return JSON.parse(value);
75
84
  } catch {
76
- if (/^{/.test(value)) {
77
- fatal('Could not parse JSON value \'' + value + '\'');
85
+ if (/^\s*[{[]/.test(value)) {
86
+ fatal('Could not parse JSON value `' + value + '`');
78
87
  }
79
88
  return value;
80
89
  }
@@ -95,10 +104,20 @@ function parseJSONRegExpArray(value) {
95
104
 
96
105
  const parseString = value => value;
97
106
 
107
+ const parseValidInt = (optionName) => (value) => {
108
+ const s = String(value).trim();
109
+ // Accept only non-negative whole integers
110
+ if (!/^\d+$/.test(s)) {
111
+ fatal(`Invalid number for \`--${paramCase(optionName)}: "${value}"\``);
112
+ }
113
+ const num = Number(s);
114
+ return num;
115
+ };
116
+
98
117
  const mainOptions = {
99
118
  caseSensitive: 'Treat attributes in case-sensitive manner (useful for custom HTML elements)',
100
119
  collapseBooleanAttributes: 'Omit attribute values from boolean attributes',
101
- customFragmentQuantifierLimit: ['Set maximum quantifier limit for custom fragments to prevent ReDoS attacks (default: 200)', parseInt],
120
+ customFragmentQuantifierLimit: ['Set maximum quantifier limit for custom fragments to prevent ReDoS attacks (default: 200)', parseValidInt('customFragmentQuantifierLimit')],
102
121
  collapseInlineTagWhitespace: 'Don’t leave any spaces between “display: inline;” elements when collapsing—use with “collapseWhitespace=true”',
103
122
  collapseWhitespace: 'Collapse whitespace that contributes to text nodes in a document tree',
104
123
  conservativeCollapse: 'Always collapse to 1 space (never remove it entirely)—use with “collapseWhitespace=true”',
@@ -114,8 +133,8 @@ const mainOptions = {
114
133
  includeAutoGeneratedTags: 'Insert elements generated by HTML parser',
115
134
  inlineCustomElements: ['Array of names of custom elements which are inline', parseJSONArray],
116
135
  keepClosingSlash: 'Keep the trailing slash on void elements',
117
- maxInputLength: ['Maximum input length to prevent ReDoS attacks', parseInt],
118
- maxLineLength: ['Specify a maximum line length; compressed output will be split by newlines at valid HTML split-points', parseInt],
136
+ maxInputLength: ['Maximum input length to prevent ReDoS attacks', parseValidInt('maxInputLength')],
137
+ maxLineLength: ['Specify a maximum line length; compressed output will be split by newlines at valid HTML split-points', parseValidInt('maxLineLength')],
119
138
  minifyCSS: ['Minify CSS in “style” elements and “style” attributes (uses clean-css)', parseJSON],
120
139
  minifyJS: ['Minify JavaScript in “script” elements and event attributes (uses Terser)', parseJSON],
121
140
  minifyURLs: ['Minify URLs in various attributes (uses relateurl)', parseJSON],
@@ -154,7 +173,9 @@ mainOptionKeys.forEach(function (key) {
154
173
  program.option('--' + paramCase(key), option);
155
174
  }
156
175
  });
157
- program.option('-o --output <file>', 'Specify output file (if not specified STDOUT will be used for output)');
176
+ program.option('-o --output <file>', 'Specify output file (reads from file arguments or STDIN; outputs to STDOUT if not specified)');
177
+ program.option('-v --verbose', 'Show detailed processing information');
178
+ program.option('-d --dry', 'Dry run: process and report statistics without writing output');
158
179
 
159
180
  function readFile(file) {
160
181
  try {
@@ -164,179 +185,419 @@ function readFile(file) {
164
185
  }
165
186
  }
166
187
 
167
- let config = {};
168
- program.option('-c --config-file <file>', 'Use config file', function (configPath) {
188
+ /**
189
+ * Load config from a file path, trying JSON, CJS, then ESM
190
+ * @param {string} configPath - Path to config file
191
+ * @returns {Promise<object>} Loaded config object
192
+ */
193
+ async function loadConfigFromPath(configPath) {
169
194
  const data = readFile(configPath);
195
+
196
+ // Try JSON first
170
197
  try {
171
- config = JSON.parse(data);
198
+ return JSON.parse(data);
172
199
  } catch (je) {
200
+ const abs = path.resolve(configPath);
201
+
202
+ // Try CJS require
173
203
  try {
174
- config = require(path.resolve(configPath));
204
+ const result = require(abs);
205
+ // Handle ESM interop: if `require()` loads an ESM file, it may return `{__esModule: true, default: …}`
206
+ return (result && result.__esModule && result.default) ? result.default : result;
175
207
  } catch (ne) {
176
- fatal('Cannot read the specified config file.\nAs JSON: ' + je.message + '\nAs module: ' + ne.message);
208
+ // Try ESM import
209
+ try {
210
+ const mod = await import(pathToFileURL(abs).href);
211
+ return mod.default || mod;
212
+ } catch (ee) {
213
+ fatal('Cannot read the specified config file.\nAs JSON: ' + je.message + '\nAs CJS: ' + ne.message + '\nAs ESM: ' + ee.message);
214
+ }
177
215
  }
178
216
  }
217
+ }
218
+
219
+ /**
220
+ * Normalize and validate config object by applying parsers and transforming values.
221
+ * @param {object} config - Raw config object
222
+ * @returns {object} Normalized config object
223
+ */
224
+ function normalizeConfig(config) {
225
+ const normalized = { ...config };
226
+
227
+ // Apply parsers to main options
179
228
  mainOptionKeys.forEach(function (key) {
180
- if (key in config) {
229
+ if (key in normalized) {
181
230
  const option = mainOptions[key];
182
231
  if (Array.isArray(option)) {
183
- const value = config[key];
184
- config[key] = option[1](typeof value === 'string' ? value : JSON.stringify(value));
232
+ const value = normalized[key];
233
+ normalized[key] = option[1](typeof value === 'string' ? value : JSON.stringify(value));
185
234
  }
186
235
  }
187
236
  });
188
237
 
189
238
  // Handle fileExt in config file
190
- if ('fileExt' in config) {
239
+ if ('fileExt' in normalized) {
191
240
  // Support both string (`html,htm`) and array (`["html", "htm"]`) formats
192
- if (Array.isArray(config.fileExt)) {
193
- config.fileExt = config.fileExt.join(',');
241
+ if (Array.isArray(normalized.fileExt)) {
242
+ normalized.fileExt = normalized.fileExt.join(',');
194
243
  }
195
244
  }
196
- });
245
+
246
+ return normalized;
247
+ }
248
+
249
+ let config = {};
250
+ program.option('-c --config-file <file>', 'Use config file');
197
251
  program.option('--input-dir <dir>', 'Specify an input directory');
198
252
  program.option('--output-dir <dir>', 'Specify an output directory');
199
253
  program.option('--file-ext <extensions>', 'Specify file extension(s) to process (comma-separated), e.g., “html” or “html,htm,php”');
200
254
 
201
- let content;
202
- program.arguments('[files...]').action(function (files) {
203
- content = files.map(readFile).join('');
204
- }).parse(process.argv);
255
+ (async () => {
256
+ let content;
257
+ await program.arguments('[files...]').action(function (files) {
258
+ content = files.map(readFile).join('');
259
+ }).parseAsync(process.argv);
205
260
 
206
- const programOptions = program.opts();
261
+ const programOptions = program.opts();
207
262
 
208
- function createOptions() {
209
- const options = {};
263
+ // Load and normalize config if `--config-file` was specified
264
+ if (programOptions.configFile) {
265
+ config = await loadConfigFromPath(programOptions.configFile);
266
+ config = normalizeConfig(config);
267
+ }
210
268
 
211
- mainOptionKeys.forEach(function (key) {
212
- const param = programOptions[key === 'minifyURLs' ? 'minifyUrls' : camelCase(key)];
269
+ function createOptions() {
270
+ const options = {};
213
271
 
214
- if (typeof param !== 'undefined') {
215
- options[key] = param;
216
- } else if (key in config) {
217
- options[key] = config[key];
218
- }
219
- });
220
- return options;
221
- }
272
+ mainOptionKeys.forEach(function (key) {
273
+ const param = programOptions[key === 'minifyURLs' ? 'minifyUrls' : camelCase(key)];
274
+
275
+ if (typeof param !== 'undefined') {
276
+ options[key] = param;
277
+ } else if (key in config) {
278
+ options[key] = config[key];
279
+ }
280
+ });
281
+ return options;
282
+ }
222
283
 
223
- function mkdir(outputDir, callback) {
224
- fs.mkdir(outputDir, { recursive: true }, function (err) {
225
- if (err) {
226
- fatal('Cannot create directory ' + outputDir + '\n' + err.message);
284
+ function getActiveOptionsDisplay(minifierOptions) {
285
+ const activeOptions = Object.entries(minifierOptions)
286
+ .filter(([k]) => program.getOptionValueSource(k === 'minifyURLs' ? 'minifyUrls' : camelCase(k)) === 'cli')
287
+ .map(([k, v]) => (typeof v === 'boolean' ? (v ? k : `no-${k}`) : k));
288
+ if (activeOptions.length > 0) {
289
+ console.error('CLI options: ' + activeOptions.join(', '));
227
290
  }
228
- callback();
229
- });
230
- }
291
+ }
292
+
293
+ function calculateStats(original, minified) {
294
+ const originalSize = Buffer.byteLength(original, 'utf8');
295
+ const minifiedSize = Buffer.byteLength(minified, 'utf8');
296
+ const saved = originalSize - minifiedSize;
297
+ const sign = saved >= 0 ? '-' : '+';
298
+ const percentage = originalSize ? ((Math.abs(saved) / originalSize) * 100).toFixed(1) : '0.0';
299
+ return { originalSize, minifiedSize, saved, sign, percentage };
300
+ }
231
301
 
232
- function processFile(inputFile, outputFile) {
233
- fs.readFile(inputFile, { encoding: 'utf8' }, async function (err, data) {
234
- if (err) {
302
+ async function processFile(inputFile, outputFile, isDryRun = false, isVerbose = false) {
303
+ const data = await fs.promises.readFile(inputFile, { encoding: 'utf8' }).catch(err => {
235
304
  fatal('Cannot read ' + inputFile + '\n' + err.message);
236
- }
305
+ });
306
+
237
307
  let minified;
238
308
  try {
239
309
  minified = await minify(data, createOptions());
240
310
  } catch (e) {
241
311
  fatal('Minification error on ' + inputFile + '\n' + e.message);
242
312
  }
243
- fs.writeFile(outputFile, minified, { encoding: 'utf8' }, function (err) {
244
- if (err) {
245
- fatal('Cannot write ' + outputFile + '\n' + err.message);
246
- }
313
+
314
+ const stats = calculateStats(data, minified);
315
+
316
+ // Show stats if dry run or verbose mode
317
+ if (isDryRun || isVerbose) {
318
+ console.error(` ✓ ${path.relative(process.cwd(), inputFile)}: ${stats.originalSize.toLocaleString()} → ${stats.minifiedSize.toLocaleString()} bytes (${stats.sign}${Math.abs(stats.saved).toLocaleString()}, ${stats.percentage}%)`);
319
+ }
320
+
321
+ if (isDryRun) {
322
+ return { originalSize: stats.originalSize, minifiedSize: stats.minifiedSize, saved: stats.saved };
323
+ }
324
+
325
+ await fs.promises.writeFile(outputFile, minified, { encoding: 'utf8' }).catch(err => {
326
+ fatal('Cannot write ' + outputFile + '\n' + err.message);
247
327
  });
248
- });
249
- }
250
328
 
251
- function parseFileExtensions(fileExt) {
252
- if (!fileExt) return [];
253
- const list = fileExt
254
- .split(',')
255
- .map(ext => ext.trim().replace(/^\.+/, '').toLowerCase())
256
- .filter(ext => ext.length > 0);
257
- return [...new Set(list)];
258
- }
329
+ return { originalSize: stats.originalSize, minifiedSize: stats.minifiedSize, saved: stats.saved };
330
+ }
259
331
 
260
- function shouldProcessFile(filename, fileExtensions) {
261
- if (!fileExtensions || fileExtensions.length === 0) {
262
- return true; // No extensions specified, process all files
332
+ function parseFileExtensions(fileExt) {
333
+ if (!fileExt) return [];
334
+ const list = fileExt
335
+ .split(',')
336
+ .map(ext => ext.trim().replace(/^\.+/, '').toLowerCase())
337
+ .filter(ext => ext.length > 0);
338
+ return [...new Set(list)];
263
339
  }
264
340
 
265
- const fileExt = path.extname(filename).replace(/^\.+/, '').toLowerCase();
266
- return fileExtensions.includes(fileExt);
267
- }
341
+ function shouldProcessFile(filename, fileExtensions) {
342
+ if (!fileExtensions || fileExtensions.length === 0) {
343
+ return true; // No extensions specified, process all files
344
+ }
268
345
 
269
- function processDirectory(inputDir, outputDir, extensions) {
270
- // If first call provided a string, normalize once; otherwise assume pre-parsed array
271
- if (typeof extensions === 'string') {
272
- extensions = parseFileExtensions(extensions);
346
+ const fileExt = path.extname(filename).replace(/^\.+/, '').toLowerCase();
347
+ return fileExtensions.includes(fileExt);
273
348
  }
274
349
 
275
- fs.readdir(inputDir, function (err, files) {
276
- if (err) {
277
- fatal('Cannot read directory ' + inputDir + '\n' + err.message);
350
+ async function countFiles(dir, extensions, skipRootAbs) {
351
+ let count = 0;
352
+
353
+ const files = await fs.promises.readdir(dir).catch(() => []);
354
+
355
+ for (const file of files) {
356
+ const filePath = path.join(dir, file);
357
+
358
+ // Skip anything inside the output root
359
+ if (skipRootAbs) {
360
+ const real = await fs.promises.realpath(filePath).catch(() => undefined);
361
+ if (real && (real === skipRootAbs || real.startsWith(skipRootAbs + path.sep))) {
362
+ continue;
363
+ }
364
+ }
365
+
366
+ const lst = await fs.promises.lstat(filePath).catch(() => null);
367
+ if (!lst || lst.isSymbolicLink()) {
368
+ continue;
369
+ }
370
+
371
+ if (lst.isDirectory()) {
372
+ count += await countFiles(filePath, extensions, skipRootAbs);
373
+ } else if (shouldProcessFile(file, extensions)) {
374
+ count++;
375
+ }
376
+ }
377
+
378
+ return count;
379
+ }
380
+
381
+ function updateProgress(current, total) {
382
+ // Clear the line first, then write simple progress
383
+ process.stderr.write(`\r\x1b[K`);
384
+ if (total) {
385
+ const ratio = Math.min(current / total, 1);
386
+ const percentage = (ratio * 100).toFixed(1);
387
+ process.stderr.write(`Processing ${current.toLocaleString()}/${total.toLocaleString()} (${percentage}%)`);
388
+ } else {
389
+ // Indeterminate progress - no total known yet
390
+ process.stderr.write(`Processing ${current.toLocaleString()} files…`);
391
+ }
392
+ }
393
+
394
+ function clearProgress() {
395
+ process.stderr.write('\r\x1b[K'); // Clear the line
396
+ }
397
+
398
+ async function processDirectory(inputDir, outputDir, extensions, isDryRun = false, isVerbose = false, skipRootAbs, progress = null) {
399
+ // If first call provided a string, normalize once; otherwise assume pre-parsed array
400
+ if (typeof extensions === 'string') {
401
+ extensions = parseFileExtensions(extensions);
278
402
  }
279
403
 
280
- files.forEach(function (file) {
404
+ const files = await fs.promises.readdir(inputDir).catch(err => {
405
+ fatal('Cannot read directory ' + inputDir + '\n' + err.message);
406
+ });
407
+
408
+ const allStats = [];
409
+
410
+ for (const file of files) {
281
411
  const inputFile = path.join(inputDir, file);
282
412
  const outputFile = path.join(outputDir, file);
283
413
 
284
- fs.stat(inputFile, function (err, stat) {
285
- if (err) {
286
- fatal('Cannot read ' + inputFile + '\n' + err.message);
287
- } else if (stat.isDirectory()) {
288
- processDirectory(inputFile, outputFile, extensions);
289
- } else if (shouldProcessFile(file, extensions)) {
290
- mkdir(outputDir, function () {
291
- processFile(inputFile, outputFile);
292
- });
414
+ // Skip anything inside the output root to avoid reprocessing
415
+ if (skipRootAbs) {
416
+ const real = await fs.promises.realpath(inputFile).catch(() => undefined);
417
+ if (real && (real === skipRootAbs || real.startsWith(skipRootAbs + path.sep))) {
418
+ continue;
293
419
  }
420
+ }
421
+
422
+ const lst = await fs.promises.lstat(inputFile).catch(err => {
423
+ fatal('Cannot read ' + inputFile + '\n' + err.message);
294
424
  });
295
- });
296
- });
297
- }
298
425
 
299
- const writeMinify = async () => {
300
- const minifierOptions = createOptions();
301
- let minified;
426
+ if (lst.isSymbolicLink()) {
427
+ continue;
428
+ }
302
429
 
303
- try {
304
- minified = await minify(content, minifierOptions);
305
- } catch (e) {
306
- fatal('Minification error:\n' + e.message);
430
+ if (lst.isDirectory()) {
431
+ const dirStats = await processDirectory(inputFile, outputFile, extensions, isDryRun, isVerbose, skipRootAbs, progress);
432
+ if (dirStats) {
433
+ allStats.push(...dirStats);
434
+ }
435
+ } else if (shouldProcessFile(file, extensions)) {
436
+ if (!isDryRun) {
437
+ await fs.promises.mkdir(outputDir, { recursive: true }).catch(err => {
438
+ fatal('Cannot create directory ' + outputDir + '\n' + err.message);
439
+ });
440
+ }
441
+ const fileStats = await processFile(inputFile, outputFile, isDryRun, isVerbose);
442
+ if (fileStats) {
443
+ allStats.push(fileStats);
444
+ }
445
+
446
+ // Update progress after processing
447
+ if (progress) {
448
+ progress.current++;
449
+ updateProgress(progress.current, progress.total);
450
+ }
451
+ }
452
+ }
453
+
454
+ return allStats;
307
455
  }
308
456
 
309
- let stream = process.stdout;
457
+ const writeMinify = async () => {
458
+ const minifierOptions = createOptions();
459
+
460
+ // Show config info if verbose
461
+ if (programOptions.verbose || programOptions.dry) {
462
+ getActiveOptionsDisplay(minifierOptions);
463
+ }
464
+
465
+ let minified;
466
+
467
+ try {
468
+ minified = await minify(content, minifierOptions);
469
+ } catch (e) {
470
+ fatal('Minification error:\n' + e.message);
471
+ }
472
+
473
+ const stats = calculateStats(content, minified);
474
+
475
+ if (programOptions.dry) {
476
+ const inputSource = program.args.length > 0 ? program.args.join(', ') : 'STDIN';
477
+ const outputDest = programOptions.output || 'STDOUT';
478
+
479
+ console.error(`[DRY RUN] Would minify: ${inputSource} → ${outputDest}`);
480
+ console.error(` Original: ${stats.originalSize.toLocaleString()} bytes`);
481
+ console.error(` Minified: ${stats.minifiedSize.toLocaleString()} bytes`);
482
+ console.error(` Saved: ${stats.sign}${Math.abs(stats.saved).toLocaleString()} bytes (${stats.percentage}%)`);
483
+ return;
484
+ }
485
+
486
+ // Show stats if verbose
487
+ if (programOptions.verbose) {
488
+ const inputSource = program.args.length > 0 ? program.args.join(', ') : 'STDIN';
489
+ console.error(` ✓ ${inputSource}: ${stats.originalSize.toLocaleString()} → ${stats.minifiedSize.toLocaleString()} bytes (${stats.sign}${Math.abs(stats.saved).toLocaleString()}, ${stats.percentage}%)`);
490
+ }
310
491
 
311
- if (programOptions.output) {
312
- stream = fs.createWriteStream(programOptions.output)
313
- .on('error', (e) => {
492
+ if (programOptions.output) {
493
+ await fs.promises.mkdir(path.dirname(programOptions.output), { recursive: true }).catch((e) => {
494
+ fatal('Cannot create directory ' + path.dirname(programOptions.output) + '\n' + e.message);
495
+ });
496
+ await new Promise((resolve, reject) => {
497
+ const fileStream = fs.createWriteStream(programOptions.output)
498
+ .on('error', reject)
499
+ .on('finish', resolve);
500
+ fileStream.end(minified);
501
+ }).catch((e) => {
314
502
  fatal('Cannot write ' + programOptions.output + '\n' + e.message);
315
503
  });
316
- }
504
+ return;
505
+ }
317
506
 
318
- stream.write(minified);
319
- };
507
+ process.stdout.write(minified);
508
+ };
509
+
510
+ const { inputDir, outputDir, fileExt } = programOptions;
320
511
 
321
- const { inputDir, outputDir, fileExt } = programOptions;
512
+ // Resolve file extensions: CLI argument takes priority over config file, even if empty string
513
+ const hasCliFileExt = program.getOptionValueSource('fileExt') === 'cli';
514
+ const resolvedFileExt = hasCliFileExt ? fileExt : config.fileExt;
322
515
 
323
- // Resolve file extensions: CLI argument takes priority over config file, even if empty string
324
- const hasCliFileExt = program.getOptionValueSource('fileExt') === 'cli';
325
- const resolvedFileExt = hasCliFileExt ? fileExt : config.fileExt;
516
+ if (inputDir || outputDir) {
517
+ if (!inputDir) {
518
+ fatal('The option `output-dir` needs to be used with the option `input-dir`—if you are working with a single file, use `-o`');
519
+ } else if (!outputDir) {
520
+ fatal('You need to specify where to write the output files with the option `--output-dir`');
521
+ }
522
+
523
+ await (async () => {
524
+ // `--dry` automatically enables verbose mode
525
+ const isVerbose = programOptions.verbose || programOptions.dry;
526
+
527
+ // Show config info if verbose
528
+ if (isVerbose) {
529
+ const minifierOptions = createOptions();
530
+ getActiveOptionsDisplay(minifierOptions);
531
+ }
532
+
533
+ // Prevent traversing into the output directory when it is inside the input directory
534
+ let inputReal;
535
+ let outputReal;
536
+ inputReal = await fs.promises.realpath(inputDir).catch(() => undefined);
537
+ try {
538
+ outputReal = await fs.promises.realpath(outputDir);
539
+ } catch {
540
+ outputReal = path.resolve(outputDir);
541
+ }
542
+ let skipRootAbs;
543
+ if (inputReal && outputReal && (outputReal === inputReal || outputReal.startsWith(inputReal + path.sep))) {
544
+ // Instead of aborting, skip traversing into the output directory
545
+ skipRootAbs = outputReal;
546
+ }
547
+
548
+ if (programOptions.dry) {
549
+ console.error(`[DRY RUN] Would process directory: ${inputDir} → ${outputDir}`);
550
+ }
551
+
552
+ // Set up progress indicator (only in TTY and when not verbose/dry)
553
+ const showProgress = process.stderr.isTTY && !isVerbose;
554
+ let progress = null;
555
+
556
+ if (showProgress) {
557
+ // Start with indeterminate progress, count in background
558
+ progress = {current: 0, total: null};
559
+
560
+ // Note: `countFiles` runs asynchronously and mutates `progress.total` when complete.
561
+ // This shared-state mutation is safe because JavaScript is single-threaded—
562
+ // `updateProgress` may read `progress.total` as `null` initially,
563
+ // then see the updated value once `countFiles` resolves,
564
+ // transitioning the indicator from indeterminate to determinate progress without race conditions.
565
+ const extensions = typeof resolvedFileExt === 'string' ? parseFileExtensions(resolvedFileExt) : resolvedFileExt;
566
+ countFiles(inputDir, extensions, skipRootAbs).then(total => {
567
+ if (progress) {
568
+ progress.total = total;
569
+ }
570
+ }).catch(() => {
571
+ // Ignore count errors, just keep showing indeterminate progress
572
+ });
573
+ }
574
+
575
+ const stats = await processDirectory(inputDir, outputDir, resolvedFileExt, programOptions.dry, isVerbose, skipRootAbs, progress);
326
576
 
327
- if (inputDir || outputDir) {
328
- if (!inputDir) {
329
- fatal('The option output-dir needs to be used with the option input-dir. If you are working with a single file, use -o.');
330
- } else if (!outputDir) {
331
- fatal('You need to specify where to write the output files with the option --output-dir');
577
+ // Show completion message and clear progress indicator
578
+ if (progress) {
579
+ clearProgress();
580
+ console.error(`Processed ${progress.current.toLocaleString()} file${progress.current === 1 ? '' : 's'}`);
581
+ }
582
+
583
+ if (isVerbose && stats && stats.length > 0) {
584
+ const totalOriginal = stats.reduce((sum, s) => sum + s.originalSize, 0);
585
+ const totalMinified = stats.reduce((sum, s) => sum + s.minifiedSize, 0);
586
+ const totalSaved = totalOriginal - totalMinified;
587
+ const sign = totalSaved >= 0 ? '-' : '+';
588
+ const totalPercentage = totalOriginal ? ((Math.abs(totalSaved) / totalOriginal) * 100).toFixed(1) : '0.0';
589
+
590
+ console.error('---');
591
+ console.error(`Total: ${totalOriginal.toLocaleString()} → ${totalMinified.toLocaleString()} bytes (${sign}${Math.abs(totalSaved).toLocaleString()}, ${totalPercentage}%)`);
592
+ }
593
+ })();
594
+ } else if (content) { // Minifying one or more files specified on the CMD line
595
+ writeMinify();
596
+ } else { // Minifying input coming from STDIN
597
+ content = '';
598
+ process.stdin.setEncoding('utf8');
599
+ process.stdin.on('data', function (data) {
600
+ content += data;
601
+ }).on('end', writeMinify);
332
602
  }
333
- processDirectory(inputDir, outputDir, resolvedFileExt);
334
- } else if (content) { // Minifying one or more files specified on the CMD line
335
- writeMinify();
336
- } else { // Minifying input coming from STDIN
337
- content = '';
338
- process.stdin.setEncoding('utf8');
339
- process.stdin.on('data', function (data) {
340
- content += data;
341
- }).on('end', writeMinify);
342
- }
603
+ })();
package/package.json CHANGED
@@ -15,15 +15,15 @@
15
15
  "description": "Highly configurable, well-tested, JavaScript-based HTML minifier",
16
16
  "devDependencies": {
17
17
  "@commitlint/cli": "^20.1.0",
18
- "@eslint/js": "^9.36.0",
19
- "@rollup/plugin-commonjs": "^28.0.6",
18
+ "@eslint/js": "^9.37.0",
19
+ "@rollup/plugin-commonjs": "^28.0.8",
20
20
  "@rollup/plugin-json": "^6.1.0",
21
- "@rollup/plugin-node-resolve": "^16.0.2",
21
+ "@rollup/plugin-node-resolve": "^16.0.3",
22
22
  "@rollup/plugin-terser": "^0.4.4",
23
- "eslint": "^9.36.0",
23
+ "eslint": "^9.37.0",
24
24
  "rollup": "^4.52.4",
25
25
  "rollup-plugin-polyfill-node": "^0.13.0",
26
- "vite": "^7.1.9"
26
+ "vite": "^7.1.12"
27
27
  },
28
28
  "exports": {
29
29
  ".": {
@@ -78,5 +78,5 @@
78
78
  "test:watch": "node --test --watch tests/*.spec.js"
79
79
  },
80
80
  "type": "module",
81
- "version": "3.0.0"
81
+ "version": "3.2.0"
82
82
  }