html-minifier 4.0.0 → 5.0.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/LICENSE.txt +23 -0
- package/README.md +851 -110
- package/cli.js +991 -207
- package/dist/types/htmlminifier.d.ts +629 -0
- package/dist/types/htmlminifier.d.ts.map +1 -0
- package/dist/types/htmlparser.d.ts +28 -0
- package/dist/types/htmlparser.d.ts.map +1 -0
- package/dist/types/lib/attributes.d.ts +87 -0
- package/dist/types/lib/attributes.d.ts.map +1 -0
- package/dist/types/lib/constants.d.ts +421 -0
- package/dist/types/lib/constants.d.ts.map +1 -0
- package/dist/types/lib/content.d.ts +44 -0
- package/dist/types/lib/content.d.ts.map +1 -0
- package/dist/types/lib/elements.d.ts +48 -0
- package/dist/types/lib/elements.d.ts.map +1 -0
- package/dist/types/lib/fragments.d.ts +47 -0
- package/dist/types/lib/fragments.d.ts.map +1 -0
- package/dist/types/lib/option-definitions.d.ts +33 -0
- package/dist/types/lib/option-definitions.d.ts.map +1 -0
- package/dist/types/lib/options.d.ts +93 -0
- package/dist/types/lib/options.d.ts.map +1 -0
- package/dist/types/lib/unused-css.d.ts +76 -0
- package/dist/types/lib/unused-css.d.ts.map +1 -0
- package/dist/types/lib/urls.d.ts +15 -0
- package/dist/types/lib/urls.d.ts.map +1 -0
- package/dist/types/lib/utils.d.ts +132 -0
- package/dist/types/lib/utils.d.ts.map +1 -0
- package/dist/types/lib/whitespace.d.ts +97 -0
- package/dist/types/lib/whitespace.d.ts.map +1 -0
- package/dist/types/presets.d.ts +50 -0
- package/dist/types/presets.d.ts.map +1 -0
- package/dist/types/tokenchain.d.ts +36 -0
- package/dist/types/tokenchain.d.ts.map +1 -0
- package/html-minifier.schema.json +322 -0
- package/package.json +70 -58
- package/src/htmlminifier.js +2845 -1107
- package/src/htmlparser.js +1163 -452
- package/src/lib/attributes.js +1196 -0
- package/src/lib/constants.js +521 -0
- package/src/lib/content.js +237 -0
- package/src/lib/elements.js +268 -0
- package/src/lib/file-pool.js +193 -0
- package/src/lib/file-write.js +87 -0
- package/src/lib/fragments.js +340 -0
- package/src/lib/option-definitions.js +246 -0
- package/src/lib/options.js +841 -0
- package/src/lib/unused-css.js +499 -0
- package/src/lib/urls.js +146 -0
- package/src/lib/utils.js +1026 -0
- package/src/lib/whitespace.js +431 -0
- package/src/presets.js +58 -0
- package/src/tokenchain.js +142 -56
- package/LICENSE +0 -22
- package/sample-cli-config-file.conf +0 -40
- package/src/utils.js +0 -18
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Output writing for the CLI, shared by in-process runs and the worker pool
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import crypto from 'node:crypto';
|
|
6
|
+
import fs from 'node:fs';
|
|
7
|
+
import path from 'node:path';
|
|
8
|
+
|
|
9
|
+
// A random name is taken by chance only, so a few attempts are plenty
|
|
10
|
+
const ATTEMPTS_MAX = 10;
|
|
11
|
+
// As many links as a system commonly follows before reporting a loop
|
|
12
|
+
const LINKS_MAX = 40;
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* The file a write replaces: the output itself or, for a symlink, the file it
|
|
16
|
+
* points to—followed by hand where that file does not exist yet, which
|
|
17
|
+
* `realpath` cannot resolve
|
|
18
|
+
* @param {string} outputFile
|
|
19
|
+
* @returns {Promise<string>}
|
|
20
|
+
*/
|
|
21
|
+
async function resolveTarget(outputFile) {
|
|
22
|
+
let file = outputFile;
|
|
23
|
+
for (let i = 0; i < LINKS_MAX; i++) {
|
|
24
|
+
try {
|
|
25
|
+
return await fs.promises.realpath(file);
|
|
26
|
+
} catch {
|
|
27
|
+
const link = await fs.promises.readlink(file).catch(() => undefined);
|
|
28
|
+
if (link === undefined) return file;
|
|
29
|
+
// A relative link is read from the real location of its folder
|
|
30
|
+
const dir = await fs.promises.realpath(path.dirname(file)).catch(() => path.dirname(file));
|
|
31
|
+
file = path.resolve(dir, link);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
// A loop, which writing through wouldn’t resolve either—refused, so the rename can’t replace it
|
|
35
|
+
throw Object.assign(new Error(`ELOOP: Too many symbolic links encountered, '${outputFile}'`), { code: 'ELOOP', path: outputFile });
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Create a temporary file next to the target under a random name, exclusively,
|
|
40
|
+
* so that it can neither be predicted nor write through a file or link already there
|
|
41
|
+
* @param {string} targetFile
|
|
42
|
+
* @param {number | undefined} mode
|
|
43
|
+
*/
|
|
44
|
+
async function openTempFile(targetFile, mode) {
|
|
45
|
+
for (let attempt = 1; ; attempt++) {
|
|
46
|
+
const tempFile = path.join(path.dirname(targetFile), `.${path.basename(targetFile)}.${crypto.randomBytes(8).toString('hex')}.tmp`);
|
|
47
|
+
try {
|
|
48
|
+
return { tempFile, handle: await fs.promises.open(tempFile, 'wx', mode) };
|
|
49
|
+
} catch (err) {
|
|
50
|
+
if (/** @type {any} */ (err).code !== 'EEXIST' || attempt >= ATTEMPTS_MAX) throw err;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Write a file by way of a temporary file in the same folder, so that an exit
|
|
57
|
+
* that leaves no chance to clean up—a native crash in a minifier, an OOM
|
|
58
|
+
* kill—cannot leave a half-written file where the output belongs
|
|
59
|
+
* @param {string} outputFile
|
|
60
|
+
* @param {string} data
|
|
61
|
+
*/
|
|
62
|
+
export async function writeFileAtomic(outputFile, data) {
|
|
63
|
+
// A rename would replace a symlink itself, so work on the file it points to
|
|
64
|
+
const targetFile = await resolveTarget(outputFile);
|
|
65
|
+
// The rename replaces the output rather than writing through it, so its mode is carried over
|
|
66
|
+
const stats = await fs.promises.stat(targetFile).catch(() => undefined);
|
|
67
|
+
const mode = stats ? stats.mode & 0o7777 : undefined;
|
|
68
|
+
|
|
69
|
+
// Outside the `try`, so that only a file this write created is ever removed
|
|
70
|
+
const { tempFile, handle } = await openTempFile(targetFile, mode);
|
|
71
|
+
|
|
72
|
+
try {
|
|
73
|
+
try {
|
|
74
|
+
await handle.writeFile(data, 'utf8');
|
|
75
|
+
} finally {
|
|
76
|
+
await handle.close();
|
|
77
|
+
}
|
|
78
|
+
// `mode` on creation is subject to the umask, so the exact bits have to be set separately
|
|
79
|
+
if (mode !== undefined) {
|
|
80
|
+
await fs.promises.chmod(tempFile, mode);
|
|
81
|
+
}
|
|
82
|
+
await fs.promises.rename(tempFile, targetFile);
|
|
83
|
+
} catch (err) {
|
|
84
|
+
await fs.promises.rm(tempFile, { force: true });
|
|
85
|
+
throw err;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
@@ -0,0 +1,340 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Custom fragment matching
|
|
3
|
+
*
|
|
4
|
+
* `ignoreCustomFragments` patterns nearly always describe the same shape: a literal
|
|
5
|
+
* opening delimiter, an any-character or negated-class body, and a literal closing
|
|
6
|
+
* delimiter, as in `<%[\s\S]*?%>` or `\{\{[^}]*?\}\}`. Such a fragment can be found
|
|
7
|
+
* with `indexOf` in linear time, where running the pattern as a regex costs O(n²)
|
|
8
|
+
* on input that opens fragments it never closes—and can cost far more than that when
|
|
9
|
+
* the pattern itself backtracks. Patterns of other shapes keep running as regexes,
|
|
10
|
+
* one per pattern, so each keeps its own flags.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
const RE_WHITESPACE = /\s/;
|
|
14
|
+
|
|
15
|
+
// Any-character and negated-class bodies; without the `s` flag, `.` is itself a
|
|
16
|
+
// negated class, excluding line terminators
|
|
17
|
+
const RE_DELIMITED = /^(.*?)(?:\[\\s\\S\]|\[\\S\\s\]|\[\^\]|(\.)|\[\^((?:\\[^]|[^\]\\])+)\])(?:([*+])|\{(\d+)(?:,(\d*))?\})\?(.*)$/;
|
|
18
|
+
|
|
19
|
+
// Flags that leave literal matching alone, so a pattern carrying them can still be
|
|
20
|
+
// scanned for; `i` in particular cannot, since case folding moves character indexes
|
|
21
|
+
const RE_LITERAL_FLAGS = /^[gds]*$/;
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* @typedef {{open: string, close: string, min: number, max: number, excluded: RegExp | null}} DelimitedFragment
|
|
25
|
+
* Literal delimiters around a body, found by scanning; `excluded` holds the
|
|
26
|
+
* characters a negated-class body cannot cross, null when the body spans every
|
|
27
|
+
* character
|
|
28
|
+
* @typedef {{search: RegExp, anchored: RegExp, close: string | null}} PatternFragment
|
|
29
|
+
* Everything else, found by running the pattern itself; `close` holds the literal
|
|
30
|
+
* every match ends with, where one follows an unbounded any-character body
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Read a regex source as a literal string, so it can be matched with `indexOf`
|
|
35
|
+
* @param {string} source
|
|
36
|
+
* @returns {string | null} Null when the source is more than literal characters
|
|
37
|
+
*/
|
|
38
|
+
function toLiteral(source) {
|
|
39
|
+
let literal = '';
|
|
40
|
+
|
|
41
|
+
for (let i = 0; i < source.length; i++) {
|
|
42
|
+
const char = source[i] ?? '';
|
|
43
|
+
if (char === '\\') {
|
|
44
|
+
const escaped = source[++i];
|
|
45
|
+
// A trailing backslash is half an escape the body token was split out of,
|
|
46
|
+
// as in `<%\.*?%>`, where the `.` is literal and not a body
|
|
47
|
+
if (escaped === undefined) return null;
|
|
48
|
+
// `\n` and friends are literal characters, `\s` and `\1` are not
|
|
49
|
+
if (escaped === 'n') literal += '\n';
|
|
50
|
+
else if (escaped === 't') literal += '\t';
|
|
51
|
+
else if (escaped === 'r') literal += '\r';
|
|
52
|
+
else if (escaped === 'f') literal += '\f';
|
|
53
|
+
else if (escaped === 'v') literal += '\v';
|
|
54
|
+
else if (/[A-Za-z0-9]/.test(escaped)) return null;
|
|
55
|
+
else literal += escaped;
|
|
56
|
+
} else if ('.*+?()[]{}|^$'.includes(char)) {
|
|
57
|
+
return null;
|
|
58
|
+
} else {
|
|
59
|
+
literal += char;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
return literal;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Describe a fragment pattern as literal delimiters around an any-character body
|
|
68
|
+
* @param {RegExp} pattern
|
|
69
|
+
* @returns {DelimitedFragment | null} Null when the pattern has another shape, which
|
|
70
|
+
* the caller answers by running it as a regex
|
|
71
|
+
*/
|
|
72
|
+
function toDelimitedFragment(pattern) {
|
|
73
|
+
if (!RE_LITERAL_FLAGS.test(pattern.flags)) return null;
|
|
74
|
+
|
|
75
|
+
const match = RE_DELIMITED.exec(pattern.source);
|
|
76
|
+
if (!match) return null;
|
|
77
|
+
|
|
78
|
+
const [, rawOpen, dot, negated, simple, exact, upper, rawClose] = match;
|
|
79
|
+
const open = toLiteral(rawOpen ?? '');
|
|
80
|
+
const close = toLiteral(rawClose ?? '');
|
|
81
|
+
// Both delimiters have to be there: without them a match has no boundary to scan to
|
|
82
|
+
if (!open || !close) return null;
|
|
83
|
+
|
|
84
|
+
// The class content doubles as the search for characters the body cannot cross;
|
|
85
|
+
// a `.` body spans every character under the `s` flag, and excludes line
|
|
86
|
+
// terminators without it
|
|
87
|
+
const inner = dot ? (pattern.flags.includes('s') ? null : '\\n\\r\\u2028\\u2029') : negated ?? null;
|
|
88
|
+
|
|
89
|
+
const min = simple ? (simple === '+' ? 1 : 0) : Number(exact);
|
|
90
|
+
const max = simple || upper === '' ? Infinity : Number(upper ?? exact);
|
|
91
|
+
|
|
92
|
+
return { open, close, min, max, excluded: inner === null ? null : new RegExp('[' + inner + ']', 'g') };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Whether a regex source has a `|` outside groups and classes, which lets a match skip the rest
|
|
97
|
+
* @param {string} source
|
|
98
|
+
* @returns {boolean}
|
|
99
|
+
*/
|
|
100
|
+
function hasTopLevelAlternation(source) {
|
|
101
|
+
let depth = 0;
|
|
102
|
+
let inClass = false;
|
|
103
|
+
|
|
104
|
+
for (let i = 0; i < source.length; i++) {
|
|
105
|
+
const char = source[i];
|
|
106
|
+
if (char === '\\') i++;
|
|
107
|
+
else if (inClass) inClass = char !== ']';
|
|
108
|
+
else if (char === '[') inClass = true;
|
|
109
|
+
else if (char === '(') depth++;
|
|
110
|
+
else if (char === ')') depth--;
|
|
111
|
+
else if (char === '|' && depth === 0) return true;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
return false;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* The literal all matches of a pattern end with, where an unbounded any-character body leads
|
|
119
|
+
* up to it: Without that literal ahead, each opener runs the body to the end of the input
|
|
120
|
+
* only to fail, which costs O(n²) on input that opens fragments it never closes
|
|
121
|
+
* @param {RegExp} pattern
|
|
122
|
+
* @returns {string | null}
|
|
123
|
+
*/
|
|
124
|
+
function toClosingLiteral(pattern) {
|
|
125
|
+
const match = RE_DELIMITED.exec(pattern.source);
|
|
126
|
+
if (!match) return null;
|
|
127
|
+
|
|
128
|
+
const [, rawOpen, dot, negated, simple, , , rawClose] = match;
|
|
129
|
+
if (!simple || negated !== undefined || (dot && !pattern.flags.includes('s'))) return null;
|
|
130
|
+
if (hasTopLevelAlternation(rawOpen ?? '')) return null;
|
|
131
|
+
|
|
132
|
+
const close = toLiteral(rawClose ?? '');
|
|
133
|
+
// `indexOf` can stand in for the pattern only where case folding leaves the literal alone
|
|
134
|
+
if (!close || (pattern.flags.includes('i') && close.toLowerCase() !== close.toUpperCase())) return null;
|
|
135
|
+
|
|
136
|
+
return close;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Prepare a fragment pattern for matching, by scanning where the shape allows it
|
|
141
|
+
* @param {RegExp} pattern
|
|
142
|
+
* @returns {DelimitedFragment | PatternFragment}
|
|
143
|
+
*/
|
|
144
|
+
function toFragment(pattern) {
|
|
145
|
+
const delimited = toDelimitedFragment(pattern);
|
|
146
|
+
if (delimited) return delimited;
|
|
147
|
+
|
|
148
|
+
// `g` and `y` are ours to set, the rest belong to the pattern
|
|
149
|
+
const flags = pattern.flags.replace(/[gy]/g, '');
|
|
150
|
+
return {
|
|
151
|
+
search: new RegExp(pattern.source, flags + 'g'),
|
|
152
|
+
anchored: new RegExp(pattern.source, flags + 'y'),
|
|
153
|
+
close: toClosingLiteral(pattern)
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Replace runs of custom fragments, and the whitespace padding them
|
|
159
|
+
* @param {string} value - Document to scan
|
|
160
|
+
* @param {(DelimitedFragment | PatternFragment)[]} fragments - In the order the patterns
|
|
161
|
+
* were given, since the earliest match wins and ties go to the pattern listed first
|
|
162
|
+
* @param {(match: string) => string} replacer - Called with each match, as `replace` would
|
|
163
|
+
* @returns {string}
|
|
164
|
+
*/
|
|
165
|
+
function replaceCustomFragments(value, fragments, replacer) {
|
|
166
|
+
// Where each fragment's next match may start, its next closing delimiter may be
|
|
167
|
+
// found, and its next excluded character sits; all only ever move forward, which
|
|
168
|
+
// is what keeps the whole scan linear
|
|
169
|
+
const found = fragments.map(() => /** @type {{start: number, end: number} | null} */ (null));
|
|
170
|
+
const closesAt = fragments.map(() => -1);
|
|
171
|
+
const excludedAt = fragments.map(() => -1);
|
|
172
|
+
const exhausted = fragments.map(() => false);
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Whether a body region holds a character its class excludes
|
|
176
|
+
* @param {DelimitedFragment} fragment
|
|
177
|
+
* @param {number} index - Which fragment, for the forward-only bookkeeping
|
|
178
|
+
* @param {number} bodyStart
|
|
179
|
+
* @param {number} close
|
|
180
|
+
* @returns {boolean}
|
|
181
|
+
*/
|
|
182
|
+
const bodyBlocked = (fragment, index, bodyStart, close) => {
|
|
183
|
+
if (!fragment.excluded) return false;
|
|
184
|
+
if (/** @type {number} */ (excludedAt[index]) < bodyStart) {
|
|
185
|
+
fragment.excluded.lastIndex = bodyStart;
|
|
186
|
+
const blocked = fragment.excluded.exec(value);
|
|
187
|
+
excludedAt[index] = blocked ? blocked.index : Infinity;
|
|
188
|
+
}
|
|
189
|
+
return /** @type {number} */ (excludedAt[index]) < close;
|
|
190
|
+
};
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Earliest match of one delimited fragment at or after `from`
|
|
194
|
+
* @param {DelimitedFragment} fragment
|
|
195
|
+
* @param {number} index - Which fragment, for the forward-only bookkeeping
|
|
196
|
+
* @param {number} from
|
|
197
|
+
* @param {boolean} anchored - Whether the match has to start exactly at `from`
|
|
198
|
+
* @returns {{start: number, end: number} | null}
|
|
199
|
+
*/
|
|
200
|
+
const scan = (fragment, index, from, anchored) => {
|
|
201
|
+
// The closing delimiter first: An opener starting with `<` is slow to find in markup
|
|
202
|
+
if (/** @type {number} */ (closesAt[index]) < from) {
|
|
203
|
+
closesAt[index] = value.indexOf(fragment.close, from);
|
|
204
|
+
if (closesAt[index] === -1) {
|
|
205
|
+
exhausted[index] = true;
|
|
206
|
+
return null;
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
let openFrom = from;
|
|
211
|
+
|
|
212
|
+
for (;;) {
|
|
213
|
+
const open = anchored
|
|
214
|
+
? (value.startsWith(fragment.open, from) ? from : -1)
|
|
215
|
+
: value.indexOf(fragment.open, openFrom);
|
|
216
|
+
if (open === -1) {
|
|
217
|
+
if (!anchored) exhausted[index] = true;
|
|
218
|
+
return null;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
const bodyStart = open + fragment.open.length;
|
|
222
|
+
if (/** @type {number} */ (closesAt[index]) < bodyStart + fragment.min) {
|
|
223
|
+
closesAt[index] = value.indexOf(fragment.close, bodyStart + fragment.min);
|
|
224
|
+
}
|
|
225
|
+
const close = /** @type {number} */ (closesAt[index]);
|
|
226
|
+
if (close === -1) {
|
|
227
|
+
exhausted[index] = true;
|
|
228
|
+
return null;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
if (close - bodyStart <= fragment.max && !bodyBlocked(fragment, index, bodyStart, close)) {
|
|
232
|
+
return { start: open, end: close + fragment.close.length };
|
|
233
|
+
}
|
|
234
|
+
// The body is longer than the pattern allows, or holds a character its class
|
|
235
|
+
// excludes, so the match has to start later
|
|
236
|
+
if (anchored) return null;
|
|
237
|
+
openFrom = open + 1;
|
|
238
|
+
}
|
|
239
|
+
};
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* Earliest match of one regex fragment at or after `from`
|
|
243
|
+
* @param {PatternFragment} fragment
|
|
244
|
+
* @param {number} index - Which fragment, for the forward-only bookkeeping
|
|
245
|
+
* @param {number} from
|
|
246
|
+
* @param {boolean} anchored
|
|
247
|
+
* @returns {{start: number, end: number} | null}
|
|
248
|
+
*/
|
|
249
|
+
const run = (fragment, index, from, anchored) => {
|
|
250
|
+
if (fragment.close !== null) {
|
|
251
|
+
// `Infinity` marks a closing literal that is nowhere ahead, so the search is not repeated
|
|
252
|
+
if (/** @type {number} */ (closesAt[index]) < from) {
|
|
253
|
+
const close = value.indexOf(fragment.close, from);
|
|
254
|
+
closesAt[index] = close === -1 ? Infinity : close;
|
|
255
|
+
}
|
|
256
|
+
if (closesAt[index] === Infinity) return null;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
const pattern = anchored ? fragment.anchored : fragment.search;
|
|
260
|
+
pattern.lastIndex = from;
|
|
261
|
+
|
|
262
|
+
for (;;) {
|
|
263
|
+
const match = pattern.exec(value);
|
|
264
|
+
if (!match) return null;
|
|
265
|
+
// A pattern that matches nothing would leave the run in place forever
|
|
266
|
+
if (match[0].length > 0) return { start: match.index, end: match.index + match[0].length };
|
|
267
|
+
if (anchored) return null;
|
|
268
|
+
pattern.lastIndex = match.index + 1;
|
|
269
|
+
}
|
|
270
|
+
};
|
|
271
|
+
|
|
272
|
+
/**
|
|
273
|
+
* @param {number} from
|
|
274
|
+
* @param {boolean} anchored
|
|
275
|
+
* @returns {{start: number, end: number} | null}
|
|
276
|
+
*/
|
|
277
|
+
const find = (from, anchored) => {
|
|
278
|
+
/** @type {{start: number, end: number} | null} */
|
|
279
|
+
let earliest = null;
|
|
280
|
+
|
|
281
|
+
for (let i = 0; i < fragments.length; i++) {
|
|
282
|
+
if (exhausted[i]) continue;
|
|
283
|
+
const fragment = /** @type {DelimitedFragment | PatternFragment} */ (fragments[i]);
|
|
284
|
+
|
|
285
|
+
/** @type {{start: number, end: number} | null} */
|
|
286
|
+
let match;
|
|
287
|
+
if (anchored) {
|
|
288
|
+
match = 'open' in fragment ? scan(fragment, i, from, true) : run(fragment, i, from, true);
|
|
289
|
+
} else {
|
|
290
|
+
// Matches only ever move forward, so the last one found still stands
|
|
291
|
+
const previous = found[i];
|
|
292
|
+
match = previous && previous.start >= from
|
|
293
|
+
? previous
|
|
294
|
+
: ('open' in fragment ? scan(fragment, i, from, false) : run(fragment, i, from, false));
|
|
295
|
+
found[i] = match;
|
|
296
|
+
// Nothing ahead now means nothing ahead later, either
|
|
297
|
+
if (!match) exhausted[i] = true;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
// Ties go to the fragment listed first, the way alternation would resolve them
|
|
301
|
+
if (match && (!earliest || match.start < earliest.start)) earliest = match;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
return earliest;
|
|
305
|
+
};
|
|
306
|
+
|
|
307
|
+
let out = '';
|
|
308
|
+
let copied = 0;
|
|
309
|
+
let search = 0;
|
|
310
|
+
|
|
311
|
+
while (search <= value.length) {
|
|
312
|
+
const first = find(search, false);
|
|
313
|
+
if (!first) break;
|
|
314
|
+
|
|
315
|
+
// Fragments running straight into each other are one match
|
|
316
|
+
let end = first.end;
|
|
317
|
+
for (;;) {
|
|
318
|
+
const next = find(end, true);
|
|
319
|
+
if (!next) break;
|
|
320
|
+
end = next.end;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
// …as is the whitespace on either side, back to where the last match left off
|
|
324
|
+
let start = first.start;
|
|
325
|
+
while (start > copied && RE_WHITESPACE.test(value[start - 1] ?? '')) start--;
|
|
326
|
+
while (end < value.length && RE_WHITESPACE.test(value[end] ?? '')) end++;
|
|
327
|
+
|
|
328
|
+
out += value.slice(copied, start) + replacer(value.slice(start, end));
|
|
329
|
+
copied = end;
|
|
330
|
+
search = end;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
return copied === 0 ? value : out + value.slice(copied);
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
export {
|
|
337
|
+
toDelimitedFragment,
|
|
338
|
+
toFragment,
|
|
339
|
+
replaceCustomFragments
|
|
340
|
+
};
|
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Single source of truth for minifier option names, descriptions, types, and shared defaults
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* @typedef {object} OptionDefinition
|
|
7
|
+
* @property {string} description Help text, phrased for the option’s primary CLI form
|
|
8
|
+
* @property {string} [descriptionAffirmative] Help text for what enabling the option does, where `description` describes the negated form
|
|
9
|
+
* @property {string} type Key into the parser and JSON Schema type maps in cli.js and scripts/build-schema.js
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
/** @type {Record<string, OptionDefinition>} */
|
|
13
|
+
const optionDefinitions = {
|
|
14
|
+
cacheCSS: {
|
|
15
|
+
description: 'Set CSS minification cache size (number of entries, default: 500)',
|
|
16
|
+
type: 'int'
|
|
17
|
+
},
|
|
18
|
+
cacheJS: {
|
|
19
|
+
description: 'Set JavaScript minification cache size (number of entries, default: 500)',
|
|
20
|
+
type: 'int'
|
|
21
|
+
},
|
|
22
|
+
cacheSVG: {
|
|
23
|
+
description: 'Set SVG minification cache size (number of entries, default: 500)',
|
|
24
|
+
type: 'int'
|
|
25
|
+
},
|
|
26
|
+
caseSensitive: {
|
|
27
|
+
description: 'Keep element and attribute names as written (e.g., for framework components like `<MyButton>`)',
|
|
28
|
+
type: 'boolean'
|
|
29
|
+
},
|
|
30
|
+
collapseAttributeWhitespace: {
|
|
31
|
+
description: 'Trim and collapse whitespace characters within attribute values',
|
|
32
|
+
type: 'boolean'
|
|
33
|
+
},
|
|
34
|
+
collapseBooleanAttributes: {
|
|
35
|
+
description: 'Omit attribute values from boolean attributes',
|
|
36
|
+
type: 'boolean'
|
|
37
|
+
},
|
|
38
|
+
collapseEmptyAttributes: {
|
|
39
|
+
description: 'Omit empty attribute values (e.g., `alt=""` → `alt`)',
|
|
40
|
+
type: 'boolean'
|
|
41
|
+
},
|
|
42
|
+
collapseInlineTagWhitespace: {
|
|
43
|
+
description: 'Collapse whitespace more aggressively between inline elements—use with `--collapse-whitespace`',
|
|
44
|
+
type: 'boolean'
|
|
45
|
+
},
|
|
46
|
+
collapseNoBreakSpaces: {
|
|
47
|
+
description: 'Remove whitespace next to no-break spaces, narrow no-break spaces, and figure spaces in text (e.g., `a b` → `a b`), keeping runs of them—use with `--collapse-whitespace`',
|
|
48
|
+
type: 'boolean'
|
|
49
|
+
},
|
|
50
|
+
collapseWhitespace: {
|
|
51
|
+
description: 'Collapse whitespace that contributes to text nodes in a document tree',
|
|
52
|
+
type: 'boolean'
|
|
53
|
+
},
|
|
54
|
+
conservativeCollapse: {
|
|
55
|
+
description: 'Always collapse to one space (never remove it entirely)—use with `--collapse-whitespace`',
|
|
56
|
+
type: 'boolean'
|
|
57
|
+
},
|
|
58
|
+
continueOnMinifyError: {
|
|
59
|
+
description: 'Abort on minification errors',
|
|
60
|
+
descriptionAffirmative: 'Continue on minification errors',
|
|
61
|
+
type: 'invertedBoolean'
|
|
62
|
+
},
|
|
63
|
+
continueOnParseError: {
|
|
64
|
+
description: 'Handle parse errors instead of aborting',
|
|
65
|
+
type: 'boolean'
|
|
66
|
+
},
|
|
67
|
+
customAttrAssign: {
|
|
68
|
+
description: 'Array of regexes that allow to support custom attribute assign expressions (e.g., `<div flex?="{{mode != cover}}"></div>`)',
|
|
69
|
+
type: 'regexpArray'
|
|
70
|
+
},
|
|
71
|
+
customAttrCollapse: {
|
|
72
|
+
description: 'Regex that specifies custom attribute to strip newlines from (e.g., `/ng-class/`)',
|
|
73
|
+
type: 'regexp'
|
|
74
|
+
},
|
|
75
|
+
customAttrSurround: {
|
|
76
|
+
description: 'Array of regexes that allow to support custom attribute surround expressions (e.g., `<input {{#if value}}checked="checked"{{/if}}>`)',
|
|
77
|
+
type: 'regexpArray'
|
|
78
|
+
},
|
|
79
|
+
customEventAttributes: {
|
|
80
|
+
description: 'Array of regexes that allow to support custom event attributes (e.g., `ng-click`)—use with `--minify-js`',
|
|
81
|
+
type: 'regexpArray'
|
|
82
|
+
},
|
|
83
|
+
decodeEntities: {
|
|
84
|
+
description: 'Use direct Unicode characters whenever possible',
|
|
85
|
+
type: 'boolean'
|
|
86
|
+
},
|
|
87
|
+
ignoreCustomComments: {
|
|
88
|
+
description: 'Array of regexes that allow to ignore matching comments',
|
|
89
|
+
type: 'regexpArray'
|
|
90
|
+
},
|
|
91
|
+
ignoreCustomFragments: {
|
|
92
|
+
description: 'Array of regexes that allow to ignore certain fragments, when matched (e.g., `<?php \u2026 ?>`, `{{ \u2026 }}`, etc.)',
|
|
93
|
+
type: 'regexpArray'
|
|
94
|
+
},
|
|
95
|
+
includeAutoGeneratedTags: {
|
|
96
|
+
description: 'Insert elements generated by HTML parser',
|
|
97
|
+
type: 'boolean'
|
|
98
|
+
},
|
|
99
|
+
inlineCustomElements: {
|
|
100
|
+
description: 'Array of names of custom elements which are inline, for whitespace handling—use with `--collapse-whitespace`',
|
|
101
|
+
type: 'jsonArray'
|
|
102
|
+
},
|
|
103
|
+
keepClosingSlash: {
|
|
104
|
+
description: 'Keep the trailing slash on void elements and other start tags that carry one, and read it as closing the element',
|
|
105
|
+
type: 'boolean'
|
|
106
|
+
},
|
|
107
|
+
maxInputLength: {
|
|
108
|
+
description: 'Maximum input length to prevent ReDoS attacks',
|
|
109
|
+
type: 'int'
|
|
110
|
+
},
|
|
111
|
+
maxLineLength: {
|
|
112
|
+
description: 'Specify a maximum line length; output will be split by newlines in tags only (between attributes or before a tag’s `>`), where they can’t change the page, so lines may run longer',
|
|
113
|
+
type: 'int'
|
|
114
|
+
},
|
|
115
|
+
mergeScripts: {
|
|
116
|
+
description: 'Merge consecutive inline `script` elements into one',
|
|
117
|
+
type: 'boolean'
|
|
118
|
+
},
|
|
119
|
+
minifyCSS: {
|
|
120
|
+
description: 'Minify CSS in `style` elements and attributes (uses Lightning CSS)',
|
|
121
|
+
type: 'jsonObject'
|
|
122
|
+
},
|
|
123
|
+
minifyJS: {
|
|
124
|
+
description: 'Minify JavaScript in `script` elements and event attributes (uses Terser or SWC; pass `{"engine": "swc"}` for SWC)',
|
|
125
|
+
type: 'jsonObject'
|
|
126
|
+
},
|
|
127
|
+
minifyJSON: {
|
|
128
|
+
description: 'Minify JSON in `script` elements (e.g., `application/ld+json`, `importmap`, etc.) by removing whitespace',
|
|
129
|
+
type: 'boolean'
|
|
130
|
+
},
|
|
131
|
+
minifySVG: {
|
|
132
|
+
description: 'Minify SVG elements (uses SVGO)',
|
|
133
|
+
type: 'jsonObject'
|
|
134
|
+
},
|
|
135
|
+
minifyURLs: {
|
|
136
|
+
description: 'Minify URLs in various attributes',
|
|
137
|
+
type: 'json'
|
|
138
|
+
},
|
|
139
|
+
noNewlinesBeforeTagClose: {
|
|
140
|
+
description: 'Never split a line in a tag that closes an element—use with `--max-line-length`',
|
|
141
|
+
type: 'boolean'
|
|
142
|
+
},
|
|
143
|
+
partialMarkup: {
|
|
144
|
+
description: 'Treat input as a partial HTML fragment, preserving stray end tags and unclosed tags',
|
|
145
|
+
type: 'boolean'
|
|
146
|
+
},
|
|
147
|
+
preserveLineBreaks: {
|
|
148
|
+
description: 'Always collapse to one line break (never remove it entirely) when whitespace between tags includes a line break—use with `--collapse-whitespace`',
|
|
149
|
+
type: 'boolean'
|
|
150
|
+
},
|
|
151
|
+
preventAttributesEscaping: {
|
|
152
|
+
description: 'Prevents the escaping of the values of attributes',
|
|
153
|
+
type: 'boolean'
|
|
154
|
+
},
|
|
155
|
+
processScripts: {
|
|
156
|
+
description: 'Array of strings corresponding to types of `script` elements to process through minifier (e.g., `text/ng-template`, `text/x-handlebars-template`, etc.)',
|
|
157
|
+
type: 'jsonArray'
|
|
158
|
+
},
|
|
159
|
+
quoteCharacter: {
|
|
160
|
+
description: 'Type of quote to use for attribute values (`\'` or `"`)',
|
|
161
|
+
type: 'string'
|
|
162
|
+
},
|
|
163
|
+
removeAttributeQuotes: {
|
|
164
|
+
description: 'Remove quotes around attributes when possible',
|
|
165
|
+
type: 'boolean'
|
|
166
|
+
},
|
|
167
|
+
removeComments: {
|
|
168
|
+
description: 'Strip HTML comments',
|
|
169
|
+
type: 'boolean'
|
|
170
|
+
},
|
|
171
|
+
removeDefaultTypeAttributes: {
|
|
172
|
+
description: 'Remove default `type` attributes from `style`/`link` (e.g., `type="text/css"`) and `script` (e.g., `type="text/javascript"`) elements; other `type` attribute values are left intact',
|
|
173
|
+
type: 'boolean'
|
|
174
|
+
},
|
|
175
|
+
removeEmptyAttributes: {
|
|
176
|
+
description: 'Remove all attributes with whitespace-only values',
|
|
177
|
+
type: 'boolean'
|
|
178
|
+
},
|
|
179
|
+
removeEmptyElements: {
|
|
180
|
+
description: 'Remove all elements with empty contents',
|
|
181
|
+
type: 'boolean'
|
|
182
|
+
},
|
|
183
|
+
removeEmptyElementsExcept: {
|
|
184
|
+
description: 'Array of elements to preserve (e.g., `td`, `<i class="test">`, etc.)—use with `--remove-empty-elements`',
|
|
185
|
+
type: 'jsonArray'
|
|
186
|
+
},
|
|
187
|
+
removeOptionalTags: {
|
|
188
|
+
description: 'Remove optional tags',
|
|
189
|
+
type: 'boolean'
|
|
190
|
+
},
|
|
191
|
+
removeRedundantAttributes: {
|
|
192
|
+
description: 'Remove attributes when value matches default or has no effect (as well as `rel` keywords and `meta` elements without effect)',
|
|
193
|
+
type: 'boolean'
|
|
194
|
+
},
|
|
195
|
+
removeTagWhitespace: {
|
|
196
|
+
description: 'Remove space between attributes whenever possible; note that this will result in invalid HTML',
|
|
197
|
+
type: 'boolean'
|
|
198
|
+
},
|
|
199
|
+
removeUnusedCSS: {
|
|
200
|
+
description: 'Remove CSS from `style` elements that name an element, class, or ID the document doesn’t reference—use with `--minify-css`, and `{"safelist": […]}` for names only external scripts use',
|
|
201
|
+
type: 'jsonObject'
|
|
202
|
+
},
|
|
203
|
+
selfCloseForeignElements: {
|
|
204
|
+
description: 'Self-close empty SVG and MathML elements (e.g., `<path d="…"></path>` → `<path d="…"/>`)',
|
|
205
|
+
type: 'boolean'
|
|
206
|
+
},
|
|
207
|
+
sortAttributes: {
|
|
208
|
+
description: 'Sort attributes by frequency',
|
|
209
|
+
type: 'boolean'
|
|
210
|
+
},
|
|
211
|
+
sortClassNames: {
|
|
212
|
+
description: 'Sort style classes by frequency',
|
|
213
|
+
type: 'boolean'
|
|
214
|
+
},
|
|
215
|
+
trimCustomFragments: {
|
|
216
|
+
description: 'Trim whitespace around custom fragments (`--ignore-custom-fragments`) where it does not affect the output—use with `--collapse-whitespace`',
|
|
217
|
+
type: 'boolean'
|
|
218
|
+
},
|
|
219
|
+
strictCustomFragments: {
|
|
220
|
+
description: 'Reject `ignoreCustomFragments` patterns that risk catastrophic backtracking (rather than warning about them)',
|
|
221
|
+
type: 'boolean'
|
|
222
|
+
},
|
|
223
|
+
useShortDoctype: {
|
|
224
|
+
description: 'Replaces the doctype with the short HTML doctype',
|
|
225
|
+
type: 'boolean'
|
|
226
|
+
}
|
|
227
|
+
};
|
|
228
|
+
|
|
229
|
+
const optionDefaults = {
|
|
230
|
+
continueOnMinifyError: true,
|
|
231
|
+
ignoreCustomComments: [
|
|
232
|
+
/^!/,
|
|
233
|
+
/^\s*#/
|
|
234
|
+
],
|
|
235
|
+
ignoreCustomFragments: [
|
|
236
|
+
/<%[\s\S]*?%>/,
|
|
237
|
+
// Template code and XML declarations, not processing instructions HTML reads itself
|
|
238
|
+
/<\?(?:php[\t\n\r ]|=|\$|xml(?:-stylesheet)?(?![\w-])|\s)[\s\S]*?\?>/i
|
|
239
|
+
],
|
|
240
|
+
includeAutoGeneratedTags: false
|
|
241
|
+
};
|
|
242
|
+
|
|
243
|
+
export {
|
|
244
|
+
optionDefinitions,
|
|
245
|
+
optionDefaults
|
|
246
|
+
};
|