html-minifier-next 4.11.1 → 4.12.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 CHANGED
@@ -236,32 +236,32 @@ How does HTML Minifier Next compare to other minifiers? (All with the most aggre
236
236
  | 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/) |
237
237
  | --- | --- | --- | --- | --- | --- | --- | --- | --- |
238
238
  | [A List Apart](https://alistapart.com/) | 59 | **49** | 50 | 51 | 52 | 51 | 54 | 52 |
239
- | [Apple](https://www.apple.com/) | 266 | **206** | n/a | 236 | 239 | 240 | 242 | 243 |
240
- | [BBC](https://www.bbc.co.uk/) | 697 | **634** | n/a | 655 | 655 | 656 | 691 | n/a |
241
- | [CERN](https://home.cern/) | 156 | **87** | n/a | 95 | 95 | 95 | 97 | 100 |
242
- | [CSS-Tricks](https://css-tricks.com/) | 163 | **122** | n/a | 128 | 143 | 144 | 149 | 145 |
243
- | [ECMAScript](https://tc39.es/ecma262/) | 7238 | **6342** | **6342** | 6561 | 6444 | 6567 | 6614 | n/a |
239
+ | [Apple](https://www.apple.com/) | 266 | **207** | **207** | 236 | 239 | 240 | 242 | 243 |
240
+ | [BBC](https://www.bbc.co.uk/) | 739 | **675** | 685 | 695 | 695 | 697 | 733 | n/a |
241
+ | [CERN](https://home.cern/) | 156 | **87** | **87** | 95 | 95 | 95 | 97 | 100 |
242
+ | [CSS-Tricks](https://css-tricks.com/) | 161 | 121 | **119** | 127 | 142 | 142 | 147 | 144 |
243
+ | [ECMAScript](https://tc39.es/ecma262/) | 7238 | **6341** | **6341** | 6561 | 6444 | 6567 | 6614 | n/a |
244
244
  | [EDRi](https://edri.org/) | 80 | **59** | 60 | 70 | 70 | 71 | 75 | 73 |
245
- | [EFF](https://www.eff.org/) | 55 | **47** | n/a | 50 | 48 | 49 | 50 | 50 |
245
+ | [EFF](https://www.eff.org/) | 55 | **47** | **47** | 50 | 48 | 49 | 50 | 50 |
246
246
  | [European Alternatives](https://european-alternatives.eu/) | 48 | **30** | **30** | 32 | 32 | 32 | 32 | 32 |
247
- | [FAZ](https://www.faz.net/aktuell/) | 1604 | 1494 | 1499 | **1437** | 1527 | 1539 | 1550 | n/a |
247
+ | [FAZ](https://www.faz.net/aktuell/) | 1543 | 1436 | 1441 | **1383** | 1468 | 1479 | 1490 | n/a |
248
248
  | [French Tech](https://lafrenchtech.gouv.fr/) | 152 | **121** | 122 | 125 | 125 | 125 | 132 | 126 |
249
249
  | [Frontend Dogma](https://frontenddogma.com/) | 223 | **213** | 215 | 236 | 221 | 223 | 241 | 222 |
250
250
  | [Google](https://www.google.com/) | 18 | **17** | **17** | **17** | **17** | **17** | 18 | 18 |
251
- | [Ground News](https://ground.news/) | 1481 | **1259** | n/a | 1361 | 1386 | 1392 | 1468 | n/a |
252
- | [HTML Living Standard](https://html.spec.whatwg.org/multipage/) | 149 | **147** | n/a | 153 | **147** | 149 | 155 | 149 |
253
- | [Igalia](https://www.igalia.com/) | 50 | **33** | **33** | 36 | 36 | 36 | 37 | 36 |
254
- | [Leanpub](https://leanpub.com/) | 1521 | **1287** | **1287** | 1294 | 1293 | 1290 | 1516 | n/a |
255
- | [Mastodon](https://mastodon.social/explore) | 37 | **27** | n/a | 32 | 34 | 35 | 35 | 35 |
256
- | [MDN](https://developer.mozilla.org/en-US/) | 109 | **62** | n/a | 64 | 65 | 65 | 68 | 68 |
257
- | [Middle East Eye](https://www.middleeasteye.net/) | 222 | **195** | **195** | 202 | 200 | 200 | 202 | 203 |
251
+ | [Ground News](https://ground.news/) | 1437 | **1222** | 1225 | 1320 | 1346 | 1353 | 1424 | n/a |
252
+ | [HTML Living Standard](https://html.spec.whatwg.org/multipage/) | 149 | **147** | **147** | 153 | **147** | 149 | 155 | 149 |
253
+ | [Igalia](https://www.igalia.com/) | 50 | **34** | **34** | 36 | 36 | 36 | 37 | 37 |
254
+ | [Leanpub](https://leanpub.com/) | 1288 | **1092** | **1092** | 1099 | 1097 | 1094 | 1283 | n/a |
255
+ | [Mastodon](https://mastodon.social/explore) | 37 | **27** | **27** | 32 | 34 | 35 | 35 | 35 |
256
+ | [MDN](https://developer.mozilla.org/en-US/) | 109 | **62** | **62** | 64 | 65 | 65 | 68 | 68 |
257
+ | [Middle East Eye](https://www.middleeasteye.net/) | 223 | **196** | **196** | 203 | 201 | 200 | 202 | 203 |
258
258
  | [Nielsen Norman Group](https://www.nngroup.com/) | 86 | 74 | 74 | **55** | 74 | 75 | 77 | 76 |
259
259
  | [SitePoint](https://www.sitepoint.com/) | 485 | **354** | **354** | 425 | 459 | 464 | 481 | n/a |
260
260
  | [TetraLogical](https://tetralogical.com/) | 44 | 38 | 38 | **35** | 38 | 38 | 39 | 39 |
261
- | [TPGi](https://www.tpgi.com/) | 175 | **159** | n/a | 160 | 164 | 166 | 172 | 171 |
261
+ | [TPGi](https://www.tpgi.com/) | 175 | **160** | 161 | **160** | 164 | 166 | 172 | 172 |
262
262
  | [United Nations](https://www.un.org/en/) | 150 | **112** | 113 | 120 | 124 | 124 | 129 | 122 |
263
- | [W3C](https://www.w3.org/) | 50 | **36** | **36** | 38 | 38 | 38 | 40 | 38 |
264
- | **Average processing time** | | 478 ms (26/26) | 735 ms (16/26) | 174 ms (26/26) | 57 ms (26/26) | **17 ms (26/26)** | 318 ms (26/26) | 1536 ms (20/26) |
263
+ | [W3C](https://www.w3.org/) | 50 | **35** | 36 | 38 | 38 | 38 | 40 | 38 |
264
+ | **Average processing time** | | 306 ms (26/26) | 364 ms (26/26) | 162 ms (26/26) | 55 ms (26/26) | **16 ms (26/26)** | 316 ms (26/26) | 1439 ms (20/26) |
265
265
 
266
266
  (Last updated: Dec 17, 2025)
267
267
  <!-- End auto-generated -->
package/cli.js CHANGED
@@ -28,10 +28,12 @@
28
28
  import fs from 'fs';
29
29
  import path from 'path';
30
30
  import { pathToFileURL } from 'url';
31
+ import os from 'os';
31
32
  import { createRequire } from 'module';
32
33
  import { camelCase, paramCase } from 'change-case';
33
34
  import { Command } from 'commander';
34
- import { minify } from './src/htmlminifier.js';
35
+ // Lazy-load HMN to reduce CLI cold-start overhead
36
+ // import { minify } from './src/htmlminifier.js';
35
37
  import { getPreset, getPresetNames } from './src/presets.js';
36
38
 
37
39
  const require = createRequire(import.meta.url);
@@ -182,6 +184,15 @@ program.option('-o --output <file>', 'Specify output file (reads from file argum
182
184
  program.option('-v --verbose', 'Show detailed processing information');
183
185
  program.option('-d --dry', 'Dry run: process and report statistics without writing output');
184
186
 
187
+ // Lazy import wrapper for HMN
188
+ let minifyFnPromise;
189
+ async function getMinify() {
190
+ if (!minifyFnPromise) {
191
+ minifyFnPromise = import('./src/htmlminifier.js').then(m => m.minify);
192
+ }
193
+ return minifyFnPromise;
194
+ }
195
+
185
196
  function readFile(file) {
186
197
  try {
187
198
  return fs.readFileSync(file, { encoding: 'utf8' });
@@ -270,13 +281,30 @@ program.option('--file-ext <extensions>', 'Specify file extension(s) to process
270
281
  (async () => {
271
282
  let content;
272
283
  let filesProvided = false;
284
+ let capturedFiles = [];
273
285
  await program.arguments('[files...]').action(function (files) {
274
- content = files.map(readFile).join('');
286
+ capturedFiles = files;
275
287
  filesProvided = files.length > 0;
288
+ // Defer reading files until after we check for consumed filenames
276
289
  }).parseAsync(process.argv);
277
290
 
278
291
  const programOptions = program.opts();
279
292
 
293
+ // Check if any `parseJSON` options consumed a filename as their value
294
+ // If so, treat the option as boolean true and add the filename back to the files list
295
+ const jsonOptionKeys = ['minifyCss', 'minifyJs', 'minifyUrls'];
296
+ for (const key of jsonOptionKeys) {
297
+ const value = programOptions[key];
298
+ if (typeof value === 'string' && /\.(html?|php|xml|svg|xhtml|jsx|tsx|vue|ejs|hbs|mustache|twig)$/i.test(value)) {
299
+ // The option consumed a filename - inject it back
300
+ programOptions[key] = true;
301
+ capturedFiles.push(value);
302
+ filesProvided = true;
303
+ }
304
+ }
305
+
306
+ // Defer reading files—multi-file mode will process per-file later
307
+
280
308
  // Load and normalize config if `--config-file` was specified
281
309
  if (programOptions.configFile) {
282
310
  config = await loadConfigFromPath(programOptions.configFile);
@@ -344,6 +372,7 @@ program.option('--file-ext <extensions>', 'Specify file extension(s) to process
344
372
 
345
373
  let minified;
346
374
  try {
375
+ const minify = await getMinify();
347
376
  minified = await minify(data, createOptions());
348
377
  } catch (err) {
349
378
  fatal('Minification error on ' + inputFile + '\n' + err.message);
@@ -472,6 +501,54 @@ program.option('--file-ext <extensions>', 'Specify file extension(s) to process
472
501
  process.stderr.write('\r\x1b[K'); // Clear the line
473
502
  }
474
503
 
504
+ // Utility: concurrency runner
505
+ async function runWithConcurrency(items, limit, worker) {
506
+ const results = new Array(items.length);
507
+ let next = 0;
508
+ let active = 0;
509
+ return new Promise((resolve, reject) => {
510
+ const launch = () => {
511
+ while (active < limit && next < items.length) {
512
+ const current = next++;
513
+ active++;
514
+ Promise.resolve(worker(items[current], current))
515
+ .then((res) => {
516
+ results[current] = res;
517
+ active--;
518
+ launch();
519
+ })
520
+ .catch(reject);
521
+ }
522
+ if (next >= items.length && active === 0) {
523
+ resolve(results);
524
+ }
525
+ };
526
+ launch();
527
+ });
528
+ }
529
+
530
+ async function collectFiles(dir, extensions, skipRootAbs, ignorePatterns, baseDir) {
531
+ const out = [];
532
+ const entries = await fs.promises.readdir(dir).catch(() => []);
533
+ for (const name of entries) {
534
+ const filePath = path.join(dir, name);
535
+ if (skipRootAbs) {
536
+ const real = await fs.promises.realpath(filePath).catch(() => undefined);
537
+ if (real && (real === skipRootAbs || real.startsWith(skipRootAbs + path.sep))) continue;
538
+ }
539
+ const lst = await fs.promises.lstat(filePath).catch(() => null);
540
+ if (!lst || lst.isSymbolicLink()) continue;
541
+ if (lst.isDirectory()) {
542
+ if (shouldIgnoreDirectory(filePath, ignorePatterns, baseDir)) continue;
543
+ const sub = await collectFiles(filePath, extensions, skipRootAbs, ignorePatterns, baseDir);
544
+ out.push(...sub);
545
+ } else if (shouldProcessFile(name, extensions)) {
546
+ out.push(filePath);
547
+ }
548
+ }
549
+ return out;
550
+ }
551
+
475
552
  async function processDirectory(inputDir, outputDir, extensions, isDryRun = false, isVerbose = false, skipRootAbs, progress = null, ignorePatterns = [], baseDir = null) {
476
553
  // If first call provided a string, normalize once; otherwise assume pre-parsed array
477
554
  if (typeof extensions === 'string') {
@@ -483,61 +560,27 @@ program.option('--file-ext <extensions>', 'Specify file extension(s) to process
483
560
  baseDir = inputDir;
484
561
  }
485
562
 
486
- const files = await fs.promises.readdir(inputDir).catch(err => {
487
- fatal('Cannot read directory ' + inputDir + '\n' + err.message);
488
- });
489
-
490
- const allStats = [];
491
-
492
- for (const file of files) {
493
- const inputFile = path.join(inputDir, file);
494
- const outputFile = path.join(outputDir, file);
495
-
496
- // Skip anything inside the output root to avoid reprocessing
497
- if (skipRootAbs) {
498
- const real = await fs.promises.realpath(inputFile).catch(() => undefined);
499
- if (real && (real === skipRootAbs || real.startsWith(skipRootAbs + path.sep))) {
500
- continue;
501
- }
502
- }
503
-
504
- const lst = await fs.promises.lstat(inputFile).catch(err => {
505
- fatal('Cannot read ' + inputFile + '\n' + err.message);
506
- });
507
-
508
- if (lst.isSymbolicLink()) {
509
- continue;
563
+ // Collect all files first for bounded parallel processing
564
+ const list = await collectFiles(inputDir, extensions, skipRootAbs, ignorePatterns, baseDir);
565
+ const allStats = new Array(list.length);
566
+ const concurrency = Math.max(1, Math.min(os.cpus().length || 4, 8));
567
+ await runWithConcurrency(list, concurrency, async (inputFile, idx) => {
568
+ const rel = path.relative(inputDir, inputFile);
569
+ const outFile = path.join(outputDir, rel);
570
+ const outDir = path.dirname(outFile);
571
+ if (!isDryRun) {
572
+ await fs.promises.mkdir(outDir, { recursive: true }).catch(err => {
573
+ fatal('Cannot create directory ' + outDir + '\n' + err.message);
574
+ });
510
575
  }
511
-
512
- if (lst.isDirectory()) {
513
- // Skip ignored directories
514
- if (shouldIgnoreDirectory(inputFile, ignorePatterns, baseDir)) {
515
- continue;
516
- }
517
- const dirStats = await processDirectory(inputFile, outputFile, extensions, isDryRun, isVerbose, skipRootAbs, progress, ignorePatterns, baseDir);
518
- if (dirStats) {
519
- allStats.push(...dirStats);
520
- }
521
- } else if (shouldProcessFile(file, extensions)) {
522
- if (!isDryRun) {
523
- await fs.promises.mkdir(outputDir, { recursive: true }).catch(err => {
524
- fatal('Cannot create directory ' + outputDir + '\n' + err.message);
525
- });
526
- }
527
- const fileStats = await processFile(inputFile, outputFile, isDryRun, isVerbose);
528
- if (fileStats) {
529
- allStats.push(fileStats);
530
- }
531
-
532
- // Update progress after processing
533
- if (progress) {
534
- progress.current++;
535
- updateProgress(progress.current, progress.total);
536
- }
576
+ const stats = await processFile(inputFile, outFile, isDryRun, isVerbose);
577
+ allStats[idx] = stats;
578
+ if (progress) {
579
+ progress.current++;
580
+ updateProgress(progress.current, progress.total);
537
581
  }
538
- }
539
-
540
- return allStats;
582
+ });
583
+ return allStats.filter(Boolean);
541
584
  }
542
585
 
543
586
  const writeMinify = async () => {
@@ -551,6 +594,7 @@ program.option('--file-ext <extensions>', 'Specify file extension(s) to process
551
594
  let minified;
552
595
 
553
596
  try {
597
+ const minify = await getMinify();
554
598
  minified = await minify(content, minifierOptions);
555
599
  } catch (err) {
556
600
  fatal('Minification error:\n' + err.message);
@@ -576,17 +620,12 @@ program.option('--file-ext <extensions>', 'Specify file extension(s) to process
576
620
  }
577
621
 
578
622
  if (programOptions.output) {
579
- await fs.promises.mkdir(path.dirname(programOptions.output), { recursive: true }).catch((e) => {
580
- fatal('Cannot create directory ' + path.dirname(programOptions.output) + '\n' + e.message);
581
- });
582
- await new Promise((resolve, reject) => {
583
- const fileStream = fs.createWriteStream(programOptions.output)
584
- .on('error', reject)
585
- .on('finish', resolve);
586
- fileStream.end(minified);
587
- }).catch((e) => {
588
- fatal('Cannot write ' + programOptions.output + '\n' + e.message);
589
- });
623
+ try {
624
+ await fs.promises.mkdir(path.dirname(programOptions.output), { recursive: true });
625
+ await fs.promises.writeFile(programOptions.output, minified, { encoding: 'utf8' });
626
+ } catch (err) {
627
+ fatal('Cannot write ' + programOptions.output + '\n' + err.message);
628
+ }
590
629
  return;
591
630
  }
592
631
 
@@ -646,6 +685,16 @@ program.option('--file-ext <extensions>', 'Specify file extension(s) to process
646
685
  // Parse ignore patterns
647
686
  const ignorePatterns = parseIgnorePatterns(resolvedIgnoreDir);
648
687
 
688
+ // Validate that the input directory exists and is readable
689
+ try {
690
+ const stat = await fs.promises.stat(inputDir);
691
+ if (!stat.isDirectory()) {
692
+ fatal(inputDir + ' is not a directory');
693
+ }
694
+ } catch (err) {
695
+ fatal('Cannot read directory ' + inputDir + '\n' + err.message);
696
+ }
697
+
649
698
  // Resolve base directory for consistent path comparisons
650
699
  const inputDirResolved = await fs.promises.realpath(inputDir).catch(() => inputDir);
651
700
 
@@ -688,12 +737,72 @@ program.option('--file-ext <extensions>', 'Specify file extension(s) to process
688
737
  }
689
738
  })();
690
739
  } else if (filesProvided) { // Minifying one or more files specified on the CMD line
691
- writeMinify();
740
+ // Process each file independently, then concatenate outputs to preserve current behavior
741
+ const minifierOptions = createOptions();
742
+ // Show config info if verbose/dry
743
+ if (programOptions.verbose || programOptions.dry) {
744
+ getActiveOptionsDisplay(minifierOptions);
745
+ }
746
+
747
+ const concurrency = Math.max(1, Math.min(os.cpus().length || 4, 8));
748
+ const inputs = capturedFiles.slice();
749
+
750
+ // Read originals and minify in parallel with bounded concurrency
751
+ const originals = new Array(inputs.length);
752
+ const outputs = new Array(inputs.length);
753
+
754
+ await runWithConcurrency(inputs, concurrency, async (file, idx) => {
755
+ const data = await fs.promises.readFile(file, 'utf8').catch(err => fatal('Cannot read ' + file + '\n' + err.message));
756
+ const minify = await getMinify();
757
+ let out;
758
+ try {
759
+ out = await minify(data, minifierOptions);
760
+ } catch (err) {
761
+ fatal('Minification error on ' + file + '\n' + err.message);
762
+ }
763
+ originals[idx] = data;
764
+ outputs[idx] = out;
765
+ });
766
+
767
+ const originalCombined = originals.join('');
768
+ const minifiedCombined = outputs.join('');
769
+
770
+ const stats = calculateStats(originalCombined, minifiedCombined);
771
+
772
+ if (programOptions.dry) {
773
+ const inputSource = capturedFiles.join(', ');
774
+ const outputDest = programOptions.output || 'STDOUT';
775
+ console.error(`[DRY RUN] Would minify: ${inputSource} → ${outputDest}`);
776
+ console.error(` Original: ${stats.originalSize.toLocaleString()} bytes`);
777
+ console.error(` Minified: ${stats.minifiedSize.toLocaleString()} bytes`);
778
+ console.error(` Saved: ${stats.sign}${Math.abs(stats.saved).toLocaleString()} bytes (${stats.percentage}%)`);
779
+ process.exit(0);
780
+ }
781
+
782
+ if (programOptions.verbose) {
783
+ const inputSource = capturedFiles.join(', ');
784
+ console.error(` ✓ ${inputSource}: ${stats.originalSize.toLocaleString()} → ${stats.minifiedSize.toLocaleString()} bytes (${stats.sign}${Math.abs(stats.saved).toLocaleString()}, ${stats.percentage}%)`);
785
+ }
786
+
787
+ if (programOptions.output) {
788
+ try {
789
+ await fs.promises.mkdir(path.dirname(programOptions.output), { recursive: true });
790
+ await fs.promises.writeFile(programOptions.output, minifiedCombined, 'utf8');
791
+ } catch (err) {
792
+ fatal('Cannot write ' + programOptions.output + '\n' + err.message);
793
+ }
794
+ } else {
795
+ process.stdout.write(minifiedCombined);
796
+ }
797
+ process.exit(0);
692
798
  } else { // Minifying input coming from STDIN
693
799
  content = '';
694
800
  process.stdin.setEncoding('utf8');
695
801
  process.stdin.on('data', function (data) {
696
802
  content += data;
697
- }).on('end', writeMinify);
803
+ }).on('end', async function() {
804
+ await writeMinify();
805
+ process.exit(0);
806
+ });
698
807
  }
699
808
  })();
@@ -92,7 +92,7 @@ const preCompiledStackedTags = {
92
92
  // Cache for compiled attribute regexes per handler configuration
93
93
  const attrRegexCache = new WeakMap();
94
94
 
95
- function attrForHandler(handler) {
95
+ function buildAttrRegex(handler) {
96
96
  let pattern = singleAttrIdentifier.source +
97
97
  '(?:\\s*(' + joinSingleAttrAssigns(handler) + ')' +
98
98
  '[ \\t\\n\\f\\r]*(?:' + singleAttrValues.join('|') + '))?';
@@ -111,6 +111,14 @@ function attrForHandler(handler) {
111
111
  return new RegExp('^\\s*' + pattern);
112
112
  }
113
113
 
114
+ function getAttrRegexForHandler(handler) {
115
+ let cached = attrRegexCache.get(handler);
116
+ if (cached) return cached;
117
+ const compiled = buildAttrRegex(handler);
118
+ attrRegexCache.set(handler, compiled);
119
+ return compiled;
120
+ }
121
+
114
122
  function joinSingleAttrAssigns(handler) {
115
123
  return singleAttrAssigns.concat(
116
124
  handler.customAttrAssign || []
@@ -134,12 +142,8 @@ class HTMLParser {
134
142
  const fullLength = fullHtml.length;
135
143
 
136
144
  const stack = []; let lastTag;
137
- // Use cached attribute regex if available
138
- let attribute = attrRegexCache.get(handler);
139
- if (!attribute) {
140
- attribute = attrForHandler(handler);
141
- attrRegexCache.set(handler, attribute);
142
- }
145
+ // Use cached attribute regex for this handler configuration
146
+ const attribute = getAttrRegexForHandler(handler);
143
147
  let prevTag = undefined, nextTag = undefined;
144
148
 
145
149
  // Index-based parsing
@@ -1263,7 +1267,7 @@ const cssMinifyCache = new LRU(200);
1263
1267
 
1264
1268
  const trimWhitespace = str => {
1265
1269
  if (!str) return str;
1266
- // Fast path: if no whitespace at start or end, return early
1270
+ // Fast path: If no whitespace at start or end, return early
1267
1271
  if (!/^[ \n\r\t\f]/.test(str) && !/[ \n\r\t\f]$/.test(str)) {
1268
1272
  return str;
1269
1273
  }
@@ -1272,7 +1276,7 @@ const trimWhitespace = str => {
1272
1276
 
1273
1277
  function collapseWhitespaceAll(str) {
1274
1278
  if (!str) return str;
1275
- // Fast path: if there are no common whitespace characters, return early
1279
+ // Fast path: If there are no common whitespace characters, return early
1276
1280
  if (!/[ \n\r\t\f\xA0]/.test(str)) {
1277
1281
  return str;
1278
1282
  }
@@ -2027,7 +2031,7 @@ async function normalizeAttr(attr, attrs, tag, options) {
2027
2031
  let attrValue = attr.value;
2028
2032
 
2029
2033
  if (options.decodeEntities && attrValue) {
2030
- // Fast path: only decode when entities are present
2034
+ // Fast path: Only decode when entities are present
2031
2035
  if (attrValue.indexOf('&') !== -1) {
2032
2036
  attrValue = entities.decodeHTMLStrict(attrValue);
2033
2037
  }
@@ -2227,7 +2231,7 @@ const processOptions = (inputOptions) => {
2227
2231
  const lightningCssOptions = typeof option === 'object' ? option : {};
2228
2232
 
2229
2233
  options.minifyCSS = async function (text, type) {
2230
- // Fast path: nothing to minify
2234
+ // Fast path: Nothing to minify
2231
2235
  if (!text || !text.trim()) {
2232
2236
  return text;
2233
2237
  }
@@ -2319,7 +2323,7 @@ const processOptions = (inputOptions) => {
2319
2323
 
2320
2324
  let jsKey;
2321
2325
  try {
2322
- // Fast path: avoid invoking Terser for empty/whitespace-only content
2326
+ // Fast path: Avoid invoking Terser for empty/whitespace-only content
2323
2327
  if (!code || !code.trim()) {
2324
2328
  return '';
2325
2329
  }
@@ -2371,9 +2375,18 @@ const processOptions = (inputOptions) => {
2371
2375
  relateUrlOptions = {};
2372
2376
  }
2373
2377
 
2378
+ // Cache RelateURL instance for reuse (expensive to create)
2379
+ const relateUrlInstance = new RelateURL(relateUrlOptions.site || '', relateUrlOptions);
2380
+
2374
2381
  options.minifyURLs = function (text) {
2382
+ // Fast-path: Skip if text doesn’t look like a URL that needs processing
2383
+ // Only process if contains URL-like characters (`/`, `:`, `#`, `?`) or spaces that need encoding
2384
+ if (!/[/:?#\s]/.test(text)) {
2385
+ return text;
2386
+ }
2387
+
2375
2388
  try {
2376
- return RelateURL.relate(text, relateUrlOptions);
2389
+ return relateUrlInstance.relate(text);
2377
2390
  } catch (err) {
2378
2391
  if (!options.continueOnMinifyError) {
2379
2392
  throw err;
@@ -2469,11 +2482,11 @@ async function createSortFns(value, options, uidIgnore, uidAttr, ignoredMarkupCh
2469
2482
 
2470
2483
  try {
2471
2484
  await parser.parse();
2472
- } catch (e) {
2485
+ } catch (err) {
2473
2486
  // If parsing fails during analysis pass, just skip it—we’ll still have
2474
2487
  // partial frequency data from what we could parse
2475
2488
  if (!options.continueOnParseError) {
2476
- throw e;
2489
+ throw err;
2477
2490
  }
2478
2491
  }
2479
2492
  }
@@ -2562,7 +2575,21 @@ async function createSortFns(value, options, uidIgnore, uidAttr, ignoredMarkupCh
2562
2575
  }
2563
2576
  if (classChain) {
2564
2577
  const sorter = classChain.createSorter();
2578
+ // Memoize `sortClassName` results—class lists often repeat in templates
2579
+ const classNameCache = new LRU(200);
2580
+
2565
2581
  options.sortClassName = function (value) {
2582
+ // Fast path: Single class (no spaces) needs no sorting
2583
+ if (value.indexOf(' ') === -1) {
2584
+ return value;
2585
+ }
2586
+
2587
+ // Check cache first
2588
+ const cached = classNameCache.get(value);
2589
+ if (cached !== undefined) {
2590
+ return cached;
2591
+ }
2592
+
2566
2593
  // Expand UID tokens back to original content before sorting
2567
2594
  // Fast path: Skip if no HTML comments (UID markers) present
2568
2595
  let expandedValue = value;
@@ -2577,7 +2604,11 @@ async function createSortFns(value, options, uidIgnore, uidAttr, ignoredMarkupCh
2577
2604
  return cls !== '';
2578
2605
  });
2579
2606
  const sorted = sorter.sort(classes);
2580
- return sorted.join(' ');
2607
+ const result = sorted.join(' ');
2608
+
2609
+ // Cache the result
2610
+ classNameCache.set(value, result);
2611
+ return result;
2581
2612
  };
2582
2613
  }
2583
2614
  }
@@ -2618,7 +2649,7 @@ async function minifyHTML(value, options, partialMarkup) {
2618
2649
  const customElementsInput = options.inlineCustomElements ?? [];
2619
2650
  const customElementsArr = Array.isArray(customElementsInput) ? customElementsInput : Array.from(customElementsInput);
2620
2651
  const normalizedCustomElements = customElementsArr.map(name => options.name(name));
2621
- // Fast path: reuse base Sets if no custom elements
2652
+ // Fast path: Reuse base Sets if no custom elements
2622
2653
  const inlineTextSet = normalizedCustomElements.length
2623
2654
  ? new Set([...inlineElementsToKeepWhitespaceWithin, ...normalizedCustomElements])
2624
2655
  : inlineElementsToKeepWhitespaceWithin;
@@ -5234,7 +5234,7 @@ const preCompiledStackedTags = {
5234
5234
  // Cache for compiled attribute regexes per handler configuration
5235
5235
  const attrRegexCache = new WeakMap();
5236
5236
 
5237
- function attrForHandler(handler) {
5237
+ function buildAttrRegex(handler) {
5238
5238
  let pattern = singleAttrIdentifier.source +
5239
5239
  '(?:\\s*(' + joinSingleAttrAssigns(handler) + ')' +
5240
5240
  '[ \\t\\n\\f\\r]*(?:' + singleAttrValues.join('|') + '))?';
@@ -5253,6 +5253,14 @@ function attrForHandler(handler) {
5253
5253
  return new RegExp('^\\s*' + pattern);
5254
5254
  }
5255
5255
 
5256
+ function getAttrRegexForHandler(handler) {
5257
+ let cached = attrRegexCache.get(handler);
5258
+ if (cached) return cached;
5259
+ const compiled = buildAttrRegex(handler);
5260
+ attrRegexCache.set(handler, compiled);
5261
+ return compiled;
5262
+ }
5263
+
5256
5264
  function joinSingleAttrAssigns(handler) {
5257
5265
  return singleAttrAssigns.concat(
5258
5266
  handler.customAttrAssign || []
@@ -5276,12 +5284,8 @@ class HTMLParser {
5276
5284
  const fullLength = fullHtml.length;
5277
5285
 
5278
5286
  const stack = []; let lastTag;
5279
- // Use cached attribute regex if available
5280
- let attribute = attrRegexCache.get(handler);
5281
- if (!attribute) {
5282
- attribute = attrForHandler(handler);
5283
- attrRegexCache.set(handler, attribute);
5284
- }
5287
+ // Use cached attribute regex for this handler configuration
5288
+ const attribute = getAttrRegexForHandler(handler);
5285
5289
  let prevTag = undefined, nextTag = undefined;
5286
5290
 
5287
5291
  // Index-based parsing
@@ -6405,7 +6409,7 @@ const cssMinifyCache = new LRU(200);
6405
6409
 
6406
6410
  const trimWhitespace = str => {
6407
6411
  if (!str) return str;
6408
- // Fast path: if no whitespace at start or end, return early
6412
+ // Fast path: If no whitespace at start or end, return early
6409
6413
  if (!/^[ \n\r\t\f]/.test(str) && !/[ \n\r\t\f]$/.test(str)) {
6410
6414
  return str;
6411
6415
  }
@@ -6414,7 +6418,7 @@ const trimWhitespace = str => {
6414
6418
 
6415
6419
  function collapseWhitespaceAll(str) {
6416
6420
  if (!str) return str;
6417
- // Fast path: if there are no common whitespace characters, return early
6421
+ // Fast path: If there are no common whitespace characters, return early
6418
6422
  if (!/[ \n\r\t\f\xA0]/.test(str)) {
6419
6423
  return str;
6420
6424
  }
@@ -7169,7 +7173,7 @@ async function normalizeAttr(attr, attrs, tag, options) {
7169
7173
  let attrValue = attr.value;
7170
7174
 
7171
7175
  if (options.decodeEntities && attrValue) {
7172
- // Fast path: only decode when entities are present
7176
+ // Fast path: Only decode when entities are present
7173
7177
  if (attrValue.indexOf('&') !== -1) {
7174
7178
  attrValue = decodeHTMLStrict(attrValue);
7175
7179
  }
@@ -7369,7 +7373,7 @@ const processOptions = (inputOptions) => {
7369
7373
  const lightningCssOptions = typeof option === 'object' ? option : {};
7370
7374
 
7371
7375
  options.minifyCSS = async function (text, type) {
7372
- // Fast path: nothing to minify
7376
+ // Fast path: Nothing to minify
7373
7377
  if (!text || !text.trim()) {
7374
7378
  return text;
7375
7379
  }
@@ -7461,7 +7465,7 @@ const processOptions = (inputOptions) => {
7461
7465
 
7462
7466
  let jsKey;
7463
7467
  try {
7464
- // Fast path: avoid invoking Terser for empty/whitespace-only content
7468
+ // Fast path: Avoid invoking Terser for empty/whitespace-only content
7465
7469
  if (!code || !code.trim()) {
7466
7470
  return '';
7467
7471
  }
@@ -7513,9 +7517,18 @@ const processOptions = (inputOptions) => {
7513
7517
  relateUrlOptions = {};
7514
7518
  }
7515
7519
 
7520
+ // Cache RelateURL instance for reuse (expensive to create)
7521
+ const relateUrlInstance = new RelateURL(relateUrlOptions.site || '', relateUrlOptions);
7522
+
7516
7523
  options.minifyURLs = function (text) {
7524
+ // Fast-path: Skip if text doesn’t look like a URL that needs processing
7525
+ // Only process if contains URL-like characters (`/`, `:`, `#`, `?`) or spaces that need encoding
7526
+ if (!/[/:?#\s]/.test(text)) {
7527
+ return text;
7528
+ }
7529
+
7517
7530
  try {
7518
- return RelateURL.relate(text, relateUrlOptions);
7531
+ return relateUrlInstance.relate(text);
7519
7532
  } catch (err) {
7520
7533
  if (!options.continueOnMinifyError) {
7521
7534
  throw err;
@@ -7611,11 +7624,11 @@ async function createSortFns(value, options, uidIgnore, uidAttr, ignoredMarkupCh
7611
7624
 
7612
7625
  try {
7613
7626
  await parser.parse();
7614
- } catch (e) {
7627
+ } catch (err) {
7615
7628
  // If parsing fails during analysis pass, just skip it—we’ll still have
7616
7629
  // partial frequency data from what we could parse
7617
7630
  if (!options.continueOnParseError) {
7618
- throw e;
7631
+ throw err;
7619
7632
  }
7620
7633
  }
7621
7634
  }
@@ -7704,7 +7717,21 @@ async function createSortFns(value, options, uidIgnore, uidAttr, ignoredMarkupCh
7704
7717
  }
7705
7718
  if (classChain) {
7706
7719
  const sorter = classChain.createSorter();
7720
+ // Memoize `sortClassName` results—class lists often repeat in templates
7721
+ const classNameCache = new LRU(200);
7722
+
7707
7723
  options.sortClassName = function (value) {
7724
+ // Fast path: Single class (no spaces) needs no sorting
7725
+ if (value.indexOf(' ') === -1) {
7726
+ return value;
7727
+ }
7728
+
7729
+ // Check cache first
7730
+ const cached = classNameCache.get(value);
7731
+ if (cached !== undefined) {
7732
+ return cached;
7733
+ }
7734
+
7708
7735
  // Expand UID tokens back to original content before sorting
7709
7736
  // Fast path: Skip if no HTML comments (UID markers) present
7710
7737
  let expandedValue = value;
@@ -7719,7 +7746,11 @@ async function createSortFns(value, options, uidIgnore, uidAttr, ignoredMarkupCh
7719
7746
  return cls !== '';
7720
7747
  });
7721
7748
  const sorted = sorter.sort(classes);
7722
- return sorted.join(' ');
7749
+ const result = sorted.join(' ');
7750
+
7751
+ // Cache the result
7752
+ classNameCache.set(value, result);
7753
+ return result;
7723
7754
  };
7724
7755
  }
7725
7756
  }
@@ -7760,7 +7791,7 @@ async function minifyHTML(value, options, partialMarkup) {
7760
7791
  const customElementsInput = options.inlineCustomElements ?? [];
7761
7792
  const customElementsArr = Array.isArray(customElementsInput) ? customElementsInput : Array.from(customElementsInput);
7762
7793
  const normalizedCustomElements = customElementsArr.map(name => options.name(name));
7763
- // Fast path: reuse base Sets if no custom elements
7794
+ // Fast path: Reuse base Sets if no custom elements
7764
7795
  const inlineTextSet = normalizedCustomElements.length
7765
7796
  ? new Set([...inlineElementsToKeepWhitespaceWithin, ...normalizedCustomElements])
7766
7797
  : inlineElementsToKeepWhitespaceWithin;
@@ -1 +1 @@
1
- {"version":3,"file":"htmlminifier.d.ts","sourceRoot":"","sources":["../../src/htmlminifier.js"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"htmlminifier.d.ts","sourceRoot":"","sources":["../../src/htmlminifier.js"],"names":[],"mappings":"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"}
@@ -1 +1 @@
1
- {"version":3,"file":"htmlparser.d.ts","sourceRoot":"","sources":["../../src/htmlparser.js"],"names":[],"mappings":"AA8CA,4BAAoE;AAuEpE;IACE,qCAGC;IAFC,UAAgB;IAChB,aAAsB;IAGxB,uBAoeC;CACF"}
1
+ {"version":3,"file":"htmlparser.d.ts","sourceRoot":"","sources":["../../src/htmlparser.js"],"names":[],"mappings":"AA8CA,4BAAoE;AA+EpE;IACE,qCAGC;IAFC,UAAgB;IAChB,aAAsB;IAGxB,uBAgeC;CACF"}
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.11.1"
87
+ "version": "4.12.0"
88
88
  }
@@ -463,7 +463,7 @@ const cssMinifyCache = new LRU(200);
463
463
 
464
464
  const trimWhitespace = str => {
465
465
  if (!str) return str;
466
- // Fast path: if no whitespace at start or end, return early
466
+ // Fast path: If no whitespace at start or end, return early
467
467
  if (!/^[ \n\r\t\f]/.test(str) && !/[ \n\r\t\f]$/.test(str)) {
468
468
  return str;
469
469
  }
@@ -472,7 +472,7 @@ const trimWhitespace = str => {
472
472
 
473
473
  function collapseWhitespaceAll(str) {
474
474
  if (!str) return str;
475
- // Fast path: if there are no common whitespace characters, return early
475
+ // Fast path: If there are no common whitespace characters, return early
476
476
  if (!/[ \n\r\t\f\xA0]/.test(str)) {
477
477
  return str;
478
478
  }
@@ -1227,7 +1227,7 @@ async function normalizeAttr(attr, attrs, tag, options) {
1227
1227
  let attrValue = attr.value;
1228
1228
 
1229
1229
  if (options.decodeEntities && attrValue) {
1230
- // Fast path: only decode when entities are present
1230
+ // Fast path: Only decode when entities are present
1231
1231
  if (attrValue.indexOf('&') !== -1) {
1232
1232
  attrValue = decodeHTMLStrict(attrValue);
1233
1233
  }
@@ -1427,7 +1427,7 @@ const processOptions = (inputOptions) => {
1427
1427
  const lightningCssOptions = typeof option === 'object' ? option : {};
1428
1428
 
1429
1429
  options.minifyCSS = async function (text, type) {
1430
- // Fast path: nothing to minify
1430
+ // Fast path: Nothing to minify
1431
1431
  if (!text || !text.trim()) {
1432
1432
  return text;
1433
1433
  }
@@ -1519,7 +1519,7 @@ const processOptions = (inputOptions) => {
1519
1519
 
1520
1520
  let jsKey;
1521
1521
  try {
1522
- // Fast path: avoid invoking Terser for empty/whitespace-only content
1522
+ // Fast path: Avoid invoking Terser for empty/whitespace-only content
1523
1523
  if (!code || !code.trim()) {
1524
1524
  return '';
1525
1525
  }
@@ -1571,9 +1571,18 @@ const processOptions = (inputOptions) => {
1571
1571
  relateUrlOptions = {};
1572
1572
  }
1573
1573
 
1574
+ // Cache RelateURL instance for reuse (expensive to create)
1575
+ const relateUrlInstance = new RelateURL(relateUrlOptions.site || '', relateUrlOptions);
1576
+
1574
1577
  options.minifyURLs = function (text) {
1578
+ // Fast-path: Skip if text doesn’t look like a URL that needs processing
1579
+ // Only process if contains URL-like characters (`/`, `:`, `#`, `?`) or spaces that need encoding
1580
+ if (!/[/:?#\s]/.test(text)) {
1581
+ return text;
1582
+ }
1583
+
1575
1584
  try {
1576
- return RelateURL.relate(text, relateUrlOptions);
1585
+ return relateUrlInstance.relate(text);
1577
1586
  } catch (err) {
1578
1587
  if (!options.continueOnMinifyError) {
1579
1588
  throw err;
@@ -1669,11 +1678,11 @@ async function createSortFns(value, options, uidIgnore, uidAttr, ignoredMarkupCh
1669
1678
 
1670
1679
  try {
1671
1680
  await parser.parse();
1672
- } catch (e) {
1681
+ } catch (err) {
1673
1682
  // If parsing fails during analysis pass, just skip it—we’ll still have
1674
1683
  // partial frequency data from what we could parse
1675
1684
  if (!options.continueOnParseError) {
1676
- throw e;
1685
+ throw err;
1677
1686
  }
1678
1687
  }
1679
1688
  }
@@ -1762,7 +1771,21 @@ async function createSortFns(value, options, uidIgnore, uidAttr, ignoredMarkupCh
1762
1771
  }
1763
1772
  if (classChain) {
1764
1773
  const sorter = classChain.createSorter();
1774
+ // Memoize `sortClassName` results—class lists often repeat in templates
1775
+ const classNameCache = new LRU(200);
1776
+
1765
1777
  options.sortClassName = function (value) {
1778
+ // Fast path: Single class (no spaces) needs no sorting
1779
+ if (value.indexOf(' ') === -1) {
1780
+ return value;
1781
+ }
1782
+
1783
+ // Check cache first
1784
+ const cached = classNameCache.get(value);
1785
+ if (cached !== undefined) {
1786
+ return cached;
1787
+ }
1788
+
1766
1789
  // Expand UID tokens back to original content before sorting
1767
1790
  // Fast path: Skip if no HTML comments (UID markers) present
1768
1791
  let expandedValue = value;
@@ -1777,7 +1800,11 @@ async function createSortFns(value, options, uidIgnore, uidAttr, ignoredMarkupCh
1777
1800
  return cls !== '';
1778
1801
  });
1779
1802
  const sorted = sorter.sort(classes);
1780
- return sorted.join(' ');
1803
+ const result = sorted.join(' ');
1804
+
1805
+ // Cache the result
1806
+ classNameCache.set(value, result);
1807
+ return result;
1781
1808
  };
1782
1809
  }
1783
1810
  }
@@ -1818,7 +1845,7 @@ async function minifyHTML(value, options, partialMarkup) {
1818
1845
  const customElementsInput = options.inlineCustomElements ?? [];
1819
1846
  const customElementsArr = Array.isArray(customElementsInput) ? customElementsInput : Array.from(customElementsInput);
1820
1847
  const normalizedCustomElements = customElementsArr.map(name => options.name(name));
1821
- // Fast path: reuse base Sets if no custom elements
1848
+ // Fast path: Reuse base Sets if no custom elements
1822
1849
  const inlineTextSet = normalizedCustomElements.length
1823
1850
  ? new Set([...inlineElementsToKeepWhitespaceWithin, ...normalizedCustomElements])
1824
1851
  : inlineElementsToKeepWhitespaceWithin;
package/src/htmlparser.js CHANGED
@@ -85,7 +85,7 @@ const preCompiledStackedTags = {
85
85
  // Cache for compiled attribute regexes per handler configuration
86
86
  const attrRegexCache = new WeakMap();
87
87
 
88
- function attrForHandler(handler) {
88
+ function buildAttrRegex(handler) {
89
89
  let pattern = singleAttrIdentifier.source +
90
90
  '(?:\\s*(' + joinSingleAttrAssigns(handler) + ')' +
91
91
  '[ \\t\\n\\f\\r]*(?:' + singleAttrValues.join('|') + '))?';
@@ -104,6 +104,14 @@ function attrForHandler(handler) {
104
104
  return new RegExp('^\\s*' + pattern);
105
105
  }
106
106
 
107
+ function getAttrRegexForHandler(handler) {
108
+ let cached = attrRegexCache.get(handler);
109
+ if (cached) return cached;
110
+ const compiled = buildAttrRegex(handler);
111
+ attrRegexCache.set(handler, compiled);
112
+ return compiled;
113
+ }
114
+
107
115
  function joinSingleAttrAssigns(handler) {
108
116
  return singleAttrAssigns.concat(
109
117
  handler.customAttrAssign || []
@@ -127,12 +135,8 @@ export class HTMLParser {
127
135
  const fullLength = fullHtml.length;
128
136
 
129
137
  const stack = []; let lastTag;
130
- // Use cached attribute regex if available
131
- let attribute = attrRegexCache.get(handler);
132
- if (!attribute) {
133
- attribute = attrForHandler(handler);
134
- attrRegexCache.set(handler, attribute);
135
- }
138
+ // Use cached attribute regex for this handler configuration
139
+ const attribute = getAttrRegexForHandler(handler);
136
140
  let prevTag = undefined, nextTag = undefined;
137
141
 
138
142
  // Index-based parsing