html-minifier-next 4.8.3 → 4.9.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -4,7 +4,7 @@
4
4
 
5
5
  HTML Minifier Next (HMN) is a **super-configurable, well-tested, JavaScript-based HTML minifier**.
6
6
 
7
- The project was based on [HTML Minifier Terser](https://github.com/terser/html-minifier-terser), which in turn had been based on [Juriy Zaytsev’s HTML Minifier](https://github.com/kangax/html-minifier). HMN offers additional features, but is backwards-compatible with both. The project was set up because as of 2025, both HTML Minifier Terser and HTML Minifier had been unmaintained for a few years. As the project seems maintainable [to me, [Jens](https://meiert.com/)]—even more so with community support—, it’s being [updated, extended, and documented](https://github.com/j9t/html-minifier-next/blob/main/CHANGELOG.md) further in this place.
7
+ The project was based on [HTML Minifier Terser](https://github.com/terser/html-minifier-terser), which in turn had been based on [Juriy “kangax” Zaytsev’s HTML Minifier](https://github.com/kangax/html-minifier). HMN offers additional features, but is backwards-compatible with both. The project was set up because as of 2025, both HTML Minifier Terser and HTML Minifier had been unmaintained for a few years. As the project seems maintainable [to me, [Jens](https://meiert.com/), an HTML optimizer]—even more so with community support—, it’s being [updated, extended, and documented](https://github.com/j9t/html-minifier-next/blob/main/CHANGELOG.md) further in this place.
8
8
 
9
9
  ## Installation
10
10
 
@@ -29,11 +29,12 @@ Use `html-minifier-next --help` to check all available options:
29
29
  | Option | Description | Example |
30
30
  | --- | --- | --- |
31
31
  | `--input-dir <dir>` | Specify an input directory (best restricted with `--file-ext`) | `--input-dir=src` |
32
+ | `--ignore-dir <patterns>` | Exclude directories—relative to input directory—from processing (comma-separated, overrides config file setting) | `--ignore-dir=libs`, `--ignore-dir=libs,vendor,node_modules` |
32
33
  | `--output-dir <dir>` | Specify an output directory | `--output-dir=dist` |
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"` |
34
+ | `--file-ext <extensions>` | Specify file extension(s) to process (comma-separated, overrides config file setting) | `--file-ext=html`, `--file-ext=html,htm,php`, `--file-ext="html, htm, php"` |
34
35
  | `-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
36
  | `-c <file>`, `--config-file <file>` | Use a configuration file | `--config-file=html-minifier.json` |
36
- | `--preset <name>` | Use a preset configuration (conservative, comprehensive) | `--preset=conservative` |
37
+ | `--preset <name>` | Use a preset configuration (conservative or comprehensive) | `--preset=conservative` |
37
38
  | `-v`, `--verbose` | Show detailed processing information (active options, file statistics) | `html-minifier-next --input-dir=src --output-dir=dist --verbose --collapse-whitespace` |
38
39
  | `-d`, `--dry` | Dry run: Process and report statistics without writing output | `html-minifier-next input.html --dry --collapse-whitespace` |
39
40
  | `-V`, `--version` | Output the version number | `html-minifier-next --version` |
@@ -48,7 +49,8 @@ You can also use a configuration file to specify options. The file can be either
48
49
  {
49
50
  "collapseWhitespace": true,
50
51
  "removeComments": true,
51
- "fileExt": "html,htm"
52
+ "fileExt": "html,htm",
53
+ "ignoreDir": "libs,vendor"
52
54
  }
53
55
  ```
54
56
 
@@ -58,7 +60,8 @@ You can also use a configuration file to specify options. The file can be either
58
60
  module.exports = {
59
61
  collapseWhitespace: true,
60
62
  removeComments: true,
61
- fileExt: "html,htm"
63
+ fileExt: "html,htm",
64
+ ignoreDir: ["libs", "vendor"]
62
65
  };
63
66
  ```
64
67
 
@@ -230,29 +233,30 @@ How does HTML Minifier Next compare to other minifiers? (All with the most aggre
230
233
  | Site | Original Size (KB) | [HTML Minifier Next](https://github.com/j9t/html-minifier-next)<br>[![npm last update](https://img.shields.io/npm/last-update/html-minifier-next)](https://socket.dev/npm/package/html-minifier-next) | [HTML Minifier Terser](https://github.com/terser/html-minifier-terser)<br>[![npm last update](https://img.shields.io/npm/last-update/html-minifier-terser)](https://socket.dev/npm/package/html-minifier-terser) | [htmlnano](https://github.com/posthtml/htmlnano)<br>[![npm last update](https://img.shields.io/npm/last-update/htmlnano)](https://socket.dev/npm/package/htmlnano) | [@swc/html](https://github.com/swc-project/swc)<br>[![npm last update](https://img.shields.io/npm/last-update/@swc/html)](https://socket.dev/npm/package/@swc/html) | [minify-html](https://github.com/wilsonzlin/minify-html)<br>[![npm last update](https://img.shields.io/npm/last-update/@minify-html/node)](https://socket.dev/npm/package/@minify-html/node) | [minimize](https://github.com/Swaagie/minimize)<br>[![npm last update](https://img.shields.io/npm/last-update/minimize)](https://socket.dev/npm/package/minimize) | [html­com­pressor.­com](https://htmlcompressor.com/) |
231
234
  | --- | --- | --- | --- | --- | --- | --- | --- | --- |
232
235
  | [A List Apart](https://alistapart.com/) | 59 | **49** | 50 | 51 | 52 | 51 | 54 | 52 |
233
- | [Apple](https://www.apple.com/) | 258 | **202** | **202** | 230 | 233 | 234 | 236 | 237 |
234
- | [BBC](https://www.bbc.co.uk/) | 633 | **575** | 585 | 594 | 594 | 595 | 628 | n/a |
235
- | [CSS-Tricks](https://css-tricks.com/) | 161 | 121 | **119** | 126 | 141 | 141 | 147 | 143 |
236
+ | [Apple](https://www.apple.com/) | 260 | **204** | **204** | 232 | 235 | 236 | 238 | 239 |
237
+ | [BBC](https://www.bbc.co.uk/) | 803 | **683** | 693 | 746 | 757 | 760 | 796 | n/a |
238
+ | [CSS-Tricks](https://css-tricks.com/) | 161 | 121 | **119** | 126 | 142 | 142 | 147 | 143 |
236
239
  | [ECMAScript](https://tc39.es/ecma262/) | 7238 | **6341** | **6341** | 6561 | 6444 | 6567 | 6614 | n/a |
237
- | [EFF](https://www.eff.org/) | 55 | **47** | **47** | 50 | 48 | 49 | 50 | 50 |
238
- | [FAZ](https://www.faz.net/aktuell/) | 1573 | 1465 | 1470 | **1411** | 1498 | 1509 | 1520 | n/a |
239
- | [Frontend Dogma](https://frontenddogma.com/) | 222 | **213** | **213** | 234 | 219 | 221 | 239 | 220 |
240
- | [Google](https://www.google.com/) | 76 | **71** | 72 | n/a | 72 | 73 | 76 | 75 |
240
+ | [EFF](https://www.eff.org/) | 56 | **47** | 48 | 50 | 49 | 49 | 51 | 51 |
241
+ | [FAZ](https://www.faz.net/aktuell/) | 1602 | 1492 | 1497 | **1435** | 1525 | 1537 | 1548 | n/a |
242
+ | [Frontend Dogma](https://frontenddogma.com/) | 222 | **212** | 213 | 234 | 219 | 221 | 240 | 221 |
243
+ | [Google](https://www.google.com/) | 18 | **17** | **17** | **17** | **17** | **17** | 18 | 18 |
244
+ | [Ground News](https://ground.news/) | 1576 | **1353** | 1356 | 1450 | 1473 | 1478 | 1563 | n/a |
241
245
  | [HTML Living Standard](https://html.spec.whatwg.org/multipage/) | 149 | **147** | **147** | 153 | **147** | 149 | 155 | 149 |
242
246
  | [Igalia](https://www.igalia.com/) | 49 | **33** | **33** | 35 | 35 | 35 | 36 | 36 |
243
- | [Leanpub](https://leanpub.com/) | 1457 | **1225** | **1225** | 1232 | 1231 | 1226 | 1451 | n/a |
244
- | [Mastodon](https://mastodon.social/explore) | 35 | **26** | **26** | 30 | 33 | 33 | 34 | 34 |
245
- | [MDN](https://developer.mozilla.org/en-US/) | 107 | **62** | **62** | 64 | 64 | 65 | 67 | 68 |
246
- | [Middle East Eye](https://www.middleeasteye.net/) | 222 | **195** | **195** | 202 | 200 | 200 | 202 | 203 |
247
- | [Nielsen Norman Group](https://www.nngroup.com/) | 84 | 71 | 71 | **53** | 71 | 72 | 74 | 73 |
248
- | [SitePoint](https://www.sitepoint.com/) | 495 | **353** | **353** | 431 | 469 | 473 | 491 | n/a |
249
- | [TetraLogical](https://tetralogical.com/) | 44 | 38 | 38 | **35** | 37 | 38 | 39 | 39 |
250
- | [TPGi](https://www.tpgi.com/) | 99 | **79** | **79** | 84 | 84 | 84 | 88 | 86 |
251
- | [United Nations](https://www.un.org/en/) | 154 | **115** | 116 | 123 | 127 | 127 | 133 | 126 |
252
- | [W3C](https://www.w3.org/) | 50 | **36** | **36** | 38 | 38 | 38 | 40 | 39 |
253
- | **Average processing time** | | 352 ms (21/21) | 401 ms (21/21) | 175 ms (20/21) | 56 ms (21/21) | **17 ms (21/21)** | 395 ms (21/21) | 1481 ms (16/21) |
254
-
255
- (Last updated: Dec 10, 2025)
247
+ | [Leanpub](https://leanpub.com/) | 2036 | **1755** | **1755** | 1762 | 1761 | 1759 | 2031 | n/a |
248
+ | [Mastodon](https://mastodon.social/explore) | 36 | **27** | **27** | 31 | 34 | 34 | 35 | 35 |
249
+ | [MDN](https://developer.mozilla.org/en-US/) | 107 | **61** | **61** | 63 | 63 | 64 | 66 | 67 |
250
+ | [Middle East Eye](https://www.middleeasteye.net/) | 223 | **195** | 196 | 203 | 201 | 200 | 202 | 203 |
251
+ | [Nielsen Norman Group](https://www.nngroup.com/) | 84 | 72 | 72 | **53** | 72 | 73 | 74 | 73 |
252
+ | [SitePoint](https://www.sitepoint.com/) | 487 | **346** | **346** | 424 | 461 | 466 | 484 | n/a |
253
+ | [TetraLogical](https://tetralogical.com/) | 44 | 38 | 38 | **35** | 38 | 38 | 39 | 39 |
254
+ | [TPGi](https://www.tpgi.com/) | 175 | **160** | 162 | **160** | 165 | 166 | 172 | 172 |
255
+ | [United Nations](https://www.un.org/en/) | 150 | **112** | 113 | 120 | 124 | 124 | 129 | 122 |
256
+ | [W3C](https://www.w3.org/) | 50 | **35** | 36 | 38 | 38 | 38 | 40 | 38 |
257
+ | **Average processing time** | | 338 ms (22/22) | 385 ms (22/22) | 191 ms (22/22) | 70 ms (22/22) | **18 ms (22/22)** | 365 ms (22/22) | 1403 ms (16/22) |
258
+
259
+ (Last updated: Dec 12, 2025)
256
260
  <!-- End auto-generated -->
257
261
 
258
262
  ## Examples
@@ -283,6 +287,19 @@ html-minifier-next --collapse-whitespace --input-dir=src --output-dir=dist
283
287
  # Consider restricting with `--file-ext` to avoid touching binaries (e.g., images, archives)
284
288
  ```
285
289
 
290
+ **Exclude directories from processing:**
291
+
292
+ ```bash
293
+ # Ignore a single directory
294
+ html-minifier-next --collapse-whitespace --input-dir=src --output-dir=dist --ignore-dir=libs
295
+
296
+ # Ignore multiple directories
297
+ html-minifier-next --collapse-whitespace --input-dir=src --output-dir=dist --ignore-dir=libs,vendor,node_modules
298
+
299
+ # Ignore by relative path (only ignores src/static/libs, not other “libs” directories)
300
+ html-minifier-next --collapse-whitespace --input-dir=src --output-dir=dist --ignore-dir=static/libs
301
+ ```
302
+
286
303
  **Dry run mode (preview outcome without writing files):**
287
304
 
288
305
  ```bash
@@ -414,4 +431,4 @@ npm run benchmarks
414
431
 
415
432
  ## Acknowledgements
416
433
 
417
- With many thanks to all the previous authors of HTML Minifier, especially [Juriy Zaytsev](https://github.com/kangax), and to everyone who helped make this new edition better, particularly [Daniel Ruf](https://github.com/DanielRuf) and [Jonas Geiler](https://github.com/jonasgeiler).
434
+ With many thanks to all the previous authors of HTML Minifier, especially [Juriy “kangax” Zaytsev](https://github.com/kangax), and to everyone who helped make this new edition better, particularly [Daniel Ruf](https://github.com/DanielRuf) and [Jonas Geiler](https://github.com/jonasgeiler).
package/cli.js CHANGED
@@ -247,6 +247,14 @@ function normalizeConfig(config) {
247
247
  }
248
248
  }
249
249
 
250
+ // Handle `ignoreDir` in config file
251
+ if ('ignoreDir' in normalized) {
252
+ // Support both string (`libs,vendor`) and array (`["libs", "vendor"]`) formats
253
+ if (Array.isArray(normalized.ignoreDir)) {
254
+ normalized.ignoreDir = normalized.ignoreDir.join(',');
255
+ }
256
+ }
257
+
250
258
  return normalized;
251
259
  }
252
260
 
@@ -254,8 +262,9 @@ let config = {};
254
262
  program.option('-c --config-file <file>', 'Use config file');
255
263
  program.option('--preset <name>', `Use a preset configuration (${getPresetNames().join(', ')})`);
256
264
  program.option('--input-dir <dir>', 'Specify an input directory');
265
+ program.option('--ignore-dir <patterns>', 'Exclude directories—relative to input directory—from processing (comma-separated), e.g., “libs” or “libs,vendor,node_modules”');
257
266
  program.option('--output-dir <dir>', 'Specify an output directory');
258
- program.option('--file-ext <extensions>', 'Specify file extension(s) to process (comma-separated), e.g., "html" or "html,htm,php"');
267
+ program.option('--file-ext <extensions>', 'Specify file extension(s) to process (comma-separated), e.g., html or html,htm,php');
259
268
 
260
269
  (async () => {
261
270
  let content;
@@ -375,7 +384,42 @@ program.option('--file-ext <extensions>', 'Specify file extension(s) to process
375
384
  return fileExtensions.includes(fileExt);
376
385
  }
377
386
 
378
- async function countFiles(dir, extensions, skipRootAbs) {
387
+ /**
388
+ * Parse comma-separated ignore patterns into an array
389
+ * @param {string} patterns - Comma-separated directory patterns (e.g., "libs,vendor")
390
+ * @returns {string[]} Array of trimmed pattern strings with normalized separators
391
+ */
392
+ function parseIgnorePatterns(patterns) {
393
+ if (!patterns) return [];
394
+ return patterns
395
+ .split(',')
396
+ .map(p => p.trim().replace(/\\/g, '/').replace(/\/+$/, ''))
397
+ .filter(p => p.length > 0);
398
+ }
399
+
400
+ /**
401
+ * Check if a directory should be ignored based on ignore patterns
402
+ * Supports matching by directory name or relative path
403
+ * @param {string} dirPath - Absolute path to the directory
404
+ * @param {string[]} ignorePatterns - Array of patterns to match against (with forward slashes)
405
+ * @param {string} baseDir - Base directory for relative path calculation
406
+ * @returns {boolean} True if directory should be ignored
407
+ */
408
+ function shouldIgnoreDirectory(dirPath, ignorePatterns, baseDir) {
409
+ if (!ignorePatterns || ignorePatterns.length === 0) return false;
410
+
411
+ // Normalize to forward slashes for cross-platform comparison
412
+ const relativePath = path.relative(baseDir, dirPath).replace(/\\/g, '/');
413
+ const dirName = path.basename(dirPath);
414
+
415
+ return ignorePatterns.some(pattern => {
416
+ // Support both exact directory names and relative paths
417
+ return dirName === pattern || relativePath === pattern ||
418
+ relativePath.startsWith(pattern + '/');
419
+ });
420
+ }
421
+
422
+ async function countFiles(dir, extensions, skipRootAbs, ignorePatterns, baseDir) {
379
423
  let count = 0;
380
424
 
381
425
  const files = await fs.promises.readdir(dir).catch(() => []);
@@ -397,7 +441,11 @@ program.option('--file-ext <extensions>', 'Specify file extension(s) to process
397
441
  }
398
442
 
399
443
  if (lst.isDirectory()) {
400
- count += await countFiles(filePath, extensions, skipRootAbs);
444
+ // Skip ignored directories
445
+ if (shouldIgnoreDirectory(filePath, ignorePatterns, baseDir)) {
446
+ continue;
447
+ }
448
+ count += await countFiles(filePath, extensions, skipRootAbs, ignorePatterns, baseDir);
401
449
  } else if (shouldProcessFile(file, extensions)) {
402
450
  count++;
403
451
  }
@@ -423,12 +471,17 @@ program.option('--file-ext <extensions>', 'Specify file extension(s) to process
423
471
  process.stderr.write('\r\x1b[K'); // Clear the line
424
472
  }
425
473
 
426
- async function processDirectory(inputDir, outputDir, extensions, isDryRun = false, isVerbose = false, skipRootAbs, progress = null) {
474
+ async function processDirectory(inputDir, outputDir, extensions, isDryRun = false, isVerbose = false, skipRootAbs, progress = null, ignorePatterns = [], baseDir = null) {
427
475
  // If first call provided a string, normalize once; otherwise assume pre-parsed array
428
476
  if (typeof extensions === 'string') {
429
477
  extensions = parseFileExtensions(extensions);
430
478
  }
431
479
 
480
+ // Set `baseDir` on first call
481
+ if (baseDir === null) {
482
+ baseDir = inputDir;
483
+ }
484
+
432
485
  const files = await fs.promises.readdir(inputDir).catch(err => {
433
486
  fatal('Cannot read directory ' + inputDir + '\n' + err.message);
434
487
  });
@@ -456,7 +509,11 @@ program.option('--file-ext <extensions>', 'Specify file extension(s) to process
456
509
  }
457
510
 
458
511
  if (lst.isDirectory()) {
459
- const dirStats = await processDirectory(inputFile, outputFile, extensions, isDryRun, isVerbose, skipRootAbs, progress);
512
+ // Skip ignored directories
513
+ if (shouldIgnoreDirectory(inputFile, ignorePatterns, baseDir)) {
514
+ continue;
515
+ }
516
+ const dirStats = await processDirectory(inputFile, outputFile, extensions, isDryRun, isVerbose, skipRootAbs, progress, ignorePatterns, baseDir);
460
517
  if (dirStats) {
461
518
  allStats.push(...dirStats);
462
519
  }
@@ -535,12 +592,16 @@ program.option('--file-ext <extensions>', 'Specify file extension(s) to process
535
592
  process.stdout.write(minified);
536
593
  };
537
594
 
538
- const { inputDir, outputDir, fileExt } = programOptions;
595
+ const { inputDir, outputDir, fileExt, ignoreDir } = programOptions;
539
596
 
540
597
  // Resolve file extensions: CLI argument takes priority over config file, even if empty string
541
598
  const hasCliFileExt = program.getOptionValueSource('fileExt') === 'cli';
542
599
  const resolvedFileExt = hasCliFileExt ? fileExt : config.fileExt;
543
600
 
601
+ // Resolve ignore patterns: CLI argument takes priority over config file
602
+ const hasCliIgnoreDir = program.getOptionValueSource('ignoreDir') === 'cli';
603
+ const resolvedIgnoreDir = hasCliIgnoreDir ? ignoreDir : config.ignoreDir;
604
+
544
605
  if (inputDir || outputDir) {
545
606
  if (!inputDir) {
546
607
  fatal('The option `output-dir` needs to be used with the option `input-dir`—if you are working with a single file, use `-o`');
@@ -581,6 +642,12 @@ program.option('--file-ext <extensions>', 'Specify file extension(s) to process
581
642
  const showProgress = process.stderr.isTTY && !isVerbose;
582
643
  let progress = null;
583
644
 
645
+ // Parse ignore patterns
646
+ const ignorePatterns = parseIgnorePatterns(resolvedIgnoreDir);
647
+
648
+ // Resolve base directory for consistent path comparisons
649
+ const inputDirResolved = await fs.promises.realpath(inputDir).catch(() => inputDir);
650
+
584
651
  if (showProgress) {
585
652
  // Start with indeterminate progress, count in background
586
653
  progress = {current: 0, total: null};
@@ -591,7 +658,7 @@ program.option('--file-ext <extensions>', 'Specify file extension(s) to process
591
658
  // then see the updated value once `countFiles` resolves,
592
659
  // transitioning the indicator from indeterminate to determinate progress without race conditions.
593
660
  const extensions = typeof resolvedFileExt === 'string' ? parseFileExtensions(resolvedFileExt) : resolvedFileExt;
594
- countFiles(inputDir, extensions, skipRootAbs).then(total => {
661
+ countFiles(inputDir, extensions, skipRootAbs, ignorePatterns, inputDirResolved).then(total => {
595
662
  if (progress) {
596
663
  progress.total = total;
597
664
  }
@@ -600,7 +667,7 @@ program.option('--file-ext <extensions>', 'Specify file extension(s) to process
600
667
  });
601
668
  }
602
669
 
603
- const stats = await processDirectory(inputDir, outputDir, resolvedFileExt, programOptions.dry, isVerbose, skipRootAbs, progress);
670
+ const stats = await processDirectory(inputDir, outputDir, resolvedFileExt, programOptions.dry, isVerbose, skipRootAbs, progress, ignorePatterns, inputDirResolved);
604
671
 
605
672
  // Show completion message and clear progress indicator
606
673
  if (progress) {
@@ -2363,6 +2363,7 @@ async function minifyHTML(value, options, partialMarkup) {
2363
2363
  const ignoredMarkupChunks = [];
2364
2364
  const ignoredCustomMarkupChunks = [];
2365
2365
  let uidIgnore;
2366
+ let uidIgnorePlaceholderPattern;
2366
2367
  let uidAttr;
2367
2368
  let uidPattern;
2368
2369
  // Create inline tags/text sets with custom elements
@@ -2396,6 +2397,7 @@ async function minifyHTML(value, options, partialMarkup) {
2396
2397
  if (!uidIgnore) {
2397
2398
  uidIgnore = uniqueId(value);
2398
2399
  const pattern = new RegExp('^' + uidIgnore + '([0-9]+)$');
2400
+ uidIgnorePlaceholderPattern = new RegExp('^<!--' + uidIgnore + '(\\d+)-->$');
2399
2401
  if (options.ignoreCustomComments) {
2400
2402
  options.ignoreCustomComments = options.ignoreCustomComments.slice();
2401
2403
  } else {
@@ -2820,6 +2822,79 @@ async function minifyHTML(value, options, partialMarkup) {
2820
2822
  optionalStartTag = '';
2821
2823
  optionalEndTag = '';
2822
2824
  }
2825
+
2826
+ // Optimize whitespace collapsing between consecutive `htmlmin:ignore` placeholder comments
2827
+ if (options.collapseWhitespace && text && uidIgnorePlaceholderPattern) {
2828
+ if (uidIgnorePlaceholderPattern.test(text)) {
2829
+ // Check if previous buffer items are: [ignore-placeholder, whitespace-only text]
2830
+ if (buffer.length >= 2) {
2831
+ const prevText = buffer[buffer.length - 1];
2832
+ const prevComment = buffer[buffer.length - 2];
2833
+
2834
+ // Check if previous item is whitespace-only and item before that is ignore-placeholder
2835
+ if (prevText && /^\s+$/.test(prevText) &&
2836
+ prevComment && uidIgnorePlaceholderPattern.test(prevComment)) {
2837
+ // Extract the index from both placeholders to check their content
2838
+ const currentMatch = text.match(uidIgnorePlaceholderPattern);
2839
+ const prevMatch = prevComment.match(uidIgnorePlaceholderPattern);
2840
+
2841
+ if (currentMatch && prevMatch) {
2842
+ const currentIndex = +currentMatch[1];
2843
+ const prevIndex = +prevMatch[1];
2844
+
2845
+ // Defensive bounds check to ensure indices are valid
2846
+ if (currentIndex < ignoredMarkupChunks.length && prevIndex < ignoredMarkupChunks.length) {
2847
+ const currentContent = ignoredMarkupChunks[currentIndex];
2848
+ const prevContent = ignoredMarkupChunks[prevIndex];
2849
+
2850
+ // Only collapse whitespace if both blocks contain HTML (start with `<`)
2851
+ // Don’t collapse if either contains plain text, as that would change meaning
2852
+ // Note: This check will match HTML comments (`<!-- … -->`), but the tag-name
2853
+ // regex below requires starting with a letter, so comments are intentionally
2854
+ // excluded by the `currentTagMatch && prevTagMatch` guard
2855
+ if (currentContent && prevContent && /^\s*</.test(currentContent) && /^\s*</.test(prevContent)) {
2856
+ // Extract tag names from the HTML content (excludes comments, processing instructions, etc.)
2857
+ const currentTagMatch = currentContent.match(/^\s*<([a-zA-Z][\w:-]*)/);
2858
+ const prevTagMatch = prevContent.match(/^\s*<([a-zA-Z][\w:-]*)/);
2859
+
2860
+ // Only collapse if both matched valid element tags (not comments/text)
2861
+ // and both tags are block-level (inline elements need whitespace preserved)
2862
+ if (currentTagMatch && prevTagMatch) {
2863
+ const currentTag = options.name(currentTagMatch[1]);
2864
+ const prevTag = options.name(prevTagMatch[1]);
2865
+
2866
+ // Don’t collapse between inline elements
2867
+ if (!inlineElements.has(currentTag) && !inlineElements.has(prevTag)) {
2868
+ // Collapse whitespace respecting context rules
2869
+ let collapsedText = prevText;
2870
+
2871
+ // Apply `collapseWhitespace` with appropriate context
2872
+ if (!stackNoTrimWhitespace.length && !stackNoCollapseWhitespace.length) {
2873
+ // Not in pre or other no-collapse context
2874
+ if (options.preserveLineBreaks && /[\n\r]/.test(prevText)) {
2875
+ // Preserve line break as single newline
2876
+ collapsedText = '\n';
2877
+ } else if (options.conservativeCollapse) {
2878
+ // Conservative mode: keep single space
2879
+ collapsedText = ' ';
2880
+ } else {
2881
+ // Aggressive mode: remove all whitespace
2882
+ collapsedText = '';
2883
+ }
2884
+ }
2885
+
2886
+ // Replace the whitespace in buffer
2887
+ buffer[buffer.length - 1] = collapsedText;
2888
+ }
2889
+ }
2890
+ }
2891
+ }
2892
+ }
2893
+ }
2894
+ }
2895
+ }
2896
+ }
2897
+
2823
2898
  buffer.push(text);
2824
2899
  },
2825
2900
  doctype: function (doctype) {
@@ -7505,6 +7505,7 @@ async function minifyHTML(value, options, partialMarkup) {
7505
7505
  const ignoredMarkupChunks = [];
7506
7506
  const ignoredCustomMarkupChunks = [];
7507
7507
  let uidIgnore;
7508
+ let uidIgnorePlaceholderPattern;
7508
7509
  let uidAttr;
7509
7510
  let uidPattern;
7510
7511
  // Create inline tags/text sets with custom elements
@@ -7538,6 +7539,7 @@ async function minifyHTML(value, options, partialMarkup) {
7538
7539
  if (!uidIgnore) {
7539
7540
  uidIgnore = uniqueId(value);
7540
7541
  const pattern = new RegExp('^' + uidIgnore + '([0-9]+)$');
7542
+ uidIgnorePlaceholderPattern = new RegExp('^<!--' + uidIgnore + '(\\d+)-->$');
7541
7543
  if (options.ignoreCustomComments) {
7542
7544
  options.ignoreCustomComments = options.ignoreCustomComments.slice();
7543
7545
  } else {
@@ -7962,6 +7964,79 @@ async function minifyHTML(value, options, partialMarkup) {
7962
7964
  optionalStartTag = '';
7963
7965
  optionalEndTag = '';
7964
7966
  }
7967
+
7968
+ // Optimize whitespace collapsing between consecutive `htmlmin:ignore` placeholder comments
7969
+ if (options.collapseWhitespace && text && uidIgnorePlaceholderPattern) {
7970
+ if (uidIgnorePlaceholderPattern.test(text)) {
7971
+ // Check if previous buffer items are: [ignore-placeholder, whitespace-only text]
7972
+ if (buffer.length >= 2) {
7973
+ const prevText = buffer[buffer.length - 1];
7974
+ const prevComment = buffer[buffer.length - 2];
7975
+
7976
+ // Check if previous item is whitespace-only and item before that is ignore-placeholder
7977
+ if (prevText && /^\s+$/.test(prevText) &&
7978
+ prevComment && uidIgnorePlaceholderPattern.test(prevComment)) {
7979
+ // Extract the index from both placeholders to check their content
7980
+ const currentMatch = text.match(uidIgnorePlaceholderPattern);
7981
+ const prevMatch = prevComment.match(uidIgnorePlaceholderPattern);
7982
+
7983
+ if (currentMatch && prevMatch) {
7984
+ const currentIndex = +currentMatch[1];
7985
+ const prevIndex = +prevMatch[1];
7986
+
7987
+ // Defensive bounds check to ensure indices are valid
7988
+ if (currentIndex < ignoredMarkupChunks.length && prevIndex < ignoredMarkupChunks.length) {
7989
+ const currentContent = ignoredMarkupChunks[currentIndex];
7990
+ const prevContent = ignoredMarkupChunks[prevIndex];
7991
+
7992
+ // Only collapse whitespace if both blocks contain HTML (start with `<`)
7993
+ // Don’t collapse if either contains plain text, as that would change meaning
7994
+ // Note: This check will match HTML comments (`<!-- … -->`), but the tag-name
7995
+ // regex below requires starting with a letter, so comments are intentionally
7996
+ // excluded by the `currentTagMatch && prevTagMatch` guard
7997
+ if (currentContent && prevContent && /^\s*</.test(currentContent) && /^\s*</.test(prevContent)) {
7998
+ // Extract tag names from the HTML content (excludes comments, processing instructions, etc.)
7999
+ const currentTagMatch = currentContent.match(/^\s*<([a-zA-Z][\w:-]*)/);
8000
+ const prevTagMatch = prevContent.match(/^\s*<([a-zA-Z][\w:-]*)/);
8001
+
8002
+ // Only collapse if both matched valid element tags (not comments/text)
8003
+ // and both tags are block-level (inline elements need whitespace preserved)
8004
+ if (currentTagMatch && prevTagMatch) {
8005
+ const currentTag = options.name(currentTagMatch[1]);
8006
+ const prevTag = options.name(prevTagMatch[1]);
8007
+
8008
+ // Don’t collapse between inline elements
8009
+ if (!inlineElements.has(currentTag) && !inlineElements.has(prevTag)) {
8010
+ // Collapse whitespace respecting context rules
8011
+ let collapsedText = prevText;
8012
+
8013
+ // Apply `collapseWhitespace` with appropriate context
8014
+ if (!stackNoTrimWhitespace.length && !stackNoCollapseWhitespace.length) {
8015
+ // Not in pre or other no-collapse context
8016
+ if (options.preserveLineBreaks && /[\n\r]/.test(prevText)) {
8017
+ // Preserve line break as single newline
8018
+ collapsedText = '\n';
8019
+ } else if (options.conservativeCollapse) {
8020
+ // Conservative mode: keep single space
8021
+ collapsedText = ' ';
8022
+ } else {
8023
+ // Aggressive mode: remove all whitespace
8024
+ collapsedText = '';
8025
+ }
8026
+ }
8027
+
8028
+ // Replace the whitespace in buffer
8029
+ buffer[buffer.length - 1] = collapsedText;
8030
+ }
8031
+ }
8032
+ }
8033
+ }
8034
+ }
8035
+ }
8036
+ }
8037
+ }
8038
+ }
8039
+
7965
8040
  buffer.push(text);
7966
8041
  },
7967
8042
  doctype: function (doctype) {
@@ -1 +1 @@
1
- {"version":3,"file":"htmlminifier.d.ts","sourceRoot":"","sources":["../../src/htmlminifier.js"],"names":[],"mappings":"AAqqEO,8BAJI,MAAM,YACN,eAAe,GACb,OAAO,CAAC,MAAM,CAAC,CAQ3B;;;;;;;;;;;;UA5oES,MAAM;YACN,MAAM;YACN,MAAM;mBACN,MAAM;iBACN,MAAM;kBACN,MAAM;;;;;;;;;;;;;4BAQN,CAAC,GAAG,EAAE,MAAM,EAAE,KAAK,EAAE,aAAa,EAAE,EAAE,qBAAqB,EAAE,CAAC,GAAG,EAAE,MAAM,KAAK,OAAO,KAAK,OAAO;;;;;;;wBAMjG,CAAC,GAAG,EAAE,MAAM,GAAG,IAAI,EAAE,KAAK,EAAE,aAAa,EAAE,GAAG,SAAS,EAAE,iBAAiB,EAAE,CAAC,GAAG,EAAE,MAAM,KAAK,OAAO,KAAK,OAAO;;;;;;;;oBAMhH,OAAO;;;;;;;;gCAOP,OAAO;;;;;;;;kCAOP,OAAO;;;;;;;;yBAOP,OAAO;;;;;;;;2BAOP,OAAO;;;;;;;;4BAOP,OAAO;;;;;;;2BAOP,OAAO;;;;;;;;uBAMP,MAAM,EAAE;;;;;;yBAOR,MAAM;;;;;;yBAKN,CAAC,MAAM,EAAE,MAAM,CAAC,EAAE;;;;;;;4BAKlB,MAAM,EAAE;;;;;;;oCAMR,MAAM;;;;;;;qBAMN,OAAO;;;;;;;YAMP,OAAO;;;;;;;;2BAMP,MAAM,EAAE;;;;;;;;;4BAOR,MAAM,EAAE;;;;;;;+BAQR,OAAO;;;;;;;2BAMP,SAAS,CAAC,MAAM,CAAC;;;;;;uBAMjB,OAAO;;;;;;;;UAKP,CAAC,OAAO,EAAE,OAAO,KAAK,IAAI;;;;;;;;qBAO1B,MAAM;;;;;;;oBAON,MAAM;;;;;;;;;;gBAMN,OAAO,GAAG,OAAO,CAAC,OAAO,cAAc,EAAE,gBAAgB,CAAC,OAAO,cAAc,EAAE,aAAa,CAAC,CAAC,GAAG,CAAC,CAAC,IAAI,EAAE,MAAM,EAAE,IAAI,CAAC,EAAE,MAAM,KAAK,OAAO,CAAC,MAAM,CAAC,GAAG,MAAM,CAAC;;;;;;;;;;eAS9J,OAAO,GAAG,OAAO,QAAQ,EAAE,aAAa,GAAG,CAAC,CAAC,IAAI,EAAE,MAAM,EAAE,MAAM,CAAC,EAAE,OAAO,KAAK,OAAO,CAAC,MAAM,CAAC,GAAG,MAAM,CAAC;;;;;;;;;;iBASzG,OAAO,GAAG,MAAM,GAAG,OAAO,WAAW,EAAE,OAAO,GAAG,CAAC,CAAC,IAAI,EAAE,MAAM,KAAK,OAAO,CAAC,MAAM,CAAC,GAAG,MAAM,CAAC;;;;;;;;WAS7F,CAAC,IAAI,EAAE,MAAM,KAAK,MAAM;;;;;;;+BAOxB,OAAO;;;;;;;;;;oBAMP,OAAO;;;;;;;;yBASP,OAAO;;;;;;;gCAOP,OAAO;;;;;;;;iCAMP,OAAO;;;;;;;;;;qBAOP,MAAM,EAAE;;;;;;;qBASR,IAAI,GAAG,GAAG;;;;;;;4BAMV,OAAO;;;;;;;;qBAMP,OAAO;;;;;;;;;4BAOP,OAAO,GAAG,CAAC,CAAC,QAAQ,EAAE,MAAM,EAAE,GAAG,EAAE,MAAM,KAAK,OAAO,CAAC;;;;;;;;0BAQtD,OAAO;;;;;;;;;;;;;;;;;;;;;;;;;;gCAOP,MAAM,EAAE;;;;;;;;yBAyBR,OAAO;;;;;;;;gCAOP,OAAO;;;;;;;iCAOP,OAAO;;;;;;;oCAMP,OAAO;;;;;;;;;;0BAMP,OAAO;;;;;;;;;qBASP,OAAO,GAAG,CAAC,CAAC,GAAG,EAAE,MAAM,EAAE,KAAK,EAAE,aAAa,EAAE,KAAK,IAAI,CAAC;;;;;;;;;oBAQzD,OAAO,GAAG,CAAC,CAAC,KAAK,EAAE,MAAM,KAAK,MAAM,CAAC;;;;;;;;0BAQrC,OAAO;;;;;;;sBAOP,OAAO;;wBAnYkC,cAAc;0BAAd,cAAc;+BAAd,cAAc"}
1
+ {"version":3,"file":"htmlminifier.d.ts","sourceRoot":"","sources":["../../src/htmlminifier.js"],"names":[],"mappings":"AAgvEO,8BAJI,MAAM,YACN,eAAe,GACb,OAAO,CAAC,MAAM,CAAC,CAQ3B;;;;;;;;;;;;UAvtES,MAAM;YACN,MAAM;YACN,MAAM;mBACN,MAAM;iBACN,MAAM;kBACN,MAAM;;;;;;;;;;;;;4BAQN,CAAC,GAAG,EAAE,MAAM,EAAE,KAAK,EAAE,aAAa,EAAE,EAAE,qBAAqB,EAAE,CAAC,GAAG,EAAE,MAAM,KAAK,OAAO,KAAK,OAAO;;;;;;;wBAMjG,CAAC,GAAG,EAAE,MAAM,GAAG,IAAI,EAAE,KAAK,EAAE,aAAa,EAAE,GAAG,SAAS,EAAE,iBAAiB,EAAE,CAAC,GAAG,EAAE,MAAM,KAAK,OAAO,KAAK,OAAO;;;;;;;;oBAMhH,OAAO;;;;;;;;gCAOP,OAAO;;;;;;;;kCAOP,OAAO;;;;;;;;yBAOP,OAAO;;;;;;;;2BAOP,OAAO;;;;;;;;4BAOP,OAAO;;;;;;;2BAOP,OAAO;;;;;;;;uBAMP,MAAM,EAAE;;;;;;yBAOR,MAAM;;;;;;yBAKN,CAAC,MAAM,EAAE,MAAM,CAAC,EAAE;;;;;;;4BAKlB,MAAM,EAAE;;;;;;;oCAMR,MAAM;;;;;;;qBAMN,OAAO;;;;;;;YAMP,OAAO;;;;;;;;2BAMP,MAAM,EAAE;;;;;;;;;4BAOR,MAAM,EAAE;;;;;;;+BAQR,OAAO;;;;;;;2BAMP,SAAS,CAAC,MAAM,CAAC;;;;;;uBAMjB,OAAO;;;;;;;;UAKP,CAAC,OAAO,EAAE,OAAO,KAAK,IAAI;;;;;;;;qBAO1B,MAAM;;;;;;;oBAON,MAAM;;;;;;;;;;gBAMN,OAAO,GAAG,OAAO,CAAC,OAAO,cAAc,EAAE,gBAAgB,CAAC,OAAO,cAAc,EAAE,aAAa,CAAC,CAAC,GAAG,CAAC,CAAC,IAAI,EAAE,MAAM,EAAE,IAAI,CAAC,EAAE,MAAM,KAAK,OAAO,CAAC,MAAM,CAAC,GAAG,MAAM,CAAC;;;;;;;;;;eAS9J,OAAO,GAAG,OAAO,QAAQ,EAAE,aAAa,GAAG,CAAC,CAAC,IAAI,EAAE,MAAM,EAAE,MAAM,CAAC,EAAE,OAAO,KAAK,OAAO,CAAC,MAAM,CAAC,GAAG,MAAM,CAAC;;;;;;;;;;iBASzG,OAAO,GAAG,MAAM,GAAG,OAAO,WAAW,EAAE,OAAO,GAAG,CAAC,CAAC,IAAI,EAAE,MAAM,KAAK,OAAO,CAAC,MAAM,CAAC,GAAG,MAAM,CAAC;;;;;;;;WAS7F,CAAC,IAAI,EAAE,MAAM,KAAK,MAAM;;;;;;;+BAOxB,OAAO;;;;;;;;;;oBAMP,OAAO;;;;;;;;yBASP,OAAO;;;;;;;gCAOP,OAAO;;;;;;;;iCAMP,OAAO;;;;;;;;;;qBAOP,MAAM,EAAE;;;;;;;qBASR,IAAI,GAAG,GAAG;;;;;;;4BAMV,OAAO;;;;;;;;qBAMP,OAAO;;;;;;;;;4BAOP,OAAO,GAAG,CAAC,CAAC,QAAQ,EAAE,MAAM,EAAE,GAAG,EAAE,MAAM,KAAK,OAAO,CAAC;;;;;;;;0BAQtD,OAAO;;;;;;;;;;;;;;;;;;;;;;;;;;gCAOP,MAAM,EAAE;;;;;;;;yBAyBR,OAAO;;;;;;;;gCAOP,OAAO;;;;;;;iCAOP,OAAO;;;;;;;oCAMP,OAAO;;;;;;;;;;0BAMP,OAAO;;;;;;;;;qBASP,OAAO,GAAG,CAAC,CAAC,GAAG,EAAE,MAAM,EAAE,KAAK,EAAE,aAAa,EAAE,KAAK,IAAI,CAAC;;;;;;;;;oBAQzD,OAAO,GAAG,CAAC,CAAC,KAAK,EAAE,MAAM,KAAK,MAAM,CAAC;;;;;;;;0BAQrC,OAAO;;;;;;;sBAOP,OAAO;;wBAnYkC,cAAc;0BAAd,cAAc;+BAAd,cAAc"}
package/package.json CHANGED
@@ -84,5 +84,5 @@
84
84
  "test:watch": "node --test --watch tests/*.spec.js"
85
85
  },
86
86
  "type": "module",
87
- "version": "4.8.3"
87
+ "version": "4.9.1"
88
88
  }
@@ -1657,6 +1657,7 @@ async function minifyHTML(value, options, partialMarkup) {
1657
1657
  const ignoredMarkupChunks = [];
1658
1658
  const ignoredCustomMarkupChunks = [];
1659
1659
  let uidIgnore;
1660
+ let uidIgnorePlaceholderPattern;
1660
1661
  let uidAttr;
1661
1662
  let uidPattern;
1662
1663
  // Create inline tags/text sets with custom elements
@@ -1690,6 +1691,7 @@ async function minifyHTML(value, options, partialMarkup) {
1690
1691
  if (!uidIgnore) {
1691
1692
  uidIgnore = uniqueId(value);
1692
1693
  const pattern = new RegExp('^' + uidIgnore + '([0-9]+)$');
1694
+ uidIgnorePlaceholderPattern = new RegExp('^<!--' + uidIgnore + '(\\d+)-->$');
1693
1695
  if (options.ignoreCustomComments) {
1694
1696
  options.ignoreCustomComments = options.ignoreCustomComments.slice();
1695
1697
  } else {
@@ -2114,6 +2116,79 @@ async function minifyHTML(value, options, partialMarkup) {
2114
2116
  optionalStartTag = '';
2115
2117
  optionalEndTag = '';
2116
2118
  }
2119
+
2120
+ // Optimize whitespace collapsing between consecutive `htmlmin:ignore` placeholder comments
2121
+ if (options.collapseWhitespace && text && uidIgnorePlaceholderPattern) {
2122
+ if (uidIgnorePlaceholderPattern.test(text)) {
2123
+ // Check if previous buffer items are: [ignore-placeholder, whitespace-only text]
2124
+ if (buffer.length >= 2) {
2125
+ const prevText = buffer[buffer.length - 1];
2126
+ const prevComment = buffer[buffer.length - 2];
2127
+
2128
+ // Check if previous item is whitespace-only and item before that is ignore-placeholder
2129
+ if (prevText && /^\s+$/.test(prevText) &&
2130
+ prevComment && uidIgnorePlaceholderPattern.test(prevComment)) {
2131
+ // Extract the index from both placeholders to check their content
2132
+ const currentMatch = text.match(uidIgnorePlaceholderPattern);
2133
+ const prevMatch = prevComment.match(uidIgnorePlaceholderPattern);
2134
+
2135
+ if (currentMatch && prevMatch) {
2136
+ const currentIndex = +currentMatch[1];
2137
+ const prevIndex = +prevMatch[1];
2138
+
2139
+ // Defensive bounds check to ensure indices are valid
2140
+ if (currentIndex < ignoredMarkupChunks.length && prevIndex < ignoredMarkupChunks.length) {
2141
+ const currentContent = ignoredMarkupChunks[currentIndex];
2142
+ const prevContent = ignoredMarkupChunks[prevIndex];
2143
+
2144
+ // Only collapse whitespace if both blocks contain HTML (start with `<`)
2145
+ // Don’t collapse if either contains plain text, as that would change meaning
2146
+ // Note: This check will match HTML comments (`<!-- … -->`), but the tag-name
2147
+ // regex below requires starting with a letter, so comments are intentionally
2148
+ // excluded by the `currentTagMatch && prevTagMatch` guard
2149
+ if (currentContent && prevContent && /^\s*</.test(currentContent) && /^\s*</.test(prevContent)) {
2150
+ // Extract tag names from the HTML content (excludes comments, processing instructions, etc.)
2151
+ const currentTagMatch = currentContent.match(/^\s*<([a-zA-Z][\w:-]*)/);
2152
+ const prevTagMatch = prevContent.match(/^\s*<([a-zA-Z][\w:-]*)/);
2153
+
2154
+ // Only collapse if both matched valid element tags (not comments/text)
2155
+ // and both tags are block-level (inline elements need whitespace preserved)
2156
+ if (currentTagMatch && prevTagMatch) {
2157
+ const currentTag = options.name(currentTagMatch[1]);
2158
+ const prevTag = options.name(prevTagMatch[1]);
2159
+
2160
+ // Don’t collapse between inline elements
2161
+ if (!inlineElements.has(currentTag) && !inlineElements.has(prevTag)) {
2162
+ // Collapse whitespace respecting context rules
2163
+ let collapsedText = prevText;
2164
+
2165
+ // Apply `collapseWhitespace` with appropriate context
2166
+ if (!stackNoTrimWhitespace.length && !stackNoCollapseWhitespace.length) {
2167
+ // Not in pre or other no-collapse context
2168
+ if (options.preserveLineBreaks && /[\n\r]/.test(prevText)) {
2169
+ // Preserve line break as single newline
2170
+ collapsedText = '\n';
2171
+ } else if (options.conservativeCollapse) {
2172
+ // Conservative mode: keep single space
2173
+ collapsedText = ' ';
2174
+ } else {
2175
+ // Aggressive mode: remove all whitespace
2176
+ collapsedText = '';
2177
+ }
2178
+ }
2179
+
2180
+ // Replace the whitespace in buffer
2181
+ buffer[buffer.length - 1] = collapsedText;
2182
+ }
2183
+ }
2184
+ }
2185
+ }
2186
+ }
2187
+ }
2188
+ }
2189
+ }
2190
+ }
2191
+
2117
2192
  buffer.push(text);
2118
2193
  },
2119
2194
  doctype: function (doctype) {