html-minifier-next 3.1.0 → 3.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/README.md +136 -114
  2. package/cli.js +357 -187
  3. package/package.json +1 -1
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,61 +20,22 @@ 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
38
- html-minifier-next --collapse-whitespace --input-dir=src --output-dir=dist --file-ext=html
39
-
40
- # Process multiple file extensions
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
- **Dry run mode (preview outcome without writing files):**
53
-
54
- ```bash
55
- # Preview with output file
56
- html-minifier-next input.html -o output.html --dry --collapse-whitespace
57
-
58
- # Preview directory processing with statistics per file and total
59
- html-minifier-next --input-dir=src --output-dir=dist --dry --collapse-whitespace
60
- # Output: [DRY RUN] Would process directory: src → dist
61
- # index.html: 1,234 → 892 bytes (-342, 27.7%)
62
- # about.html: 2,100 → 1,654 bytes (-446, 21.2%)
63
- # ---
64
- # Total: 3,334 → 2,546 bytes (-788, 23.6%)
65
- ```
66
-
67
- ### CLI options
25
+ ### CLI
68
26
 
69
27
  Use `html-minifier-next --help` to check all available options:
70
28
 
71
29
  | Option | Description | Example |
72
30
  | --- | --- | --- |
73
- | `--input-dir <dir>` | Specify an input directory | `--input-dir=src` |
31
+ | `--input-dir <dir>` | Specify an input directory (best restricted with `--file-ext`) | `--input-dir=src` |
74
32
  | `--output-dir <dir>` | Specify an output directory | `--output-dir=dist` |
