html-minifier-next 8.4.0 → 8.4.2

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
@@ -36,13 +36,13 @@ Use `npx html-minifier-next --help` to check all available options:
36
36
  | `--input-dir <dir>`, `-I <dir>` | Specify an input directory | `--input-dir=src` |
37
37
  | `--ignore-dir <patterns>`, `-X <patterns>` | Exclude directories—relative to input directory—from processing (comma-separated, overrides config file setting) | `--ignore-dir=libs`, `--ignore-dir=libs,vendor,node_modules` |
38
38
  | `--output-dir <dir>`, `-O <dir>` | Specify an output directory | `--output-dir=dist` |
39
- | `--workers <n>`, `-w <n>` | Number of worker threads for multi-file runs; defaults to half the available cores, at most 6, for runs of 128 files or more (below that threads cost more than they save) | `--workers=4` |
39
+ | `--workers <n>`, `-w <n>` | Number of worker threads for multi-file runs; defaults to half the available cores (at most 6) for runs large enough to justify them | `--workers=4` |
40
40
  | `--input <file>`, `-i <file>` | Specify input file (alternative to positional argument; pair with `--output` for file output) | `npx html-minifier-next -i input.html -o output.html` |
41
41
  | `--output <file>`, `-o <file>` | Specify output file (reads from `--input` file argument or STDIN; outputs to STDOUT if not specified) | File to file: `npx html-minifier-next input.html -o output.html`<br>File to file (explicit): `npx html-minifier-next -i input.html -o output.html`<br>Pipe to file: `cat input.html \| npx html-minifier-next -o output.html`<br>File to STDOUT: `npx html-minifier-next input.html` |
42
42
  | `--file-ext <extensions>`, `-f <extensions>` | Specify file extension(s) to process (comma-separated, overrides config file setting); defaults to `html,htm,shtml,shtm`; use `*` for all files | `--file-ext=html,php`, `--file-ext='*'` |
43
43
  | `--preset <name>`, `-p <name>` | Use a preset configuration (conservative or comprehensive) | `--preset=conservative` |
44
44
  | `--config-file <file>`, `-c <file>` | Use a configuration file (defaults to html-minifier-next.config.json in the working directory, if present) | `--config-file=path/to/config.json` |
45
- | `--verbose`, `-v` | Show detailed processing information (active options, file statistics, and minifier warnings) | `npx html-minifier-next --input-dir=src --output-dir=dist --verbose --collapse-whitespace` |
45
+ | `--verbose`, `-v` | Show detailed processing information (active options, worker thread count, file statistics, and minifier warnings) | `npx html-minifier-next --input-dir=src --output-dir=dist --verbose --collapse-whitespace` |
46
46
  | `--dry`, `-d` | Dry run: Process and report statistics without writing output | `npx html-minifier-next input.html --dry --collapse-whitespace` |
47
47
 
48
48
  ### Configuration file
