html-minifier-next 3.1.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 -99
- package/cli.js +357 -187
- package/package.json +1 -1
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,50 +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
|
|
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
|
|
|
@@ -73,9 +31,11 @@ Use `html-minifier-next --help` to check all available options:
|
|
|
73
31
|
| `--input-dir <dir>` | Specify an input directory | `--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
|
|
77
|
-
| `-c
|
|
78
|
-
| `-
|
|
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, they form long, repetitive character chains that should improve the compression ratio of gzip used for HTTP.
|
|
143
158
|
|
|
144
159
|
## Minification comparison
|
|
145
160
|
|
|
@@ -167,58 +182,63 @@ How does HTML Minifier Next compare to other solutions, like [minimize](https://
|
|
|
167
182
|
| [United Nations](https://www.un.org/en/) | 151 | **114** | 130 | 123 | 121 | 124 |
|
|
168
183
|
| [W3C](https://www.w3.org/) | 50 | **36** | 41 | 39 | 39 | 39 |
|
|
169
184
|
|
|
170
|
-
##
|
|
185
|
+
## Examples
|
|
171
186
|
|
|
172
|
-
|
|
187
|
+
### CLI
|
|
173
188
|
|
|
174
|
-
|
|
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
|
-
|
|
191
|
+
```bash
|
|
192
|
+
html-minifier-next --collapse-whitespace --remove-comments --minify-js true --input-dir=. --output-dir=example
|
|
193
|
+
```
|
|
220
194
|
|
|
221
|
-
|
|
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
|
+
```
|
|
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
|
|
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
|
-
##
|
|
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 (
|
|
85
|
-
fatal('Could not parse JSON 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)',
|
|
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',
|
|
126
|
-
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')],
|
|
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
|
-
|
|
177
|
-
|
|
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
|
-
|
|
198
|
+
return JSON.parse(data);
|
|
181
199
|
} catch (je) {
|
|
200
|
+
const abs = path.resolve(configPath);
|
|
201
|
+
|
|
202
|
+
// Try CJS require
|
|
182
203
|
try {
|
|
183
|
-
|
|
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
|
-
|
|
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
|
|
229
|
+
if (key in normalized) {
|
|
190
230
|
const option = mainOptions[key];
|
|
191
231
|
if (Array.isArray(option)) {
|
|
192
|
-
const value =
|
|
193
|
-
|
|
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
|
|
239
|
+
if ('fileExt' in normalized) {
|
|
200
240
|
// Support both string (`html,htm`) and array (`["html", "htm"]`) formats
|
|
201
|
-
if (Array.isArray(
|
|
202
|
-
|
|
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
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
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
|
-
|
|
218
|
-
|
|
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
|
-
|
|
221
|
-
const
|
|
269
|
+
function createOptions() {
|
|
270
|
+
const options = {};
|
|
222
271
|
|
|
223
|
-
|
|
224
|
-
|
|
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
|
-
|
|
233
|
-
|
|
234
|
-
|
|
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
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
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
|
-
|
|
245
|
-
const originalSize = Buffer.byteLength(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
257
|
-
fatal('Cannot write ' + outputFile + '\n' + err.message);
|
|
258
|
-
});
|
|
314
|
+
const stats = calculateStats(data, minified);
|
|
259
315
|
|
|
260
|
-
|
|
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
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
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
|
-
|
|
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
|
-
|
|
278
|
-
|
|
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
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
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
|
-
|
|
288
|
-
|
|
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
|
-
|
|
355
|
+
for (const file of files) {
|
|
356
|
+
const filePath = path.join(dir, file);
|
|
292
357
|
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
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
|
-
|
|
298
|
-
|
|
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
|
-
|
|
306
|
-
|
|
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
|
-
|
|
310
|
-
|
|
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
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
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
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
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
|
-
|
|
325
|
-
if (
|
|
326
|
-
|
|
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
|
-
|
|
332
|
-
|
|
457
|
+
const writeMinify = async () => {
|
|
458
|
+
const minifierOptions = createOptions();
|
|
333
459
|
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
460
|
+
// Show config info if verbose
|
|
461
|
+
if (programOptions.verbose || programOptions.dry) {
|
|
462
|
+
getActiveOptionsDisplay(minifierOptions);
|
|
463
|
+
}
|
|
337
464
|
|
|
338
|
-
|
|
339
|
-
minified = await minify(content, minifierOptions);
|
|
340
|
-
} catch (e) {
|
|
341
|
-
fatal('Minification error:\n' + e.message);
|
|
342
|
-
}
|
|
465
|
+
let minified;
|
|
343
466
|
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
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
|
|
352
|
-
const outputDest = programOptions.output || 'STDOUT';
|
|
473
|
+
const stats = calculateStats(content, minified);
|
|
353
474
|
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
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
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
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
|
-
|
|
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
|
-
|
|
507
|
+
process.stdout.write(minified);
|
|
508
|
+
};
|
|
380
509
|
|
|
381
|
-
|
|
382
|
-
const hasCliFileExt = program.getOptionValueSource('fileExt') === 'cli';
|
|
383
|
-
const resolvedFileExt = hasCliFileExt ? fileExt : config.fileExt;
|
|
510
|
+
const { inputDir, outputDir, fileExt } = programOptions;
|
|
384
511
|
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
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
|
-
(
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
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
|
-
|
|
409
|
-
|
|
410
|
-
|
|
523
|
+
await (async () => {
|
|
524
|
+
// `--dry` automatically enables verbose mode
|
|
525
|
+
const isVerbose = programOptions.verbose || programOptions.dry;
|
|
411
526
|
|
|
412
|
-
|
|
527
|
+
// Show config info if verbose
|
|
528
|
+
if (isVerbose) {
|
|
529
|
+
const minifierOptions = createOptions();
|
|
530
|
+
getActiveOptionsDisplay(minifierOptions);
|
|
531
|
+
}
|
|
413
532
|
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
}
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
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