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 +2 -2
- package/cli.js +45 -13
- package/dist/types/lib/whitespace.d.ts.map +1 -1
- package/package.json +4 -4
- package/src/lib/whitespace.js +133 -91
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
|
|
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
|
|
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
|
-
//
|
|
547
|
-
//
|
|
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 {
|
|
557
|
+
* @param {string[]} files
|
|
556
558
|
*/
|
|
557
|
-
async function createPool(
|
|
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
|
|
569
|
-
return
|
|
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
|
|
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
|
-
|
|
613
|
+
const pool = createFilePool({
|
|
584
614
|
options,
|
|
585
|
-
size:
|
|
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
|
|
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
|
|
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;
|
|
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.
|
|
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.
|
|
20
|
-
"@types/node": "^26.
|
|
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.
|
|
98
|
+
"version": "8.4.2"
|
|
99
99
|
}
|
package/src/lib/whitespace.js
CHANGED
|
@@ -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
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
return str.
|
|
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 (
|
|
59
|
+
if (!RE_ANY_WS_NBSP.test(str)) {
|
|
37
60
|
return str;
|
|
38
61
|
}
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
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 (
|
|
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 &&
|
|
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 &&
|
|
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
|
-
//
|
|
105
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 &&
|
|
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 =
|
|
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(
|
|
156
|
-
if (
|
|
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 (
|
|
232
|
+
if (!RE_ANY_WS_NBSP.test(str)) {
|
|
181
233
|
return str;
|
|
182
234
|
}
|
|
183
235
|
|
|
184
|
-
const
|
|
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
|
-
|
|
198
|
-
|
|
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
|
-
|
|
212
|
-
|
|
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
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
if (
|
|
219
|
-
trimLeft =
|
|
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
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
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
|
|
239
|
-
|
|
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
|
-
|
|
243
|
-
if (trimRight && options.
|
|
244
|
-
|
|
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
|
-
|
|
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
|