package/cli.js CHANGED
@@ -197,7 +197,7 @@ Object.entries(optionDefinitions).forEach(function ([key, { description, descrip
197
197
  });
198
198
  program.option('-i --input <file>', 'Specify input file (alternative to positional argument; pair with `--output` for output)');
199
199
  program.option('-o --output <file>', 'Specify output file (reads from `--input` file argument or STDIN; outputs to STDOUT if not specified)');
200
- program.option('-v --verbose', 'Show detailed processing information');
200
+ program.option('-v --verbose', 'Show detailed processing information (active options, worker thread count, file statistics, and minifier warnings)');
201
201
  program.option('-d --dry', 'Dry run: Process and report statistics without writing output');
202
202
  program.addHelpText('after', '\nBoolean options support a `--no-<flag>` form to disable them, overriding a preset or config file (e.g., `--preset=comprehensive --no-collapse-whitespace`).');
203
203
 
@@ -312,7 +312,7 @@ program.option('-z, --zero', 'Minify all HTML files in the current folder and it
312
312
  program.option('-I --input-dir <dir>', 'Specify an input directory');
313
313
  program.option('-X --ignore-dir <patterns>', 'Exclude directories—relative to input directory—from processing (comma-separated, overrides config file setting)');
314
314
  program.option('-O --output-dir <dir>', 'Specify an output directory');
315
- program.option('-w --workers <n>', 'Number of worker threads for multi-file runs; defaults to half the available cores, at most 6, for runs of 128 files or more (below that threads cost more than they save)', parseValidInt('workers'));
315
+ program.option('-w --workers <n>', 'Number of worker threads for multi-file runs; defaults to half the available cores (at most 6) for runs large enough to justify them', parseValidInt('workers'));
316
316
  program.option('-f --file-ext <extensions>', 'Specify file extension(s) to process (comma-separated, overrides config file setting); defaults to `html,htm,shtml,shtm`; use `*` for all files');
317
317
  program.option('-p --preset <name>', `Use a preset configuration (${getPresetNames().join(', ')})`);
318
318
  program.option('-c --config-file <file>', 'Use a configuration file (defaults to html-minifier-next.config.json in the working directory, if present)');
@@ -543,19 +543,29 @@ program.helpOption('-h, --help', 'Display help for command');
543
543
  return { originalSize, minifiedSize, saved, sign, percentage };
544
544
  }
545
545
 
546
- // Below this many files the threads spend more time warming up than they save: Each
547
- // runs its own isolate and so warms its own JIT, which a short run never earns back
546
+ // A run with many files earns back each worker’s isolate/JIT startup cost. A smaller
547
+ // run can do so too when it carries enough bytes to share out, which a file count
548
+ // alone cannot see.
548
549
  const FILES_PER_RUN_MIN = 128;
550
+ const PARALLEL_BYTES_MIN = 2 * 1024 * 1024;
549
551
  // Each worker warms up its own JIT, so the useful count tops out well short of the cores
550
552
  const WORKERS_MAX = 6;
551
553
 
552
554
  /**
553
555
  * A pool for this run, or `null` where one wouldn’t pay or wouldn’t work—in which case
554
556
  * the caller minifies in process exactly as before
555
- * @param {number} fileCount
557
+ * @param {string[]} files
556
558
  */
557
- async function createPool(fileCount) {
559
+ async function createPool(files) {
560
+ const fileCount = files.length;
558
561
  const requested = programOptions.workers;
562
+ const isVerbose = Boolean(programOptions.verbose || programOptions.dry);
563
+ // Whether the run went wide is worth saying, since the default weighs the file
564
+ // count and the bytes there are to share out, neither of which the caller sees
565
+ const inProcess = () => {
566
+ if (isVerbose) console.error('Worker threads: none (minifying in process)');
567
+ return null;
568
+ };
559
569
  // `availableParallelism` reports what this process may actually use, which under a
560
570
  // CPU affinity mask—a container, commonly—is less than the machine’s core count
561
571
  const isDefault = requested === undefined;
@@ -565,8 +575,21 @@ program.helpOption('-h, --help', 'Display help for command');
565
575
 
566
576
  // The file count only decides the default; asking for workers outright is the
567
577
  // caller’s judgment to make, whatever the run looks like
568
- if (size <= 1 || (isDefault && fileCount < FILES_PER_RUN_MIN)) {
569
- return null;
578
+ if (size <= 1) {
579
+ return inProcess();
580
+ }
581
+
582
+ if (isDefault && fileCount < FILES_PER_RUN_MIN) {
583
+ // `stat()` is cheaper than starting workers and is only needed for a run that
584
+ // would otherwise stay in-process. A failed stat leaves normal file processing
585
+ // to report the read error with its usual filename and wording.
586
+ const sizes = await Promise.all(files.map(file => fs.promises.stat(file).then(stat => stat.size, () => 0)));
587
+ // No single file is split across workers, so the largest one sets a floor no
588
+ // pool can beat; what the rest carry is the work there is to share out
589
+ const parallelBytes = sizes.reduce((sum, byteSize) => sum + byteSize, 0) - Math.max(0, ...sizes);
590
+ if (parallelBytes < PARALLEL_BYTES_MIN) {
591
+ return inProcess();
592
+ }
570
593
  }
571
594
 
572
595
  // `log` is a closure over this process’s console and is rebuilt inside the worker;
@@ -575,22 +598,31 @@ program.helpOption('-h, --help', 'Display help for command');
575
598
  try {
576
599
  structuredClone(options);
577
600
  } catch {
578
- return null;
601
+ return inProcess();
602
+ }
603
+
604
+ // A pool this small has nothing to share out and still pays for its workers, so an
605
+ // explicit request for them lands in process as `--workers=1` does
606
+ const poolSize = Math.min(size, fileCount);
607
+ if (poolSize <= 1) {
608
+ return inProcess();
579
609
  }
580
610
 
581
611
  try {
582
612
  const { createFilePool } = await import('./src/lib/file-pool.js');
583
- return createFilePool({
613
+ const pool = createFilePool({
584
614
  options,
585
- size: Math.min(size, fileCount),
615
+ size: poolSize,
586
616
  // `log` is only set when the run asks for diagnostics
587
617
  onLog: log
588
618
  ? (/** @type {string} */ message, /** @type {boolean} */ isError, /** @type {string} */ file) =>
589
619
  createLog(path.relative(process.cwd(), file))(isError ? new Error(message) : message)
590
620
  : undefined
591
621
  });
622
+ if (isVerbose) console.error(`Worker threads: ${poolSize}`);
623
+ return pool;
592
624
  } catch {
593
- return null;
625
+ return inProcess();
594
626
  }
595
627
  }
596
628
 
@@ -891,7 +923,7 @@ program.helpOption('-h, --help', 'Display help for command');
891
923
  }
892
924
  };
893
925
 
894
- const pool = await createPool(list.length);
926
+ const pool = await createPool(list);
895
927
  if (pool) {
896
928
  try {
897
929
  await Promise.all(list.map(async (inputFile, idx) => {
@@ -1 +1 @@
1
- {"version":3,"file":"whitespace.d.ts","sourceRoot":"","sources":["../../../src/lib/whitespace.js"],"names":[],"mappings":"AAAA;;GAEG;AAiBH,0BAA0B;AAC1B,QAAA,MAAM,cAAc,QADR,MAAM,WAQjB,CAAC;AAIF,0BAA0B;AAC1B,iBAAS,qBAAqB,CAAC,GAAG,EADtB,MACsB,UAgBjC;AAID;;;;;;GAMG;AACH,iBAAS,kBAAkB,CAAC,GAAG,EANpB,MAMoB,EAAE,OAAO,EAL7B;IAAC,kBAAkB,CAAC,EAAE,OAAO,GAAG,SAAS,CAAC;IAAC,oBAAoB,CAAC,EAAE,OAAO,GAAG,SAAS,CAAA;CAKxD,EAAE,QAAQ,EAJvC,OAIuC,EAAE,SAAS,EAHlD,OAGkD,EAAE,WAAW,AAFvE,CAA8B,EAAtB,OAEuE,UAqFjF;AAoBD;;;;;;;;;GASG;AACH,iBAAS,uBAAuB,CAAC,GAAG,EATzB,MASyB,EAAE,OAAO,EARlC,MAQkC,EAAE,OAAO,EAP3C,MAO2C,EAAE,SAAS,EANtD,KAAK,CAAC;IAAC,IAAI,EAAE,MAAM,CAAC;IAAC,KAAK,CAAC,EAAE,MAAM,CAAA;CAAC,CAMkB,EAAE,SAAS,EALjE,KAAK,CAAC;IAAC,IAAI,EAAE,MAAM,CAAC;IAAC,KAAK,CAAC,EAAE,MAAM,CAAA;CAAC,CAK6B,EAAE,OAAO,EAJ1E;IAAC,kBAAkB,CAAC,EAAE,OAAO,CAAC;IAAC,oBAAoB,CAAC,EAAE,OAAO,CAAC;IAAC,2BAA2B,CAAC,EAAE,OAAO,CAAA;CAI1B,EAAE,cAAc,EAH1F,GAAG,CAAC,MAAM,CAGgF,EAAE,aAAa,EAFzG,GAAG,CAAC,MAAM,CAE+F,UA4EnH;AASD,0BAA0B;AAC1B,iBAAS,qBAAqB,CAAC,GAAG,EADtB,MACsB,WAEjC;AAED,0BAA0B;AAC1B,iBAAS,iBAAiB,CAAC,GAAG,EADlB,MACkB,WAE7B;AAID,OAAO,EACL,cAAc,EACd,qBAAqB,EACrB,kBAAkB,EAClB,uBAAuB,EACvB,qBAAqB,EACrB,iBAAiB,EAClB,CAAC"}
1
+ {"version":3,"file":"whitespace.d.ts","sourceRoot":"","sources":["../../../src/lib/whitespace.js"],"names":[],"mappings":"AAAA;;GAEG;AAqBH,0BAA0B;AAC1B,QAAA,MAAM,cAAc,QADR,MAAM,WAQjB,CAAC;AAuBF,0BAA0B;AAC1B,iBAAS,qBAAqB,CAAC,GAAG,EADtB,MACsB,UAOjC;AAeD;;;;;;GAMG;AACH,iBAAS,kBAAkB,CAAC,GAAG,EANpB,MAMoB,EAAE,OAAO,EAL7B;IAAC,kBAAkB,CAAC,EAAE,OAAO,GAAG,SAAS,CAAC;IAAC,oBAAoB,CAAC,EAAE,OAAO,GAAG,SAAS,CAAA;CAKxD,EAAE,QAAQ,EAJvC,OAIuC,EAAE,SAAS,EAHlD,OAGkD,EAAE,WAAW,AAFvE,CAA8B,EAAtB,OAEuE,UAcjF;AAsHD;;;;;;;;;GASG;AACH,iBAAS,uBAAuB,CAAC,GAAG,EATzB,MASyB,EAAE,OAAO,EARlC,MAQkC,EAAE,OAAO,EAP3C,MAO2C,EAAE,SAAS,EANtD,KAAK,CAAC;IAAC,IAAI,EAAE,MAAM,CAAC;IAAC,KAAK,CAAC,EAAE,MAAM,CAAA;CAAC,CAMkB,EAAE,SAAS,EALjE,KAAK,CAAC;IAAC,IAAI,EAAE,MAAM,CAAC;IAAC,KAAK,CAAC,EAAE,MAAM,CAAA;CAAC,CAK6B,EAAE,OAAO,EAJ1E;IAAC,kBAAkB,CAAC,EAAE,OAAO,CAAC;IAAC,oBAAoB,CAAC,EAAE,OAAO,CAAC;IAAC,2BAA2B,CAAC,EAAE,OAAO,CAAA;CAI1B,EAAE,cAAc,EAH1F,GAAG,CAAC,MAAM,CAGgF,EAAE,aAAa,EAFzG,GAAG,CAAC,MAAM,CAE+F,UA6DnH;AAcD,0BAA0B;AAC1B,iBAAS,qBAAqB,CAAC,GAAG,EADtB,MACsB,WAEjC;AAED,0BAA0B;AAC1B,iBAAS,iBAAiB,CAAC,GAAG,EADlB,MACkB,WAE7B;AAID,OAAO,EACL,cAAc,EACd,qBAAqB,EACrB,kBAAkB,EAClB,uBAAuB,EACvB,qBAAqB,EACrB,iBAAiB,EAClB,CAAC"}
package/package.json CHANGED
@@ -7,7 +7,7 @@
7
7
  "bugs": "https://github.com/j9t/html-minifier-next/issues",
8
8
  "dependencies": {
9
9
  "commander": "^15.0.0",
10
- "entities": "^8.0.0",
10
+ "entities": "^8.1.0",
11
11
  "lightningcss": "^1.33.0",
12
12
  "svgo": "^4.1.0",
13
13
  "terser": "^5.51.2"
@@ -16,8 +16,8 @@
16
16
  "devDependencies": {
17
17
  "@commitlint/cli": "^21.2.2",
18
18
  "@eslint/js": "^10.0.1",
19
- "@swc/core": "^1.16.1",
20
- "@types/node": "^26.4.1",
19
+ "@swc/core": "^1.16.2",
20
+ "@types/node": "^26.5.0",
21
21
  "eslint": "^10.10.0",
22
22
  "globals": "^17.12.0",
23
23
  "typescript": "^7.0.2",
@@ -95,5 +95,5 @@
95
95
  },
96
96
  "type": "module",
97
97
  "types": "dist/types/htmlminifier.d.ts",
98
- "version": "8.4.0"
98
+ "version": "8.4.2"
99
99
  }
@@ -3,8 +3,6 @@
3
3
  */
4
4
 
5
5
  import {
6
- RE_WS_START,
7
- RE_WS_END,
8
6
  RE_ALL_WS_NBSP,
9
7
  RE_NBSP_LEADING_GROUP,
10
8
  RE_NBSP_LEAD_GROUP,
@@ -15,37 +13,64 @@ import {
15
13
  formControlElements
16
14
  } from './constants.js';
17
15
 
16
+ const RE_ANY_WS_NBSP = /[ \n\r\t\f\xA0]/;
17
+ const RE_TAB_NBSP = /[\t\xA0]/;
18
+ const RE_ASCII_WS_RUN = /[ \n\r\f]+/g;
19
+ const RE_NEEDS_COLLAPSE = /[\n\r\f]| {2}/;
20
+ const RE_NON_WS = /\S/;
21
+
18
22
  // Trim whitespace
19
23
 
20
24
  /** @param {string} str */
21
25
  const trimWhitespace = str => {
22
26
  if (!str) return str;
23
- // Fast path: If no whitespace at start or end, return early
24
- if (!/^[ \n\r\t\f]/.test(str) && !/[ \n\r\t\f]$/.test(str)) {
25
- return str;
26
- }
27
- return str.replace(RE_WS_START, '').replace(RE_WS_END, '');
27
+ let start = 0;
28
+ let end = str.length;
29
+ while (start < end && isAsciiWs(str.charCodeAt(start))) start++;
30
+ while (end > start && isAsciiWs(str.charCodeAt(end - 1))) end--;
31
+ return start === 0 && end === str.length ? str : str.slice(start, end);
28
32
  };
29
33
 
34
+ /** @param {number} code */
35
+ function isAsciiWs(code) {
36
+ return code === 32 || code === 10 || code === 13 || code === 9 || code === 12;
37
+ }
38
+
39
+ /** @param {number} code */
40
+ function isWsOrNbsp(code) {
41
+ return code === 32 || code === 10 || code === 13 || code === 9 || code === 12 || code === 160;
42
+ }
43
+
30
44
  // Collapse all whitespace
31
45
 
46
+ // Module scope so the hot path doesn’t allocate a closure per call
47
+ /** @param {string} spaces */
48
+ function collapseRun(spaces) {
49
+ // Preserve standalone tabs
50
+ if (spaces === '\t') return '\t';
51
+ if (spaces.indexOf('\xA0') === -1) return ' ';
52
+ return spaces.replace(RE_NBSP_LEADING_GROUP, '$1 ');
53
+ }
54
+
32
55
  /** @param {string} str */
33
56
  function collapseWhitespaceAll(str) {
34
57
  if (!str) return str;
35
58
  // Fast path: If there are no common whitespace characters, return early
36
- if (!/[ \n\r\t\f\xA0]/.test(str)) {
59
+ if (!RE_ANY_WS_NBSP.test(str)) {
37
60
  return str;
38
61
  }
39
- // No-break space is specifically handled inside the replacer function here:
40
- return str.replace(RE_ALL_WS_NBSP, function (/** @param {string} spaces */ spaces) {
41
- // Preserve standalone tabs
42
- if (spaces === '\t') return '\t';
43
- // Fast path: No no-break space, common case—just collapse to single space
44
- // This avoids the nested regex for the majority of cases
45
- if (spaces.indexOf('\xA0') === -1) return ' ';
46
- // For no-break space handling, use the nested regex
47
- return spaces.replace(RE_NBSP_LEADING_GROUP, '$1 ');
48
- });
62
+ return collapseWhitespaceAllKnown(str);
63
+ }
64
+
65
+ // As `collapseWhitespaceAll`, for callers that already know `str` holds whitespace
66
+ /** @param {string} str */
67
+ function collapseWhitespaceAllKnown(str) {
68
+ // Only a tab or a no-break space makes the replacement depend on the run itself;
69
+ // without either, every run becomes one space and no per-match callback is needed
70
+ if (!RE_TAB_NBSP.test(str)) {
71
+ return RE_NEEDS_COLLAPSE.test(str) ? str.replace(RE_ASCII_WS_RUN, ' ') : str;
72
+ }
73
+ return str.replace(RE_ALL_WS_NBSP, collapseRun);
49
74
  }
50
75
 
51
76
  // Collapse whitespace with options
@@ -58,8 +83,6 @@ function collapseWhitespaceAll(str) {
58
83
  * @param {boolean} [collapseAll]
59
84
  */
60
85
  function collapseWhitespace(str, options, trimLeft, trimRight, collapseAll = false) {
61
- let lineBreakBefore = ''; let lineBreakAfter = '';
62
-
63
86
  if (!str) return str;
64
87
 
65
88
  // Fast path: Nothing to do
@@ -68,16 +91,30 @@ function collapseWhitespace(str, options, trimLeft, trimRight, collapseAll = fal
68
91
  }
69
92
 
70
93
  // Fast path: No whitespace at all
71
- if (!/[ \n\r\t\f\xA0]/.test(str)) {
94
+ if (!RE_ANY_WS_NBSP.test(str)) {
72
95
  return str;
73
96
  }
74
97
 
98
+ return collapseWhitespaceKnown(str, options, trimLeft, trimRight, collapseAll);
99
+ }
100
+
101
+ // As `collapseWhitespace`, for callers that already know `str` is non-empty, holds
102
+ // whitespace, and has something to do
103
+ /**
104
+ * @param {string} str
105
+ * @param {{preserveLineBreaks?: boolean | undefined, conservativeCollapse?: boolean | undefined}} options
106
+ * @param {boolean} trimLeft
107
+ * @param {boolean} trimRight
108
+ * @param {boolean} collapseAll
109
+ */
110
+ function collapseWhitespaceKnown(str, options, trimLeft, trimRight, collapseAll) {
111
+ let lineBreakBefore = ''; let lineBreakAfter = '';
112
+
75
113
  if (options.preserveLineBreaks) {
76
114
  // Find leading/trailing whitespace containing line breaks manually
77
115
  // (avoids polynomial backtracking with end-anchored lazy quantifiers)
78
- const WS_CHARS = ' \n\r\t\f';
79
116
  let leadEnd = 0;
80
- while (leadEnd < str.length && WS_CHARS.includes(str.charAt(leadEnd))) {
117
+ while (leadEnd < str.length && isAsciiWs(str.charCodeAt(leadEnd))) {
81
118
  leadEnd++;
82
119
  }
83
120
  if (leadEnd > 0) {
@@ -88,7 +125,7 @@ function collapseWhitespace(str, options, trimLeft, trimRight, collapseAll = fal
88
125
  }
89
126
  }
90
127
  let trailStart = str.length;
91
- while (trailStart > 0 && WS_CHARS.includes(str.charAt(trailStart - 1))) {
128
+ while (trailStart > 0 && isAsciiWs(str.charCodeAt(trailStart - 1))) {
92
129
  trailStart--;
93
130
  }
94
131
  if (trailStart < str.length) {
@@ -101,21 +138,34 @@ function collapseWhitespace(str, options, trimLeft, trimRight, collapseAll = fal
101
138
  }
102
139
 
103
140
  if (trimLeft) {
104
- // No-break space is specifically handled inside the replacer function
105
- str = str.replace(/^[ \n\r\t\f\xA0]+/, function (/** @type {string} */ spaces) {
141
+ // Find the leading whitespace boundary with a loop, so the common case neither
142
+ // allocates a replacer closure nor runs the regex machinery
143
+ let start = 0;
144
+ while (start < str.length && isWsOrNbsp(str.charCodeAt(start))) {
145
+ start++;
146
+ }
147
+ if (start > 0) {
148
+ const spaces = str.slice(0, start);
106
149
  const conservative = !lineBreakBefore && options.conservativeCollapse;
150
+ let replacement;
107
151
  if (conservative && spaces === '\t') {
108
- return '\t';
152
+ replacement = '\t';
153
+ } else if (spaces.indexOf('\xA0') === -1) {
154
+ // No no-break space: The whole run goes
155
+ replacement = conservative ? ' ' : '';
156
+ } else {
157
+ // No-break space is specifically handled via the nested regexes
158
+ replacement = spaces.replace(/^[^\xA0]+/, '').replace(RE_NBSP_LEAD_GROUP, '$1 ') || (conservative ? ' ' : '');
109
159
  }
110
- return spaces.replace(/^[^\xA0]+/, '').replace(RE_NBSP_LEAD_GROUP, '$1 ') || (conservative ? ' ' : '');
111
- });
160
+ str = replacement + str.slice(start);
161
+ }
112
162
  }
113
163
 
114
164
  if (trimRight) {
115
165
  // Find trailing whitespace boundary manually (avoids polynomial backtracking
116
166
  // with `/[ \n\r\t\f\xA0]+$/` on strings with long internal whitespace runs)
117
167
  let end = str.length;
118
- while (end > 0 && ' \n\r\t\f\xA0'.includes(str.charAt(end - 1))) {
168
+ while (end > 0 && isWsOrNbsp(str.charCodeAt(end - 1))) {
119
169
  end--;
120
170
  }
121
171
  if (end < str.length) {
@@ -124,6 +174,9 @@ function collapseWhitespace(str, options, trimLeft, trimRight, collapseAll = fal
124
174
  let replacement;
125
175
  if (conservative && spaces === '\t') {
126
176
  replacement = '\t';
177
+ } else if (spaces.indexOf('\xA0') === -1) {
178
+ // No no-break space: The whole run goes
179
+ replacement = conservative ? ' ' : '';
127
180
  } else {
128
181
  // No-break space is specifically handled via the nested regexes
129
182
  replacement = spaces.replace(RE_NBSP_TRAILING_GROUP, ' $1').replace(RE_NBSP_TRAILING_STRIP, '') || (conservative ? ' ' : '');
@@ -132,9 +185,9 @@ function collapseWhitespace(str, options, trimLeft, trimRight, collapseAll = fal
132
185
  }
133
186
  }
134
187
 
135
- if (collapseAll) {
188
+ if (collapseAll && str) {
136
189
  // Strip non-space whitespace then compress spaces to one
137
- str = collapseWhitespaceAll(str);
190
+ str = collapseWhitespaceAllKnown(str);
138
191
  }
139
192
 
140
193
  // Avoid string concatenation when no line breaks (common case)
@@ -149,11 +202,10 @@ function collapseWhitespace(str, options, trimLeft, trimRight, collapseAll = fal
149
202
  // Check if an input element has `type="hidden"`; module-scope so the hot path
150
203
  // doesn’t allocate a closure per text node
151
204
  /**
152
- * @param {string} tagName
153
205
  * @param {Array<{name: string, value?: string}>} attrs
154
206
  */
155
- function isHiddenInput(tagName, attrs) {
156
- if (tagName !== 'input' || !attrs || !attrs.length) return false;
207
+ function isHiddenInput(attrs) {
208
+ if (!attrs || !attrs.length) return false;
157
209
  for (const attr of attrs) {
158
210
  if (attr.name === 'type') {
159
211
  return attr.value === 'hidden';
@@ -177,77 +229,67 @@ function collapseWhitespaceSmart(str, prevTag, nextTag, prevAttrs, nextAttrs, op
177
229
 
178
230
  // Fast path: No whitespace at all—every decision below would leave `str`
179
231
  // untouched (`collapseWhitespace` returns such strings unchanged)
180
- if (!/[ \n\r\t\f\xA0]/.test(str)) {
232
+ if (!RE_ANY_WS_NBSP.test(str)) {
181
233
  return str;
182
234
  }
183
235
 
184
- const prevTagName = prevTag && (prevTag.charAt(0) === '/' ? prevTag.slice(1) : prevTag);
185
- const nextTagName = nextTag && (nextTag.charAt(0) === '/' ? nextTag.slice(1) : nextTag);
186
-
187
- // Check if prev/next are non-rendering (hidden) elements
188
- const prevIsHidden = isHiddenInput(prevTagName, prevAttrs);
189
- const nextIsHidden = isHiddenInput(nextTagName, nextAttrs);
190
-
191
- let trimLeft = prevTag && !inlineElementsToKeepWhitespace.has(prevTag);
192
-
193
- // Whether the text is whitespace only—one scan, shared by the checks below
194
- // (`str` is known non-empty here)
195
- const isPureWhitespace = !/\S/.test(str);
236
+ const inlineOption = Boolean(options.collapseInlineTagWhitespace);
196
237
 
197
- // Smart default behavior: Collapse space after non-rendering elements (`type="hidden"`)
198
- // This happens even in basic `collapseWhitespace` mode (safe optimization)
199
- if (!trimLeft && prevIsHidden && isPureWhitespace) {
200
- trimLeft = true;
201
- }
202
-
203
- // Aggressive mode: Collapse between all form controls (pure whitespace only)
204
- if (!trimLeft && prevTagName && nextTagName &&
205
- options.collapseInlineTagWhitespace &&
206
- isPureWhitespace &&
207
- formControlElements.has(prevTagName) && formControlElements.has(nextTagName)) {
208
- trimLeft = true;
209
- }
238
+ let trimLeft = Boolean(prevTag) && !inlineElementsToKeepWhitespace.has(prevTag);
239
+ let trimRight = Boolean(nextTag) && !inlineElementsToKeepWhitespace.has(nextTag);
210
240
 
211
- if (trimLeft && !options.collapseInlineTagWhitespace) {
212
- trimLeft = prevTag.charAt(0) === '/' ? !inlineElements.has(prevTag.slice(1)) : !inlineTextSet.has(prevTag);
213
- }
241
+ // Every branch below that consults the text’s content needs a side left untrimmed,
242
+ // so the scan for a non-whitespace character is only worth making then
243
+ const isPureWhitespace = (!trimLeft || !trimRight) && !RE_NON_WS.test(str);
214
244
 
215
- // When `collapseInlineTagWhitespace` is enabled, still preserve whitespace around inline text elements
216
- if (trimLeft && options.collapseInlineTagWhitespace) {
217
- const tagName = prevTag.charAt(0) === '/' ? prevTag.slice(1) : prevTag;
218
- if (inlineElementsToKeepWhitespaceWithin.has(tagName)) {
219
- trimLeft = false;
245
+ if (isPureWhitespace) {
246
+ // Smart default behavior: Collapse space around non-rendering elements
247
+ // (`type="hidden"`); this happens even in basic `collapseWhitespace` mode
248
+ if (!trimLeft && prevTag === 'input' && isHiddenInput(prevAttrs)) {
249
+ trimLeft = true;
250
+ }
251
+ if (!trimRight && nextTag === 'input' && isHiddenInput(nextAttrs)) {
252
+ trimRight = true;
253
+ }
254
+ // Aggressive mode: Collapse between all form controls
255
+ if (inlineOption && (!trimLeft || !trimRight) &&
256
+ formControlElements.has(stripSlash(prevTag)) && formControlElements.has(stripSlash(nextTag))) {
257
+ trimLeft = true;
258
+ trimRight = true;
220
259
  }
221
260
  }
222
261
 
223
- let trimRight = nextTag && !inlineElementsToKeepWhitespace.has(nextTag);
224
-
225
- // Smart default behavior: Collapse space before non-rendering elements (`type="hidden"`)
226
- if (!trimRight && nextIsHidden && isPureWhitespace) {
227
- trimRight = true;
228
- }
229
-
230
- // Aggressive mode: Same as `trimLeft`
231
- if (!trimRight && prevTagName && nextTagName &&
232
- options.collapseInlineTagWhitespace &&
233
- isPureWhitespace &&
234
- formControlElements.has(prevTagName) && formControlElements.has(nextTagName)) {
235
- trimRight = true;
262
+ if (trimLeft) {
263
+ if (inlineOption) {
264
+ // Still preserve whitespace around inline text elements
265
+ if (inlineElementsToKeepWhitespaceWithin.has(stripSlash(prevTag))) {
266
+ trimLeft = false;
267
+ }
268
+ } else {
269
+ trimLeft = prevTag.charCodeAt(0) === 47 /* / */ ? !inlineElements.has(prevTag.slice(1)) : !inlineTextSet.has(prevTag);
270
+ }
236
271
  }
237
272
 
238
- if (trimRight && !options.collapseInlineTagWhitespace) {
239
- trimRight = nextTag.charAt(0) === '/' ? !inlineTextSet.has(nextTag.slice(1)) : !inlineElements.has(nextTag);
273
+ if (trimRight) {
274
+ if (inlineOption) {
275
+ if (inlineElementsToKeepWhitespaceWithin.has(stripSlash(nextTag))) {
276
+ trimRight = false;
277
+ }
278
+ } else {
279
+ trimRight = nextTag.charCodeAt(0) === 47 /* / */ ? !inlineTextSet.has(nextTag.slice(1)) : !inlineElements.has(nextTag);
280
+ }
240
281
  }
241
282
 
242
- // When `collapseInlineTagWhitespace` is enabled, still preserve whitespace around inline text elements
243
- if (trimRight && options.collapseInlineTagWhitespace) {
244
- const tagName = nextTag.charAt(0) === '/' ? nextTag.slice(1) : nextTag;
245
- if (inlineElementsToKeepWhitespaceWithin.has(tagName)) {
246
- trimRight = false;
247
- }
283
+ const collapseAll = Boolean(prevTag && nextTag);
284
+ if (!trimLeft && !trimRight && !collapseAll && !options.preserveLineBreaks) {
285
+ return str;
248
286
  }
287
+ return collapseWhitespaceKnown(str, options, trimLeft, trimRight, collapseAll);
288
+ }
249
289
 
250
- return collapseWhitespace(str, options, Boolean(trimLeft), Boolean(trimRight), Boolean(prevTag && nextTag));
290
+ /** @param {string} tag */
291
+ function stripSlash(tag) {
292
+ return tag && tag.charCodeAt(0) === 47 /* / */ ? tag.slice(1) : tag;
251
293
  }
252
294
 
253
295
  // Collapse/trim whitespace for given tag