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.
- package/README.md +121 -83
- package/cli.js +381 -120
- package/package.json +6 -6
package/README.md
CHANGED
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
# HTML Minifier Next
|
|
2
2
|
|
|
3
|
-
[](https://www.npmjs.com/package/html-minifier-next)
|
|
4
|
-
[](https://github.com/j9t/html-minifier-next/actions)
|
|
3
|
+
[](https://www.npmjs.com/package/html-minifier-next) [](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
|
-
##
|
|
23
|
+
## General usage
|
|
25
24
|
|
|
26
|
-
|
|
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
|
|
62
|
-
| `-c
|
|
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
|
-
##
|
|
185
|
+
## Examples
|
|
155
186
|
|
|
156
|
-
|
|
187
|
+
### CLI
|
|
157
188
|
|
|
158
|
-
|
|
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
|
-
|
|
191
|
+
```bash
|
|
192
|
+
html-minifier-next --collapse-whitespace --remove-comments --minify-js true --input-dir=. --output-dir=example
|
|
193
|
+
```
|
|
204
194
|
|
|
205
|
-
|
|
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
|
|
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
|
-
##
|
|
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
|
-
* --
|
|
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 (
|
|
77
|
-
fatal('Could not parse JSON 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)',
|
|
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',
|
|
118
|
-
maxLineLength: ['Specify a maximum line length; compressed output will be split by newlines at valid HTML split-points',
|
|
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 (
|
|
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
|
-
|
|
168
|
-
|
|
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
|
-
|
|
198
|
+
return JSON.parse(data);
|
|
172
199
|
} catch (je) {
|
|
200
|
+
const abs = path.resolve(configPath);
|
|
201
|
+
|
|
202
|
+
// Try CJS require
|
|
173
203
|
try {
|
|
174
|
-
|
|
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
|
-
|
|
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
|
|
229
|
+
if (key in normalized) {
|
|
181
230
|
const option = mainOptions[key];
|
|
182
231
|
if (Array.isArray(option)) {
|
|
183
|
-
const value =
|
|
184
|
-
|
|
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
|
|
239
|
+
if ('fileExt' in normalized) {
|
|
191
240
|
// Support both string (`html,htm`) and array (`["html", "htm"]`) formats
|
|
192
|
-
if (Array.isArray(
|
|
193
|
-
|
|
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
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
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
|
-
|
|
209
|
-
|
|
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
|
-
|
|
212
|
-
const
|
|
269
|
+
function createOptions() {
|
|
270
|
+
const options = {};
|
|
213
271
|
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
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
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
244
|
-
|
|
245
|
-
|
|
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
|
-
|
|
252
|
-
|
|
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
|
|
261
|
-
|
|
262
|
-
|
|
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
|
-
|
|
266
|
-
|
|
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
|
-
|
|
270
|
-
|
|
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
|
-
|
|
276
|
-
|
|
277
|
-
|
|
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.
|
|
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
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
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
|
-
|
|
300
|
-
|
|
301
|
-
|
|
426
|
+
if (lst.isSymbolicLink()) {
|
|
427
|
+
continue;
|
|
428
|
+
}
|
|
302
429
|
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
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
|
-
|
|
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
|
-
|
|
312
|
-
|
|
313
|
-
|
|
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
|
-
|
|
319
|
-
};
|
|
507
|
+
process.stdout.write(minified);
|
|
508
|
+
};
|
|
509
|
+
|
|
510
|
+
const { inputDir, outputDir, fileExt } = programOptions;
|
|
320
511
|
|
|
321
|
-
|
|
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
|
-
|
|
324
|
-
|
|
325
|
-
|
|
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
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
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
|
-
|
|
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.
|
|
19
|
-
"@rollup/plugin-commonjs": "^28.0.
|
|
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.
|
|
21
|
+
"@rollup/plugin-node-resolve": "^16.0.3",
|
|
22
22
|
"@rollup/plugin-terser": "^0.4.4",
|
|
23
|
-
"eslint": "^9.
|
|
23
|
+
"eslint": "^9.37.0",
|
|
24
24
|
"rollup": "^4.52.4",
|
|
25
25
|
"rollup-plugin-polyfill-node": "^0.13.0",
|
|
26
|
-
"vite": "^7.1.
|
|
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.
|
|
81
|
+
"version": "3.2.0"
|
|
82
82
|
}
|