75
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"` |
76
- | `-o --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` |
77
- | `-c --config-file <file>` | Use a configuration file | `--config-file=html-minifier.json` |
78
- | `-d --dry` | Dry run: Process and report statistics without writing output | `html-minifier-next input.html --dry --collapse-whitespace` |
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` |
79
39
 
80
40
  ### Configuration file
81
41
 
@@ -139,7 +99,62 @@ const { minify } = require('html-minifier-next');
139
99
 
140
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).
141
101
 
142
- 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, using these options for more consistent ordering improves the compression ratio for gzip and Brotli used over HTTP.
143
158
 
144
159
  ## Minification comparison
145
160
 
@@ -148,77 +163,82 @@ How does HTML Minifier Next compare to other solutions, like [minimize](https://
148
163
  | Site | Original Size (KB) | HTML Minifier Next | minimize | html­compressor.com | htmlnano | minify-html |
149
164
  | --- | --- | --- | --- | --- | --- | --- |
150
165
  | [A List Apart](https://alistapart.com/) | 62 | **53** | 58 | 56 | 54 | 55 |
151
- | [Amazon](https://www.amazon.com/) | 715 | **642** | 701 | n/a | n/a | n/a |
152
- | [Apple](https://www.apple.com/) | 184 | **143** | 170 | 167 | 161 | 166 |
153
- | [BBC](https://www.bbc.co.uk/) | 618 | **568** | 613 | n/a | 580 | 582 |
154
- | [CSS-Tricks](https://css-tricks.com/) | 161 | **121** | 148 | 145 | 126 | 144 |
166
+ | [Amazon](https://www.amazon.com/) | 206 | **195** | 203 | 200 | 196 | n/a |
167
+ | [Apple](https://www.apple.com/) | 183 | **143** | 169 | 167 | 160 | 166 |
168
+ | [BBC](https://www.bbc.co.uk/) | 689 | **633** | 683 | n/a | 646 | 648 |
169
+ | [CSS-Tricks](https://css-tricks.com/) | 163 | **121** | 149 | 146 | 127 | 145 |
155
170
  | [ECMAScript](https://tc39.es/ecma262/) | 7233 | **6338** | 6610 | n/a | 6557 | 6563 |
156
- | [EFF](https://www.eff.org/) | 57 | **48** | 51 | 51 | 51 | 49 |
157
- | [FAZ](https://www.faz.net/aktuell/) | 1876 | 1753 | 1790 | n/a | **1652** | n/a |
158
- | [Frontend Dogma](https://frontenddogma.com/) | 119 | **114** | 128 | 118 | 125 | 119 |
159
- | [Google](https://www.google.com/) | 18 | **17** | 18 | 18 | **17** | n/a |
160
- | [Ground News](https://ground.news/) | 1840 | **1591** | 1827 | n/a | 1689 | n/a |
171
+ | [EFF](https://www.eff.org/) | 57 | **48** | 52 | 52 | 51 | 50 |
172
+ | [FAZ](https://www.faz.net/aktuell/) | 1871 | 1747 | 1785 | n/a | **1647** | n/a |
173
+ | [Frontend Dogma](https://frontenddogma.com/) | 119 | **114** | 128 | 119 | 126 | 119 |
174
+ | [Google](https://www.google.com/) | 18 | **16** | 18 | 18 | 17 | n/a |
175
+ | [Ground News](https://ground.news/) | 3308 | **2946** | 3295 | n/a | 3042 | n/a |
161
176
  | [HTML](https://html.spec.whatwg.org/multipage/) | 149 | **147** | 155 | 148 | 153 | 149 |
162
- | [Leanpub](https://leanpub.com/) | 1567 | **1292** | 1561 | n/a | 1299 | n/a |
177
+ | [Leanpub](https://leanpub.com/) | 1279 | **1063** | 1274 | n/a | 1070 | n/a |
163
178
  | [Mastodon](https://mastodon.social/explore) | 35 | **26** | 34 | 34 | 30 | 33 |
164
- | [MDN](https://developer.mozilla.org/en-US/) | 104 | **62** | 67 | 68 | 64 | n/a |
165
- | [Middle East Eye](https://www.middleeasteye.net/) | 224 | **197** | 204 | 204 | 204 | 201 |
166
- | [SitePoint](https://www.sitepoint.com/) | 476 | **345** | 473 | n/a | 415 | 456 |
179
+ | [MDN](https://developer.mozilla.org/en-US/) | 107 | **61** | 67 | 67 | 63 | n/a |
180
+ | [Middle East Eye](https://www.middleeasteye.net/) | 224 | **197** | 204 | 205 | 204 | 202 |
181
+ | [SitePoint](https://www.sitepoint.com/) | 485 | **354** | 481 | n/a | 423 | 465 |
167
182
  | [United Nations](https://www.un.org/en/) | 151 | **114** | 130 | 123 | 121 | 124 |
168
- | [W3C](https://www.w3.org/) | 50 | **36** | 41 | 39 | 39 | 39 |
183
+ | [W3C](https://www.w3.org/) | 50 | **36** | 41 | 39 | 38 | 39 |
169
184
 
170
- ## Options quick reference
185
+ ## Examples
171
186
 
172
- Most of the options are disabled by default.
187
+ ### CLI
173
188
 
174
- | Option | Description | Default |
175
- | --- | --- | --- |
176
- | `caseSensitive` | Treat attributes in case-sensitive manner (useful for custom HTML elements) | `false` |
177
- | `collapseBooleanAttributes` | [Omit attribute values from boolean attributes](http://perfectionkills.com/experimenting-with-html-minifier#collapse_boolean_attributes) | `false` |
178
- | `customFragmentQuantifierLimit` | Set maximum quantifier limit for custom fragments to prevent ReDoS attacks | `200` |
179
- | `collapseInlineTagWhitespace` | Don’t leave any spaces between `display: inline;` elements when collapsing—use with `collapseWhitespace=true` | `false` |
180
- | `collapseWhitespace` | [Collapse whitespace that contributes to text nodes in a document tree](http://perfectionkills.com/experimenting-with-html-minifier#collapse_whitespace) | `false` |
181
- | `conservativeCollapse` | Always collapse to 1 space (never remove it entirely)—use with `collapseWhitespace=true` | `false` |
182
- | `continueOnParseError` | [Handle parse errors](https://html.spec.whatwg.org/multipage/parsing.html#parse-errors) instead of aborting | `false` |
183
- | `customAttrAssign` | Arrays of regexes that allow to support custom attribute assign expressions (e.g., `'<div flex?="{{mode != cover}}"></div>'`) | `[]` |
184
- | `customAttrCollapse` | Regex that specifies custom attribute to strip newlines from (e.g., `/ng-class/`) | |
185
- | `customAttrSurround` | Arrays of regexes that allow to support custom attribute surround expressions (e.g., `<input {{#if value}}checked="checked"{{/if}}>`) | `[]` |
186
- | `customEventAttributes` | Arrays of regexes that allow to support custom event attributes for `minifyJS` (e.g., `ng-click`) | `[ /^on[a-z]{3,}$/ ]` |
187
- | `decodeEntities` | Use direct Unicode characters whenever possible | `false` |
188
- | `html5` | Parse input according to the HTML specification | `true` |
189
- | `ignoreCustomComments` | Array of regexes that allow to ignore certain comments, when matched | `[ /^!/, /^\s*#/ ]` |
190
- | `ignoreCustomFragments` | Array of regexes that allow to ignore certain fragments, when matched (e.g., `<?php … ?>`, `{{ … }}`, etc.) | `[ /<%[\s\S]*?%>/, /<\?[\s\S]*?\?>/ ]` |
191
- | `includeAutoGeneratedTags` | Insert elements generated by HTML parser | `true` |
192
- | `inlineCustomElements` | Array of names of custom elements which are inline | `[]` |
193
- | `keepClosingSlash` | Keep the trailing slash on void elements | `false` |
194
- | `maxInputLength` | Maximum input length to prevent ReDoS attacks (disabled by default) | `undefined` |
195
- | `maxLineLength` | Specify a maximum line length; compressed output will be split by newlines at valid HTML split-points | |
196
- | `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)`) |
197
- | `minifyJS` | Minify JavaScript in `script` elements and event attributes (uses [Terser](https://github.com/terser/terser)) | `false` (could be `true`, `Object`, `Function(text, inline)`) |
198
- | `minifyURLs` | Minify URLs in various attributes (uses [relateurl](https://github.com/stevenvachon/relateurl)) | `false` (could be `String`, `Object`, `Function(text)`, `async Function(text)`) |
199
- | `noNewlinesBeforeTagClose` | Never add a newline before a tag that closes an element | `false` |
200
- | `preserveLineBreaks` | Always collapse to 1 line break (never remove it entirely) when whitespace between tags includes a line break—use with `collapseWhitespace=true` | `false` |
201
- | `preventAttributesEscaping` | Prevents the escaping of the values of attributes | `false` |
202
- | `processConditionalComments` | Process contents of conditional comments through minifier | `false` |
203
- | `processScripts` | Array of strings corresponding to types of `script` elements to process through minifier (e.g., `text/ng-template`, `text/x-handlebars-template`, etc.) | `[]` |
204
- | `quoteCharacter` | Type of quote to use for attribute values (`'` or `"`) | |
205
- | `removeAttributeQuotes` | [Remove quotes around attributes when possible](http://perfectionkills.com/experimenting-with-html-minifier#remove_attribute_quotes) | `false` |
206
- | `removeComments` | [Strip HTML comments](http://perfectionkills.com/experimenting-with-html-minifier#remove_comments) | `false` |
207
- | `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)`) |
208
- | `removeEmptyElements` | [Remove all elements with empty contents](http://perfectionkills.com/experimenting-with-html-minifier#remove_empty_elements) | `false` |
209
- | `removeOptionalTags` | [Remove optional tags](http://perfectionkills.com/experimenting-with-html-minifier#remove_optional_tags) | `false` |
210
- | `removeRedundantAttributes` | [Remove attributes when value matches default](https://meiert.com/blog/optional-html/#toc-attribute-values) | `false` |
211
- | `removeScriptTypeAttributes` | Remove `type="text/javascript"` from `script` elements; other `type` attribute values are left intact | `false` |
212
- | `removeStyleLinkTypeAttributes`| Remove `type="text/css"` from `style` and `link` elements; other `type` attribute values are left intact | `false` |
213
- | `removeTagWhitespace` | Remove space between attributes whenever possible; **note that this will result in invalid HTML** | `false` |
214
- | `sortAttributes` | [Sort attributes by frequency](#sorting-attributes-and-style-classes) | `false` |
215
- | `sortClassName` | [Sort style classes by frequency](#sorting-attributes-and-style-classes) | `false` |
216
- | `trimCustomFragments` | Trim whitespace around `ignoreCustomFragments` | `false` |
217
- | `useShortDoctype` | [Replaces the doctype with the short (HTML) doctype](http://perfectionkills.com/experimenting-with-html-minifier#use_short_doctype) | `false` |
189
+ **Sample command line:**
218
190
 
219
- ### Sorting attributes and style classes
191
+ ```bash
192
+ html-minifier-next --collapse-whitespace --remove-comments --minify-js true --input-dir=. --output-dir=example
193
+ ```
194
+
195
+ **Process specific files and directories:**
196
+
197
+ ```bash
198
+ # Process only HTML files
199
+ html-minifier-next --collapse-whitespace --input-dir=src --output-dir=dist --file-ext=html
220
200
 
221
- 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.
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
+ # 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
+ # `--dry` automatically enables verbose output
240
+ html-minifier-next --input-dir=src --output-dir=dist --dry --collapse-whitespace
241
+ ```
222
242
 
223
243
  ## Special cases
224
244
 
@@ -299,7 +319,9 @@ ignoreCustomFragments: [/\{\{[\s\S]{0,500}?\}\}/]
299
319
 
300
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.
301
321
 
302
- ## Running benchmarks
322
+ ## Running HTML Minifier Next
323
+
324
+ ### Benchmarks
303
325
 
304
326
  Benchmarks for minified HTML:
305
327
 
@@ -309,7 +331,7 @@ npm install
309
331
  npm run benchmarks
310
332
  ```
311
333
 
312
- ## Running local server
334
+ ### Local server
313
335
 
314
336
  ```shell
315
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';
@@ -81,8 +82,8 @@ function parseJSON(value) {
81
82
  try {
82
83
  return JSON.parse(value);
83
84
  } catch {
84
- if (/^{/.test(value)) {
85
- fatal('Could not parse JSON value \'' + value + '\'');
85
+ if (/^\s*[{[]/.test(value)) {
86
+ fatal('Could not parse JSON value `' + value + '`');
86
87
  }
87
88
  return value;
88
89
  }
@@ -103,10 +104,20 @@ function parseJSONRegExpArray(value) {
103
104
 
104
105
  const parseString = value => value;
105
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
+
106
117
  const mainOptions = {
107
118
  caseSensitive: 'Treat attributes in case-sensitive manner (useful for custom HTML elements)',
108
119
  collapseBooleanAttributes: 'Omit attribute values from boolean attributes',
109
- 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')],
110
121
  collapseInlineTagWhitespace: 'Don’t leave any spaces between “display: inline;” elements when collapsing—use with “collapseWhitespace=true”',
111
122
  collapseWhitespace: 'Collapse whitespace that contributes to text nodes in a document tree',
112
123
  conservativeCollapse: 'Always collapse to 1 space (never remove it entirely)—use with “collapseWhitespace=true”',
@@ -122,8 +133,8 @@ const mainOptions = {
122
133
  includeAutoGeneratedTags: 'Insert elements generated by HTML parser',
123
134
  inlineCustomElements: ['Array of names of custom elements which are inline', parseJSONArray],
124
135
  keepClosingSlash: 'Keep the trailing slash on void elements',
125
- maxInputLength: ['Maximum input length to prevent ReDoS attacks', parseInt],
126
- 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')],
127
138
  minifyCSS: ['Minify CSS in “style” elements and “style” attributes (uses clean-css)', parseJSON],
128
139
  minifyJS: ['Minify JavaScript in “script” elements and event attributes (uses Terser)', parseJSON],
129
140
  minifyURLs: ['Minify URLs in various attributes (uses relateurl)', parseJSON],
@@ -163,6 +174,7 @@ mainOptionKeys.forEach(function (key) {
163
174
  }
164
175
  });
165
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');
166
178
  program.option('-d --dry', 'Dry run: process and report statistics without writing output');
167
179
 
168
180
  function readFile(file) {
@@ -173,261 +185,419 @@ function readFile(file) {
173
185
  }
174
186
  }
175
187
 
176
- let config = {};
177
- 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) {
178
194
  const data = readFile(configPath);
195
+
196
+ // Try JSON first
179
197
  try {
180
- config = JSON.parse(data);
198
+ return JSON.parse(data);
181
199
  } catch (je) {
200
+ const abs = path.resolve(configPath);
201
+
202
+ // Try CJS require
182
203
  try {
183
- 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;
184
207
  } catch (ne) {
185
- 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
+ }
186
215
  }
187
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
188
228
  mainOptionKeys.forEach(function (key) {
189
- if (key in config) {
229
+ if (key in normalized) {
190
230
  const option = mainOptions[key];
191
231
  if (Array.isArray(option)) {
192
- const value = config[key];
193
- 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));
194
234
  }
195
235
  }
196
236
  });
197
237
 
198
238
  // Handle fileExt in config file
199
- if ('fileExt' in config) {
239
+ if ('fileExt' in normalized) {
200
240
  // Support both string (`html,htm`) and array (`["html", "htm"]`) formats
201
- if (Array.isArray(config.fileExt)) {
202
- config.fileExt = config.fileExt.join(',');
241
+ if (Array.isArray(normalized.fileExt)) {
242
+ normalized.fileExt = normalized.fileExt.join(',');
203
243
  }
204
244
  }
205
- });
245
+
246
+ return normalized;
247
+ }
248
+
249
+ let config = {};
250
+ program.option('-c --config-file <file>', 'Use config file');
206
251
  program.option('--input-dir <dir>', 'Specify an input directory');
207
252
  program.option('--output-dir <dir>', 'Specify an output directory');
208
253
  program.option('--file-ext <extensions>', 'Specify file extension(s) to process (comma-separated), e.g., “html” or “html,htm,php”');
209
254
 
210
- let content;
211
- program.arguments('[files...]').action(function (files) {
212
- content = files.map(readFile).join('');
213
- }).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);
214
260
 
215
- const programOptions = program.opts();
261
+ const programOptions = program.opts();
216
262
 
217
- function createOptions() {
218
- 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
+ }
219
268
 
220
- mainOptionKeys.forEach(function (key) {
221
- const param = programOptions[key === 'minifyURLs' ? 'minifyUrls' : camelCase(key)];
269
+ function createOptions() {
270
+ const options = {};
222
271
 
223
- if (typeof param !== 'undefined') {
224
- options[key] = param;
225
- } else if (key in config) {
226
- options[key] = config[key];
227
- }
228
- });
229
- return options;
230
- }
272
+ mainOptionKeys.forEach(function (key) {
273
+ const param = programOptions[key === 'minifyURLs' ? 'minifyUrls' : camelCase(key)];
231
274
 
232
- async function processFile(inputFile, outputFile, isDryRun = false) {
233
- const data = await fs.promises.readFile(inputFile, { encoding: 'utf8' }).catch(err => {
234
- fatal('Cannot read ' + inputFile + '\n' + err.message);
235
- });
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
+ }
236
283
 
237
- let minified;
238
- try {
239
- minified = await minify(data, createOptions());
240
- } catch (e) {
241
- fatal('Minification error on ' + inputFile + '\n' + e.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(', '));
290
+ }
242
291
  }
243
292
 
244
- if (isDryRun) {
245
- const originalSize = Buffer.byteLength(data, 'utf8');
293
+ function calculateStats(original, minified) {
294
+ const originalSize = Buffer.byteLength(original, 'utf8');
246
295
  const minifiedSize = Buffer.byteLength(minified, 'utf8');
247
296
  const saved = originalSize - minifiedSize;
248
297
  const sign = saved >= 0 ? '-' : '+';
249
298
  const percentage = originalSize ? ((Math.abs(saved) / originalSize) * 100).toFixed(1) : '0.0';
299
+ return { originalSize, minifiedSize, saved, sign, percentage };
300
+ }
250
301
 
251
- console.error(` ${path.relative(process.cwd(), inputFile)}: ${originalSize.toLocaleString()} ${minifiedSize.toLocaleString()} bytes (${sign}${Math.abs(saved).toLocaleString()}, ${percentage}%)`);
302
+ async function processFile(inputFile, outputFile, isDryRun = false, isVerbose = false) {
303
+ const data = await fs.promises.readFile(inputFile, { encoding: 'utf8' }).catch(err => {
304
+ fatal('Cannot read ' + inputFile + '\n' + err.message);
305
+ });
252
306
 
253
- return { originalSize, minifiedSize, saved };
254
- }
307
+ let minified;
308
+ try {
309
+ minified = await minify(data, createOptions());
310
+ } catch (e) {
311
+ fatal('Minification error on ' + inputFile + '\n' + e.message);
312
+ }
255
313
 
256
- await fs.promises.writeFile(outputFile, minified, { encoding: 'utf8' }).catch(err => {
257
- fatal('Cannot write ' + outputFile + '\n' + err.message);
258
- });
314
+ const stats = calculateStats(data, minified);
259
315
 
260
- return null;
261
- }
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
+ }
262
320
 
263
- function parseFileExtensions(fileExt) {
264
- if (!fileExt) return [];
265
- const list = fileExt
266
- .split(',')
267
- .map(ext => ext.trim().replace(/^\.+/, '').toLowerCase())
268
- .filter(ext => ext.length > 0);
269
- return [...new Set(list)];
270
- }
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);
327
+ });
271
328
 
272
- function shouldProcessFile(filename, fileExtensions) {
273
- if (!fileExtensions || fileExtensions.length === 0) {
274
- return true; // No extensions specified, process all files
329
+ return { originalSize: stats.originalSize, minifiedSize: stats.minifiedSize, saved: stats.saved };
275
330
  }
276
331
 
277
- const fileExt = path.extname(filename).replace(/^\.+/, '').toLowerCase();
278
- return fileExtensions.includes(fileExt);
279
- }
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)];
339
+ }
280
340
 
281
- async function processDirectory(inputDir, outputDir, extensions, isDryRun = false, skipRootAbs) {
282
- // If first call provided a string, normalize once; otherwise assume pre-parsed array
283
- if (typeof extensions === 'string') {
284
- extensions = parseFileExtensions(extensions);
341
+ function shouldProcessFile(filename, fileExtensions) {
342
+ if (!fileExtensions || fileExtensions.length === 0) {
343
+ return true; // No extensions specified, process all files
344
+ }
345
+
346
+ const fileExt = path.extname(filename).replace(/^\.+/, '').toLowerCase();
347
+ return fileExtensions.includes(fileExt);
285
348
  }
286
349
 
287
- const files = await fs.promises.readdir(inputDir).catch(err => {
288
- fatal('Cannot read directory ' + inputDir + '\n' + err.message);
289
- });
350
+ async function countFiles(dir, extensions, skipRootAbs) {
351
+ let count = 0;
352
+
353
+ const files = await fs.promises.readdir(dir).catch(() => []);
290
354
 
291
- const allStats = [];
355
+ for (const file of files) {
356
+ const filePath = path.join(dir, file);
292
357
 
293
- for (const file of files) {
294
- const inputFile = path.join(inputDir, file);
295
- const outputFile = path.join(outputDir, file);
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
+ }
296
365
 
297
- // Skip anything inside the output root to avoid reprocessing
298
- if (skipRootAbs) {
299
- const real = await fs.promises.realpath(inputFile).catch(() => undefined);
300
- if (real && (real === skipRootAbs || real.startsWith(skipRootAbs + path.sep))) {
366
+ const lst = await fs.promises.lstat(filePath).catch(() => null);
367
+ if (!lst || lst.isSymbolicLink()) {
301
368
  continue;
302
369
  }
370
+
371
+ if (lst.isDirectory()) {
372
+ count += await countFiles(filePath, extensions, skipRootAbs);
373
+ } else if (shouldProcessFile(file, extensions)) {
374
+ count++;
375
+ }
303
376
  }
304
377
 
305
- const lst = await fs.promises.lstat(inputFile).catch(err => {
306
- fatal('Cannot read ' + inputFile + '\n' + err.message);
307
- });
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
+ }
308
397
 
309
- if (lst.isSymbolicLink()) {
310
- continue;
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);
311
402
  }
312
403
 
313
- if (lst.isDirectory()) {
314
- const dirStats = await processDirectory(inputFile, outputFile, extensions, isDryRun, skipRootAbs);
315
- if (dirStats) {
316
- allStats.push(...dirStats);
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) {
411
+ const inputFile = path.join(inputDir, file);
412
+ const outputFile = path.join(outputDir, file);
413
+
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;
419
+ }
317
420
  }
318
- } else if (shouldProcessFile(file, extensions)) {
319
- if (!isDryRun) {
320
- await fs.promises.mkdir(outputDir, { recursive: true }).catch(err => {
321
- fatal('Cannot create directory ' + outputDir + '\n' + err.message);
322
- });
421
+
422
+ const lst = await fs.promises.lstat(inputFile).catch(err => {
423
+ fatal('Cannot read ' + inputFile + '\n' + err.message);
424
+ });
425
+
426
+ if (lst.isSymbolicLink()) {
427
+ continue;
323
428
  }
324
- const fileStats = await processFile(inputFile, outputFile, isDryRun);
325
- if (fileStats) {
326
- allStats.push(fileStats);
429
+
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
+ }
327
451
  }
328
452
  }
453
+
454
+ return allStats;
329
455
  }
330
456
 
331
- return allStats;
332
- }
457
+ const writeMinify = async () => {
458
+ const minifierOptions = createOptions();
333
459
 
334
- const writeMinify = async () => {
335
- const minifierOptions = createOptions();
336
- let minified;
460
+ // Show config info if verbose
461
+ if (programOptions.verbose || programOptions.dry) {
462
+ getActiveOptionsDisplay(minifierOptions);
463
+ }
337
464
 
338
- try {
339
- minified = await minify(content, minifierOptions);
340
- } catch (e) {
341
- fatal('Minification error:\n' + e.message);
342
- }
465
+ let minified;
343
466
 
344
- if (programOptions.dry) {
345
- const originalSize = Buffer.byteLength(content, 'utf8');
346
- const minifiedSize = Buffer.byteLength(minified, 'utf8');
347
- const saved = originalSize - minifiedSize;
348
- const sign = saved >= 0 ? '-' : '+';
349
- const percentage = originalSize ? ((Math.abs(saved) / originalSize) * 100).toFixed(1) : '0.0';
467
+ try {
468
+ minified = await minify(content, minifierOptions);
469
+ } catch (e) {
470
+ fatal('Minification error:\n' + e.message);
471
+ }
350
472
 
351
- const inputSource = program.args.length > 0 ? program.args.join(', ') : 'STDIN';
352
- const outputDest = programOptions.output || 'STDOUT';
473
+ const stats = calculateStats(content, minified);
353
474
 
354
- console.error(`[DRY RUN] Would minify: ${inputSource} → ${outputDest}`);
355
- console.error(` Original: ${originalSize.toLocaleString()} bytes`);
356
- console.error(` Minified: ${minifiedSize.toLocaleString()} bytes`);
357
- console.error(` Saved: ${sign}${Math.abs(saved).toLocaleString()} bytes (${percentage}%)`);
358
- return;
359
- }
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
+ }
360
485
 
361
- if (programOptions.output) {
362
- await fs.promises.mkdir(path.dirname(programOptions.output), { recursive: true }).catch((e) => {
363
- fatal('Cannot create directory ' + path.dirname(programOptions.output) + '\n' + e.message);
364
- });
365
- await new Promise((resolve, reject) => {
366
- const fileStream = fs.createWriteStream(programOptions.output)
367
- .on('error', reject)
368
- .on('finish', resolve);
369
- fileStream.end(minified);
370
- }).catch((e) => {
371
- fatal('Cannot write ' + programOptions.output + '\n' + e.message);
372
- });
373
- return;
374
- }
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
+ }
375
491
 
376
- process.stdout.write(minified);
377
- };
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) => {
502
+ fatal('Cannot write ' + programOptions.output + '\n' + e.message);
503
+ });
504
+ return;
505
+ }
378
506
 
379
- const { inputDir, outputDir, fileExt } = programOptions;
507
+ process.stdout.write(minified);
508
+ };
380
509
 
381
- // Resolve file extensions: CLI argument takes priority over config file, even if empty string
382
- const hasCliFileExt = program.getOptionValueSource('fileExt') === 'cli';
383
- const resolvedFileExt = hasCliFileExt ? fileExt : config.fileExt;
510
+ const { inputDir, outputDir, fileExt } = programOptions;
384
511
 
385
- if (inputDir || outputDir) {
386
- if (!inputDir) {
387
- fatal('The option output-dir needs to be used with the option input-dir. If you are working with a single file, use -o.');
388
- } else if (!outputDir) {
389
- fatal('You need to specify where to write the output files with the option --output-dir');
390
- }
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;
391
515
 
392
- (async () => {
393
- // Prevent traversing into the output directory when it is inside the input directory
394
- let inputReal;
395
- let outputReal;
396
- inputReal = await fs.promises.realpath(inputDir).catch(() => undefined);
397
- try {
398
- outputReal = await fs.promises.realpath(outputDir);
399
- } catch {
400
- outputReal = path.resolve(outputDir);
401
- }
402
- let skipRootAbs;
403
- if (inputReal && outputReal && (outputReal === inputReal || outputReal.startsWith(inputReal + path.sep))) {
404
- // Instead of aborting, skip traversing into the output directory
405
- skipRootAbs = outputReal;
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`');
406
521
  }
407
522
 
408
- if (programOptions.dry) {
409
- console.error(`[DRY RUN] Would process directory: ${inputDir} → ${outputDir}`);
410
- }
523
+ await (async () => {
524
+ // `--dry` automatically enables verbose mode
525
+ const isVerbose = programOptions.verbose || programOptions.dry;
411
526
 
412
- const stats = await processDirectory(inputDir, outputDir, resolvedFileExt, programOptions.dry, skipRootAbs);
527
+ // Show config info if verbose
528
+ if (isVerbose) {
529
+ const minifierOptions = createOptions();
530
+ getActiveOptionsDisplay(minifierOptions);
531
+ }
413
532
 
414
- if (programOptions.dry) {
415
- const totalOriginal = stats.reduce((sum, s) => sum + s.originalSize, 0);
416
- const totalMinified = stats.reduce((sum, s) => sum + s.minifiedSize, 0);
417
- const totalSaved = totalOriginal - totalMinified;
418
- const sign = totalSaved >= 0 ? '-' : '+';
419
- const totalPercentage = totalOriginal ? ((Math.abs(totalSaved) / totalOriginal) * 100).toFixed(1) : '0.0';
420
-
421
- console.error('---');
422
- console.error(`Total: ${totalOriginal.toLocaleString()} → ${totalMinified.toLocaleString()} bytes (${sign}${Math.abs(totalSaved).toLocaleString()}, ${totalPercentage}%)`);
423
- }
424
- })();
425
- } else if (content) { // Minifying one or more files specified on the CMD line
426
- writeMinify();
427
- } else { // Minifying input coming from STDIN
428
- content = '';
429
- process.stdin.setEncoding('utf8');
430
- process.stdin.on('data', function (data) {
431
- content += data;
432
- }).on('end', writeMinify);
433
- }
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);
576
+
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);
602
+ }
603
+ })();
package/package.json CHANGED
@@ -78,5 +78,5 @@
78
78
  "test:watch": "node --test --watch tests/*.spec.js"
79
79
  },
80
80
  "type": "module",
81
- "version": "3.1.0"
81
+ "version": "3.2.1"
82
82
  }