html-minifier 4.0.0 → 5.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (55) hide show
  1. package/LICENSE.txt +23 -0
  2. package/README.md +851 -110
  3. package/cli.js +991 -207
  4. package/dist/types/htmlminifier.d.ts +629 -0
  5. package/dist/types/htmlminifier.d.ts.map +1 -0
  6. package/dist/types/htmlparser.d.ts +28 -0
  7. package/dist/types/htmlparser.d.ts.map +1 -0
  8. package/dist/types/lib/attributes.d.ts +87 -0
  9. package/dist/types/lib/attributes.d.ts.map +1 -0
  10. package/dist/types/lib/constants.d.ts +421 -0
  11. package/dist/types/lib/constants.d.ts.map +1 -0
  12. package/dist/types/lib/content.d.ts +44 -0
  13. package/dist/types/lib/content.d.ts.map +1 -0
  14. package/dist/types/lib/elements.d.ts +48 -0
  15. package/dist/types/lib/elements.d.ts.map +1 -0
  16. package/dist/types/lib/fragments.d.ts +47 -0
  17. package/dist/types/lib/fragments.d.ts.map +1 -0
  18. package/dist/types/lib/option-definitions.d.ts +33 -0
  19. package/dist/types/lib/option-definitions.d.ts.map +1 -0
  20. package/dist/types/lib/options.d.ts +93 -0
  21. package/dist/types/lib/options.d.ts.map +1 -0
  22. package/dist/types/lib/unused-css.d.ts +76 -0
  23. package/dist/types/lib/unused-css.d.ts.map +1 -0
  24. package/dist/types/lib/urls.d.ts +15 -0
  25. package/dist/types/lib/urls.d.ts.map +1 -0
  26. package/dist/types/lib/utils.d.ts +132 -0
  27. package/dist/types/lib/utils.d.ts.map +1 -0
  28. package/dist/types/lib/whitespace.d.ts +97 -0
  29. package/dist/types/lib/whitespace.d.ts.map +1 -0
  30. package/dist/types/presets.d.ts +50 -0
  31. package/dist/types/presets.d.ts.map +1 -0
  32. package/dist/types/tokenchain.d.ts +36 -0
  33. package/dist/types/tokenchain.d.ts.map +1 -0
  34. package/html-minifier.schema.json +322 -0
  35. package/package.json +71 -60
  36. package/src/htmlminifier.js +2862 -1106
  37. package/src/htmlparser.js +1163 -452
  38. package/src/lib/attributes.js +1196 -0
  39. package/src/lib/constants.js +521 -0
  40. package/src/lib/content.js +237 -0
  41. package/src/lib/elements.js +268 -0
  42. package/src/lib/file-pool.js +193 -0
  43. package/src/lib/file-write.js +87 -0
  44. package/src/lib/fragments.js +340 -0
  45. package/src/lib/option-definitions.js +246 -0
  46. package/src/lib/options.js +841 -0
  47. package/src/lib/unused-css.js +499 -0
  48. package/src/lib/urls.js +146 -0
  49. package/src/lib/utils.js +1026 -0
  50. package/src/lib/whitespace.js +431 -0
  51. package/src/presets.js +58 -0
  52. package/src/tokenchain.js +142 -56
  53. package/LICENSE +0 -22
  54. package/sample-cli-config-file.conf +0 -40
  55. 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 &nbsp; b` → `a&nbsp;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
+ };