html-minifier 3.5.21 → 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 -109
- package/cli.js +991 -206
- 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 +2848 -1095
- package/src/htmlparser.js +1165 -450
- 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 -39
- package/src/utils.js +0 -18
package/src/htmlminifier.js
CHANGED
|
@@ -1,1329 +1,3082 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
1
|
+
import { HTMLParser } from './htmlparser.js';
|
|
2
|
+
import TokenChain from './tokenchain.js';
|
|
3
|
+
import { presets, getPreset, getPresetNames } from './presets.js';
|
|
4
|
+
|
|
5
|
+
import { LRU, decodeForeignText, describeDependencyFailure, escapeXML, findTagEnd, identity, isThenable, lowercase, uniqueId } from './lib/utils.js';
|
|
6
|
+
import { collectUsage } from './lib/unused-css.js';
|
|
7
|
+
|
|
8
|
+
import {
|
|
9
|
+
RE_CR,
|
|
10
|
+
RE_LEGACY_ENTITIES,
|
|
11
|
+
RE_LINE_BREAK_CR,
|
|
12
|
+
RE_ESCAPE_LT,
|
|
13
|
+
RE_ESCAPE_LT_RAW_TEXT,
|
|
14
|
+
RE_HTML_ENCODING,
|
|
15
|
+
RE_WS_START,
|
|
16
|
+
RE_WS_END,
|
|
17
|
+
RE_WS_ONLY,
|
|
18
|
+
RE_WS_NBSP_END,
|
|
19
|
+
RE_STYLE_ELEMENT,
|
|
20
|
+
formattingElements,
|
|
21
|
+
inlineElementsToKeepWhitespaceAround,
|
|
22
|
+
inlineElementsToKeepWhitespaceWithin,
|
|
23
|
+
inlineElementsTransparent,
|
|
24
|
+
inlineElementsTransparentAfter,
|
|
25
|
+
specialContentElements,
|
|
26
|
+
rawTextElements,
|
|
27
|
+
htmlElements,
|
|
28
|
+
optionalStartTags,
|
|
29
|
+
optionalEndTags,
|
|
30
|
+
topLevelElements,
|
|
31
|
+
compactElements,
|
|
32
|
+
looseElements,
|
|
33
|
+
trailingElements,
|
|
34
|
+
pClosingParents,
|
|
35
|
+
namesForeignMixedCase,
|
|
36
|
+
integrationPointsForeign,
|
|
37
|
+
MISSING_DEPENDENCY
|
|
38
|
+
} from './lib/constants.js';
|
|
39
|
+
|
|
40
|
+
import {
|
|
41
|
+
buildInlineSets,
|
|
42
|
+
defaultInlineSets,
|
|
43
|
+
endsWithWhitespace,
|
|
44
|
+
trimWhitespace,
|
|
45
|
+
collapseWhitespaceAll,
|
|
46
|
+
collapseNoBreakSpaces,
|
|
47
|
+
collapseWhitespace,
|
|
48
|
+
collapseWhitespaceSmart,
|
|
49
|
+
canCollapseWhitespace as defaultCanCollapseWhitespace,
|
|
50
|
+
canTrimWhitespace as defaultCanTrimWhitespace,
|
|
51
|
+
isAllWhitespace
|
|
52
|
+
} from './lib/whitespace.js';
|
|
53
|
+
|
|
54
|
+
import {
|
|
55
|
+
isIgnoredComment,
|
|
56
|
+
isExecutableScript,
|
|
57
|
+
isStyleElement,
|
|
58
|
+
isMetaWithoutEffect,
|
|
59
|
+
normalizeAttr,
|
|
60
|
+
buildAttr,
|
|
61
|
+
deduplicateAttributes,
|
|
62
|
+
replaceContentTypePragma
|
|
63
|
+
} from './lib/attributes.js';
|
|
64
|
+
|
|
65
|
+
import {
|
|
66
|
+
canRemoveParentTag,
|
|
67
|
+
isStartTagMandatory,
|
|
68
|
+
canRemovePrecedingTag,
|
|
69
|
+
canRemoveElement,
|
|
70
|
+
parseRemoveEmptyElementsExcept,
|
|
71
|
+
shouldPreserveEmptyElement
|
|
72
|
+
} from './lib/elements.js';
|
|
73
|
+
|
|
74
|
+
import {
|
|
75
|
+
hasJsonScriptType,
|
|
76
|
+
extractScriptBodies,
|
|
77
|
+
processScript
|
|
78
|
+
} from './lib/content.js';
|
|
79
|
+
|
|
80
|
+
import { processOptions } from './lib/options.js';
|
|
81
|
+
import { toFragment, replaceCustomFragments } from './lib/fragments.js';
|
|
82
|
+
|
|
83
|
+
/** @import { ProcessedOptions } from './lib/options.js' */
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* @typedef {Object} HTMLAttribute
|
|
87
|
+
* Representation of an attribute from the HTML parser.
|
|
88
|
+
* Internal counterpart: `HTMLAttribute` in lib/attributes.js—keep in sync.
|
|
89
|
+
*
|
|
90
|
+
* @prop {string} name
|
|
91
|
+
* @prop {string} [value]
|
|
92
|
+
* @prop {string} [quote]
|
|
93
|
+
* @prop {string} [customAssign]
|
|
94
|
+
* @prop {string} [customOpen]
|
|
95
|
+
* @prop {string} [customClose]
|
|
96
|
+
*/
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* @typedef {Object} MinifierOptions
|
|
100
|
+
* Options that control how HTML is minified. All of these are optional
|
|
101
|
+
* and usually default to a disabled/safe value unless noted.
|
|
102
|
+
*
|
|
103
|
+
* @prop {(tag: string, attrs: HTMLAttribute[], canCollapseWhitespace: (tag: string) => boolean) => boolean} [canCollapseWhitespace]
|
|
104
|
+
* Predicate that determines whether runs of whitespace inside a given element
|
|
105
|
+
* can be collapsed (whether its content may be trimmed is up to `canTrimWhitespace`).
|
|
106
|
+
*
|
|
107
|
+
* Default: Built-in `canCollapseWhitespace` function
|
|
108
|
+
*
|
|
109
|
+
* @prop {((text: string, type?: 'inline' | 'media') => boolean) | null} [canMinifyCSS]
|
|
110
|
+
* Synchronous predicate that determines whether the built-in `minifyCSS` may
|
|
111
|
+
* process a given piece of CSS—`type` is `undefined` for `style` elements,
|
|
112
|
+
* `'inline'` for `style` attributes, and `'media'` for `media` attributes.
|
|
113
|
+
* Returning false leaves that CSS as if `minifyCSS` were off, including its URLs
|
|
114
|
+
* and unused rules (within `svg` elements, SVGO still minifies it under `minifySVG`).
|
|
115
|
+
*
|
|
116
|
+
* Default: `null` (all CSS is minified)
|
|
117
|
+
*
|
|
118
|
+
* @prop {((text: string, inline?: boolean) => boolean) | null} [canMinifyJS]
|
|
119
|
+
* Synchronous predicate that determines whether the built-in `minifyJS` may
|
|
120
|
+
* process a given piece of JavaScript—`inline` is true for event handler
|
|
121
|
+
* attributes. Returning false leaves that JavaScript unminified (`mergeScripts`
|
|
122
|
+
* still applies).
|
|
123
|
+
*
|
|
124
|
+
* Default: `null` (all JavaScript is minified)
|
|
125
|
+
*
|
|
126
|
+
* @prop {((text: string) => boolean) | null} [canMinifySVG]
|
|
127
|
+
* Synchronous predicate that determines whether the built-in `minifySVG` may
|
|
128
|
+
* pass a given outermost `svg` element, nested ones included, to SVGO. It
|
|
129
|
+
* receives the element as serialized by the HTML pass (e.g., without comments
|
|
130
|
+
* under `removeComments`); returning false leaves it as that pass wrote it.
|
|
131
|
+
*
|
|
132
|
+
* Default: `null` (all SVG is minified)
|
|
133
|
+
*
|
|
134
|
+
* @prop {(tag: string | null, attrs: HTMLAttribute[] | undefined, canTrimWhitespace: (tag: string) => boolean) => boolean} [canTrimWhitespace]
|
|
135
|
+
* Predicate that determines whether whitespace inside the element may be
|
|
136
|
+
* trimmed; where not, it stays as written, as in `pre`.
|
|
137
|
+
*
|
|
138
|
+
* Default: Built-in `canTrimWhitespace` function
|
|
139
|
+
*
|
|
140
|
+
* @prop {number} [cacheCSS]
|
|
141
|
+
* The maximum number of entries for the CSS minification cache. Higher values
|
|
142
|
+
* improve performance for inputs with repeated CSS (e.g., batch processing).
|
|
143
|
+
* - Cache is created on first `minify()` call and persists for the process lifetime
|
|
144
|
+
* - Cache size is locked after first call—subsequent calls reuse the same cache
|
|
145
|
+
* - `0` switches the cache off; negative and non-finite values fall back to the default size
|
|
146
|
+
*
|
|
147
|
+
* Default: `500`
|
|
148
|
+
*
|
|
149
|
+
* @prop {number} [cacheJS]
|
|
150
|
+
* The maximum number of entries for the JavaScript minification cache. Higher
|
|
151
|
+
* values improve performance for inputs with repeated JavaScript.
|
|
152
|
+
* - Cache is created on first `minify()` call and persists for the process lifetime
|
|
153
|
+
* - Cache size is locked after first call—subsequent calls reuse the same cache
|
|
154
|
+
* - `0` switches the cache off; negative and non-finite values fall back to the default size
|
|
155
|
+
*
|
|
156
|
+
* Default: `500`
|
|
157
|
+
*
|
|
158
|
+
* @prop {number} [cacheSVG]
|
|
159
|
+
* The maximum number of entries for the SVG minification cache. Higher
|
|
160
|
+
* values improve performance for inputs with repeated SVG content.
|
|
161
|
+
* - Cache is created on first `minify()` call and persists for the process lifetime
|
|
162
|
+
* - Cache size is locked after first call—subsequent calls reuse the same cache
|
|
163
|
+
* - `0` switches the cache off; negative and non-finite values fall back to the default size
|
|
164
|
+
*
|
|
165
|
+
* Default: `500`
|
|
166
|
+
*
|
|
167
|
+
* @prop {boolean} [caseSensitive]
|
|
168
|
+
* When true, tag and attribute names are written as in the source rather than in lowercase.
|
|
169
|
+
* Useful for framework components like `<MyButton>`. HTML names are still read in any case,
|
|
170
|
+
* so every option applies to them as without it; hooks get names as written.
|
|
171
|
+
*
|
|
172
|
+
* Default: `false`
|
|
173
|
+
*
|
|
174
|
+
* @prop {boolean} [collapseAttributeWhitespace]
|
|
175
|
+
* Collapse multiple whitespace characters within attribute values into a
|
|
176
|
+
* single space. Also trims leading and trailing whitespace from attribute
|
|
177
|
+
* values. Applied as an early normalization step before special attribute
|
|
178
|
+
* handlers (CSS minification, class sorting, etc.) run.
|
|
179
|
+
*
|
|
180
|
+
* Default: `false`
|
|
181
|
+
*
|
|
182
|
+
* @prop {boolean} [collapseBooleanAttributes]
|
|
183
|
+
* Collapse boolean attributes to their name only (for example
|
|
184
|
+
* `disabled="disabled"` → `disabled`).
|
|
185
|
+
* See also: https://perfectionkills.com/experimenting-with-html-minifier/#collapse_boolean_attributes
|
|
186
|
+
*
|
|
187
|
+
* Default: `false`
|
|
188
|
+
*
|
|
189
|
+
* @prop {boolean} [collapseEmptyAttributes]
|
|
190
|
+
* Collapse attributes with empty values to their name only (for example
|
|
191
|
+
* `alt=""` → `alt`), which the HTML parser reads the same way.
|
|
192
|
+
*
|
|
193
|
+
* Default: `false`
|
|
194
|
+
*
|
|
195
|
+
* @prop {boolean} [collapseInlineTagWhitespace]
|
|
196
|
+
* When false (default) whitespace around `inline` tags is preserved in
|
|
197
|
+
* more cases. When true, whitespace between inline tags may be collapsed,
|
|
198
|
+
* except around text-level elements (like `a`, `code`, or `em`); whitespace
|
|
199
|
+
* next to text is kept as without the option.
|
|
200
|
+
* Must also enable `collapseWhitespace` to have effect.
|
|
201
|
+
*
|
|
202
|
+
* Default: `false`
|
|
203
|
+
*
|
|
204
|
+
* @prop {boolean} [collapseNoBreakSpaces]
|
|
205
|
+
* Remove whitespace next to a no-break space (`U+00A0`), narrow no-break
|
|
206
|
+
* space (`U+202F`), or figure space (`U+2007`) in text (for example
|
|
207
|
+
* `a b` → `a b`), which changes how the text renders. Runs of
|
|
208
|
+
* no-break spaces are kept.
|
|
209
|
+
* Must also enable `collapseWhitespace` to have effect.
|
|
210
|
+
*
|
|
211
|
+
* Default: `false`
|
|
212
|
+
*
|
|
213
|
+
* @prop {boolean} [collapseWhitespace]
|
|
214
|
+
* Collapse multiple whitespace characters into one where allowed. Also
|
|
215
|
+
* controls trimming behaviour in several code paths.
|
|
216
|
+
* See also: https://perfectionkills.com/experimenting-with-html-minifier/#collapse_whitespace
|
|
217
|
+
*
|
|
218
|
+
* Default: `false`
|
|
219
|
+
*
|
|
220
|
+
* @prop {boolean} [conservativeCollapse]
|
|
221
|
+
* If true, be conservative when collapsing whitespace (preserve more
|
|
222
|
+
* whitespace in edge cases). Affects collapse algorithms.
|
|
223
|
+
* Must also enable `collapseWhitespace` to have effect.
|
|
224
|
+
*
|
|
225
|
+
* Default: `false`
|
|
226
|
+
*
|
|
227
|
+
* @prop {boolean} [continueOnMinifyError]
|
|
228
|
+
* When set to `false`, minification errors may throw.
|
|
229
|
+
* By default, the minifier will attempt to recover from minification
|
|
230
|
+
* errors, or ignore them and preserve the original content.
|
|
231
|
+
*
|
|
232
|
+
* Default: `true`
|
|
233
|
+
*
|
|
234
|
+
* @prop {boolean} [continueOnParseError]
|
|
235
|
+
* When true, the parser will attempt to continue on recoverable parse
|
|
236
|
+
* errors. Otherwise, parsing errors may throw.
|
|
237
|
+
*
|
|
238
|
+
* Default: `false`
|
|
239
|
+
*
|
|
240
|
+
* @prop {RegExp[]} [customAttrAssign]
|
|
241
|
+
* Array of regexes used to recognise custom attribute assignment
|
|
242
|
+
* operators (e.g., `'<div flex?="{{mode != cover}}"></div>'`).
|
|
243
|
+
* These are concatenated with the built-in assignment patterns.
|
|
244
|
+
*
|
|
245
|
+
* Default: `[]`
|
|
246
|
+
*
|
|
247
|
+
* @prop {RegExp} [customAttrCollapse]
|
|
248
|
+
* Regex matching attribute names whose values should be collapsed.
|
|
249
|
+
* Basically used to remove newlines and excess spaces inside attribute values,
|
|
250
|
+
* e.g., `/ng-class/`.
|
|
251
|
+
*
|
|
252
|
+
* @prop {[RegExp, RegExp][]} [customAttrSurround]
|
|
253
|
+
* Array of `[openRegExp, closeRegExp]` pairs used by the parser to
|
|
254
|
+
* detect custom attribute surround patterns (for non-standard syntaxes,
|
|
255
|
+
* e.g., `<input {{#if value}}checked="checked"{{/if}}>`).
|
|
256
|
+
*
|
|
257
|
+
* @prop {RegExp[]} [customEventAttributes]
|
|
258
|
+
* Array of regexes used to detect event handler attributes for `minifyJS`
|
|
259
|
+
* (e.g., `ng-click`). The default matches standard `on…` event attributes.
|
|
260
|
+
*
|
|
261
|
+
* Default: `[/^on[a-z]{3,}$/]`
|
|
262
|
+
*
|
|
263
|
+
* @prop {boolean} [strictCustomFragments]
|
|
264
|
+
* Rejects `ignoreCustomFragments` patterns that risk catastrophic backtracking,
|
|
265
|
+
* rather than warning about them: those that compound quantifiers or alternation
|
|
266
|
+
* (`(a+)+`, `(a|b)*`), those that repeat the same unbounded atom twice in a row
|
|
267
|
+
* (`.*.*`), and those too long or too deeply nested to analyze at all.
|
|
268
|
+
*
|
|
269
|
+
* Default: `false`
|
|
270
|
+
*
|
|
271
|
+
* @prop {boolean} [decodeEntities]
|
|
272
|
+
* When true, decodes HTML entities in text and attributes before
|
|
273
|
+
* processing, and re-encodes ambiguous ampersands when outputting.
|
|
274
|
+
*
|
|
275
|
+
* Default: `false`
|
|
276
|
+
*
|
|
277
|
+
*
|
|
278
|
+
* @prop {RegExp[]} [ignoreCustomComments]
|
|
279
|
+
* Comments matching any pattern in this array of regexes will be
|
|
280
|
+
* preserved when `removeComments` is enabled. The default preserves
|
|
281
|
+
* “bang” comments and comments starting with `#`.
|
|
282
|
+
*
|
|
283
|
+
* Default: `[/^!/, /^\s*#/]`
|
|
284
|
+
*
|
|
285
|
+
* @prop {RegExp[]} [ignoreCustomFragments]
|
|
286
|
+
* Array of regexes used to identify fragments that should be
|
|
287
|
+
* preserved (for example server templates). These fragments are temporarily
|
|
288
|
+
* replaced during minification to avoid corrupting template code.
|
|
289
|
+
* The default preserves ASP/PHP-style tags and XML declarations.
|
|
290
|
+
*
|
|
291
|
+
* Default: `[/<%[\s\S]*?%>/, /<\?(?:php[\t\n\r ]|=|\$|xml(?:-stylesheet)?(?![\w-])|\s)[\s\S]*?\?>/i]`
|
|
292
|
+
*
|
|
293
|
+
* @prop {boolean} [includeAutoGeneratedTags]
|
|
294
|
+
* If false, tags marked as auto-generated by the parser will be omitted
|
|
295
|
+
* from output. Useful to skip injected tags.
|
|
296
|
+
*
|
|
297
|
+
* Default: `false`
|
|
298
|
+
*
|
|
299
|
+
* @prop {ArrayLike<string>} [inlineCustomElements]
|
|
300
|
+
* Collection of custom element tag names that should be treated as inline
|
|
301
|
+
* elements for white-space handling, alongside the built-in inline elements.
|
|
302
|
+
*
|
|
303
|
+
* Default: `[]`
|
|
304
|
+
*
|
|
305
|
+
* @prop {boolean} [keepClosingSlash]
|
|
306
|
+
* Keep the trailing slash on void elements and other start tags that carry one, and read it
|
|
307
|
+
* as closing the element. The default drops the slash and ignores it, as HTML does; in SVG
|
|
308
|
+
* and MathML a slash closes the element either way.
|
|
309
|
+
*
|
|
310
|
+
* Default: `false`
|
|
311
|
+
*
|
|
312
|
+
* @prop {(message: unknown) => void} [log]
|
|
313
|
+
* Logging function used by the minifier for warnings/errors/info.
|
|
314
|
+
* You can directly provide `console.log`, but `message` may also be an `Error`
|
|
315
|
+
* object or other non-string value.
|
|
316
|
+
*
|
|
317
|
+
* Default: `() => {}` (no-op function)
|
|
318
|
+
*
|
|
319
|
+
* @prop {number} [maxInputLength]
|
|
320
|
+
* The maximum allowed input length. Used as a guard against ReDoS via
|
|
321
|
+
* pathological inputs. If the input exceeds this length an error is
|
|
322
|
+
* thrown.
|
|
323
|
+
*
|
|
324
|
+
* Default: No limit
|
|
325
|
+
*
|
|
326
|
+
* @prop {number} [maxLineLength]
|
|
327
|
+
* Maximum line length for the output. When set the minifier will wrap
|
|
328
|
+
* output to the given number of characters where possible, splitting
|
|
329
|
+
* lines only in tags—between attributes or before a tag’s `>`.
|
|
330
|
+
*
|
|
331
|
+
* Default: No limit
|
|
332
|
+
*
|
|
333
|
+
* @prop {boolean} [mergeScripts]
|
|
334
|
+
* When true, consecutive inline `<script>` elements are merged into one.
|
|
335
|
+
* Only merges compatible scripts (same `type`, matching `async`/`defer`/
|
|
336
|
+
* `nomodule`/`nonce` attributes). Does not merge external scripts (with `src`).
|
|
337
|
+
*
|
|
338
|
+
* Default: `false`
|
|
339
|
+
*
|
|
340
|
+
* @prop {boolean | Partial<import("lightningcss").TransformOptions<import("lightningcss").CustomAtRules>> | ((text: string, type?: string) => Promise<string> | string)} [minifyCSS]
|
|
341
|
+
* When true, enables CSS minification for inline `<style>` tags or
|
|
342
|
+
* `style` attributes. If an object is provided, it is passed to
|
|
343
|
+
* [Lightning CSS](https://www.npmjs.com/package/lightningcss)
|
|
344
|
+
* as transform options. If a function is provided, it will be used to perform
|
|
345
|
+
* custom CSS minification. If disabled, CSS is not minified.
|
|
346
|
+
*
|
|
347
|
+
* Default: `false`
|
|
348
|
+
*
|
|
349
|
+
* @prop {boolean | import("terser").MinifyOptions | {engine?: 'terser' | 'swc', [key: string]: any} | ((text: string, inline?: boolean) => Promise<string> | string)} [minifyJS]
|
|
350
|
+
* When true, enables JS minification for `<script>` contents and
|
|
351
|
+
* event handler attributes. If an object is provided, it can include:
|
|
352
|
+
* - `engine`: The minifier to use (`terser` or `swc`). Default: `terser`.
|
|
353
|
+
* - Engine-specific options (e.g., Terser options if `engine: 'terser'`,
|
|
354
|
+
* SWC options if `engine: 'swc'`).
|
|
355
|
+
* If a function is provided, it will be used to perform
|
|
356
|
+
* custom JS minification. If disabled, JS is not minified.
|
|
357
|
+
*
|
|
358
|
+
* Default: `false`
|
|
359
|
+
*
|
|
360
|
+
* @prop {boolean} [minifyJSON]
|
|
361
|
+
* When true, removes whitespace from the content of JSON `<script>` types
|
|
362
|
+
* (e.g., `application/ld+json`, `importmap`, `speculationrules`), leaving
|
|
363
|
+
* values as written. Malformed JSON is left as is.
|
|
364
|
+
*
|
|
365
|
+
* Default: `false`
|
|
366
|
+
*
|
|
367
|
+
* @prop {boolean | string | {site?: string} | ((text: string) => Promise<string> | string)} [minifyURLs]
|
|
368
|
+
* When true, enables URL rewriting/minification. If an object is provided,
|
|
369
|
+
* the `site` property sets the base URL for computing relative paths.
|
|
370
|
+
* If a string is provided, it is treated as an `{ site: string }` options
|
|
371
|
+
* object. If a function is provided, it will be used to perform custom URL
|
|
372
|
+
* minification. If disabled, URLs are not minified.
|
|
373
|
+
*
|
|
374
|
+
* Default: `false`
|
|
375
|
+
*
|
|
376
|
+
* @prop {boolean | Object} [minifySVG]
|
|
377
|
+
* When true, enables SVG minification using [SVGO](https://github.com/svg/svgo).
|
|
378
|
+
* Complete SVG subtrees are extracted and optimized as a block, with the SVGO
|
|
379
|
+
* plugins that assume a standalone SVG file turned off.
|
|
380
|
+
* If an object is provided, it is passed to SVGO as configuration options; unless
|
|
381
|
+
* it sets `plugins`, it keeps that plugin set.
|
|
382
|
+
* If disabled, SVG content is minified using standard HTML rules only.
|
|
383
|
+
*
|
|
384
|
+
* Default: `false`
|
|
385
|
+
*
|
|
386
|
+
* @prop {boolean} [noNewlinesBeforeTagClose]
|
|
387
|
+
* When wrapping lines, never split a line in an end tag (e.g., keeps
|
|
388
|
+
* `</a>` from becoming `</a` and `>` on two lines).
|
|
389
|
+
*
|
|
390
|
+
* Default: `false`
|
|
391
|
+
*
|
|
392
|
+
* @prop {boolean} [partialMarkup]
|
|
393
|
+
* When true, treat input as a partial HTML fragment rather than a complete
|
|
394
|
+
* document. This preserves stray end tags (closing tags without corresponding
|
|
395
|
+
* opening tags) and prevents auto-closing of unclosed tags at the end of input.
|
|
396
|
+
* Useful for minifying template fragments, SSI includes, or other partial HTML
|
|
397
|
+
* that will be combined with other fragments.
|
|
398
|
+
*
|
|
399
|
+
* Default: `false`
|
|
400
|
+
*
|
|
401
|
+
* @prop {boolean} [preserveLineBreaks]
|
|
402
|
+
* Preserve a single line break at the start/end of text nodes when
|
|
403
|
+
* collapsing/trimming whitespace.
|
|
404
|
+
* Must also enable `collapseWhitespace` to have effect.
|
|
405
|
+
*
|
|
406
|
+
* Default: `false`
|
|
407
|
+
*
|
|
408
|
+
* @prop {string} [preset]
|
|
409
|
+
* Name of a preset configuration to use as a base (`conservative` or
|
|
410
|
+
* `comprehensive`). Explicitly provided options take precedence over
|
|
411
|
+
* preset values. Unknown preset names are ignored (with a warning).
|
|
412
|
+
*
|
|
413
|
+
* Default: No preset
|
|
414
|
+
*
|
|
415
|
+
* @prop {boolean} [preventAttributesEscaping]
|
|
416
|
+
* When true, attribute values will not be HTML-escaped (dangerous for
|
|
417
|
+
* untrusted input). By default, attributes are escaped.
|
|
418
|
+
*
|
|
419
|
+
* Default: `false`
|
|
420
|
+
*
|
|
421
|
+
* @prop {string[]} [processScripts]
|
|
422
|
+
* Array of `type` attribute values for `<script>` elements whose contents
|
|
423
|
+
* should be processed as HTML
|
|
424
|
+
* (e.g., `text/ng-template`, `text/x-handlebars-template`, etc.).
|
|
425
|
+
* When present, the contents of matching script tags are recursively minified,
|
|
426
|
+
* like normal HTML content.
|
|
427
|
+
*
|
|
428
|
+
* Default: `[]`
|
|
429
|
+
*
|
|
430
|
+
* @prop {"\"" | "'"} [quoteCharacter]
|
|
431
|
+
* Preferred quote character for attribute values. If unspecified the
|
|
432
|
+
* minifier picks the safest quote based on the attribute value.
|
|
433
|
+
*
|
|
434
|
+
* Default: Auto-detected
|
|
435
|
+
*
|
|
436
|
+
* @prop {boolean} [removeAttributeQuotes]
|
|
437
|
+
* Remove quotes around attribute values where it is safe to do so.
|
|
438
|
+
* See also: https://perfectionkills.com/experimenting-with-html-minifier/#remove_attribute_quotes
|
|
439
|
+
*
|
|
440
|
+
* Default: `false`
|
|
441
|
+
*
|
|
442
|
+
* @prop {boolean} [removeComments]
|
|
443
|
+
* Remove HTML comments. Comments that match `ignoreCustomComments` will
|
|
444
|
+
* still be preserved.
|
|
445
|
+
* See also: https://perfectionkills.com/experimenting-with-html-minifier/#remove_comments
|
|
446
|
+
*
|
|
447
|
+
* Default: `false`
|
|
448
|
+
*
|
|
449
|
+
* @prop {boolean} [removeDefaultTypeAttributes]
|
|
450
|
+
* Remove default `type` attributes from `<style>`/`<link>` (e.g., `type="text/css"`)
|
|
451
|
+
* and `<script>` (e.g., `type="text/javascript"`) elements.
|
|
452
|
+
*
|
|
453
|
+
* Default: `false`
|
|
454
|
+
*
|
|
455
|
+
* @prop {boolean | ((attrName: string, tag: string) => boolean)} [removeEmptyAttributes]
|
|
456
|
+
* If true, removes attributes whose values are empty (some attributes
|
|
457
|
+
* are excluded by name). Can also be a function to customise which empty
|
|
458
|
+
* attributes are removed.
|
|
459
|
+
* See also: https://perfectionkills.com/experimenting-with-html-minifier/#remove_empty_or_blank_attributes
|
|
460
|
+
*
|
|
461
|
+
* Default: `false`
|
|
462
|
+
*
|
|
463
|
+
* @prop {boolean} [removeEmptyElements]
|
|
464
|
+
* Remove elements that are empty and safe to remove (for example
|
|
465
|
+
* `<script />` without `src`), including elements left empty that way.
|
|
466
|
+
* See also: https://perfectionkills.com/experimenting-with-html-minifier/#remove_empty_elements
|
|
467
|
+
*
|
|
468
|
+
* Default: `false`
|
|
469
|
+
*
|
|
470
|
+
* @prop {string[]} [removeEmptyElementsExcept]
|
|
471
|
+
* Specifies empty elements to preserve when `removeEmptyElements` is enabled.
|
|
472
|
+
* Has no effect unless `removeEmptyElements: true`.
|
|
473
|
+
*
|
|
474
|
+
* Accepts tag names or HTML-like element specifications:
|
|
475
|
+
*
|
|
476
|
+
* * Tag name only: `["td", "span"]`—preserves all empty elements of these types
|
|
477
|
+
* * With valued attributes: `["<i class='test'>"]`—preserves only when attribute values match
|
|
478
|
+
* * With boolean attributes: `["<input disabled>"]`—preserves only when the
|
|
479
|
+
* boolean attribute is present
|
|
480
|
+
* * Mixed: `["<button type='button' disabled>"]`—all specified attributes must match
|
|
481
|
+
*
|
|
482
|
+
* Attribute matching:
|
|
483
|
+
*
|
|
484
|
+
* * All specified attributes must be present and match (valued attributes must have exact values)
|
|
485
|
+
* * Additional attributes on the element are allowed
|
|
486
|
+
* * Element and attribute names match in any case
|
|
487
|
+
* * Supports double quotes, single quotes, and unquoted attribute values in specifications
|
|
488
|
+
*
|
|
489
|
+
* Limitations:
|
|
490
|
+
*
|
|
491
|
+
* * Self-closing syntax (e.g., `["<span/>"]`) is not supported; use `["span"]` instead
|
|
492
|
+
* * Definitions containing `>` within quoted attribute values (e.g.,
|
|
493
|
+
* `["<span title='a>b'>"]`) are not supported
|
|
494
|
+
*
|
|
495
|
+
* Default: `[]`
|
|
496
|
+
*
|
|
497
|
+
* @prop {boolean} [removeOptionalTags]
|
|
498
|
+
* Drop optional start/end tags where the HTML specification permits it
|
|
499
|
+
* (for example `</li>`, optional `<html>` etc.).
|
|
500
|
+
* See also: https://perfectionkills.com/experimenting-with-html-minifier/#remove_optional_tags
|
|
501
|
+
*
|
|
502
|
+
* Default: `false`
|
|
503
|
+
*
|
|
504
|
+
* @prop {boolean} [removeRedundantAttributes]
|
|
505
|
+
* Remove attributes that are redundant because they match the element’s
|
|
506
|
+
* default values (for example `<button type="submit">`) or have no effect
|
|
507
|
+
* (for example `xml:lang`), along with `meta` elements and `rel` keywords
|
|
508
|
+
* that have no effect.
|
|
509
|
+
* See also: https://perfectionkills.com/experimenting-with-html-minifier/#remove_redundant_attributes
|
|
510
|
+
*
|
|
511
|
+
* Default: `false`
|
|
512
|
+
*
|
|
513
|
+
* @prop {boolean} [removeTagWhitespace]
|
|
514
|
+
* **Note that this will result in invalid HTML!**
|
|
515
|
+
*
|
|
516
|
+
* When true, extra whitespace between tag name and attributes (or before
|
|
517
|
+
* the closing bracket) will be removed where possible. Affects output spacing
|
|
518
|
+
* such as the space used in the short doctype representation.
|
|
519
|
+
*
|
|
520
|
+
* Default: `false`
|
|
521
|
+
*
|
|
522
|
+
* @prop {boolean | {safelist?: Array<string | RegExp>, scripts?: boolean}} [removeUnusedCSS]
|
|
523
|
+
* Remove selectors from `style` elements that name an element, class, or ID the
|
|
524
|
+
* document doesn’t reference, as well as rules that are left without selectors.
|
|
525
|
+
* Requires `minifyCSS` (removal runs through Lightning CSS) and has no effect on
|
|
526
|
+
* `style` or `media` attributes.
|
|
527
|
+
*
|
|
528
|
+
* Element, class, and ID names are collected from the markup and—unless `scripts`
|
|
529
|
+
* is set to `false`—from inline `script` elements; class names and IDs also from
|
|
530
|
+
* `data-*` attributes. Names that only ever appear in external scripts cannot
|
|
531
|
+
* be detected; list those under `safelist` (strings or regular expressions) to
|
|
532
|
+
* keep them.
|
|
533
|
+
*
|
|
534
|
+
* Default: `false`
|
|
535
|
+
*
|
|
536
|
+
* @prop {boolean} [selfCloseForeignElements]
|
|
537
|
+
* When true, writes empty SVG and MathML elements as self-closing tags (for
|
|
538
|
+
* example `<path d="…"/>` for `<path d="…"></path>`), which HTML reads as the
|
|
539
|
+
* same element in foreign content; HTML elements, including those in
|
|
540
|
+
* `foreignObject` and `annotation-xml`, keep their end tags.
|
|
541
|
+
*
|
|
542
|
+
* Default: `false`
|
|
543
|
+
*
|
|
544
|
+
* @prop {boolean | ((tag: string, attrs: HTMLAttribute[]) => void)} [sortAttributes]
|
|
545
|
+
* When true, enables sorting of attributes. If a function is provided it
|
|
546
|
+
* will be used as a custom attribute sorter, which should mutate `attrs`
|
|
547
|
+
* in-place to the desired order. If disabled, the minifier will attempt to
|
|
548
|
+
* preserve the order from the input.
|
|
549
|
+
*
|
|
550
|
+
* Default: `false`
|
|
551
|
+
*
|
|
552
|
+
* @prop {boolean | ((value: string) => string)} [sortClassNames]
|
|
553
|
+
* When true, enables sorting of class names inside `class` attributes.
|
|
554
|
+
* If a function is provided, it will be used to transform/sort the class
|
|
555
|
+
* name string. If disabled, the minifier will attempt to preserve the
|
|
556
|
+
* class-name order from the input.
|
|
557
|
+
*
|
|
558
|
+
* Default: `false`
|
|
559
|
+
*
|
|
560
|
+
* @prop {boolean} [trimCustomFragments]
|
|
561
|
+
* When true, whitespace around ignored custom fragments is removed rather
|
|
562
|
+
* than collapsed where the minifier removes whitespace anyway (e.g., between
|
|
563
|
+
* block-level elements). Whitespace in tags, attribute values, CSS, and
|
|
564
|
+
* JavaScript is kept.
|
|
565
|
+
*
|
|
566
|
+
* Default: `false`
|
|
567
|
+
*
|
|
568
|
+
* @prop {boolean} [useShortDoctype]
|
|
569
|
+
* Replace the HTML doctype with the short `<!doctype html>` form.
|
|
570
|
+
* See also: https://perfectionkills.com/experimenting-with-html-minifier/#use_short_doctype
|
|
571
|
+
*
|
|
572
|
+
* Default: `false`
|
|
573
|
+
*/
|
|
574
|
+
|
|
575
|
+
// Lazy-load heavy dependencies only when needed
|
|
576
|
+
|
|
577
|
+
/** @type {Promise<Function> | undefined} */
|
|
578
|
+
let lightningCSSPromise;
|
|
579
|
+
async function getLightningCSS() {
|
|
580
|
+
if (!lightningCSSPromise) {
|
|
581
|
+
lightningCSSPromise = import('lightningcss').then(m => m.transform);
|
|
582
|
+
}
|
|
583
|
+
return lightningCSSPromise;
|
|
13
584
|
}
|
|
14
585
|
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
function collapseWhitespace(str, options, trimLeft, trimRight, collapseAll) {
|
|
23
|
-
var lineBreakBefore = '', lineBreakAfter = '';
|
|
24
|
-
|
|
25
|
-
if (options.preserveLineBreaks) {
|
|
26
|
-
str = str.replace(/^[ \n\r\t\f]*?[\n\r][ \n\r\t\f]*/, function() {
|
|
27
|
-
lineBreakBefore = '\n';
|
|
28
|
-
return '';
|
|
29
|
-
}).replace(/[ \n\r\t\f]*?[\n\r][ \n\r\t\f]*$/, function() {
|
|
30
|
-
lineBreakAfter = '\n';
|
|
31
|
-
return '';
|
|
32
|
-
});
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
if (trimLeft) {
|
|
36
|
-
// Non-breaking space is specifically handled inside the replacer function here:
|
|
37
|
-
str = str.replace(/^[ \n\r\t\f\xA0]+/, function(spaces) {
|
|
38
|
-
var conservative = !lineBreakBefore && options.conservativeCollapse;
|
|
39
|
-
if (conservative && spaces === '\t') {
|
|
40
|
-
return '\t';
|
|
41
|
-
}
|
|
42
|
-
return spaces.replace(/^[^\xA0]+/, '').replace(/(\xA0+)[^\xA0]+/g, '$1 ') || (conservative ? ' ' : '');
|
|
43
|
-
});
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
if (trimRight) {
|
|
47
|
-
// Non-breaking space is specifically handled inside the replacer function here:
|
|
48
|
-
str = str.replace(/[ \n\r\t\f\xA0]+$/, function(spaces) {
|
|
49
|
-
var conservative = !lineBreakAfter && options.conservativeCollapse;
|
|
50
|
-
if (conservative && spaces === '\t') {
|
|
51
|
-
return '\t';
|
|
52
|
-
}
|
|
53
|
-
return spaces.replace(/[^\xA0]+(\xA0+)/g, ' $1').replace(/[^\xA0]+$/, '') || (conservative ? ' ' : '');
|
|
54
|
-
});
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
if (collapseAll) {
|
|
58
|
-
// strip non space whitespace then compress spaces to one
|
|
59
|
-
str = collapseWhitespaceAll(str);
|
|
586
|
+
/** @type {Promise<Function> | undefined} */
|
|
587
|
+
let terserPromise;
|
|
588
|
+
async function getTerser() {
|
|
589
|
+
if (!terserPromise) {
|
|
590
|
+
terserPromise = import('terser').then(m => m.minify);
|
|
60
591
|
}
|
|
61
|
-
|
|
62
|
-
return lineBreakBefore + str + lineBreakAfter;
|
|
592
|
+
return terserPromise;
|
|
63
593
|
}
|
|
64
594
|
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
if (trimLeft && !options.collapseInlineTagWhitespace) {
|
|
76
|
-
trimLeft = prevTag.charAt(0) === '/' ? !inlineTags(prevTag.slice(1)) : !inlineTextTags(prevTag);
|
|
77
|
-
}
|
|
78
|
-
var trimRight = nextTag && !selfClosingInlineTags(nextTag);
|
|
79
|
-
if (trimRight && !options.collapseInlineTagWhitespace) {
|
|
80
|
-
trimRight = nextTag.charAt(0) === '/' ? !inlineTextTags(nextTag.slice(1)) : !inlineTags(nextTag);
|
|
81
|
-
}
|
|
82
|
-
return collapseWhitespace(str, options, trimLeft, trimRight, prevTag && nextTag);
|
|
595
|
+
/**
|
|
596
|
+
* @param {string} label - Minifier name as it should read in the message
|
|
597
|
+
* @param {string} specifier - Package that failed to load
|
|
598
|
+
* @param {unknown} cause - Error the import rejected with
|
|
599
|
+
* @returns {Error} Error marked as an unavailable dependency
|
|
600
|
+
*/
|
|
601
|
+
function unavailableDependency(label, specifier, cause) {
|
|
602
|
+
const err = new Error(describeDependencyFailure(label, specifier, cause), { cause });
|
|
603
|
+
/** @type {any} */ (err).code = MISSING_DEPENDENCY;
|
|
604
|
+
return err;
|
|
83
605
|
}
|
|
84
606
|
|
|
85
|
-
|
|
86
|
-
|
|
607
|
+
/** @type {Promise<any> | undefined} */
|
|
608
|
+
let swcPromise;
|
|
609
|
+
async function getSwc() {
|
|
610
|
+
if (!swcPromise) {
|
|
611
|
+
swcPromise = import('@swc/core')
|
|
612
|
+
.then(m => m.default || m)
|
|
613
|
+
.catch(err => {
|
|
614
|
+
throw unavailableDependency('swc', '@swc/core', err);
|
|
615
|
+
});
|
|
616
|
+
}
|
|
617
|
+
return swcPromise;
|
|
87
618
|
}
|
|
88
619
|
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
620
|
+
/** @type {Promise<Function> | undefined} */
|
|
621
|
+
let svgoPromise;
|
|
622
|
+
async function getSvgo() {
|
|
623
|
+
if (!svgoPromise) {
|
|
624
|
+
svgoPromise = import('svgo').then(m => m.optimize);
|
|
94
625
|
}
|
|
95
|
-
return
|
|
626
|
+
return svgoPromise;
|
|
96
627
|
}
|
|
97
628
|
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
}
|
|
106
|
-
return false;
|
|
629
|
+
/** @type {Promise<Function> | undefined} */
|
|
630
|
+
let decodeHTMLPromise;
|
|
631
|
+
/** @type {((text: string) => string) | undefined} The resolved `decodeHTML`, once the lazy import above has settled */
|
|
632
|
+
let decodeHTMLFn;
|
|
633
|
+
async function getDecodeHTML() {
|
|
634
|
+
if (!decodeHTMLPromise) {
|
|
635
|
+
decodeHTMLPromise = import('entities').then(m => (decodeHTMLFn = m.decodeHTML));
|
|
107
636
|
}
|
|
108
|
-
return
|
|
637
|
+
return decodeHTMLPromise;
|
|
109
638
|
}
|
|
110
639
|
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
640
|
+
// Minification caches (initialized on first use with configurable sizes)
|
|
641
|
+
/** @type {LRU | null} */
|
|
642
|
+
let cssMinifyCache = null;
|
|
643
|
+
/** @type {LRU | null} */
|
|
644
|
+
let jsMinifyCache = null;
|
|
645
|
+
/** @type {LRU | null} */
|
|
646
|
+
let svgMinifyCache = null;
|
|
647
|
+
|
|
648
|
+
/**
|
|
649
|
+
* @typedef {Object} CacheStats
|
|
650
|
+
* Hit/miss statistics and current occupancy for a single minification cache.
|
|
651
|
+
*
|
|
652
|
+
* @prop {number} gets Number of lookups attempted against the cache.
|
|
653
|
+
* @prop {number} hits Number of lookups that found a cached entry.
|
|
654
|
+
* @prop {number} size Number of entries currently held in the cache.
|
|
655
|
+
* @prop {number} limit Maximum number of entries the cache can hold.
|
|
656
|
+
*/
|
|
657
|
+
|
|
658
|
+
/**
|
|
659
|
+
* @param {LRU | null} cache
|
|
660
|
+
* @returns {CacheStats}
|
|
661
|
+
*/
|
|
662
|
+
const cacheStatsOrEmpty = (cache) => cache ? cache.stats() : { gets: 0, hits: 0, size: 0, limit: 0 };
|
|
663
|
+
|
|
664
|
+
// Patterns for script merging
|
|
665
|
+
const RE_SCRIPT_ATTRS = /([^\s=]+)(?:=(?:"([^"]*)"|'([^']*)'|([^\s>]+)))?/g;
|
|
666
|
+
const RE_SCRIPT_OPEN = /<script(?=[\s>])/gi; // Finds tag start; use `findTagEnd()` for the actual closing `>`
|
|
667
|
+
const RE_SCRIPT_CLOSE = /<\/script\s*>/gi;
|
|
668
|
+
const RE_MATH_TAG = /<(\/?)math(?=[\s/>])/gi;
|
|
669
|
+
const SCRIPT_BOOL_ATTRS = new Set(['async', 'defer', 'nomodule']);
|
|
670
|
+
const DEFAULT_JS_TYPES = new Set(['', 'text/javascript', 'application/javascript']);
|
|
671
|
+
|
|
672
|
+
// Patterns for buffer scanning
|
|
673
|
+
const RE_START_TAG = /^<[^/!]/;
|
|
674
|
+
const RE_UNQUOTED_VALUE_END = /=[^\s"'=<>`]+$/;
|
|
675
|
+
const RE_START_TAG_NAME = /^<([^\s/>]+)/;
|
|
676
|
+
const RE_LEADING_WHITESPACE = /^[ \t\n\r\f]/;
|
|
677
|
+
const RE_ASCII_WHITESPACE = /[ \t\n\r\f]/;
|
|
678
|
+
const RE_END_TAG = /^<\//;
|
|
679
|
+
const RE_WHITESPACE_ONLY = /^\s+$/;
|
|
680
|
+
const RE_TABS = /\t/g;
|
|
681
|
+
const RE_TRAILING_WHITESPACE = /\s$/;
|
|
682
|
+
const RE_LINE_BREAK_BLANKS_END = /(?:\r\n?|\n)[ \t\f]*$/;
|
|
683
|
+
const RE_BLANKS_LINE_BREAK_START = /^[ \t\f]*(?:\r\n?|\n)/;
|
|
684
|
+
const RE_BLANKS_START = /^[ \t\f]+/;
|
|
685
|
+
const RE_BLANKS_ONLY = /^[ \t\f]+$/;
|
|
686
|
+
|
|
687
|
+
// Patterns for `htmlmin:ignore` block content analysis
|
|
688
|
+
const RE_HTML_COMMENT_START = /^\s*<!--/;
|
|
689
|
+
const RE_CLOSING_TAG_START = /^\s*<\/([a-zA-Z][\w:-]*)/;
|
|
690
|
+
const RE_FIRST_HTML_TAG = /^\s*<([a-zA-Z][\w:-]*)/;
|
|
691
|
+
const RE_LAST_HTML_TAG = /[\s\S]*<(\/?[a-zA-Z][\w:-]*)/;
|
|
692
|
+
|
|
693
|
+
|
|
694
|
+
// Probe for SVG/MathML elements—when the input has none (the common case),
|
|
695
|
+
// the per-tag foreign-content machinery can be skipped entirely
|
|
696
|
+
const RE_FOREIGN_ELEMENT_PROBE = /<(?:svg|math)[\s/>]/i;
|
|
697
|
+
|
|
698
|
+
// A name in foreign content as the HTML parser reads it
|
|
699
|
+
/** @param {string} name */
|
|
700
|
+
function nameForeign(name) {
|
|
701
|
+
const nameLower = name.toLowerCase();
|
|
702
|
+
return namesForeignMixedCase.get(nameLower) ?? nameLower;
|
|
114
703
|
}
|
|
115
704
|
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
705
|
+
/**
|
|
706
|
+
* Merge consecutive inline script tags into one.
|
|
707
|
+
* Only merges scripts that are compatible:
|
|
708
|
+
* - Both inline (no `src` attribute)
|
|
709
|
+
* - Same `type` (or both default JavaScript)
|
|
710
|
+
* - No conflicting attributes (`async`, `defer`, `nomodule`, different `nonce`)
|
|
711
|
+
*
|
|
712
|
+
* Uses a scanner rather than a regex to locate script boundaries, so literal
|
|
713
|
+
* `</script>` strings inside script content are handled correctly per the HTML
|
|
714
|
+
* spec (raw text ends at the first `</script>`).
|
|
715
|
+
*
|
|
716
|
+
* Single forward pass: Each script absorbs all directly following compatible
|
|
717
|
+
* scripts, and the output is assembled from segments—this avoids rebuilding
|
|
718
|
+
* the full string and restarting the scan after every merge (O(n²) on inputs
|
|
719
|
+
* with many merges). Merges can’t cascade backwards: compatibility depends
|
|
720
|
+
* only on the attributes of the first script in a chain, which merging
|
|
721
|
+
* preserves, so one pass finds every merge the iterated rescan found.
|
|
722
|
+
*
|
|
723
|
+
* @param {string} html
|
|
724
|
+
* @param {boolean} [hasMathML] - Whether the HTML holds MathML, whose `script` elements stay apart
|
|
725
|
+
* @returns {string} HTML with consecutive scripts merged
|
|
726
|
+
*/
|
|
727
|
+
function mergeConsecutiveScripts(html, hasMathML = false) {
|
|
728
|
+
const parseAttrs = (/** @type {string} */ attrStr) => {
|
|
729
|
+
/** @type {Record<string, string>} */
|
|
730
|
+
const attrs = {};
|
|
731
|
+
RE_SCRIPT_ATTRS.lastIndex = 0;
|
|
732
|
+
let m;
|
|
733
|
+
while ((m = RE_SCRIPT_ATTRS.exec(attrStr)) !== null) {
|
|
734
|
+
const name = (m[1] ?? '').toLowerCase();
|
|
735
|
+
const value = m[2] ?? m[3] ?? m[4] ?? '';
|
|
736
|
+
attrs[name] = value;
|
|
120
737
|
}
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
}
|
|
124
|
-
|
|
125
|
-
function isAttributeRedundant(tag, attrName, attrValue, attrs) {
|
|
126
|
-
attrValue = attrValue ? trimWhitespace(attrValue.toLowerCase()) : '';
|
|
127
|
-
|
|
128
|
-
return (
|
|
129
|
-
tag === 'script' &&
|
|
130
|
-
attrName === 'language' &&
|
|
131
|
-
attrValue === 'javascript' ||
|
|
738
|
+
return attrs;
|
|
739
|
+
};
|
|
132
740
|
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
741
|
+
// In MathML, a `script` neither runs nor is hidden, so its text is shown as it is;
|
|
742
|
+
// scripts are visited in order, so the MathML tags before each are counted once
|
|
743
|
+
let mathDepth = 0;
|
|
744
|
+
let mathPos = 0;
|
|
745
|
+
const insideMath = (/** @type {number} */ index) => {
|
|
746
|
+
if (!hasMathML) return false;
|
|
747
|
+
RE_MATH_TAG.lastIndex = mathPos;
|
|
748
|
+
let tag;
|
|
749
|
+
while ((tag = RE_MATH_TAG.exec(html)) !== null && tag.index < index) {
|
|
750
|
+
mathDepth = tag[1] ? Math.max(0, mathDepth - 1) : mathDepth + 1;
|
|
751
|
+
mathPos = RE_MATH_TAG.lastIndex;
|
|
752
|
+
}
|
|
753
|
+
return mathDepth > 0;
|
|
754
|
+
};
|
|
136
755
|
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
756
|
+
/** @type {string[]} */
|
|
757
|
+
const segments = [];
|
|
758
|
+
let consumedPos = 0; // Start of the input region not yet copied to `segments`
|
|
759
|
+
|
|
760
|
+
RE_SCRIPT_OPEN.lastIndex = 0;
|
|
761
|
+
let m1;
|
|
762
|
+
|
|
763
|
+
while ((m1 = RE_SCRIPT_OPEN.exec(html)) !== null) {
|
|
764
|
+
// `findTagEnd` skips quoted attribute values to find the real closing `>`
|
|
765
|
+
const tagEnd1 = findTagEnd(html, m1.index + 7);
|
|
766
|
+
if (tagEnd1 === -1) break;
|
|
767
|
+
|
|
768
|
+
const attrs1Str = html.slice(m1.index + 7, tagEnd1);
|
|
769
|
+
|
|
770
|
+
// Find end of this script’s content (first `</script>`—per HTML spec, raw text ends here)
|
|
771
|
+
RE_SCRIPT_CLOSE.lastIndex = tagEnd1 + 1;
|
|
772
|
+
const close1 = RE_SCRIPT_CLOSE.exec(html);
|
|
773
|
+
if (!close1) break;
|
|
774
|
+
|
|
775
|
+
let mergedContent = html.slice(tagEnd1 + 1, close1.index);
|
|
776
|
+
let afterClose = close1.index + close1[0].length;
|
|
777
|
+
|
|
778
|
+
const a1 = parseAttrs(attrs1Str);
|
|
779
|
+
// `src` (external) and non-default types (modules, JSON, etc.) must not be merged:
|
|
780
|
+
// module scripts have per-script lexical scope, and non-JS content (e.g., JSON)
|
|
781
|
+
// is not concatenable; even identical non-JS types are incompatible
|
|
782
|
+
const mergeable = !('src' in a1) && DEFAULT_JS_TYPES.has((a1.type || '').toLowerCase());
|
|
783
|
+
let mergedAny = false;
|
|
784
|
+
// Whitespace between the scripts, which may separate text around them
|
|
785
|
+
let gap = '';
|
|
786
|
+
|
|
787
|
+
// Absorb all directly following compatible scripts
|
|
788
|
+
while (mergeable) {
|
|
789
|
+
let i = afterClose;
|
|
790
|
+
while (i < html.length && (html[i] === ' ' || html[i] === '\t' || html[i] === '\n' || html[i] === '\r' || html[i] === '\f')) i++;
|
|
791
|
+
const gapNext = html.slice(afterClose, i);
|
|
792
|
+
if (html.slice(i, i + 7).toLowerCase() !== '<script' || (html.charAt(i + 7) !== '>' && !/\s/.test(html.charAt(i + 7)))) break;
|
|
793
|
+
|
|
794
|
+
const tagEnd2 = findTagEnd(html, i + 7);
|
|
795
|
+
if (tagEnd2 === -1) break;
|
|
796
|
+
|
|
797
|
+
const attrs2Str = html.slice(i + 7, tagEnd2);
|
|
798
|
+
|
|
799
|
+
RE_SCRIPT_CLOSE.lastIndex = tagEnd2 + 1;
|
|
800
|
+
const close2 = RE_SCRIPT_CLOSE.exec(html);
|
|
801
|
+
if (!close2) break;
|
|
802
|
+
|
|
803
|
+
const a2 = parseAttrs(attrs2Str);
|
|
804
|
+
|
|
805
|
+
// Check compatibility: No `src`, default JS type, no conflicting boolean
|
|
806
|
+
// attributes, and `nonce` must be same or both absent
|
|
807
|
+
if ('src' in a2 || !DEFAULT_JS_TYPES.has((a2.type || '').toLowerCase())) break;
|
|
808
|
+
let boolConflict = false;
|
|
809
|
+
for (const attr of SCRIPT_BOOL_ATTRS) {
|
|
810
|
+
if ((attr in a1) !== (attr in a2)) { boolConflict = true; break; }
|
|
811
|
+
}
|
|
812
|
+
if (boolConflict || a1.nonce !== a2.nonce || (!mergedAny && insideMath(m1.index))) break;
|
|
813
|
+
|
|
814
|
+
// Join with a semicolon, or with a newline where the first ends in a `//` comment
|
|
815
|
+
const content2 = html.slice(tagEnd2 + 1, close2.index);
|
|
816
|
+
const c1 = mergedContent.trim();
|
|
817
|
+
const c2 = content2.trim();
|
|
818
|
+
if (c1 && c2) {
|
|
819
|
+
const lastLine = c1.slice(c1.lastIndexOf('\n') + 1);
|
|
820
|
+
const separator = lastLine.includes('//') ? '\n' : (c1.endsWith(';') ? '' : ';');
|
|
821
|
+
mergedContent = c1 + separator + c2;
|
|
822
|
+
} else {
|
|
823
|
+
mergedContent = c1 || c2;
|
|
824
|
+
}
|
|
140
825
|
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
826
|
+
afterClose = close2.index + close2[0].length;
|
|
827
|
+
mergedAny = true;
|
|
828
|
+
gap ||= gapNext;
|
|
829
|
+
}
|
|
144
830
|
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
831
|
+
if (mergedAny) {
|
|
832
|
+
// Whitespace goes after the merged script, unless nothing or
|
|
833
|
+
// whitespace is on either side anyway
|
|
834
|
+
if (gap && (!m1.index || afterClose === html.length || RE_ASCII_WHITESPACE.test(html.charAt(m1.index - 1) + html.charAt(afterClose)))) {
|
|
835
|
+
gap = '';
|
|
836
|
+
}
|
|
837
|
+
// Use first script’s attributes (they are compatible)
|
|
838
|
+
segments.push(html.slice(consumedPos, m1.index), `<script${attrs1Str}>${mergedContent}</script>${gap}`);
|
|
839
|
+
consumedPos = afterClose;
|
|
840
|
+
}
|
|
148
841
|
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
);
|
|
153
|
-
}
|
|
842
|
+
// Continue scanning after this script (skips `<script` occurrences inside content)
|
|
843
|
+
RE_SCRIPT_OPEN.lastIndex = afterClose;
|
|
844
|
+
}
|
|
154
845
|
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
'text/javascript',
|
|
159
|
-
'text/ecmascript',
|
|
160
|
-
'text/jscript',
|
|
161
|
-
'application/javascript',
|
|
162
|
-
'application/x-javascript',
|
|
163
|
-
'application/ecmascript'
|
|
164
|
-
]);
|
|
165
|
-
|
|
166
|
-
function isScriptTypeAttribute(attrValue) {
|
|
167
|
-
attrValue = trimWhitespace(attrValue.split(/;/, 2)[0]).toLowerCase();
|
|
168
|
-
return attrValue === '' || executableScriptsMimetypes(attrValue);
|
|
846
|
+
if (!segments.length) return html;
|
|
847
|
+
segments.push(html.slice(consumedPos));
|
|
848
|
+
return segments.join('');
|
|
169
849
|
}
|
|
170
850
|
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
851
|
+
/**
|
|
852
|
+
* @param {string} value
|
|
853
|
+
* @param {ProcessedOptions} options
|
|
854
|
+
* @param {string} uidIgnore
|
|
855
|
+
* @param {string[]} ignoredMarkupChunks
|
|
856
|
+
*/
|
|
857
|
+
async function createSortFns(value, options, uidIgnore, ignoredMarkupChunks) {
|
|
858
|
+
const attrChains = options.sortAttributes && typeof options.sortAttributes !== 'function' && Object.create(null);
|
|
859
|
+
const classChain = options.sortClassNames && typeof options.sortClassNames !== 'function' && new TokenChain();
|
|
860
|
+
const resolveName = options.name;
|
|
861
|
+
|
|
862
|
+
function attrNames(/** @type {HTMLAttribute[]} */ attrs) {
|
|
863
|
+
return attrs.map(function (attr) {
|
|
864
|
+
return resolveName(attr.name);
|
|
865
|
+
});
|
|
180
866
|
}
|
|
181
|
-
return true;
|
|
182
|
-
}
|
|
183
867
|
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
return attrValue === '' || attrValue === 'text/css';
|
|
187
|
-
}
|
|
868
|
+
const whitespaceSplitPatternScan = /[ \t\n\f\r]+/;
|
|
869
|
+
const whitespaceSplitPatternSort = /[ \n\f\r]+/;
|
|
188
870
|
|
|
189
|
-
function
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
871
|
+
async function scan(/** @type {string} */ input) {
|
|
872
|
+
/** @type {string} */
|
|
873
|
+
let currentTag;
|
|
874
|
+
/** @type {string | undefined} */
|
|
875
|
+
let currentType;
|
|
876
|
+
const parser = new HTMLParser(input, {
|
|
877
|
+
start: function (/** @type {string} */ tag, /** @type {HTMLAttribute[]} */ attrs, /** @type {boolean} */ unary) {
|
|
878
|
+
if (attrChains) {
|
|
879
|
+
// Lowercase, as the first pass writes names as in the source under `caseSensitive`
|
|
880
|
+
const tagKey = resolveName(tag);
|
|
881
|
+
if (!attrChains[tagKey]) {
|
|
882
|
+
attrChains[tagKey] = new TokenChain();
|
|
883
|
+
}
|
|
884
|
+
attrChains[tagKey].add(attrNames(attrs));
|
|
885
|
+
}
|
|
886
|
+
for (const attr of attrs) {
|
|
887
|
+
if (classChain && attr.value && resolveName(attr.name) === 'class') {
|
|
888
|
+
const classes = trimWhitespace(attr.value).split(whitespaceSplitPatternScan);
|
|
889
|
+
classChain.add(classes);
|
|
890
|
+
} else if (options.processScripts && attr.name.toLowerCase() === 'type') {
|
|
891
|
+
currentTag = tag;
|
|
892
|
+
currentType = attr.value;
|
|
893
|
+
}
|
|
894
|
+
}
|
|
895
|
+
// As on the main path: What a unary tag holds ends with the tag,
|
|
896
|
+
// so no text can belong to it
|
|
897
|
+
if (unary) {
|
|
898
|
+
currentTag = '';
|
|
899
|
+
currentType = undefined;
|
|
900
|
+
}
|
|
901
|
+
},
|
|
902
|
+
end: function () {
|
|
903
|
+
currentTag = '';
|
|
904
|
+
},
|
|
905
|
+
chars: async function (/** @type {string} */ text) {
|
|
906
|
+
// Only HTML content is scanned recursively, not JSON-LD or other non-HTML script types
|
|
907
|
+
if (options.processScripts && specialContentElements.has(currentTag) &&
|
|
908
|
+
options.processScripts.indexOf(currentType ?? '') > -1 &&
|
|
909
|
+
currentType === 'text/html') {
|
|
910
|
+
await scan(text);
|
|
911
|
+
}
|
|
912
|
+
},
|
|
913
|
+
wantsNextTag: false,
|
|
914
|
+
continueOnParseError: options.continueOnParseError,
|
|
915
|
+
selfClosingSlash: options.keepClosingSlash
|
|
916
|
+
});
|
|
917
|
+
|
|
918
|
+
try {
|
|
919
|
+
await parser.parse();
|
|
920
|
+
} catch (err) {
|
|
921
|
+
// If parsing fails during analysis pass, skip it—there’s partial frequency
|
|
922
|
+
// data from what can be parsed
|
|
923
|
+
if (!options.continueOnParseError) {
|
|
924
|
+
throw err;
|
|
925
|
+
}
|
|
197
926
|
}
|
|
198
927
|
}
|
|
199
|
-
return true;
|
|
200
|
-
}
|
|
201
928
|
|
|
202
|
-
|
|
203
|
-
|
|
929
|
+
// First-pass options: Aggressive minification is off, attribute transformations (like
|
|
930
|
+
// `removeDefaultTypeAttributes`) stay. Custom-fragment UID markers (`uidAttr`) don’t
|
|
931
|
+
// exist yet; `htmlmin:ignore` ones (`uidIgnore`) do and are expanded for analysis.
|
|
932
|
+
const firstPassOptions = Object.assign({}, options, {
|
|
933
|
+
sortAttributes: false,
|
|
934
|
+
sortClassNames: false,
|
|
935
|
+
// Disable aggressive minification that doesn’t affect attribute analysis
|
|
936
|
+
collapseWhitespace: false,
|
|
937
|
+
removeAttributeQuotes: false,
|
|
938
|
+
removeTagWhitespace: false,
|
|
939
|
+
decodeEntities: false,
|
|
940
|
+
processScripts: false,
|
|
941
|
+
// `ignoreCustomFragments` stays, and template syntax may not parse cleanly
|
|
942
|
+
continueOnParseError: true,
|
|
943
|
+
log: identity
|
|
944
|
+
});
|
|
204
945
|
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
946
|
+
// `scan()` reads `options` rather than `firstPassOptions`, so it needs the same leniency
|
|
947
|
+
const originalContinueOnParseError = options.continueOnParseError;
|
|
948
|
+
options.continueOnParseError = true;
|
|
949
|
+
|
|
950
|
+
// Pre-compile the UID replacement pattern, and read the fragments the way the
|
|
951
|
+
// main path does, so each keeps its own flags
|
|
952
|
+
const uidReplacePattern = uidIgnore && ignoredMarkupChunks
|
|
953
|
+
? new RegExp('<!--' + uidIgnore + '(\\d+)-->', 'g')
|
|
954
|
+
: null;
|
|
955
|
+
const customFragments = (options.ignoreCustomFragments || []).map(toFragment);
|
|
956
|
+
|
|
957
|
+
try {
|
|
958
|
+
// Expand UID tokens back to the original content for frequency analysis
|
|
959
|
+
let expandedValue = value;
|
|
960
|
+
if (uidReplacePattern) {
|
|
961
|
+
expandedValue = value.replace(uidReplacePattern, function (/** @type {string} */ _match, /** @type {string} */ index) {
|
|
962
|
+
return ignoredMarkupChunks[+index] || '';
|
|
963
|
+
});
|
|
964
|
+
uidReplacePattern.lastIndex = 0;
|
|
965
|
+
}
|
|
208
966
|
|
|
209
|
-
|
|
210
|
-
return (
|
|
211
|
-
/^(?:a|area|link|base)$/.test(tag) && attrName === 'href' ||
|
|
212
|
-
tag === 'img' && /^(?:src|longdesc|usemap)$/.test(attrName) ||
|
|
213
|
-
tag === 'object' && /^(?:classid|codebase|data|usemap)$/.test(attrName) ||
|
|
214
|
-
tag === 'q' && attrName === 'cite' ||
|
|
215
|
-
tag === 'blockquote' && attrName === 'cite' ||
|
|
216
|
-
(tag === 'ins' || tag === 'del') && attrName === 'cite' ||
|
|
217
|
-
tag === 'form' && attrName === 'action' ||
|
|
218
|
-
tag === 'input' && (attrName === 'src' || attrName === 'usemap') ||
|
|
219
|
-
tag === 'head' && attrName === 'profile' ||
|
|
220
|
-
tag === 'script' && (attrName === 'src' || attrName === 'for')
|
|
221
|
-
);
|
|
222
|
-
}
|
|
967
|
+
const firstPassOutput = await minifyHTML(expandedValue, firstPassOptions);
|
|
223
968
|
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
tag === 'colgroup' && attrName === 'span' ||
|
|
231
|
-
tag === 'col' && attrName === 'span' ||
|
|
232
|
-
(tag === 'th' || tag === 'td') && (attrName === 'rowspan' || attrName === 'colspan')
|
|
233
|
-
);
|
|
234
|
-
}
|
|
969
|
+
// Custom fragments are removed for frequency analysis, as HTML comments in start tags keep
|
|
970
|
+
// attributes from parsing; a space in their place preserves attribute boundaries
|
|
971
|
+
let scanValue = firstPassOutput;
|
|
972
|
+
if (customFragments.length) {
|
|
973
|
+
scanValue = replaceCustomFragments(firstPassOutput, customFragments, () => ' ');
|
|
974
|
+
}
|
|
235
975
|
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
return true;
|
|
976
|
+
await scan(scanValue);
|
|
977
|
+
} finally {
|
|
978
|
+
if (originalContinueOnParseError !== undefined) {
|
|
979
|
+
options.continueOnParseError = originalContinueOnParseError;
|
|
980
|
+
} else {
|
|
981
|
+
delete options.continueOnParseError;
|
|
243
982
|
}
|
|
244
983
|
}
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
}
|
|
984
|
+
if (attrChains) {
|
|
985
|
+
const attrSorters = Object.create(null);
|
|
986
|
+
for (const tag in attrChains) {
|
|
987
|
+
attrSorters[tag] = attrChains[tag].createSorter();
|
|
988
|
+
}
|
|
989
|
+
// Memoize sorted attribute orders—attribute sets often repeat in templates
|
|
990
|
+
const attrOrderCache = new LRU(500);
|
|
250
991
|
|
|
251
|
-
|
|
992
|
+
options.sortAttributes = function (/** @type {string} */ tag, /** @type {HTMLAttribute[]} */ attrs) {
|
|
993
|
+
if (attrs.length < 2) {
|
|
994
|
+
return;
|
|
995
|
+
}
|
|
996
|
+
// Called with the name as written, as any `sortAttributes` function is
|
|
997
|
+
const sorter = attrSorters[resolveName(tag)];
|
|
998
|
+
if (sorter) {
|
|
999
|
+
const names = attrNames(attrs);
|
|
252
1000
|
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
}
|
|
1001
|
+
// Order-independent cache key
|
|
1002
|
+
const cacheKey = tag + ':' + names.slice().sort().join(',');
|
|
1003
|
+
let sortedNames = /** @type {string[] | undefined} */ (attrOrderCache.get(cacheKey));
|
|
256
1004
|
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
}
|
|
262
|
-
else if (attrName === 'class') {
|
|
263
|
-
attrValue = trimWhitespace(attrValue);
|
|
264
|
-
if (options.sortClassName) {
|
|
265
|
-
attrValue = options.sortClassName(attrValue);
|
|
266
|
-
}
|
|
267
|
-
else {
|
|
268
|
-
attrValue = collapseWhitespaceAll(attrValue);
|
|
269
|
-
}
|
|
270
|
-
return attrValue;
|
|
271
|
-
}
|
|
272
|
-
else if (isUriTypeAttribute(attrName, tag)) {
|
|
273
|
-
attrValue = trimWhitespace(attrValue);
|
|
274
|
-
return isLinkType(tag, attrs, 'canonical') ? attrValue : options.minifyURLs(attrValue);
|
|
275
|
-
}
|
|
276
|
-
else if (isNumberTypeAttribute(attrName, tag)) {
|
|
277
|
-
return trimWhitespace(attrValue);
|
|
278
|
-
}
|
|
279
|
-
else if (attrName === 'style') {
|
|
280
|
-
attrValue = trimWhitespace(attrValue);
|
|
281
|
-
if (attrValue) {
|
|
282
|
-
if (/;$/.test(attrValue) && !/&#?[0-9a-zA-Z]+;$/.test(attrValue)) {
|
|
283
|
-
attrValue = attrValue.replace(/\s*;$/, ';');
|
|
284
|
-
}
|
|
285
|
-
attrValue = options.minifyCSS(attrValue, 'inline');
|
|
286
|
-
}
|
|
287
|
-
return attrValue;
|
|
288
|
-
}
|
|
289
|
-
else if (isSrcset(attrName, tag)) {
|
|
290
|
-
// https://html.spec.whatwg.org/multipage/embedded-content.html#attr-img-srcset
|
|
291
|
-
attrValue = trimWhitespace(attrValue).split(/\s+,\s*|\s*,\s+/).map(function(candidate) {
|
|
292
|
-
var url = candidate;
|
|
293
|
-
var descriptor = '';
|
|
294
|
-
var match = candidate.match(/\s+([1-9][0-9]*w|[0-9]+(?:\.[0-9]+)?x)$/);
|
|
295
|
-
if (match) {
|
|
296
|
-
url = url.slice(0, -match[0].length);
|
|
297
|
-
var num = +match[1].slice(0, -1);
|
|
298
|
-
var suffix = match[1].slice(-1);
|
|
299
|
-
if (num !== 1 || suffix !== 'x') {
|
|
300
|
-
descriptor = ' ' + num + suffix;
|
|
1005
|
+
if (sortedNames === undefined) {
|
|
1006
|
+
// `sort` works in place, hence the copy
|
|
1007
|
+
sortedNames = sorter.sort(names.slice());
|
|
1008
|
+
attrOrderCache.set(cacheKey, sortedNames);
|
|
301
1009
|
}
|
|
1010
|
+
|
|
1011
|
+
// Apply the sorted order to `attrs`
|
|
1012
|
+
const attrMap = Object.create(null);
|
|
1013
|
+
names.forEach(function (/** @type {string} */ name, /** @type {number} */ index) {
|
|
1014
|
+
(attrMap[name] || (attrMap[name] = [])).push(attrs[index]);
|
|
1015
|
+
});
|
|
1016
|
+
/** @type {string[]} */ (sortedNames).forEach(function (/** @type {string} */ name, /** @type {number} */ index) {
|
|
1017
|
+
attrs[index] = attrMap[name].shift();
|
|
1018
|
+
});
|
|
302
1019
|
}
|
|
303
|
-
|
|
304
|
-
}).join(', ');
|
|
305
|
-
}
|
|
306
|
-
else if (isMetaViewport(tag, attrs) && attrName === 'content') {
|
|
307
|
-
attrValue = attrValue.replace(/\s+/g, '').replace(/[0-9]+\.[0-9]+/g, function(numString) {
|
|
308
|
-
// "0.90000" -> "0.9"
|
|
309
|
-
// "1.0" -> "1"
|
|
310
|
-
// "1.0001" -> "1.0001" (unchanged)
|
|
311
|
-
return (+numString).toString();
|
|
312
|
-
});
|
|
313
|
-
}
|
|
314
|
-
else if (options.customAttrCollapse && options.customAttrCollapse.test(attrName)) {
|
|
315
|
-
attrValue = attrValue.replace(/\n+|\r+|\s{2,}/g, '');
|
|
316
|
-
}
|
|
317
|
-
else if (tag === 'script' && attrName === 'type') {
|
|
318
|
-
attrValue = trimWhitespace(attrValue.replace(/\s*;\s*/g, ';'));
|
|
1020
|
+
};
|
|
319
1021
|
}
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
1022
|
+
if (classChain) {
|
|
1023
|
+
const sorter = classChain.createSorter();
|
|
1024
|
+
// Memoize `sortClassNames` results—class lists often repeat in templates
|
|
1025
|
+
const classNameCache = new LRU(500);
|
|
1026
|
+
|
|
1027
|
+
options.sortClassNames = function (/** @type {string} */ classNames) {
|
|
1028
|
+
// Fast path: Single class (no spaces) needs no sorting
|
|
1029
|
+
if (classNames.indexOf(' ') === -1) {
|
|
1030
|
+
return classNames;
|
|
1031
|
+
}
|
|
1032
|
+
|
|
1033
|
+
const cached = /** @type {string | undefined} */ (classNameCache.get(classNames));
|
|
1034
|
+
if (cached !== undefined) {
|
|
1035
|
+
return cached;
|
|
1036
|
+
}
|
|
1037
|
+
|
|
1038
|
+
// Expand UID tokens back to original content before sorting
|
|
1039
|
+
// Fast path: Skip if no HTML comments (UID markers) present
|
|
1040
|
+
let expandedValue = classNames;
|
|
1041
|
+
if (uidReplacePattern && classNames.indexOf('<!--') !== -1) {
|
|
1042
|
+
expandedValue = classNames.replace(uidReplacePattern, function (/** @type {string} */ _match, /** @type {string} */ index) {
|
|
1043
|
+
return ignoredMarkupChunks[+index] || '';
|
|
1044
|
+
});
|
|
1045
|
+
uidReplacePattern.lastIndex = 0;
|
|
1046
|
+
}
|
|
1047
|
+
const classes = expandedValue.split(whitespaceSplitPatternSort).filter(function(/** @type {string} */ cls) {
|
|
1048
|
+
return cls !== '';
|
|
1049
|
+
});
|
|
1050
|
+
const sorted = sorter.sort(classes);
|
|
1051
|
+
const result = sorted.join(' ');
|
|
1052
|
+
|
|
1053
|
+
classNameCache.set(classNames, result);
|
|
1054
|
+
return result;
|
|
1055
|
+
};
|
|
323
1056
|
}
|
|
324
|
-
return attrValue;
|
|
325
1057
|
}
|
|
326
1058
|
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
1059
|
+
// Match `/^<\/([\w:-]+)>$/` by hand—the regex engine’s per-call setup costs more
|
|
1060
|
+
// than the scan on these short buffer entries
|
|
1061
|
+
/**
|
|
1062
|
+
* @param {string} str
|
|
1063
|
+
* @returns {string | null} The tag name, or `null` where the string is not a plain end tag
|
|
1064
|
+
*/
|
|
1065
|
+
function matchPlainEndTag(str) {
|
|
1066
|
+
const end = str.length;
|
|
1067
|
+
if (end < 4 || str.charCodeAt(0) !== 60 /* < */ || str.charCodeAt(1) !== 47 /* / */ || str.charCodeAt(end - 1) !== 62 /* > */) {
|
|
1068
|
+
return null;
|
|
1069
|
+
}
|
|
1070
|
+
for (let i = 2; i < end - 1; i++) {
|
|
1071
|
+
const code = str.charCodeAt(i);
|
|
1072
|
+
// `[\w:-]`: Letters, digits, `_`, `:`, `-`
|
|
1073
|
+
if (!((code >= 48 && code <= 57) || (code >= 65 && code <= 90) || (code >= 97 && code <= 122) || code === 95 || code === 58 || code === 45)) {
|
|
1074
|
+
return null;
|
|
334
1075
|
}
|
|
335
1076
|
}
|
|
1077
|
+
return str.slice(2, end - 1);
|
|
336
1078
|
}
|
|
337
1079
|
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
}
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
function
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
case 'media':
|
|
349
|
-
return '@media ' + text + '{a{top:0}}';
|
|
350
|
-
default:
|
|
351
|
-
return text;
|
|
1080
|
+
/**
|
|
1081
|
+
* @param {string} value
|
|
1082
|
+
* @param {ProcessedOptions} options
|
|
1083
|
+
* @param {boolean} [partialMarkup] - Whether treating input as partial markup
|
|
1084
|
+
* @returns {Promise<string>}
|
|
1085
|
+
*/
|
|
1086
|
+
async function minifyHTML(value, options, partialMarkup) {
|
|
1087
|
+
// Input length limit, against ReDoS
|
|
1088
|
+
if (options.maxInputLength && value.length > options.maxInputLength) {
|
|
1089
|
+
throw new Error(`Input length (${value.length}) exceeds maximum allowed length (${options.maxInputLength})`);
|
|
352
1090
|
}
|
|
353
|
-
}
|
|
354
|
-
|
|
355
|
-
function unwrapCSS(text, type) {
|
|
356
|
-
var matches;
|
|
357
|
-
switch (type) {
|
|
358
|
-
case 'inline':
|
|
359
|
-
matches = text.match(/^\*\{([\s\S]*)\}$/);
|
|
360
|
-
break;
|
|
361
|
-
case 'media':
|
|
362
|
-
matches = text.match(/^@media ([\s\S]*?)\s*{[\s\S]*}$/);
|
|
363
|
-
break;
|
|
364
|
-
}
|
|
365
|
-
return matches ? matches[1] : text;
|
|
366
|
-
}
|
|
367
1091
|
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
}) : comment;
|
|
372
|
-
}
|
|
1092
|
+
if (options.collapseWhitespace) {
|
|
1093
|
+
value = collapseWhitespace(value, options, true, true);
|
|
1094
|
+
}
|
|
373
1095
|
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
1096
|
+
const resolveName = options.name;
|
|
1097
|
+
/** @type {HTMLAttribute[]} */
|
|
1098
|
+
const emptyAttrs = [];
|
|
1099
|
+
|
|
1100
|
+
/** @type {string[]} */
|
|
1101
|
+
const buffer = [];
|
|
1102
|
+
// Parallel to `buffer`: whether the entry is a tag—the only place lines may break
|
|
1103
|
+
const bufferTags = options.maxLineLength ? /** @type {boolean[]} */ ([]) : null;
|
|
1104
|
+
/** @type {string} */
|
|
1105
|
+
let charsPrevTag = '';
|
|
1106
|
+
let currentChars = '';
|
|
1107
|
+
/** @type {boolean} */
|
|
1108
|
+
let hasChars = false;
|
|
1109
|
+
// Open elements under `removeEmptyElements`, so an element whose content
|
|
1110
|
+
// went is removed, too: where its start tag sits in `buffer`, and whether
|
|
1111
|
+
// anything kept went in since
|
|
1112
|
+
/** @type {Array<{tag: string, index: number, empty: boolean}>} */
|
|
1113
|
+
const openElements = [];
|
|
1114
|
+
// Under `selfCloseForeignElements`, the open `svg` and `math` elements and the integration points
|
|
1115
|
+
// in them, whose children are HTML, which a slash does not close
|
|
1116
|
+
/** @type {string[]} */
|
|
1117
|
+
const foreignScopes = [];
|
|
1118
|
+
// A start tag the parser implied without writing it, whose end tag, if it comes next, is a stray
|
|
1119
|
+
// one that browsers read as an empty element (as `</p>` is), so it must not be omitted
|
|
1120
|
+
let impliedStartTag = '';
|
|
1121
|
+
// Whether the parser found the document to be in no-quirks mode, where `table` closes a `p`
|
|
1122
|
+
let tableClosesP = false;
|
|
1123
|
+
// Under `collapseWhitespace`, the text whose trailing whitespace sits before or
|
|
1124
|
+
// between elements that don’t render, kept until what follows them shows whether
|
|
1125
|
+
// it separates anything; whether the text being processed is such text; and how
|
|
1126
|
+
// deep inside such elements parsing is
|
|
1127
|
+
let transparentGapIndex = -1;
|
|
1128
|
+
let transparentGapPending = false;
|
|
1129
|
+
let transparentDepth = 0;
|
|
1130
|
+
let currentTag = '';
|
|
1131
|
+
/** @type {Array<{name: string, value?: string, quote?: string, customAssign?: string, customOpen?: string, customClose?: string}>} */
|
|
1132
|
+
let currentAttrs = [];
|
|
1133
|
+
/** @type {string[]} */
|
|
1134
|
+
const stackNoTrimWhitespace = [];
|
|
1135
|
+
/** @type {string[]} */
|
|
1136
|
+
const stackNoCollapseWhitespace = [];
|
|
1137
|
+
let preTextareaDepth = 0; // Count of `pre`/`textarea` entries in `stackNoTrimWhitespace`
|
|
1138
|
+
// Where the last element whose whitespace may not be trimmed ended in `buffer`;
|
|
1139
|
+
// what lies up to there stays as written, also where `canTrimWhitespace` decides
|
|
1140
|
+
// by attributes the trim walk lacks
|
|
1141
|
+
let noTrimEndIndex = -1;
|
|
1142
|
+
// The same for the last element whose whitespace may not be collapsed
|
|
1143
|
+
let noCollapseEndIndex = -1;
|
|
1144
|
+
let optionalStartTag = '';
|
|
1145
|
+
let optionalEndTag = '';
|
|
1146
|
+
let optionalEndTagEmitted = false;
|
|
1147
|
+
// Whether a node was removed since the last output
|
|
1148
|
+
let removed = false;
|
|
1149
|
+
// Whether the latest text was kept verbatim, as in `pre` or `script`
|
|
1150
|
+
let textVerbatim = false;
|
|
1151
|
+
/** @type {string[]} */
|
|
1152
|
+
const ignoredMarkupChunks = [];
|
|
1153
|
+
/** @type {Array<RegExpExecArray | null>} */
|
|
1154
|
+
const ignoredCustomMarkupChunks = [];
|
|
1155
|
+
// Custom fragments whose surrounding whitespace is never trimmed: in tags, where it
|
|
1156
|
+
// separates attributes, and in CSS and JavaScript, where minifiers can’t judge it
|
|
1157
|
+
/** @type {Set<number>} */
|
|
1158
|
+
const customMarkupInTags = new Set();
|
|
1159
|
+
/** @type {Set<number>} */
|
|
1160
|
+
const customMarkupInCode = new Set();
|
|
1161
|
+
// Attributes joined with the custom fragments they run into, to be written as they are
|
|
1162
|
+
/** @type {WeakSet<HTMLAttribute>} */
|
|
1163
|
+
const opaqueAttrs = new WeakSet();
|
|
1164
|
+
/** @type {string | undefined} */
|
|
1165
|
+
let uidIgnore;
|
|
1166
|
+
/** @type {RegExp | undefined} */
|
|
1167
|
+
let uidIgnorePlaceholderPattern;
|
|
1168
|
+
/** @type {string | undefined} */
|
|
1169
|
+
let uidAttr;
|
|
1170
|
+
/** @type {RegExp | undefined} */
|
|
1171
|
+
let uidPattern;
|
|
1172
|
+
/** @type {RegExp | undefined} */
|
|
1173
|
+
let uidAttrLeadingPattern;
|
|
1174
|
+
/** @type {RegExp | undefined} */
|
|
1175
|
+
let uidAttrExactPattern;
|
|
1176
|
+
// Create inline tags/text sets with custom elements
|
|
1177
|
+
const customElementsInput = options.inlineCustomElements ?? [];
|
|
1178
|
+
const customElementsArr = Array.isArray(customElementsInput) ? customElementsInput : Array.from(customElementsInput);
|
|
1179
|
+
const normalizedCustomElements = customElementsArr.map(name => resolveName(name));
|
|
1180
|
+
// Fast path: Reuse base sets if no custom elements
|
|
1181
|
+
const inlineTextSet = normalizedCustomElements.length
|
|
1182
|
+
? new Set([...inlineElementsToKeepWhitespaceWithin, ...normalizedCustomElements])
|
|
1183
|
+
: inlineElementsToKeepWhitespaceWithin;
|
|
1184
|
+
const inlineElements = normalizedCustomElements.length
|
|
1185
|
+
? new Set([...inlineElementsToKeepWhitespaceAround, ...normalizedCustomElements])
|
|
1186
|
+
: inlineElementsToKeepWhitespaceAround;
|
|
1187
|
+
const inlineSets = normalizedCustomElements.length
|
|
1188
|
+
? buildInlineSets(inlineElements, inlineTextSet)
|
|
1189
|
+
: defaultInlineSets;
|
|
1190
|
+
|
|
1191
|
+
/** @type {Array<{tag: string, attrs: {[x: string]: string | undefined} | null}>} */
|
|
1192
|
+
let removeEmptyElementsExcept;
|
|
1193
|
+
if (options.removeEmptyElementsExcept && !Array.isArray(options.removeEmptyElementsExcept)) {
|
|
1194
|
+
options.log('Warning: `removeEmptyElementsExcept` option must be an array, received: ' + typeof options.removeEmptyElementsExcept);
|
|
1195
|
+
removeEmptyElementsExcept = [];
|
|
1196
|
+
} else {
|
|
1197
|
+
removeEmptyElementsExcept = parseRemoveEmptyElementsExcept(options.removeEmptyElementsExcept || [], options);
|
|
1198
|
+
}
|
|
1199
|
+
|
|
1200
|
+
// Temporarily replace ignored chunks with comments, so whatever they hold needn’t be parsed
|
|
1201
|
+
if (value.indexOf('<!-- htmlmin:ignore -->') !== -1) {
|
|
1202
|
+
// Use `indexOf`-based O(n) loop instead of a global regex with [\s\S]*? to avoid O(n²)
|
|
1203
|
+
// backtracking on adversarial HTML with many `<!--` prefixes but no closing marker
|
|
1204
|
+
const ignoreMarker = '<!-- htmlmin:ignore -->';
|
|
1205
|
+
const ignoreMarkerLen = ignoreMarker.length;
|
|
1206
|
+
let ignoreResult = '';
|
|
1207
|
+
let ignorePos = 0;
|
|
1208
|
+
while (ignorePos < value.length) {
|
|
1209
|
+
const ignoreStart = value.indexOf(ignoreMarker, ignorePos);
|
|
1210
|
+
if (ignoreStart === -1) { ignoreResult += value.slice(ignorePos); break; }
|
|
1211
|
+
ignoreResult += value.slice(ignorePos, ignoreStart);
|
|
1212
|
+
const ignoreEnd = value.indexOf(ignoreMarker, ignoreStart + ignoreMarkerLen);
|
|
1213
|
+
if (ignoreEnd === -1) { ignoreResult += value.slice(ignoreStart); break; }
|
|
1214
|
+
const group1 = value.slice(ignoreStart + ignoreMarkerLen, ignoreEnd);
|
|
1215
|
+
if (!uidIgnore) {
|
|
1216
|
+
uidIgnore = uniqueId(value);
|
|
1217
|
+
const pattern = new RegExp('^' + uidIgnore + '([0-9]+)$');
|
|
1218
|
+
uidIgnorePlaceholderPattern = new RegExp('^<!--' + uidIgnore + '(\\d+)-->$');
|
|
1219
|
+
if (options.ignoreCustomComments) {
|
|
1220
|
+
options.ignoreCustomComments = options.ignoreCustomComments.slice();
|
|
1221
|
+
} else {
|
|
1222
|
+
options.ignoreCustomComments = [];
|
|
1223
|
+
}
|
|
1224
|
+
options.ignoreCustomComments.push(pattern);
|
|
1225
|
+
}
|
|
1226
|
+
const token = '<!--' + uidIgnore + ignoredMarkupChunks.length + '-->';
|
|
1227
|
+
ignoredMarkupChunks.push(group1);
|
|
1228
|
+
ignoreResult += token;
|
|
1229
|
+
ignorePos = ignoreEnd + ignoreMarkerLen;
|
|
379
1230
|
}
|
|
1231
|
+
value = ignoreResult;
|
|
380
1232
|
}
|
|
381
|
-
return text;
|
|
382
|
-
}
|
|
383
|
-
|
|
384
|
-
// Tag omission rules from https://html.spec.whatwg.org/multipage/syntax.html#optional-tags
|
|
385
|
-
// with the following deviations:
|
|
386
|
-
// - retain <body> if followed by <noscript>
|
|
387
|
-
// - </rb>, </rt>, </rtc>, </rp> & </tfoot> follow https://www.w3.org/TR/html5/syntax.html#optional-tags
|
|
388
|
-
// - retain all tags which are adjacent to non-standard HTML tags
|
|
389
|
-
var optionalStartTags = createMapFromString('html,head,body,colgroup,tbody');
|
|
390
|
-
var optionalEndTags = createMapFromString('html,head,body,li,dt,dd,p,rb,rt,rtc,rp,optgroup,option,colgroup,caption,thead,tbody,tfoot,tr,td,th');
|
|
391
|
-
var headerTags = createMapFromString('meta,link,script,style,template,noscript');
|
|
392
|
-
var descriptionTags = createMapFromString('dt,dd');
|
|
393
|
-
var pBlockTags = createMapFromString('address,article,aside,blockquote,details,div,dl,fieldset,figcaption,figure,footer,form,h1,h2,h3,h4,h5,h6,header,hgroup,hr,main,menu,nav,ol,p,pre,section,table,ul');
|
|
394
|
-
var pInlineTags = createMapFromString('a,audio,del,ins,map,noscript,video');
|
|
395
|
-
var rubyTags = createMapFromString('rb,rt,rtc,rp');
|
|
396
|
-
var rtcTag = createMapFromString('rb,rtc,rp');
|
|
397
|
-
var optionTag = createMapFromString('option,optgroup');
|
|
398
|
-
var tableContentTags = createMapFromString('tbody,tfoot');
|
|
399
|
-
var tableSectionTags = createMapFromString('thead,tbody,tfoot');
|
|
400
|
-
var cellTags = createMapFromString('td,th');
|
|
401
|
-
var topLevelTags = createMapFromString('html,head,body');
|
|
402
|
-
var compactTags = createMapFromString('html,body');
|
|
403
|
-
var looseTags = createMapFromString('head,colgroup,caption');
|
|
404
|
-
var trailingTags = createMapFromString('dt,thead');
|
|
405
|
-
var htmlTags = createMapFromString('a,abbr,acronym,address,applet,area,article,aside,audio,b,base,basefont,bdi,bdo,bgsound,big,blink,blockquote,body,br,button,canvas,caption,center,cite,code,col,colgroup,command,content,data,datalist,dd,del,details,dfn,dialog,dir,div,dl,dt,element,em,embed,fieldset,figcaption,figure,font,footer,form,frame,frameset,h1,h2,h3,h4,h5,h6,head,header,hgroup,hr,html,i,iframe,image,img,input,ins,isindex,kbd,keygen,label,legend,li,link,listing,main,map,mark,marquee,menu,menuitem,meta,meter,multicol,nav,nobr,noembed,noframes,noscript,object,ol,optgroup,option,output,p,param,picture,plaintext,pre,progress,q,rb,rp,rt,rtc,ruby,s,samp,script,section,select,shadow,small,source,spacer,span,strike,strong,style,sub,summary,sup,table,tbody,td,template,textarea,tfoot,th,thead,time,title,tr,track,tt,u,ul,var,video,wbr,xmp');
|
|
406
|
-
|
|
407
|
-
function canRemoveParentTag(optionalStartTag, tag) {
|
|
408
|
-
switch (optionalStartTag) {
|
|
409
|
-
case 'html':
|
|
410
|
-
case 'head':
|
|
411
|
-
return true;
|
|
412
|
-
case 'body':
|
|
413
|
-
return !headerTags(tag);
|
|
414
|
-
case 'colgroup':
|
|
415
|
-
return tag === 'col';
|
|
416
|
-
case 'tbody':
|
|
417
|
-
return tag === 'tr';
|
|
418
|
-
}
|
|
419
|
-
return false;
|
|
420
|
-
}
|
|
421
1233
|
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
return tableSectionTags(optionalEndTag);
|
|
1234
|
+
// Sort functions are created after `htmlmin:ignore` processing but before custom fragment UID
|
|
1235
|
+
// markers, so frequency analysis can reach ignored content via UID tokens
|
|
1236
|
+
if ((options.sortAttributes && typeof options.sortAttributes !== 'function') ||
|
|
1237
|
+
(options.sortClassNames && typeof options.sortClassNames !== 'function')) {
|
|
1238
|
+
await createSortFns(value, options, uidIgnore ?? '', ignoredMarkupChunks);
|
|
428
1239
|
}
|
|
429
|
-
return false;
|
|
430
|
-
}
|
|
431
1240
|
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
case 'rt':
|
|
451
|
-
case 'rp':
|
|
452
|
-
return rubyTags(tag);
|
|
453
|
-
case 'rtc':
|
|
454
|
-
return rtcTag(tag);
|
|
455
|
-
case 'option':
|
|
456
|
-
return optionTag(tag);
|
|
457
|
-
case 'thead':
|
|
458
|
-
case 'tbody':
|
|
459
|
-
return tableContentTags(tag);
|
|
460
|
-
case 'tfoot':
|
|
461
|
-
return tag === 'tbody';
|
|
462
|
-
case 'td':
|
|
463
|
-
case 'th':
|
|
464
|
-
return cellTags(tag);
|
|
465
|
-
}
|
|
466
|
-
return false;
|
|
467
|
-
}
|
|
1241
|
+
const customFragments = (options.ignoreCustomFragments || []).map(toFragment);
|
|
1242
|
+
if (customFragments.length) {
|
|
1243
|
+
// Temporarily replace custom ignored fragments with unique attributes
|
|
1244
|
+
const replaceFragment = function (/** @type {string} */ match) {
|
|
1245
|
+
if (!uidAttr) {
|
|
1246
|
+
uidAttr = uniqueId(value);
|
|
1247
|
+
uidPattern = new RegExp('(\\s*)' + uidAttr + '([0-9]+)' + uidAttr + '(\\s*)', 'g');
|
|
1248
|
+
uidAttrLeadingPattern = new RegExp('^\\s*' + uidAttr + '(\\d+)' + uidAttr);
|
|
1249
|
+
uidAttrExactPattern = new RegExp('^' + uidAttr + '(\\d+)' + uidAttr + '$');
|
|
1250
|
+
|
|
1251
|
+
if (options.minifyCSS !== identity) {
|
|
1252
|
+
options.minifyCSS = (function (/** @type {ProcessedOptions['minifyCSS']} */ fn) {
|
|
1253
|
+
return function (/** @type {string} */ text, /** @type {string | undefined} */ type, /** @type {ProcessedOptions['cssContext']} */ context) {
|
|
1254
|
+
text = text.replace(/** @type {RegExp} */ (uidPattern), function (/** @type {string} */ _match, /** @type {string} */ _prefix, /** @type {string} */ index) {
|
|
1255
|
+
const chunks = ignoredCustomMarkupChunks[+index];
|
|
1256
|
+
customMarkupInCode.add(+index);
|
|
1257
|
+
return (chunks?.[1] ?? '') + uidAttr + index + uidAttr + (chunks?.[2] ?? '');
|
|
1258
|
+
});
|
|
468
1259
|
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
1260
|
+
return fn(text, type, context);
|
|
1261
|
+
};
|
|
1262
|
+
})(options.minifyCSS);
|
|
1263
|
+
}
|
|
472
1264
|
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
1265
|
+
if (options.minifyJS !== identity) {
|
|
1266
|
+
options.minifyJS = (function (/** @type {ProcessedOptions['minifyJS']} */ fn) {
|
|
1267
|
+
return function (/** @type {string} */ text, /** @type {boolean | undefined} */ inline, /** @type {boolean | undefined} */ isModule) {
|
|
1268
|
+
return fn(text.replace(/** @type {RegExp} */ (uidPattern), function (/** @type {string} */ _match, /** @type {string} */ _prefix, /** @type {string} */ index) {
|
|
1269
|
+
const chunks = ignoredCustomMarkupChunks[+index];
|
|
1270
|
+
customMarkupInCode.add(+index);
|
|
1271
|
+
return (chunks?.[1] ?? '') + uidAttr + index + uidAttr + (chunks?.[2] ?? '');
|
|
1272
|
+
}), inline, isModule);
|
|
1273
|
+
};
|
|
1274
|
+
})(options.minifyJS);
|
|
1275
|
+
}
|
|
1276
|
+
}
|
|
1277
|
+
|
|
1278
|
+
const token = uidAttr + ignoredCustomMarkupChunks.length + uidAttr;
|
|
1279
|
+
ignoredCustomMarkupChunks.push(/^(\s*)[\s\S]*?(\s*)$/.exec(match));
|
|
1280
|
+
return '\t' + token + '\t';
|
|
1281
|
+
};
|
|
1282
|
+
|
|
1283
|
+
value = replaceCustomFragments(value, customFragments, replaceFragment);
|
|
477
1284
|
}
|
|
478
|
-
|
|
479
|
-
|
|
1285
|
+
|
|
1286
|
+
// HTML reads every line break as a line feed, while ignored markup and custom fragments,
|
|
1287
|
+
// now placeholders, stay as written
|
|
1288
|
+
if (value.indexOf('\r') !== -1) {
|
|
1289
|
+
value = value.replace(RE_LINE_BREAK_CR, '\n');
|
|
480
1290
|
}
|
|
481
|
-
return tag === 'input' && attrName === 'value' || reEmptyAttribute.test(attrName);
|
|
482
|
-
}
|
|
483
1291
|
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
1292
|
+
// Custom fragment and `htmlmin:ignore` placeholders hold these
|
|
1293
|
+
const markers = [uidAttr, uidIgnore].filter(marker => marker !== undefined);
|
|
1294
|
+
|
|
1295
|
+
// Record the custom fragments in (part of) a tag
|
|
1296
|
+
function markTagFragments(/** @type {string} */ str) {
|
|
1297
|
+
for (const uidMatch of str.matchAll(/** @type {RegExp} */ (uidPattern))) {
|
|
1298
|
+
customMarkupInTags.add(+(uidMatch[2] ?? 0));
|
|
488
1299
|
}
|
|
489
1300
|
}
|
|
490
|
-
return false;
|
|
491
|
-
}
|
|
492
1301
|
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
return
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
1302
|
+
// Source text of an attribute, as far as it matters
|
|
1303
|
+
function rawAttr(/** @type {HTMLAttribute} */ attr) {
|
|
1304
|
+
if (opaqueAttrs.has(attr)) {
|
|
1305
|
+
return attr.name;
|
|
1306
|
+
}
|
|
1307
|
+
let raw = attr.name;
|
|
1308
|
+
// Skip values the parser filled in for bare boolean attributes
|
|
1309
|
+
if (attr.value !== undefined && !(attr.quote === '' && attr.value === attr.name)) {
|
|
1310
|
+
raw += (attr.customAssign ?? '=') + (attr.quote ?? '') + attr.value + (attr.quote ?? '');
|
|
1311
|
+
}
|
|
1312
|
+
return (attr.customOpen ?? '') + raw + (attr.customClose ?? '');
|
|
1313
|
+
}
|
|
1314
|
+
|
|
1315
|
+
/**
|
|
1316
|
+
* Join each attribute a custom fragment runs into without whitespace with that fragment,
|
|
1317
|
+
* so that no later step separates, drops, changes, or reorders them
|
|
1318
|
+
* @param {HTMLAttribute[]} attrs - Joined in place
|
|
1319
|
+
* @returns {boolean} Whether the first attribute runs into the tag name
|
|
1320
|
+
*/
|
|
1321
|
+
function joinFragmentAttrs(attrs) {
|
|
1322
|
+
const pattern = /** @type {RegExp} */ (uidAttrExactPattern);
|
|
1323
|
+
let joinsTag = false;
|
|
1324
|
+
let writeIndex = 0;
|
|
1325
|
+
// Padding of the previous attribute if that is a fragment without value
|
|
1326
|
+
/** @type {RegExpExecArray | null | undefined} */
|
|
1327
|
+
let prevChunks = null;
|
|
1328
|
+
for (const attr of attrs) {
|
|
1329
|
+
const uidMatch = pattern.exec(attr.name);
|
|
1330
|
+
const chunks = uidMatch ? ignoredCustomMarkupChunks[+(uidMatch[1] ?? 0)] : null;
|
|
1331
|
+
const joins = prevChunks ? prevChunks[2] === '' : chunks?.[1] === '';
|
|
1332
|
+
prevChunks = attr.value === undefined ? chunks : null;
|
|
1333
|
+
if (!joins) {
|
|
1334
|
+
attrs[writeIndex++] = attr;
|
|
1335
|
+
continue;
|
|
512
1336
|
}
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
return false;
|
|
1337
|
+
const prev = writeIndex ? attrs[writeIndex - 1] : undefined;
|
|
1338
|
+
if (!prev) {
|
|
1339
|
+
joinsTag = true;
|
|
517
1340
|
}
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
}
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
}
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
1341
|
+
/** @type {HTMLAttribute} */
|
|
1342
|
+
const joined = { name: (prev ? rawAttr(prev) : '') + rawAttr(attr), customAssign: '=', customOpen: '', customClose: '', quote: '' };
|
|
1343
|
+
opaqueAttrs.add(joined);
|
|
1344
|
+
attrs[prev ? writeIndex - 1 : writeIndex++] = joined;
|
|
1345
|
+
}
|
|
1346
|
+
attrs.length = writeIndex;
|
|
1347
|
+
return joinsTag;
|
|
1348
|
+
}
|
|
1349
|
+
|
|
1350
|
+
/**
|
|
1351
|
+
* Restore the whitespace on one side of a custom fragment
|
|
1352
|
+
* @param {string} str - Whitespace the minifier left there and the fragment’s own padding
|
|
1353
|
+
* @param {boolean} trim
|
|
1354
|
+
* @param {boolean} leading
|
|
1355
|
+
*/
|
|
1356
|
+
function restoreFragmentSide(str, trim, leading) {
|
|
1357
|
+
if (!str) {
|
|
1358
|
+
return str;
|
|
1359
|
+
}
|
|
1360
|
+
return collapseWhitespace(str, {
|
|
1361
|
+
preserveLineBreaks: options.preserveLineBreaks,
|
|
1362
|
+
conservativeCollapse: !trim
|
|
1363
|
+
}, leading, !leading);
|
|
1364
|
+
}
|
|
1365
|
+
|
|
1366
|
+
// Under `caseSensitive`, names are read in lowercase all the same, while `buffer` and hooks
|
|
1367
|
+
// get them as written, so the defaults a hook may call take any spelling then
|
|
1368
|
+
const namesAsWritten = Boolean(options.namesAsWritten);
|
|
1369
|
+
const defaultCanCollapse = namesAsWritten
|
|
1370
|
+
? (/** @type {string} */ tag) => defaultCanCollapseWhitespace(tag.toLowerCase())
|
|
1371
|
+
: defaultCanCollapseWhitespace;
|
|
1372
|
+
const defaultCanTrim = namesAsWritten
|
|
1373
|
+
? (/** @type {string} */ tag) => defaultCanTrimWhitespace(tag.toLowerCase())
|
|
1374
|
+
: defaultCanTrimWhitespace;
|
|
1375
|
+
|
|
1376
|
+
// The defaults decide by `tag`, a hook gets `tagOut`; without a hook, the
|
|
1377
|
+
// option holds the default itself
|
|
1378
|
+
function canCollapseWhitespace(/** @type {string} */ tag, /** @type {string} */ tagOut, /** @type {HTMLAttribute[]} */ attrs) {
|
|
1379
|
+
const hook = options.canCollapseWhitespace;
|
|
1380
|
+
return hook === defaultCanCollapseWhitespace ? defaultCanCollapseWhitespace(tag) : hook(tagOut, attrs, defaultCanCollapse);
|
|
1381
|
+
}
|
|
1382
|
+
|
|
1383
|
+
function canTrimWhitespace(/** @type {string} */ tag, /** @type {string} */ tagOut, /** @type {HTMLAttribute[]} */ attrs) {
|
|
1384
|
+
const hook = options.canTrimWhitespace;
|
|
1385
|
+
return hook === defaultCanTrimWhitespace ? defaultCanTrimWhitespace(tag) : hook(tagOut, attrs, defaultCanTrim);
|
|
1386
|
+
}
|
|
1387
|
+
|
|
1388
|
+
function bufferPush(/** @type {string} */ entry, /** @type {boolean} */ isTag) {
|
|
1389
|
+
buffer.push(entry);
|
|
1390
|
+
bufferTags?.push(isTag);
|
|
1391
|
+
if (entry) removed = false;
|
|
1392
|
+
}
|
|
1393
|
+
|
|
1394
|
+
function bufferTruncate(/** @type {number} */ length) {
|
|
1395
|
+
buffer.length = Math.max(0, length);
|
|
1396
|
+
if (bufferTags) bufferTags.length = buffer.length;
|
|
1397
|
+
if (transparentGapIndex >= buffer.length) transparentGapIndex = -1;
|
|
1398
|
+
if (noTrimEndIndex >= buffer.length) noTrimEndIndex = -1;
|
|
1399
|
+
if (noCollapseEndIndex >= buffer.length) noCollapseEndIndex = -1;
|
|
1400
|
+
removed = true;
|
|
1401
|
+
}
|
|
1402
|
+
|
|
1403
|
+
// The nearest entries before and from `index` that aren’t empty, as removed nodes leave them
|
|
1404
|
+
/** @returns {[number, number]} */
|
|
1405
|
+
function findNeighbors(/** @type {number} */ index) {
|
|
1406
|
+
let before = index - 1;
|
|
1407
|
+
while (before >= 0 && buffer[before] === '') {
|
|
1408
|
+
before--;
|
|
1409
|
+
}
|
|
1410
|
+
let after = index;
|
|
1411
|
+
while (after < buffer.length && buffer[after] === '') {
|
|
1412
|
+
after++;
|
|
1413
|
+
}
|
|
1414
|
+
return [before, after];
|
|
537
1415
|
}
|
|
538
1416
|
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
1417
|
+
// With whitespace kept as is, a removed node must not leave a line of nothing but
|
|
1418
|
+
// whitespace behind: The text around the removal (at `index`) drops that line. The start
|
|
1419
|
+
// of the output counts as a line break, and blank lines of the source stay.
|
|
1420
|
+
function removeWhitespaceLine(/** @type {number} */ index) {
|
|
1421
|
+
const [first, after] = findNeighbors(index);
|
|
1422
|
+
// The end of the output is left to `trimOutputWhitespace`
|
|
1423
|
+
if (after === buffer.length) {
|
|
1424
|
+
return;
|
|
1425
|
+
}
|
|
1426
|
+
const next = buffer[after] ?? '';
|
|
1427
|
+
if (!RE_BLANKS_LINE_BREAK_START.test(next)) {
|
|
1428
|
+
return;
|
|
1429
|
+
}
|
|
1430
|
+
// Blanks between nodes removed from the same line belong to that line
|
|
1431
|
+
let before = first;
|
|
1432
|
+
while (before >= 0 && (buffer[before] === '' || RE_BLANKS_ONLY.test(buffer[before] ?? ''))) {
|
|
1433
|
+
before--;
|
|
1434
|
+
}
|
|
1435
|
+
const prev = buffer[before] ?? '';
|
|
1436
|
+
if (before >= 0 && !RE_LINE_BREAK_BLANKS_END.test(prev)) {
|
|
1437
|
+
return;
|
|
1438
|
+
}
|
|
1439
|
+
for (let i = before + 1; i <= first; i++) {
|
|
1440
|
+
buffer[i] = '';
|
|
1441
|
+
}
|
|
1442
|
+
// The line break goes on the side of the text before the removal, so that the text after
|
|
1443
|
+
// it keeps its own for a removal that may follow
|
|
1444
|
+
if (before < 0) {
|
|
1445
|
+
buffer[after] = next.replace(RE_BLANKS_LINE_BREAK_START, '');
|
|
1446
|
+
} else {
|
|
1447
|
+
buffer[before] = prev.replace(RE_LINE_BREAK_BLANKS_END, '');
|
|
1448
|
+
buffer[after] = next.replace(RE_BLANKS_START, '');
|
|
1449
|
+
}
|
|
546
1450
|
}
|
|
547
1451
|
|
|
548
|
-
|
|
549
|
-
|
|
1452
|
+
// Whitespace at either end of the output goes, but not verbatim whitespace, nor whitespace
|
|
1453
|
+
// that `conservativeCollapse` or `preserveLineBreaks` promise to keep, nor that of partial
|
|
1454
|
+
// markup, whose ends join other markup
|
|
1455
|
+
function trimOutputWhitespace() {
|
|
1456
|
+
if (options.partialMarkup || (options.collapseWhitespace && (options.conservativeCollapse || options.preserveLineBreaks))) {
|
|
1457
|
+
return;
|
|
1458
|
+
}
|
|
1459
|
+
// Tags start with `<` and end with `>`, so whitespace at either end belongs to text
|
|
1460
|
+
if (!textVerbatim) {
|
|
1461
|
+
let index = buffer.length - 1;
|
|
1462
|
+
while (index >= 0 && (buffer[index] === '' || RE_WS_ONLY.test(buffer[index] ?? ''))) {
|
|
1463
|
+
buffer[index--] = '';
|
|
1464
|
+
}
|
|
1465
|
+
if (index >= 0) {
|
|
1466
|
+
buffer[index] = (buffer[index] ?? '').replace(RE_WS_END, '');
|
|
1467
|
+
}
|
|
1468
|
+
}
|
|
1469
|
+
let index = 0;
|
|
1470
|
+
while (index < buffer.length && (buffer[index] === '' || RE_WS_ONLY.test(buffer[index] ?? ''))) {
|
|
1471
|
+
buffer[index++] = '';
|
|
1472
|
+
}
|
|
1473
|
+
if (index < buffer.length) {
|
|
1474
|
+
buffer[index] = (buffer[index] ?? '').replace(RE_WS_START, '');
|
|
1475
|
+
}
|
|
550
1476
|
}
|
|
551
1477
|
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
1478
|
+
function lastStartTagIndex() {
|
|
1479
|
+
let index = buffer.length - 1;
|
|
1480
|
+
while (index > 0 && !RE_START_TAG.test(buffer[index] ?? '')) {
|
|
1481
|
+
index--;
|
|
1482
|
+
}
|
|
1483
|
+
return index;
|
|
555
1484
|
}
|
|
556
1485
|
|
|
557
|
-
|
|
558
|
-
|
|
1486
|
+
function removeStartTag() {
|
|
1487
|
+
bufferTruncate(lastStartTagIndex());
|
|
559
1488
|
}
|
|
560
1489
|
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
attrFragment,
|
|
574
|
-
emittedAttrValue;
|
|
575
|
-
|
|
576
|
-
if (typeof attrValue !== 'undefined' && (!options.removeAttributeQuotes ||
|
|
577
|
-
~attrValue.indexOf(uidAttr) || !canRemoveAttributeQuotes(attrValue))) {
|
|
578
|
-
if (!options.preventAttributesEscaping) {
|
|
579
|
-
if (typeof options.quoteCharacter === 'undefined') {
|
|
580
|
-
var apos = (attrValue.match(/'/g) || []).length;
|
|
581
|
-
var quot = (attrValue.match(/"/g) || []).length;
|
|
582
|
-
attrQuote = apos < quot ? '\'' : '"';
|
|
583
|
-
}
|
|
584
|
-
else {
|
|
585
|
-
attrQuote = options.quoteCharacter === '\'' ? '\'' : '"';
|
|
586
|
-
}
|
|
587
|
-
if (attrQuote === '"') {
|
|
588
|
-
attrValue = attrValue.replace(/"/g, '"');
|
|
589
|
-
}
|
|
590
|
-
else {
|
|
591
|
-
attrValue = attrValue.replace(/'/g, ''');
|
|
1490
|
+
// Write the last start tag as self-closing, where it is the last thing written and holds no
|
|
1491
|
+
// custom fragment, which may write anything; returns whether it did
|
|
1492
|
+
function closeLastStartTag() {
|
|
1493
|
+
const start = lastStartTagIndex();
|
|
1494
|
+
let last = buffer.length - 1;
|
|
1495
|
+
while (last > start && buffer[last] === '') {
|
|
1496
|
+
last--;
|
|
1497
|
+
}
|
|
1498
|
+
for (let i = start; i <= last; i++) {
|
|
1499
|
+
const entry = buffer[i] ?? '';
|
|
1500
|
+
if ((i > start && entry[0] === '<') || (uidAttr && entry.indexOf(uidAttr) !== -1)) {
|
|
1501
|
+
return false;
|
|
592
1502
|
}
|
|
593
1503
|
}
|
|
594
|
-
|
|
595
|
-
if (!
|
|
596
|
-
|
|
1504
|
+
const tagText = buffer[last] ?? '';
|
|
1505
|
+
if (!tagText.endsWith('>') || tagText.endsWith('/>')) {
|
|
1506
|
+
return false;
|
|
597
1507
|
}
|
|
1508
|
+
const tagOpen = tagText.slice(0, -1);
|
|
1509
|
+
// A slash right after an unquoted value would belong to the value
|
|
1510
|
+
buffer[last] = tagOpen + (RE_UNQUOTED_VALUE_END.test(tagOpen) ? ' />' : '/>');
|
|
1511
|
+
return true;
|
|
598
1512
|
}
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
1513
|
+
|
|
1514
|
+
/** @param {string} lowerTag */
|
|
1515
|
+
function enterForeignScope(lowerTag) {
|
|
1516
|
+
if (lowerTag === 'svg' || lowerTag === 'math' ||
|
|
1517
|
+
(options.namespaceForeign && integrationPointsForeign[options.namespaceForeign].has(lowerTag))) {
|
|
1518
|
+
foreignScopes.push(lowerTag);
|
|
1519
|
+
}
|
|
602
1520
|
}
|
|
603
|
-
|
|
604
|
-
|
|
1521
|
+
|
|
1522
|
+
// Leaves the scope the element opened, if any; returns whether a slash would close the element
|
|
1523
|
+
/** @param {string} lowerTag */
|
|
1524
|
+
function leaveForeignScope(lowerTag) {
|
|
1525
|
+
if (foreignScopes[foreignScopes.length - 1] === lowerTag) {
|
|
1526
|
+
foreignScopes.pop();
|
|
1527
|
+
}
|
|
1528
|
+
const scope = foreignScopes[foreignScopes.length - 1];
|
|
1529
|
+
return lowerTag === 'svg' || lowerTag === 'math' || scope === undefined || scope === 'svg' || scope === 'math';
|
|
605
1530
|
}
|
|
606
1531
|
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
attrFragment += ' ';
|
|
1532
|
+
function markOpenElementFilled() {
|
|
1533
|
+
// Guarded, as reading index -1 is a slow property lookup, and this runs for every text node
|
|
1534
|
+
if (openElements.length) {
|
|
1535
|
+
/** @type {{empty: boolean}} */ (openElements[openElements.length - 1]).empty = false;
|
|
612
1536
|
}
|
|
613
1537
|
}
|
|
614
|
-
|
|
615
|
-
|
|
1538
|
+
|
|
1539
|
+
// Whether the element being parsed holds text rather than markup. The namespace decides
|
|
1540
|
+
// this, not the name alone: Only HTML elements do—an SVG or MathML `script` or `title`
|
|
1541
|
+
// holds markup like any foreign element
|
|
1542
|
+
function holdsRawText() {
|
|
1543
|
+
return rawTextElements.has(currentTag) && !options.insideForeignContent;
|
|
616
1544
|
}
|
|
617
1545
|
|
|
618
|
-
|
|
619
|
-
|
|
1546
|
+
// Whether whitespace-only text between tags survives minification
|
|
1547
|
+
function keepsWhitespace() {
|
|
1548
|
+
return !options.collapseWhitespace || Boolean(options.conservativeCollapse) || Boolean(options.preserveLineBreaks);
|
|
1549
|
+
}
|
|
620
1550
|
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
1551
|
+
// The two whitespace runs an omitted tag separated become one run, which collapses to the
|
|
1552
|
+
// single character it is due: a line break under `preserveLineBreaks`, a no-break space
|
|
1553
|
+
// where the run holds one, a space under `conservativeCollapse`
|
|
1554
|
+
function mergeWhitespaceRuns(/** @type {number} */ index) {
|
|
1555
|
+
const [before, after] = findNeighbors(index);
|
|
1556
|
+
if (before < 0 || after >= buffer.length) {
|
|
1557
|
+
return;
|
|
1558
|
+
}
|
|
1559
|
+
const prev = buffer[before] ?? '';
|
|
1560
|
+
const next = buffer[after] ?? '';
|
|
1561
|
+
if (!RE_WS_NBSP_END.test(prev) || !RE_WS_START.test(next)) {
|
|
1562
|
+
return;
|
|
1563
|
+
}
|
|
1564
|
+
// A line break the run already carries outranks the whitespace ahead of it; trimming
|
|
1565
|
+
// the other side takes ASCII whitespace only, so a no-break space stays as is
|
|
1566
|
+
if (options.preserveLineBreaks && /[\n\r]/.test((next.match(RE_WS_START) ?? [''])[0])) {
|
|
1567
|
+
buffer[before] = prev.replace(RE_WS_END, '');
|
|
1568
|
+
} else {
|
|
1569
|
+
buffer[after] = next.replace(RE_WS_START, '');
|
|
1570
|
+
}
|
|
1571
|
+
}
|
|
624
1572
|
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
canCollapseWhitespace: canCollapseWhitespace,
|
|
631
|
-
canTrimWhitespace: canTrimWhitespace,
|
|
632
|
-
html5: true,
|
|
633
|
-
ignoreCustomComments: [/^!/],
|
|
634
|
-
ignoreCustomFragments: [
|
|
635
|
-
/<%[\s\S]*?%>/,
|
|
636
|
-
/<\?[\s\S]*?\?>/
|
|
637
|
-
],
|
|
638
|
-
includeAutoGeneratedTags: true,
|
|
639
|
-
log: identity,
|
|
640
|
-
minifyCSS: identity,
|
|
641
|
-
minifyJS: identity,
|
|
642
|
-
minifyURLs: identity
|
|
643
|
-
};
|
|
644
|
-
Object.keys(values).forEach(function(key) {
|
|
645
|
-
var value = values[key];
|
|
646
|
-
if (key === 'caseSensitive') {
|
|
647
|
-
if (value) {
|
|
648
|
-
options.name = identity;
|
|
649
|
-
}
|
|
1573
|
+
// `insideNoTrim` stands in for the stack once the end tag that omits this one has left it
|
|
1574
|
+
function removeEndTag(/** @type {boolean} */ insideNoTrim = false) {
|
|
1575
|
+
let index = buffer.length - 1;
|
|
1576
|
+
while (index > 0 && !RE_END_TAG.test(buffer[index] ?? '')) {
|
|
1577
|
+
index--;
|
|
650
1578
|
}
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
1579
|
+
// Text that follows the end tag is kept as is, so drop the tag alone. Whitespace kept
|
|
1580
|
+
// verbatim—in `pre`, in `textarea`, or wherever `canTrimWhitespace` says so—counts as
|
|
1581
|
+
// kept text too, no matter how whitespace is collapsed elsewhere.
|
|
1582
|
+
const noTrim = insideNoTrim || stackNoTrimWhitespace.length > 0;
|
|
1583
|
+
if (index < buffer.length - 1 && (keepsWhitespace() || noTrim) && RE_END_TAG.test(buffer[index] ?? '')) {
|
|
1584
|
+
buffer.splice(index, 1);
|
|
1585
|
+
bufferTags?.splice(index, 1);
|
|
1586
|
+
// Collapsed whitespace can merge, kept whitespace can leave a line of only whitespace
|
|
1587
|
+
if (!noTrim) {
|
|
1588
|
+
if (options.collapseWhitespace) {
|
|
1589
|
+
mergeWhitespaceRuns(index);
|
|
1590
|
+
} else {
|
|
1591
|
+
removeWhitespaceLine(index);
|
|
1592
|
+
}
|
|
654
1593
|
}
|
|
1594
|
+
return;
|
|
655
1595
|
}
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
1596
|
+
bufferTruncate(index);
|
|
1597
|
+
}
|
|
1598
|
+
|
|
1599
|
+
// Look for trailing whitespace, bypassing inline tags
|
|
1600
|
+
function trimTrailingWhitespace(/** @type {number} */ index, /** @type {string} */ nextTag) {
|
|
1601
|
+
// Nothing to the left of the text is in play here, so only its trailing run can
|
|
1602
|
+
// change—unless preserved line breaks bring the leading run into play, too
|
|
1603
|
+
const trailingOnly = !options.preserveLineBreaks;
|
|
1604
|
+
for (let prevTag = ''; index > noTrimEndIndex && canTrimWhitespace(namesAsWritten ? prevTag.toLowerCase() : prevTag, prevTag, emptyAttrs); index--) {
|
|
1605
|
+
const str = buffer[index] ?? '';
|
|
1606
|
+
const endTagName = matchPlainEndTag(str);
|
|
1607
|
+
if (endTagName !== null) {
|
|
1608
|
+
prevTag = endTagName;
|
|
1609
|
+
continue;
|
|
1610
|
+
}
|
|
1611
|
+
// A tag ends the search, and so does text that survives collapsing. Text with no
|
|
1612
|
+
// trailing run comes back from the call unchanged, so it can end the search on
|
|
1613
|
+
// the spot; empty text cannot, as the search has to carry on past it
|
|
1614
|
+
if (str.charCodeAt(str.length - 1) === 62 /* > */) {
|
|
1615
|
+
break;
|
|
659
1616
|
}
|
|
660
|
-
if (
|
|
661
|
-
|
|
1617
|
+
if (trailingOnly && str && !endsWithWhitespace(str)) {
|
|
1618
|
+
break;
|
|
662
1619
|
}
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
});
|
|
667
|
-
var cleanCssOutput = new CleanCSS(value).minify(wrapCSS(text, type));
|
|
668
|
-
if (cleanCssOutput.errors.length > 0) {
|
|
669
|
-
cleanCssOutput.errors.forEach(options.log);
|
|
670
|
-
return text;
|
|
671
|
-
}
|
|
672
|
-
return unwrapCSS(cleanCssOutput.styles, type);
|
|
673
|
-
};
|
|
674
|
-
}
|
|
675
|
-
else if (key === 'minifyJS' && typeof value !== 'function') {
|
|
676
|
-
if (!value) {
|
|
677
|
-
return;
|
|
1620
|
+
buffer[index] = collapseWhitespaceSmart(str, '', nextTag, emptyAttrs, emptyAttrs, options, inlineSets, !stackNoCollapseWhitespace.length && index > noCollapseEndIndex);
|
|
1621
|
+
if (buffer[index]) {
|
|
1622
|
+
break;
|
|
678
1623
|
}
|
|
679
|
-
if (typeof value !== 'object') {
|
|
680
|
-
value = {};
|
|
681
|
-
}
|
|
682
|
-
(value.parse || (value.parse = {})).bare_returns = false;
|
|
683
|
-
options.minifyJS = function(text, inline) {
|
|
684
|
-
var start = text.match(/^\s*<!--.*/);
|
|
685
|
-
var code = start ? text.slice(start[0].length).replace(/\n\s*-->\s*$/, '') : text;
|
|
686
|
-
value.parse.bare_returns = inline;
|
|
687
|
-
var result = UglifyJS.minify(code, value);
|
|
688
|
-
if (result.error) {
|
|
689
|
-
options.log(result.error);
|
|
690
|
-
return text;
|
|
691
|
-
}
|
|
692
|
-
return result.code.replace(/;$/, '');
|
|
693
|
-
};
|
|
694
1624
|
}
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
1625
|
+
}
|
|
1626
|
+
|
|
1627
|
+
// Look for trailing whitespace in earlier text that a following comment, or an empty element
|
|
1628
|
+
// removed since, kept from being trimmed
|
|
1629
|
+
function squashTrailingWhitespace(/** @type {string} */ nextTag) {
|
|
1630
|
+
// Fast path: With no transparent gap pending, a start tag or text without trailing
|
|
1631
|
+
// whitespace last in the buffer ends the walk below on that entry. That leaves text
|
|
1632
|
+
// alone as well where `preserveLineBreaks` has the walk collapse it again, as the
|
|
1633
|
+
// text’s own pass collapsed it already.
|
|
1634
|
+
if (transparentGapIndex === -1) {
|
|
1635
|
+
const last = buffer[buffer.length - 1] ?? '';
|
|
1636
|
+
if (last) {
|
|
1637
|
+
if (last.charCodeAt(0) !== 60 /* < */) {
|
|
1638
|
+
if (!endsWithWhitespace(last)) {
|
|
1639
|
+
return;
|
|
1640
|
+
}
|
|
1641
|
+
} else {
|
|
1642
|
+
const second = last.charCodeAt(1);
|
|
1643
|
+
if (second !== 47 /* / */ && second !== 33 /* ! */ && last.charCodeAt(last.length - 1) === 62 /* > */) {
|
|
1644
|
+
return;
|
|
1645
|
+
}
|
|
1646
|
+
}
|
|
701
1647
|
}
|
|
702
|
-
|
|
703
|
-
|
|
1648
|
+
}
|
|
1649
|
+
if (transparentGapIndex !== -1 && !transparentDepth && !inlineElementsTransparentAfter.has(nextTag) && !inlineElementsTransparent.has(nextTag)) {
|
|
1650
|
+
settleTransparentGap(collapseWhitespaceSmart(' ', '', nextTag, emptyAttrs, emptyAttrs, options, inlineSets) !== '');
|
|
1651
|
+
}
|
|
1652
|
+
let charsIndex = buffer.length - 1;
|
|
1653
|
+
// Step back over every trailing comment and empty entry, not just one—a single step
|
|
1654
|
+
// stops the search at the comment itself, leaving whitespace behind that a second
|
|
1655
|
+
// minification run then removes
|
|
1656
|
+
while (charsIndex > 0) {
|
|
1657
|
+
const item = buffer[charsIndex] ?? '';
|
|
1658
|
+
// Comments and empty entries only, as `<!…` or nothing at all
|
|
1659
|
+
const skippable = item === '' || (item.charCodeAt(0) === 60 /* < */ && item.charCodeAt(1) === 33 /* ! */);
|
|
1660
|
+
if (!skippable || (uidIgnore && item.indexOf(uidIgnore) !== -1)) {
|
|
1661
|
+
break;
|
|
704
1662
|
}
|
|
705
|
-
|
|
706
|
-
try {
|
|
707
|
-
return RelateUrl.relate(text, value);
|
|
708
|
-
}
|
|
709
|
-
catch (err) {
|
|
710
|
-
options.log(err);
|
|
711
|
-
return text;
|
|
712
|
-
}
|
|
713
|
-
};
|
|
1663
|
+
charsIndex--;
|
|
714
1664
|
}
|
|
715
|
-
|
|
716
|
-
|
|
1665
|
+
// Right before an element that doesn’t render, the text’s own pass has
|
|
1666
|
+
// weighed both sides already
|
|
1667
|
+
if (charsIndex === buffer.length - 1 && inlineElementsTransparent.has(nextTag)) {
|
|
1668
|
+
return;
|
|
717
1669
|
}
|
|
718
|
-
|
|
719
|
-
return options;
|
|
720
|
-
}
|
|
721
|
-
|
|
722
|
-
function uniqueId(value) {
|
|
723
|
-
var id;
|
|
724
|
-
do {
|
|
725
|
-
id = Math.random().toString(36).replace(/^0\.[0-9]*/, '');
|
|
726
|
-
} while (~value.indexOf(id));
|
|
727
|
-
return id;
|
|
728
|
-
}
|
|
729
|
-
|
|
730
|
-
var specialContentTags = createMapFromString('script,style');
|
|
731
|
-
|
|
732
|
-
function createSortFns(value, options, uidIgnore, uidAttr) {
|
|
733
|
-
var attrChains = options.sortAttributes && Object.create(null);
|
|
734
|
-
var classChain = options.sortClassName && new TokenChain();
|
|
735
|
-
|
|
736
|
-
function attrNames(attrs) {
|
|
737
|
-
return attrs.map(function(attr) {
|
|
738
|
-
return options.name(attr.name);
|
|
739
|
-
});
|
|
1670
|
+
trimTrailingWhitespace(charsIndex, nextTag);
|
|
740
1671
|
}
|
|
741
1672
|
|
|
742
|
-
|
|
743
|
-
|
|
1673
|
+
// Keep or trim the whitespace pending before or between elements that don’t render
|
|
1674
|
+
function settleTransparentGap(/** @type {boolean} */ keep) {
|
|
1675
|
+
if (!keep) {
|
|
1676
|
+
buffer[transparentGapIndex] = collapseWhitespace(buffer[transparentGapIndex] ?? '', options, false, true);
|
|
1677
|
+
}
|
|
1678
|
+
transparentGapIndex = -1;
|
|
744
1679
|
}
|
|
745
1680
|
|
|
746
|
-
|
|
747
|
-
|
|
1681
|
+
// The index of the start tag whose attributes, which follow it as entries
|
|
1682
|
+
// of their own, end at `index`, or -1 for text
|
|
1683
|
+
function findAttributesStartTag(/** @type {number} */ index) {
|
|
1684
|
+
if ((buffer[index] ?? '').charCodeAt((buffer[index] ?? '').length - 1) !== 62 /* > */) return -1;
|
|
1685
|
+
for (let tagIndex = index - 1; tagIndex >= 0; tagIndex--) {
|
|
1686
|
+
const str = buffer[tagIndex] ?? '';
|
|
1687
|
+
if (str.charCodeAt(0) === 60 /* < */) {
|
|
1688
|
+
return RE_START_TAG.test(str) && str.charCodeAt(str.length - 1) !== 62 /* > */ ? tagIndex : -1;
|
|
1689
|
+
}
|
|
1690
|
+
if (!str || str.charCodeAt(str.length - 1) === 62 /* > */) return -1;
|
|
1691
|
+
}
|
|
1692
|
+
return -1;
|
|
1693
|
+
}
|
|
1694
|
+
|
|
1695
|
+
// Whether what comes before the elements that don’t render at the end of `buffer`
|
|
1696
|
+
// would run into what follows them; without `skipAttributes`, the scan stops at
|
|
1697
|
+
// a start tag’s attributes and says yes, as stepping over them on every
|
|
1698
|
+
// call makes the scan quadratic over long runs
|
|
1699
|
+
function precedesTransparentRun(/** @type {boolean} */ skipAttributes = false) {
|
|
1700
|
+
for (let index = buffer.length - 1; index >= 0; index--) {
|
|
1701
|
+
let str = buffer[index] ?? '';
|
|
1702
|
+
if (!str) continue;
|
|
1703
|
+
if (str.charCodeAt(0) !== 60 /* < */) {
|
|
1704
|
+
const tagIndex = skipAttributes ? findAttributesStartTag(index) : -1;
|
|
1705
|
+
if (tagIndex === -1) {
|
|
1706
|
+
return !endsWithWhitespace(str);
|
|
1707
|
+
}
|
|
1708
|
+
index = tagIndex;
|
|
1709
|
+
str = buffer[index] ?? '';
|
|
1710
|
+
}
|
|
1711
|
+
// Comments don’t render, whereas what an `htmlmin:ignore` block
|
|
1712
|
+
// or a processing instruction stands for may
|
|
1713
|
+
if (str.charCodeAt(1) === 33 /* ! */ || str.charCodeAt(1) === 63 /* ? */) {
|
|
1714
|
+
if (str.charCodeAt(1) === 63 || (uidIgnore && str.indexOf(uidIgnore) !== -1)) return true;
|
|
1715
|
+
continue;
|
|
1716
|
+
}
|
|
1717
|
+
const endTagName = matchPlainEndTag(str);
|
|
1718
|
+
const tag = (endTagName === null ? (RE_START_TAG_NAME.exec(str)?.[1] ?? '') : '/' + endTagName).toLowerCase();
|
|
1719
|
+
if (!inlineElementsTransparentAfter.has(tag)) {
|
|
1720
|
+
return collapseWhitespaceSmart(' ', tag, '', emptyAttrs, emptyAttrs, options, inlineSets) !== '';
|
|
1721
|
+
}
|
|
1722
|
+
// Skip what an element that doesn’t render holds
|
|
1723
|
+
if (endTagName !== null) {
|
|
1724
|
+
const startTag = '<' + endTagName.toLowerCase();
|
|
1725
|
+
// Only the start of each entry is compared, as what such an element holds can be large
|
|
1726
|
+
while (index > 0 && (buffer[index - 1] ?? '').slice(0, startTag.length).toLowerCase() !== startTag) {
|
|
1727
|
+
index--;
|
|
1728
|
+
}
|
|
1729
|
+
index--;
|
|
1730
|
+
}
|
|
1731
|
+
}
|
|
1732
|
+
return false;
|
|
748
1733
|
}
|
|
749
1734
|
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
1735
|
+
// Per-text-node context for `charsCollapse`/`charsFinalize`, set by the `chars`
|
|
1736
|
+
// handler before either phase runs; shared variables (rather than parameters)
|
|
1737
|
+
// let both phases live at this scope—`charsCollapse` reassigns `textPrevTag`
|
|
1738
|
+
// and `charsFinalize` must see that change. Defining the phases inside the
|
|
1739
|
+
// `chars` handler instead would allocate two closures per text node.
|
|
1740
|
+
/** @type {string} */
|
|
1741
|
+
let textPrevTag = '';
|
|
1742
|
+
/** @type {string} */
|
|
1743
|
+
let textNextTag = '';
|
|
1744
|
+
/** @type {HTMLAttribute[]} */
|
|
1745
|
+
let textPrevAttrs = emptyAttrs;
|
|
1746
|
+
/** @type {HTMLAttribute[]} */
|
|
1747
|
+
let textNextAttrs = emptyAttrs;
|
|
1748
|
+
// Whether the text is in foreign content, and if so, whether it was read as HTML reads it
|
|
1749
|
+
// and so is to be escaped again
|
|
1750
|
+
let textForeign = false;
|
|
1751
|
+
let textForeignDecoded = false;
|
|
1752
|
+
|
|
1753
|
+
// Whitespace collapsing phase (sync)
|
|
1754
|
+
function charsCollapse(/** @type {string} */ text) {
|
|
1755
|
+
// Trim a single trailing newline inside `pre`/`textarea`, often a template engine artifact
|
|
1756
|
+
// (several are likely intentional formatting)
|
|
1757
|
+
if (options.collapseWhitespace && stackNoTrimWhitespace.length) {
|
|
1758
|
+
const topTag = stackNoTrimWhitespace[stackNoTrimWhitespace.length - 1];
|
|
1759
|
+
if (preTextareaDepth > 0) {
|
|
1760
|
+
// Treat CRLF (`\r\n`), CR (`\r`), and LF (`\n`) as single line-ending units
|
|
1761
|
+
if (textNextTag && textNextTag === '/' + topTag && /[^\r\n](?:\r\n|\r|\n)[ \t]*$/.test(text)) {
|
|
1762
|
+
text = text.replace(RE_LINE_BREAK_BLANKS_END, '');
|
|
1763
|
+
}
|
|
1764
|
+
}
|
|
1765
|
+
}
|
|
1766
|
+
if (options.collapseWhitespace) {
|
|
1767
|
+
if (!stackNoTrimWhitespace.length) {
|
|
1768
|
+
// When the prev item is a UID placeholder, compute its effective tag name for whitespace
|
|
1769
|
+
// decisions; this is only used in `collapseWhitespaceSmart`—`textPrevTag` itself is not
|
|
1770
|
+
// modified, to avoid side effects on the `inlineTextSet` branch below
|
|
1771
|
+
let effectivePrevTag = textPrevTag;
|
|
1772
|
+
if (textPrevTag === 'comment') {
|
|
1773
|
+
const prevComment = buffer[buffer.length - 1] ?? '';
|
|
1774
|
+
if (!uidIgnore || prevComment.indexOf(uidIgnore) === -1) {
|
|
1775
|
+
if (!prevComment) {
|
|
1776
|
+
textPrevTag = charsPrevTag;
|
|
1777
|
+
effectivePrevTag = textPrevTag;
|
|
1778
|
+
}
|
|
1779
|
+
if (buffer.length > 1 && (!prevComment || (!options.conservativeCollapse && / $/.test(currentChars)))) {
|
|
1780
|
+
const charsIndex = buffer.length - 2;
|
|
1781
|
+
buffer[charsIndex] = (buffer[charsIndex] ?? '').replace(/\s+$/, function (trailingSpaces) {
|
|
1782
|
+
text = trailingSpaces + text;
|
|
1783
|
+
return '';
|
|
1784
|
+
});
|
|
1785
|
+
}
|
|
1786
|
+
} else if (uidIgnorePlaceholderPattern && textNextTag !== 'comment') {
|
|
1787
|
+
// UID placeholder followed by a real element—derive the effective `prevTag` from the
|
|
1788
|
+
// placeholder’s last HTML tag so `collapseWhitespaceSmart` can make the right call;
|
|
1789
|
+
// `commentFinalize` handles a `textNextTag` of `comment` (another UID placeholder)
|
|
1790
|
+
const match = prevComment.match(uidIgnorePlaceholderPattern);
|
|
1791
|
+
if (match) {
|
|
1792
|
+
const idx = +(match[1] ?? '');
|
|
1793
|
+
if (idx < ignoredMarkupChunks.length) {
|
|
1794
|
+
const content = ignoredMarkupChunks[idx];
|
|
1795
|
+
const lastTagMatch = content && RE_LAST_HTML_TAG.exec(content);
|
|
1796
|
+
if (lastTagMatch) {
|
|
1797
|
+
const group = lastTagMatch[1] ?? '';
|
|
1798
|
+
const isClose = group.charAt(0) === '/';
|
|
1799
|
+
const tagName = resolveName(isClose ? group.slice(1) : group);
|
|
1800
|
+
effectivePrevTag = isClose ? '/' + tagName : tagName;
|
|
1801
|
+
}
|
|
1802
|
+
}
|
|
1803
|
+
}
|
|
757
1804
|
}
|
|
758
|
-
attrChains[tag].add(attrNames(attrs).filter(shouldSkipUIDs));
|
|
759
1805
|
}
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
1806
|
+
if (textPrevTag) {
|
|
1807
|
+
if (textPrevTag === '/nobr' || textPrevTag === 'wbr') {
|
|
1808
|
+
if (/^\s/.test(text)) {
|
|
1809
|
+
let tagIndex = buffer.length - 1;
|
|
1810
|
+
while (tagIndex > 0 && (namesAsWritten ? (buffer[tagIndex] ?? '').toLowerCase() : buffer[tagIndex] ?? '').lastIndexOf('<' + textPrevTag) !== 0) {
|
|
1811
|
+
tagIndex--;
|
|
1812
|
+
}
|
|
1813
|
+
trimTrailingWhitespace(tagIndex - 1, 'br');
|
|
1814
|
+
}
|
|
1815
|
+
} else if (inlineSets.withinEither.has(textPrevTag)) {
|
|
1816
|
+
text = collapseWhitespace(text, options, /(?:^|\s)$/.test(currentChars), false, false, stackNoCollapseWhitespace.length > 0);
|
|
764
1817
|
}
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
1818
|
+
}
|
|
1819
|
+
if (textPrevTag || textNextTag) {
|
|
1820
|
+
let collapsed = collapseWhitespaceSmart(text, effectivePrevTag, textNextTag, textPrevAttrs, textNextAttrs, options, inlineSets, !stackNoCollapseWhitespace.length);
|
|
1821
|
+
if (!transparentDepth && inlineElementsTransparentAfter.has(textPrevTag)) {
|
|
1822
|
+
// Between elements that don’t render, whitespace stays pending when it may
|
|
1823
|
+
// separate text, and one space does
|
|
1824
|
+
if (!collapsed && text && inlineElementsTransparent.has(textNextTag)) {
|
|
1825
|
+
if (transparentGapIndex === -1 && precedesTransparentRun()) {
|
|
1826
|
+
transparentGapPending = true;
|
|
1827
|
+
text = ' ';
|
|
1828
|
+
} else {
|
|
1829
|
+
text = '';
|
|
1830
|
+
}
|
|
1831
|
+
} else {
|
|
1832
|
+
// Text that follows decides, while a tag that follows does so in
|
|
1833
|
+
// `squashTrailingWhitespace`
|
|
1834
|
+
if (RE_LEADING_WHITESPACE.test(collapsed)) {
|
|
1835
|
+
if (transparentGapIndex !== -1) settleTransparentGap(false);
|
|
1836
|
+
// Where nothing before the elements renders a space, the text’s own goes, too
|
|
1837
|
+
if (!options.conservativeCollapse && !precedesTransparentRun(true)) {
|
|
1838
|
+
collapsed = collapseWhitespace(collapsed, options, true, false);
|
|
1839
|
+
}
|
|
1840
|
+
} else if (transparentGapIndex !== -1 && collapsed) {
|
|
1841
|
+
settleTransparentGap(true);
|
|
1842
|
+
}
|
|
1843
|
+
text = collapsed;
|
|
1844
|
+
}
|
|
1845
|
+
} else {
|
|
1846
|
+
text = collapsed;
|
|
1847
|
+
// Whitespace before an element that doesn’t render waits for what follows it, too
|
|
1848
|
+
if (!transparentDepth && inlineElementsTransparent.has(textNextTag) && endsWithWhitespace(text)) {
|
|
1849
|
+
transparentGapPending = true;
|
|
1850
|
+
}
|
|
768
1851
|
}
|
|
1852
|
+
} else {
|
|
1853
|
+
text = collapseWhitespace(text, options, true, true);
|
|
769
1854
|
}
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
currentTag = '';
|
|
773
|
-
},
|
|
774
|
-
chars: function(text) {
|
|
775
|
-
if (options.processScripts && specialContentTags(currentTag) &&
|
|
776
|
-
options.processScripts.indexOf(currentType) > -1) {
|
|
777
|
-
scan(text);
|
|
1855
|
+
if (!text && RE_TRAILING_WHITESPACE.test(currentChars) && textPrevTag && textPrevTag.charAt(0) === '/') {
|
|
1856
|
+
trimTrailingWhitespace(buffer.length - 1, textNextTag);
|
|
778
1857
|
}
|
|
779
1858
|
}
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
scan(minify(value, options));
|
|
788
|
-
options.log = log;
|
|
789
|
-
if (attrChains) {
|
|
790
|
-
var attrSorters = Object.create(null);
|
|
791
|
-
for (var tag in attrChains) {
|
|
792
|
-
attrSorters[tag] = attrChains[tag].createSorter();
|
|
1859
|
+
// Whitespace that may not be trimmed stays verbatim, as in `pre`
|
|
1860
|
+
if (!stackNoCollapseWhitespace.length && !stackNoTrimWhitespace.length && textNextTag !== 'html' && !(textPrevTag && textNextTag)) {
|
|
1861
|
+
text = collapseWhitespace(text, options, false, false, true);
|
|
1862
|
+
}
|
|
1863
|
+
if (options.collapseNoBreakSpaces && !stackNoCollapseWhitespace.length && !holdsRawText()) {
|
|
1864
|
+
text = collapseNoBreakSpaces(text, options);
|
|
1865
|
+
}
|
|
793
1866
|
}
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
1867
|
+
return text;
|
|
1868
|
+
}
|
|
1869
|
+
|
|
1870
|
+
// Finalization phase (sync): Optional tag handling, entity re-encoding, buffer push
|
|
1871
|
+
function charsFinalize(/** @type {string} */ text) {
|
|
1872
|
+
if (options.removeOptionalTags && text) {
|
|
1873
|
+
// UID-attr tokens are padded with `\t`, which would falsely look like leading whitespace;
|
|
1874
|
+
// resolve single-token text to its actual content for the space/comment checks below
|
|
1875
|
+
let effectiveText = text;
|
|
1876
|
+
if (uidAttrLeadingPattern && uidAttr && text.includes(uidAttr)) {
|
|
1877
|
+
const uidMatch = uidAttrLeadingPattern.exec(text);
|
|
1878
|
+
if (uidMatch) {
|
|
1879
|
+
const idx = +(uidMatch[1] ?? 0);
|
|
1880
|
+
const chunks = idx < ignoredCustomMarkupChunks.length ? ignoredCustomMarkupChunks[idx] : null;
|
|
1881
|
+
if (chunks != null) {
|
|
1882
|
+
effectiveText = chunks[0] ?? '';
|
|
1883
|
+
}
|
|
1884
|
+
}
|
|
805
1885
|
}
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
1886
|
+
// `<html>` may be omitted if first thing inside is not a comment
|
|
1887
|
+
// `<body>` may be omitted if first thing inside is not space, comment, `<meta>`, `<link>`,
|
|
1888
|
+
// `<script>`, `<style>`, or `<template>`
|
|
1889
|
+
if (optionalStartTag === 'html' || (optionalStartTag === 'body' && !/^\s/.test(effectiveText))) {
|
|
1890
|
+
removeStartTag();
|
|
1891
|
+
}
|
|
1892
|
+
optionalStartTag = '';
|
|
1893
|
+
// `</html>` or `</body>` may be omitted if not followed by comment
|
|
1894
|
+
// `</head>`, `</colgroup>`, or `</caption>` may be omitted if not followed by space or
|
|
1895
|
+
// comment
|
|
1896
|
+
if (optionalEndTagEmitted && (compactElements.has(optionalEndTag) || (looseElements.has(optionalEndTag) && !/^\s/.test(effectiveText)))) {
|
|
1897
|
+
removeEndTag();
|
|
1898
|
+
// The tag is gone from the buffer, so nothing is left to omit later
|
|
1899
|
+
optionalEndTag = '';
|
|
1900
|
+
optionalEndTagEmitted = false;
|
|
1901
|
+
}
|
|
1902
|
+
// Whitespace-only text doesn’t prevent omission of the pending end tag: It’s either
|
|
1903
|
+
// collapsed away, or kept in place while the tag alone is dropped—except for
|
|
1904
|
+
// `</head>`, `</colgroup>`, and `</caption>`, which may not be followed by space.
|
|
1905
|
+
// Only ASCII whitespace counts, as no-break space and friends are content
|
|
1906
|
+
// https://html.spec.whatwg.org/multipage/syntax.html#optional-tags
|
|
1907
|
+
if (!RE_WS_ONLY.test(text) || (keepsWhitespace() && looseElements.has(optionalEndTag))) {
|
|
1908
|
+
optionalEndTag = '';
|
|
1909
|
+
optionalEndTagEmitted = false;
|
|
1910
|
+
}
|
|
1911
|
+
}
|
|
1912
|
+
charsPrevTag = isAllWhitespace(text) ? textPrevTag : 'comment';
|
|
1913
|
+
const rawText = Boolean(text) && holdsRawText();
|
|
1914
|
+
if (text) {
|
|
1915
|
+
textVerbatim = rawText || stackNoTrimWhitespace.length > 0;
|
|
1916
|
+
}
|
|
1917
|
+
if (text && options.insideSVG && options.minifySVG) {
|
|
1918
|
+
// SVGO reads XML, so text is escaped for it, while raw text, which XML cannot read as
|
|
1919
|
+
// HTML does (as in `foreignObject`), passes SVGO as a placeholder
|
|
1920
|
+
if (!rawText) {
|
|
1921
|
+
text = escapeXML(text);
|
|
1922
|
+
} else if (text.indexOf('&') !== -1 || text.indexOf('<') !== -1 || text.indexOf(']]>') !== -1) {
|
|
1923
|
+
uidSVGRawText ||= uniqueId(value);
|
|
1924
|
+
svgRawTexts.push(text);
|
|
1925
|
+
text = '/*!' + uidSVGRawText + (svgRawTexts.length - 1) + '*/';
|
|
1926
|
+
}
|
|
1927
|
+
} else if (text && (textForeign ? textForeignDecoded : options.decodeEntities && !rawText)) {
|
|
1928
|
+
// Escape every `&` that starts a character reference, legacy-named (without `;`) or not;
|
|
1929
|
+
// `&` needs no semicolon either
|
|
1930
|
+
// https://mathiasbynens.be/notes/ambiguous-ampersands
|
|
1931
|
+
if (text.indexOf('&') !== -1) {
|
|
1932
|
+
text = text.replace(RE_LEGACY_ENTITIES, '&$1');
|
|
1933
|
+
}
|
|
1934
|
+
if (text.indexOf('<') !== -1) {
|
|
1935
|
+
// Escapable raw text ends at its own end tag alone, so only that one needs escaping;
|
|
1936
|
+
// in foreign content the same element holds markup, where every `<` does
|
|
1937
|
+
const escapeLt = (textForeign ? undefined : RE_ESCAPE_LT_RAW_TEXT[currentTag]) ?? RE_ESCAPE_LT;
|
|
1938
|
+
text = text.replace(escapeLt, '<');
|
|
1939
|
+
}
|
|
1940
|
+
if (text.indexOf('\r') !== -1) {
|
|
1941
|
+
text = text.replace(RE_CR, ' ');
|
|
1942
|
+
}
|
|
1943
|
+
} else if (text && textForeign && text.lastIndexOf('<![CDATA[') > text.lastIndexOf(']]>')) {
|
|
1944
|
+
// A CDATA section left open at the end of the input closes before the
|
|
1945
|
+
// end tags written after it
|
|
1946
|
+
text += ']]>';
|
|
1947
|
+
}
|
|
1948
|
+
if (uidPattern && options.collapseWhitespace && stackNoTrimWhitespace.length) {
|
|
1949
|
+
text = text.replace(/** @type {RegExp} */ (uidPattern), function (/** @type {string} */ match, /** @type {string} */ _prefix, /** @type {string} */ index) {
|
|
1950
|
+
return ignoredCustomMarkupChunks[+index]?.[0] ?? match;
|
|
1951
|
+
});
|
|
1952
|
+
}
|
|
1953
|
+
currentChars += text;
|
|
1954
|
+
if (text) {
|
|
1955
|
+
hasChars = true;
|
|
1956
|
+
markOpenElementFilled();
|
|
1957
|
+
}
|
|
1958
|
+
const followsRemoval = removed && Boolean(text) && !options.collapseWhitespace && !stackNoTrimWhitespace.length;
|
|
1959
|
+
if (transparentGapPending) {
|
|
1960
|
+
transparentGapPending = false;
|
|
1961
|
+
if (text) transparentGapIndex = buffer.length;
|
|
1962
|
+
}
|
|
1963
|
+
bufferPush(text, false);
|
|
1964
|
+
if (followsRemoval) {
|
|
1965
|
+
removeWhitespaceLine(buffer.length - 1);
|
|
1966
|
+
}
|
|
813
1967
|
}
|
|
814
|
-
}
|
|
815
1968
|
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
1969
|
+
// Comment finalization (sync): Optional tags, `htmlmin:ignore` whitespace, buffer push
|
|
1970
|
+
function commentFinalize(/** @type {string} */ comment) {
|
|
1971
|
+
if (options.removeOptionalTags && comment) {
|
|
1972
|
+
if (uidIgnorePlaceholderPattern) {
|
|
1973
|
+
const match = uidIgnorePlaceholderPattern.exec(comment);
|
|
1974
|
+
if (match) {
|
|
1975
|
+
// UID placeholders represent real HTML content, not true HTML comments;
|
|
1976
|
+
// if there’s a pending optional end tag and the ignored content isn’t itself
|
|
1977
|
+
// a comment (which per the HTML spec prevents omission), resolve it now,
|
|
1978
|
+
// before the UID is pushed to the buffer
|
|
1979
|
+
const idx = +(match[1] ?? 0);
|
|
1980
|
+
const content = idx < ignoredMarkupChunks.length ? ignoredMarkupChunks[idx] : null;
|
|
1981
|
+
if (optionalEndTag && optionalEndTagEmitted && content != null && !/^\s*<!--/.test(content)) {
|
|
1982
|
+
const firstTagMatch = content.match(/^\s*<([a-zA-Z][^\s/>]*)/);
|
|
1983
|
+
const firstTagGroup = firstTagMatch?.[1] ?? '';
|
|
1984
|
+
const firstTag = firstTagGroup ? resolveName(firstTagGroup) : '';
|
|
1985
|
+
if (canRemovePrecedingTag(optionalEndTag, firstTag)) {
|
|
1986
|
+
removeEndTag();
|
|
1987
|
+
}
|
|
1988
|
+
}
|
|
1989
|
+
}
|
|
1990
|
+
}
|
|
1991
|
+
// Comments (real or placeholder) always suppress optional start tag omissions
|
|
1992
|
+
optionalStartTag = '';
|
|
1993
|
+
optionalEndTag = '';
|
|
1994
|
+
optionalEndTagEmitted = false;
|
|
1995
|
+
}
|
|
820
1996
|
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
// so that we don't have to worry what's there.
|
|
839
|
-
// for all we care there might be
|
|
840
|
-
// completely-horribly-broken-alien-non-html-emoj-cthulhu-filled content
|
|
841
|
-
value = value.replace(/<!-- htmlmin:ignore -->([\s\S]*?)<!-- htmlmin:ignore -->/g, function(match, group1) {
|
|
842
|
-
if (!uidIgnore) {
|
|
843
|
-
uidIgnore = uniqueId(value);
|
|
844
|
-
var pattern = new RegExp('^' + uidIgnore + '([0-9]+)$');
|
|
845
|
-
if (options.ignoreCustomComments) {
|
|
846
|
-
options.ignoreCustomComments = options.ignoreCustomComments.slice();
|
|
847
|
-
}
|
|
848
|
-
else {
|
|
849
|
-
options.ignoreCustomComments = [];
|
|
850
|
-
}
|
|
851
|
-
options.ignoreCustomComments.push(pattern);
|
|
852
|
-
}
|
|
853
|
-
var token = '<!--' + uidIgnore + ignoredMarkupChunks.length + '-->';
|
|
854
|
-
ignoredMarkupChunks.push(group1);
|
|
855
|
-
return token;
|
|
856
|
-
});
|
|
1997
|
+
// Whitespace between an element that doesn’t render and an `htmlmin:ignore` block
|
|
1998
|
+
// that starts with one depends on what lies before both
|
|
1999
|
+
if (options.collapseWhitespace && comment && uidIgnorePlaceholderPattern && !transparentDepth && !stackNoTrimWhitespace.length && buffer.length >= 2) {
|
|
2000
|
+
const prevText = buffer[buffer.length - 1] ?? '';
|
|
2001
|
+
const match = prevText && RE_WS_ONLY.test(prevText) && uidIgnorePlaceholderPattern.exec(comment);
|
|
2002
|
+
const firstTag = match ? RE_FIRST_HTML_TAG.exec(ignoredMarkupChunks[+(match[1] ?? 0)] ?? '')?.[1] : undefined;
|
|
2003
|
+
if (firstTag && inlineElementsTransparent.has(resolveName(firstTag)) && inlineElementsTransparentAfter.has(charsPrevTag)) {
|
|
2004
|
+
// Emptied first, as `precedesTransparentRun` stops at text
|
|
2005
|
+
buffer[buffer.length - 1] = '';
|
|
2006
|
+
if (transparentGapIndex === buffer.length - 1) transparentGapIndex = -1;
|
|
2007
|
+
buffer[buffer.length - 1] = options.conservativeCollapse || precedesTransparentRun(true) ? ' ' : '';
|
|
2008
|
+
}
|
|
2009
|
+
}
|
|
2010
|
+
// What an `htmlmin:ignore` block holds is unknown, so whitespace pending before it stays
|
|
2011
|
+
if (transparentGapIndex !== -1 && !transparentDepth && comment && uidIgnorePlaceholderPattern?.test(comment)) {
|
|
2012
|
+
settleTransparentGap(true);
|
|
2013
|
+
}
|
|
857
2014
|
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
if (
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
2015
|
+
// Optimize whitespace collapsing between consecutive `htmlmin:ignore` placeholder comments
|
|
2016
|
+
if (options.collapseWhitespace && comment && uidIgnorePlaceholderPattern) {
|
|
2017
|
+
if (uidIgnorePlaceholderPattern.test(comment)) {
|
|
2018
|
+
// Check if previous buffer items are: [ignore-placeholder, whitespace-only text]
|
|
2019
|
+
if (buffer.length >= 2) {
|
|
2020
|
+
const prevText = buffer[buffer.length - 1];
|
|
2021
|
+
const prevComment = buffer[buffer.length - 2];
|
|
2022
|
+
|
|
2023
|
+
if (prevText && RE_WHITESPACE_ONLY.test(prevText) && prevComment && uidIgnorePlaceholderPattern.test(prevComment)) {
|
|
2024
|
+
const currentMatch = comment.match(uidIgnorePlaceholderPattern);
|
|
2025
|
+
const prevMatch = prevComment.match(uidIgnorePlaceholderPattern);
|
|
2026
|
+
|
|
2027
|
+
if (currentMatch && prevMatch) {
|
|
2028
|
+
const currentIndex = +(currentMatch[1] ?? 0);
|
|
2029
|
+
const prevIndex = +(prevMatch[1] ?? 0);
|
|
2030
|
+
|
|
2031
|
+
if (currentIndex < ignoredMarkupChunks.length && prevIndex < ignoredMarkupChunks.length) {
|
|
2032
|
+
const currentContent = ignoredMarkupChunks[currentIndex];
|
|
2033
|
+
const prevContent = ignoredMarkupChunks[prevIndex];
|
|
2034
|
+
|
|
2035
|
+
// Only collapse whitespace if both blocks contain HTML (start with `<`)
|
|
2036
|
+
// Don’t collapse if either contains plain text, as that would change meaning
|
|
2037
|
+
if (currentContent && prevContent && /^\s*</.test(currentContent) && /^\s*</.test(prevContent)) {
|
|
2038
|
+
const currentTagMatch = currentContent.match(RE_FIRST_HTML_TAG);
|
|
2039
|
+
const prevTagMatch = prevContent.match(RE_FIRST_HTML_TAG);
|
|
2040
|
+
// HTML comments are invisible (no block/inline nature), treat as non-inline
|
|
2041
|
+
const prevIsHtmlComment = !prevTagMatch && RE_HTML_COMMENT_START.test(prevContent);
|
|
2042
|
+
const currentIsHtmlComment = !currentTagMatch && RE_HTML_COMMENT_START.test(currentContent);
|
|
2043
|
+
// Closing tags (e.g., `</div>`)—inline-ness determines whether to collapse
|
|
2044
|
+
const prevClosingTagMatch = !prevTagMatch && RE_CLOSING_TAG_START.exec(prevContent);
|
|
2045
|
+
const currentClosingTagMatch = !currentTagMatch && RE_CLOSING_TAG_START.exec(currentContent);
|
|
2046
|
+
|
|
2047
|
+
// Collapse if both sides are element/closing tags or HTML
|
|
2048
|
+
// comments, and neither is inline
|
|
2049
|
+
if ((currentTagMatch || currentIsHtmlComment || currentClosingTagMatch) &&
|
|
2050
|
+
(prevTagMatch || prevIsHtmlComment || prevClosingTagMatch)) {
|
|
2051
|
+
const currentTagName = currentTagMatch ? resolveName(currentTagMatch[1] ?? '')
|
|
2052
|
+
: currentClosingTagMatch ? resolveName(currentClosingTagMatch[1] ?? '') : null;
|
|
2053
|
+
const prevTagName = prevTagMatch ? resolveName(prevTagMatch[1] ?? '')
|
|
2054
|
+
: prevClosingTagMatch ? resolveName(prevClosingTagMatch[1] ?? '') : null;
|
|
2055
|
+
|
|
2056
|
+
// Don’t collapse between inline elements (HTML comments count as non-inline)
|
|
2057
|
+
if (!inlineElements.has(currentTagName ?? '') && !inlineElements.has(prevTagName ?? '')) {
|
|
2058
|
+
let collapsedText = prevText;
|
|
2059
|
+
|
|
2060
|
+
if (!stackNoTrimWhitespace.length && !stackNoCollapseWhitespace.length) {
|
|
2061
|
+
// Not in pre or other no-collapse context
|
|
2062
|
+
if (prevText.includes('\xA0')) {
|
|
2063
|
+
// No-break spaces are content, so every one of them survives in place;
|
|
2064
|
+
// the ASCII whitespace around them collapses as it would anywhere else
|
|
2065
|
+
collapsedText = collapseWhitespace(prevText, options, true, true, true);
|
|
2066
|
+
} else if (options.preserveLineBreaks && /[\n\r]/.test(prevText)) {
|
|
2067
|
+
// Preserve line break as single newline
|
|
2068
|
+
collapsedText = '\n';
|
|
2069
|
+
} else if (options.conservativeCollapse) {
|
|
2070
|
+
// Conservative mode: Keep single space
|
|
2071
|
+
collapsedText = ' ';
|
|
2072
|
+
} else {
|
|
2073
|
+
// Aggressive mode: Remove all whitespace
|
|
2074
|
+
collapsedText = '';
|
|
2075
|
+
}
|
|
2076
|
+
}
|
|
2077
|
+
|
|
2078
|
+
buffer[buffer.length - 1] = collapsedText;
|
|
2079
|
+
}
|
|
2080
|
+
}
|
|
882
2081
|
}
|
|
883
|
-
}
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
text = text.replace(ignoreCSS(id), id);
|
|
887
|
-
});
|
|
888
|
-
return text;
|
|
889
|
-
};
|
|
890
|
-
})(options.minifyCSS);
|
|
891
|
-
}
|
|
892
|
-
if (options.minifyJS) {
|
|
893
|
-
options.minifyJS = (function(fn) {
|
|
894
|
-
return function(text, type) {
|
|
895
|
-
return fn(text.replace(uidPattern, function(match, prefix, index) {
|
|
896
|
-
var chunks = ignoredCustomMarkupChunks[+index];
|
|
897
|
-
return chunks[1] + uidAttr + index + chunks[2];
|
|
898
|
-
}), type);
|
|
899
|
-
};
|
|
900
|
-
})(options.minifyJS);
|
|
2082
|
+
}
|
|
2083
|
+
}
|
|
2084
|
+
}
|
|
901
2085
|
}
|
|
902
2086
|
}
|
|
903
|
-
|
|
904
|
-
ignoredCustomMarkupChunks.push(/^(\s*)[\s\S]*?(\s*)$/.exec(match));
|
|
905
|
-
return '\t' + token + '\t';
|
|
906
|
-
});
|
|
907
|
-
}
|
|
2087
|
+
}
|
|
908
2088
|
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
2089
|
+
bufferPush(comment, false);
|
|
2090
|
+
}
|
|
2091
|
+
|
|
2092
|
+
// Inline scripts minified as the parse reaches them run one at a time; dispatching them all up
|
|
2093
|
+
// front lets the minifier overlap the work. Bodies are matched by exact content, so one the parse
|
|
2094
|
+
// asks for in another form misses and takes the original path.
|
|
2095
|
+
/** @type {Map<string, Promise<string>> | null} */
|
|
2096
|
+
let jsPrewarmed = null;
|
|
2097
|
+
if (options.parallelJS && options.minifyJS !== identity && !options.processScripts?.length) {
|
|
2098
|
+
const bodies = extractScriptBodies(value);
|
|
2099
|
+
if (bodies.length > 1) {
|
|
2100
|
+
jsPrewarmed = new Map();
|
|
2101
|
+
// `collapseWhitespace` reaches script bodies through the whole-document pass above,
|
|
2102
|
+
// so the parse sees them trimmed; other whitespace modes leave them as they are
|
|
2103
|
+
const trims = Boolean(options.collapseWhitespace) &&
|
|
2104
|
+
!options.conservativeCollapse && !options.preserveLineBreaks;
|
|
2105
|
+
for (const { code, isModule } of bodies) {
|
|
2106
|
+
const body = trims ? code.trim() : code;
|
|
2107
|
+
const key = (isModule ? 'm|' : '|') + body;
|
|
2108
|
+
if (jsPrewarmed.has(key)) {
|
|
2109
|
+
continue;
|
|
2110
|
+
}
|
|
2111
|
+
const pending = Promise.resolve(options.minifyJS(body, false, isModule));
|
|
2112
|
+
// The awaiting consumer reports the failure; a body the parse never asks for
|
|
2113
|
+
// would not have been minified at all, so its failure is not ours to raise
|
|
2114
|
+
pending.catch(() => {});
|
|
2115
|
+
jsPrewarmed.set(key, pending);
|
|
2116
|
+
}
|
|
2117
|
+
}
|
|
912
2118
|
}
|
|
913
2119
|
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
2120
|
+
// Writes out a start tag for the `start` handler—right away, or once attribute values
|
|
2121
|
+
// that need asynchronous work (lazy module imports, minified URLs) resolve
|
|
2122
|
+
/**
|
|
2123
|
+
* @param {Array<{name: string, value: string | undefined, attr: HTMLAttribute} | undefined>} normalizedAttrs
|
|
2124
|
+
* @param {string} tag
|
|
2125
|
+
* @param {boolean} unary
|
|
2126
|
+
* @param {boolean} autoGenerated
|
|
2127
|
+
* @param {boolean | undefined} fragmentJoinsTag
|
|
2128
|
+
* @param {string | boolean | undefined} hasUnarySlash
|
|
2129
|
+
* @param {boolean | undefined} optional
|
|
2130
|
+
* @param {ProcessedOptions | null} restoreOptions - Context a unary tag pushed, or null
|
|
2131
|
+
*/
|
|
2132
|
+
function emitStartTag(normalizedAttrs, tag, unary, autoGenerated, fragmentJoinsTag, hasUnarySlash, optional, restoreOptions) {
|
|
2133
|
+
// Find the last kept attribute, then emit in order, with no intermediate array
|
|
2134
|
+
let lastKeptIndex = -1;
|
|
2135
|
+
for (let i = normalizedAttrs.length - 1; i >= 0; i--) {
|
|
2136
|
+
if (normalizedAttrs[i]) {
|
|
2137
|
+
lastKeptIndex = i;
|
|
2138
|
+
break;
|
|
2139
|
+
}
|
|
2140
|
+
}
|
|
2141
|
+
// Without `maxLineLength`, a start tag is one buffer entry rather than one per attribute,
|
|
2142
|
+
// which keeps the buffer and the final join small (lines only break between entries);
|
|
2143
|
+
// the loop is written out per case, as a check per attribute measurably slows it down
|
|
2144
|
+
let tagText = '';
|
|
2145
|
+
if (lastKeptIndex !== -1) {
|
|
2146
|
+
if (!bufferTags) {
|
|
2147
|
+
tagText = fragmentJoinsTag ? '' : ' ';
|
|
2148
|
+
for (let i = 0; i <= lastKeptIndex; i++) {
|
|
2149
|
+
const normalizedAttr = normalizedAttrs[i];
|
|
2150
|
+
if (normalizedAttr) {
|
|
2151
|
+
if (uidAttr && normalizedAttr.name.includes(uidAttr)) {
|
|
2152
|
+
markTagFragments(normalizedAttr.name);
|
|
2153
|
+
}
|
|
2154
|
+
tagText += buildAttr(normalizedAttr, hasUnarySlash, options, i === lastKeptIndex, uidAttr);
|
|
2155
|
+
}
|
|
2156
|
+
}
|
|
2157
|
+
} else {
|
|
2158
|
+
if (!fragmentJoinsTag) {
|
|
2159
|
+
bufferPush(' ', true);
|
|
2160
|
+
}
|
|
2161
|
+
for (let i = 0; i <= lastKeptIndex; i++) {
|
|
2162
|
+
const normalizedAttr = normalizedAttrs[i];
|
|
2163
|
+
if (normalizedAttr) {
|
|
2164
|
+
if (uidAttr && normalizedAttr.name.includes(uidAttr)) {
|
|
2165
|
+
markTagFragments(normalizedAttr.name);
|
|
2166
|
+
}
|
|
2167
|
+
const builtAttr = buildAttr(normalizedAttr, hasUnarySlash, options, i === lastKeptIndex, uidAttr);
|
|
2168
|
+
// One entry with the tag name, so that no line break can come between them
|
|
2169
|
+
if (i === 0 && fragmentJoinsTag) {
|
|
2170
|
+
buffer[buffer.length - 1] += builtAttr;
|
|
2171
|
+
} else {
|
|
2172
|
+
bufferPush(builtAttr, true);
|
|
2173
|
+
}
|
|
2174
|
+
}
|
|
2175
|
+
}
|
|
2176
|
+
}
|
|
2177
|
+
} else if (optional && optionalStartTags.has(tag)) {
|
|
2178
|
+
// Start tag must never be omitted if it has any attributes
|
|
2179
|
+
optionalStartTag = tag;
|
|
2180
|
+
}
|
|
917
2181
|
|
|
918
|
-
|
|
919
|
-
return options.canTrimWhitespace(tag, attrs, canTrimWhitespace);
|
|
920
|
-
}
|
|
2182
|
+
buffer[buffer.length - 1] += tagText + (hasUnarySlash ? '/' : '') + '>';
|
|
921
2183
|
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
2184
|
+
impliedStartTag = autoGenerated && !options.includeAutoGeneratedTags ? tag : '';
|
|
2185
|
+
if (impliedStartTag) {
|
|
2186
|
+
removeStartTag();
|
|
2187
|
+
optionalStartTag = '';
|
|
2188
|
+
currentTag = '';
|
|
926
2189
|
}
|
|
927
|
-
buffer.length = Math.max(0, index);
|
|
928
|
-
}
|
|
929
2190
|
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
index--;
|
|
2191
|
+
// `end` restores what a start tag pushed, and a unary tag never reaches it
|
|
2192
|
+
if (restoreOptions) {
|
|
2193
|
+
options = restoreOptions;
|
|
934
2194
|
}
|
|
935
|
-
buffer.length = Math.max(0, index);
|
|
936
|
-
}
|
|
937
2195
|
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
var str = buffer[index];
|
|
942
|
-
var match = str.match(/^<\/([\w:-]+)>$/);
|
|
943
|
-
if (match) {
|
|
944
|
-
endTag = match[1];
|
|
945
|
-
}
|
|
946
|
-
else if (/>$/.test(str) || (buffer[index] = collapseWhitespaceSmart(str, null, nextTag, options))) {
|
|
947
|
-
break;
|
|
948
|
-
}
|
|
2196
|
+
// What a unary tag holds ends with the tag, so no text can belong to it
|
|
2197
|
+
if (unary) {
|
|
2198
|
+
currentTag = '';
|
|
949
2199
|
}
|
|
950
2200
|
}
|
|
951
2201
|
|
|
952
|
-
//
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
2202
|
+
// SVG subtree capture: When SVGO is active, record buffer positions for post-processing
|
|
2203
|
+
/** @type {Array<{start: number, end: number}>} */
|
|
2204
|
+
const svgBlocks = [];
|
|
2205
|
+
let svgBufferStartIndex = -1;
|
|
2206
|
+
let svgDepth = 0;
|
|
2207
|
+
// HTML raw text in an SVG block, which XML cannot read as HTML does,
|
|
2208
|
+
// passes SVGO as a placeholder—a comment, as SVGO minifies `style` elements
|
|
2209
|
+
// wherever they are
|
|
2210
|
+
/** @type {string[]} */
|
|
2211
|
+
const svgRawTexts = [];
|
|
2212
|
+
let uidSVGRawText = '';
|
|
2213
|
+
|
|
2214
|
+
// One-time probe: If the input contains no SVG/MathML elements and the call
|
|
2215
|
+
// isn’t already inside foreign content (recursive calls inherit options),
|
|
2216
|
+
// the per-tag foreign-content handling in `start`/`end` can be skipped
|
|
2217
|
+
const hasForeignContext = Boolean(options.insideForeignContent) || RE_FOREIGN_ELEMENT_PROBE.test(value);
|
|
2218
|
+
|
|
2219
|
+
const parser = new HTMLParser(value, {
|
|
2220
|
+
partialMarkup: partialMarkup ?? options.partialMarkup,
|
|
2221
|
+
// The short doctype makes the output no-quirks, so the input is read as such
|
|
2222
|
+
noQuirksMode: options.useShortDoctype,
|
|
2223
|
+
continueOnParseError: options.continueOnParseError,
|
|
2224
|
+
// Recovered errors would have aborted without the option, so they are worth knowing about
|
|
2225
|
+
parseError: options.continueOnParseError && options.log !== identity
|
|
2226
|
+
? (/** @type {string} */ message) => options.log('Warning: ' + message)
|
|
2227
|
+
: undefined,
|
|
2228
|
+
customAttrAssign: options.customAttrAssign,
|
|
2229
|
+
customAttrSurround: options.customAttrSurround,
|
|
2230
|
+
// Compute `nextTag` only when whitespace collapse features require it
|
|
2231
|
+
wantsNextTag: !!(options.collapseWhitespace || options.collapseInlineTagWhitespace || options.conservativeCollapse),
|
|
2232
|
+
// `keepClosingSlash` calls the slash meaningful, which decides how the input is read as well
|
|
2233
|
+
selfClosingSlash: options.keepClosingSlash,
|
|
2234
|
+
|
|
2235
|
+
start: function (/** @type {string} */ tag, /** @type {HTMLAttribute[]} */ attrs, /** @type {boolean} */ unary, /** @type {string} */ unarySlash, /** @type {boolean} */ autoGenerated) {
|
|
2236
|
+
// `lowerTag` stays '' when no foreign content is around—the SVG/MathML
|
|
2237
|
+
// checks below can then never match, and no per-tag lowercasing is needed
|
|
2238
|
+
let lowerTag = '';
|
|
2239
|
+
let useNameParentForTag = false;
|
|
2240
|
+
// The context the start tag sits in, which the branches below leave behind
|
|
2241
|
+
const optionsParent = options;
|
|
2242
|
+
let pushedContext = false;
|
|
2243
|
+
if (hasForeignContext) {
|
|
2244
|
+
lowerTag = tag.toLowerCase();
|
|
2245
|
+
if (lowerTag === 'svg' || lowerTag === 'math') {
|
|
2246
|
+
if (lowerTag === 'math' && options.documentState) {
|
|
2247
|
+
options.documentState.hasMathML = true;
|
|
2248
|
+
}
|
|
2249
|
+
// Preserve the surrounding HTML context’s slash handling so
|
|
2250
|
+
// `foreignObject`/`annotation-xml` can restore it
|
|
2251
|
+
const keepClosingSlashHTML = Boolean(options.keepClosingSlash);
|
|
2252
|
+
options = Object.create(options);
|
|
2253
|
+
// SVGO reads an SVG block whole, nested MathML included, so what the
|
|
2254
|
+
// block is stays true inside it
|
|
2255
|
+
options.insideSVG = lowerTag === 'svg' || Boolean(options.insideSVG);
|
|
2256
|
+
// XML reads names as written, so wherever SVGO reads, they are written the way HTML reads
|
|
2257
|
+
// them (`viewbox` as `viewBox`), and attributes differing only in case are one attribute
|
|
2258
|
+
const readBySVGO = options.insideSVG && Boolean(options.minifySVG);
|
|
2259
|
+
options.caseSensitive = !readBySVGO;
|
|
2260
|
+
options.keepClosingSlash = true;
|
|
2261
|
+
options.name = readBySVGO ? nameForeign : identity;
|
|
2262
|
+
options.keepClosingSlashHTML = keepClosingSlashHTML;
|
|
2263
|
+
options.insideForeignContent = true;
|
|
2264
|
+
options.namespaceForeign = lowerTag;
|
|
2265
|
+
// Disable HTML-specific options that produce invalid XML:
|
|
2266
|
+
// SVG with `minifySVG` enabled is passed to SVGO, which requires valid XML input;
|
|
2267
|
+
// MathML is never processed by SVGO, so these restrictions never apply to it
|
|
2268
|
+
if (lowerTag === 'svg' && options.minifySVG) {
|
|
2269
|
+
options.removeAttributeQuotes = false;
|
|
2270
|
+
// Text and attribute values are decoded as HTML reads them, for transforms to read
|
|
2271
|
+
// them so, too, and then escaped for XML (see `escapeXML`) rather than for HTML
|
|
2272
|
+
options.decodeEntities = true;
|
|
2273
|
+
// Omitting a start and end tag both would keep the block well-formed, which this misses
|
|
2274
|
+
options.removeOptionalTags = false;
|
|
2275
|
+
options.collapseBooleanAttributes = false;
|
|
2276
|
+
options.collapseEmptyAttributes = false;
|
|
2277
|
+
options.includeAutoGeneratedTags = true;
|
|
2278
|
+
}
|
|
2279
|
+
options.removeTagWhitespace = false;
|
|
2280
|
+
pushedContext = true;
|
|
2281
|
+
}
|
|
2282
|
+
// `foreignObject` in SVG and `annotation-xml` in MathML contain HTML content
|
|
2283
|
+
// (the element itself is in SVG/MathML namespace, only its children are HTML);
|
|
2284
|
+
// a repeated attribute is dropped after the first, so the first `encoding` decides
|
|
2285
|
+
if (options.insideForeignContent && (lowerTag === 'foreignobject' ||
|
|
2286
|
+
(lowerTag === 'annotation-xml' && RE_HTML_ENCODING.test(
|
|
2287
|
+
attrs.find((/** @type {HTMLAttribute} */ a) => a.name.toLowerCase() === 'encoding')?.value ?? '')))) {
|
|
2288
|
+
const nameParent = options.name;
|
|
2289
|
+
options = Object.create(options);
|
|
2290
|
+
options.caseSensitive = false;
|
|
2291
|
+
// What the parser read the slash as, so that the two agree here as they do in HTML
|
|
2292
|
+
options.keepClosingSlash = options.keepClosingSlashHTML ?? false;
|
|
2293
|
+
options.nameParent = nameParent; // Preserve for the element tag itself
|
|
2294
|
+
options.name = lowercase;
|
|
2295
|
+
options.insideForeignContent = false;
|
|
2296
|
+
// Note: `removeAttributeQuotes` and `removeTagWhitespace` stay disabled,
|
|
2297
|
+
// and values stay escaped for XML (inherited from SVG context), because
|
|
2298
|
+
// the entire SVG block must be valid XML for SVGO processing
|
|
2299
|
+
useNameParentForTag = true;
|
|
2300
|
+
pushedContext = true;
|
|
2301
|
+
}
|
|
2302
|
+
if (options.selfCloseForeignElements && options.insideForeignContent && !unary) {
|
|
2303
|
+
enterForeignScope(lowerTag);
|
|
2304
|
+
}
|
|
961
2305
|
}
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
2306
|
+
// `nameParent` is set whenever `useNameParentForTag` is; the extra check narrows the type
|
|
2307
|
+
const nameTag = useNameParentForTag && options.nameParent ? options.nameParent : options.name;
|
|
2308
|
+
const tagSource = tag;
|
|
2309
|
+
tag = nameTag(tag);
|
|
2310
|
+
// HTML names are written as in the source under `caseSensitive`, foreign ones as named anyway
|
|
2311
|
+
const tagOut = namesAsWritten && nameTag === lowercase ? tagSource : tag;
|
|
2312
|
+
if (tag === 'meta' && options.removeRedundantAttributes) {
|
|
2313
|
+
deduplicateAttributes(attrs, Boolean(options.caseSensitive));
|
|
2314
|
+
// As if not written
|
|
2315
|
+
if (isMetaWithoutEffect(attrs, markers)) {
|
|
2316
|
+
removed = true;
|
|
2317
|
+
return undefined;
|
|
2318
|
+
}
|
|
2319
|
+
replaceContentTypePragma(attrs, markers);
|
|
976
2320
|
}
|
|
977
|
-
tag = options.name(tag);
|
|
978
2321
|
currentTag = tag;
|
|
979
2322
|
charsPrevTag = tag;
|
|
980
|
-
if (!
|
|
2323
|
+
if (!inlineTextSet.has(tag)) {
|
|
981
2324
|
currentChars = '';
|
|
982
2325
|
}
|
|
983
2326
|
hasChars = false;
|
|
984
2327
|
currentAttrs = attrs;
|
|
985
2328
|
|
|
986
|
-
|
|
2329
|
+
let optional = options.removeOptionalTags;
|
|
987
2330
|
if (optional) {
|
|
988
|
-
|
|
989
|
-
//
|
|
990
|
-
//
|
|
991
|
-
//
|
|
992
|
-
//
|
|
993
|
-
//
|
|
2331
|
+
const htmlTag = htmlElements.has(tag);
|
|
2332
|
+
// `<html>` may be omitted if first thing inside is not a comment
|
|
2333
|
+
// `<head>` may be omitted if first thing inside is an element
|
|
2334
|
+
// `<body>` may be omitted if first thing inside is not space, comment, `<meta>`, `<link>`,
|
|
2335
|
+
// `<script>`, `<style>`, or `<template>`
|
|
2336
|
+
// `<colgroup>` may be omitted if first thing inside is `<col>`
|
|
2337
|
+
// `<tbody>` may be omitted if first thing inside is `<tr>`
|
|
994
2338
|
if (htmlTag && canRemoveParentTag(optionalStartTag, tag)) {
|
|
995
2339
|
removeStartTag();
|
|
996
2340
|
}
|
|
997
2341
|
optionalStartTag = '';
|
|
998
|
-
//
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
//
|
|
1002
|
-
|
|
2342
|
+
// End-tag-followed-by-start-tag omission rules
|
|
2343
|
+
// Outside no-quirks mode, `table` doesn’t (or may not) close a `p`
|
|
2344
|
+
if (htmlTag && canRemovePrecedingTag(optionalEndTag, tag) && (tableClosesP || optionalEndTag !== 'p' || tag !== 'table')) {
|
|
2345
|
+
// Only a start tag that gets written can stand in for the end tag
|
|
2346
|
+
if (optionalEndTagEmitted && (!autoGenerated || options.includeAutoGeneratedTags)) {
|
|
2347
|
+
removeEndTag();
|
|
2348
|
+
}
|
|
2349
|
+
// `<colgroup>` cannot be omitted if preceding `</colgroup>` is omitted
|
|
2350
|
+
// `<tbody>` cannot be omitted if preceding `</tbody>`, `</thead>`, or `</tfoot>` is
|
|
2351
|
+
// omitted
|
|
1003
2352
|
optional = !isStartTagMandatory(optionalEndTag, tag);
|
|
1004
2353
|
}
|
|
1005
2354
|
optionalEndTag = '';
|
|
2355
|
+
optionalEndTagEmitted = false;
|
|
1006
2356
|
}
|
|
1007
2357
|
|
|
1008
|
-
//
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
2358
|
+
// Set whitespace flags for nested tags (e.g., `<code>` within a `<pre>`); verbatim
|
|
2359
|
+
// whitespace is tracked without `collapseWhitespace` too, as `removeWhitespaceLine` spares it
|
|
2360
|
+
if (options.collapseWhitespace && !stackNoTrimWhitespace.length) {
|
|
2361
|
+
squashTrailingWhitespace(tag);
|
|
2362
|
+
}
|
|
2363
|
+
if (options.collapseWhitespace && !unary && inlineElementsTransparent.has(tag)) {
|
|
2364
|
+
transparentDepth++;
|
|
2365
|
+
}
|
|
2366
|
+
if (!unary) {
|
|
2367
|
+
if (!canTrimWhitespace(tag, tagOut, attrs) || stackNoTrimWhitespace.length) {
|
|
2368
|
+
stackNoTrimWhitespace.push(tag);
|
|
2369
|
+
if (tag === 'pre' || tag === 'textarea') preTextareaDepth++;
|
|
1012
2370
|
}
|
|
1013
|
-
if (!
|
|
1014
|
-
|
|
1015
|
-
stackNoTrimWhitespace.push(tag);
|
|
1016
|
-
}
|
|
1017
|
-
if (!_canCollapseWhitespace(tag, attrs) || stackNoCollapseWhitespace.length) {
|
|
1018
|
-
stackNoCollapseWhitespace.push(tag);
|
|
1019
|
-
}
|
|
2371
|
+
if (options.collapseWhitespace && (!canCollapseWhitespace(tag, tagOut, attrs) || stackNoCollapseWhitespace.length)) {
|
|
2372
|
+
stackNoCollapseWhitespace.push(tag);
|
|
1020
2373
|
}
|
|
1021
2374
|
}
|
|
1022
2375
|
|
|
1023
|
-
|
|
1024
|
-
|
|
2376
|
+
// Track SVG subtree for SVGO block processing
|
|
2377
|
+
if (lowerTag === 'svg' && options.minifySVG && !unary) {
|
|
2378
|
+
if (svgDepth === 0) {
|
|
2379
|
+
svgBufferStartIndex = buffer.length; // Record position before <svg> is pushed
|
|
2380
|
+
}
|
|
2381
|
+
svgDepth++;
|
|
2382
|
+
}
|
|
1025
2383
|
|
|
1026
|
-
|
|
2384
|
+
const openTag = '<' + tagOut;
|
|
2385
|
+
// SVGO reads the whole SVG block as XML, where an element closes itself or not, so
|
|
2386
|
+
// every unary element in there is written with a slash
|
|
2387
|
+
const needsXMLSlash = unary && Boolean(options.insideSVG) && Boolean(options.minifySVG);
|
|
2388
|
+
const hasUnarySlash = needsXMLSlash || (unarySlash && (useNameParentForTag ? optionsParent : options).keepClosingSlash);
|
|
1027
2389
|
|
|
1028
|
-
|
|
1029
|
-
options.sortAttributes(tag, attrs);
|
|
1030
|
-
}
|
|
2390
|
+
bufferPush(openTag, true);
|
|
1031
2391
|
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
2392
|
+
if (options.removeEmptyElements) {
|
|
2393
|
+
if (unary) {
|
|
2394
|
+
markOpenElementFilled();
|
|
2395
|
+
} else {
|
|
2396
|
+
// A start tag not written can’t be removed with its element
|
|
2397
|
+
openElements.push({ tag, index: buffer.length - 1, empty: !autoGenerated || Boolean(options.includeAutoGeneratedTags) });
|
|
1038
2398
|
}
|
|
1039
2399
|
}
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
2400
|
+
|
|
2401
|
+
const fragmentJoinsTag = uidAttrExactPattern ? joinFragmentAttrs(attrs) : false;
|
|
2402
|
+
// Whatever runs into the tag name has to stay first
|
|
2403
|
+
const tagFragment = fragmentJoinsTag ? attrs.shift() : undefined;
|
|
2404
|
+
|
|
2405
|
+
// Duplicate attributes are invalid; per spec, the first one wins
|
|
2406
|
+
// https://html.spec.whatwg.org/multipage/parsing.html#attribute-name-state
|
|
2407
|
+
deduplicateAttributes(attrs, Boolean(options.caseSensitive));
|
|
2408
|
+
|
|
2409
|
+
if (options.sortAttributes) {
|
|
2410
|
+
// By the time tags are processed, `createSortFns` has replaced any
|
|
2411
|
+
// truthy non-function value
|
|
2412
|
+
/** @type {(tag: string, attrs: HTMLAttribute[]) => void} */ (options.sortAttributes)(tagOut, attrs);
|
|
1043
2413
|
}
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
optionalStartTag = tag;
|
|
2414
|
+
if (tagFragment) {
|
|
2415
|
+
attrs.unshift(tagFragment);
|
|
1047
2416
|
}
|
|
1048
2417
|
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
2418
|
+
const attrResults = new Array(attrs.length);
|
|
2419
|
+
let anyThenable = false;
|
|
2420
|
+
for (let i = 0; i < attrs.length; i++) {
|
|
2421
|
+
const attr = /** @type {HTMLAttribute} */ (attrs[i]);
|
|
2422
|
+
const result = opaqueAttrs.has(attr)
|
|
2423
|
+
? { name: attr.name, value: undefined, attr }
|
|
2424
|
+
: normalizeAttr(attr, attrs, tag, tagOut, options, minifyHTML, markers);
|
|
2425
|
+
if (!anyThenable && isThenable(result)) {
|
|
2426
|
+
anyThenable = true;
|
|
2427
|
+
}
|
|
2428
|
+
attrResults[i] = result;
|
|
2429
|
+
}
|
|
2430
|
+
// Only an attribute value that needs asynchronous work defers the write-out;
|
|
2431
|
+
// with none of those, the tag is written before the handler returns
|
|
2432
|
+
const restoreOptions = unary && pushedContext ? optionsParent : null;
|
|
2433
|
+
if (anyThenable) {
|
|
2434
|
+
return Promise.all(attrResults).then(resolved => {
|
|
2435
|
+
emitStartTag(resolved, tag, unary, autoGenerated, fragmentJoinsTag, hasUnarySlash, optional, restoreOptions);
|
|
2436
|
+
});
|
|
1054
2437
|
}
|
|
2438
|
+
emitStartTag(/** @type {Array<{name: string, value: string | undefined, attr: HTMLAttribute} | undefined>} */ (attrResults), tag, unary, autoGenerated, fragmentJoinsTag, hasUnarySlash, optional, restoreOptions);
|
|
2439
|
+
return undefined;
|
|
1055
2440
|
},
|
|
1056
|
-
end: function(tag, attrs, autoGenerated) {
|
|
1057
|
-
|
|
1058
|
-
|
|
2441
|
+
end: function (/** @type {string} */ tag, /** @type {HTMLAttribute[]} */ attrs, /** @type {boolean} */ autoGenerated, /** @type {string | undefined} */ rest, /** @type {boolean | undefined} */ holdsTable) {
|
|
2442
|
+
// As in `start`: `lowerTag` stays '' when no foreign content is around
|
|
2443
|
+
let lowerTag = '';
|
|
2444
|
+
// Named as the start tag was: an `svg` or `math` element in its own context,
|
|
2445
|
+
// a `foreignObject` or `annotation-xml` element in its parent’s
|
|
2446
|
+
let name = options.name;
|
|
2447
|
+
// Whether the element is SVG or MathML, which a slash can close, and SVGO, if it
|
|
2448
|
+
// reads the element, writes so anyway
|
|
2449
|
+
let selfCloses = Boolean(options.selfCloseForeignElements) && hasForeignContext &&
|
|
2450
|
+
!(options.insideSVG && options.minifySVG);
|
|
2451
|
+
if (hasForeignContext) {
|
|
2452
|
+
lowerTag = tag.toLowerCase();
|
|
2453
|
+
// An integration point is named in its parent’s context, which it opened one inside of
|
|
2454
|
+
selfCloses &&= Boolean(options.insideForeignContent) || ((lowerTag === 'foreignobject' || lowerTag === 'annotation-xml') &&
|
|
2455
|
+
Boolean(Object.getPrototypeOf(options).insideForeignContent));
|
|
2456
|
+
if (foreignScopes.length && !leaveForeignScope(lowerTag)) {
|
|
2457
|
+
selfCloses = false;
|
|
2458
|
+
}
|
|
2459
|
+
// Restore parent context when exiting SVG/MathML or HTML-in-foreign-content elements
|
|
2460
|
+
if (lowerTag === 'svg' || lowerTag === 'math') {
|
|
2461
|
+
options = Object.getPrototypeOf(options);
|
|
2462
|
+
} else if ((lowerTag === 'foreignobject' || lowerTag === 'annotation-xml') &&
|
|
2463
|
+
!options.insideForeignContent && Object.getPrototypeOf(options).insideForeignContent) {
|
|
2464
|
+
options = Object.getPrototypeOf(options);
|
|
2465
|
+
name = options.name;
|
|
2466
|
+
}
|
|
2467
|
+
}
|
|
2468
|
+
const tagSource = tag;
|
|
2469
|
+
tag = name(tag);
|
|
2470
|
+
// As in `start`
|
|
2471
|
+
const tagOut = namesAsWritten && name === lowercase ? tagSource : tag;
|
|
2472
|
+
let endTagText = '</' + tagOut + '>';
|
|
2473
|
+
// Custom fragments after the name are kept, next to it if they were in the source
|
|
2474
|
+
if (rest && uidAttr && rest.includes(uidAttr)) {
|
|
2475
|
+
markTagFragments(rest);
|
|
2476
|
+
const uidMatch = /** @type {RegExp} */ (uidAttrLeadingPattern).exec(rest);
|
|
2477
|
+
const joinsTag = uidMatch !== null && rest.startsWith('\t' + uidAttr) &&
|
|
2478
|
+
ignoredCustomMarkupChunks[+(uidMatch[1] ?? 0)]?.[1] === '';
|
|
2479
|
+
// Anything else an end tag holds is dropped, as without fragments
|
|
2480
|
+
const marker = uidAttr;
|
|
2481
|
+
const fragments = Array.from(rest.matchAll(/** @type {RegExp} */ (uidPattern)), uid => marker + (uid[2] ?? '') + marker);
|
|
2482
|
+
endTagText = '</' + tagOut + (joinsTag ? '' : ' ') + fragments.join(' ') + '>';
|
|
2483
|
+
// Which a slash would drop
|
|
2484
|
+
selfCloses = false;
|
|
1059
2485
|
}
|
|
1060
|
-
tag = options.name(tag);
|
|
1061
2486
|
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
2487
|
+
const insideNoTrim = stackNoTrimWhitespace.length > 0;
|
|
2488
|
+
let closesNoTrim = false;
|
|
2489
|
+
if (stackNoTrimWhitespace.length) {
|
|
2490
|
+
if (tag === stackNoTrimWhitespace[stackNoTrimWhitespace.length - 1]) {
|
|
2491
|
+
if (tag === 'pre' || tag === 'textarea') preTextareaDepth--;
|
|
2492
|
+
stackNoTrimWhitespace.pop();
|
|
2493
|
+
closesNoTrim = !stackNoTrimWhitespace.length;
|
|
1068
2494
|
}
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
2495
|
+
} else if (options.collapseWhitespace) {
|
|
2496
|
+
squashTrailingWhitespace('/' + tag);
|
|
2497
|
+
}
|
|
2498
|
+
if (transparentDepth && inlineElementsTransparent.has(tag)) {
|
|
2499
|
+
transparentDepth--;
|
|
2500
|
+
}
|
|
2501
|
+
let closesNoCollapse = false;
|
|
2502
|
+
if (stackNoCollapseWhitespace.length &&
|
|
1073
2503
|
tag === stackNoCollapseWhitespace[stackNoCollapseWhitespace.length - 1]) {
|
|
1074
|
-
|
|
1075
|
-
|
|
2504
|
+
stackNoCollapseWhitespace.pop();
|
|
2505
|
+
closesNoCollapse = !stackNoCollapseWhitespace.length;
|
|
1076
2506
|
}
|
|
1077
2507
|
|
|
1078
|
-
|
|
2508
|
+
const endsImpliedStart = !autoGenerated && impliedStartTag === tag;
|
|
2509
|
+
// A `p` that holds a `table` while the mode is unknown needs its end tag as written: One
|
|
2510
|
+
// after the `table` that the source lacks would be stray in no-quirks mode, and one that it
|
|
2511
|
+
// has, closing the `p` in quirks mode, would be missed there
|
|
2512
|
+
const skipsEndTag = autoGenerated && (!options.includeAutoGeneratedTags || Boolean(holdsTable));
|
|
2513
|
+
impliedStartTag = '';
|
|
2514
|
+
|
|
2515
|
+
let isElementEmpty = false;
|
|
1079
2516
|
if (tag === currentTag) {
|
|
1080
2517
|
currentTag = '';
|
|
1081
2518
|
isElementEmpty = !hasChars;
|
|
1082
2519
|
}
|
|
2520
|
+
selfCloses &&= isElementEmpty;
|
|
2521
|
+
// A stray end tag has no open element of its own
|
|
2522
|
+
const openElement = options.removeEmptyElements && openElements[openElements.length - 1]?.tag === tag ? openElements.pop() : undefined;
|
|
2523
|
+
// Emptied by removing what it held, as long as its start tag is still the last one written
|
|
2524
|
+
const isElementEmptied = !isElementEmpty && Boolean(openElement?.empty) && lastStartTagIndex() === openElement?.index;
|
|
1083
2525
|
|
|
1084
2526
|
if (options.removeOptionalTags) {
|
|
1085
|
-
//
|
|
1086
|
-
if (isElementEmpty &&
|
|
2527
|
+
// `<html>`, `<head>`, or `<body>` may be omitted if the element is empty
|
|
2528
|
+
if (isElementEmpty && topLevelElements.has(optionalStartTag)) {
|
|
1087
2529
|
removeStartTag();
|
|
1088
2530
|
}
|
|
1089
2531
|
optionalStartTag = '';
|
|
1090
|
-
//
|
|
1091
|
-
//
|
|
1092
|
-
//
|
|
1093
|
-
//
|
|
1094
|
-
|
|
1095
|
-
|
|
2532
|
+
// `</html>` or `</body>` may be omitted if not followed by comment
|
|
2533
|
+
// `</head>` may be omitted if not followed by space or comment
|
|
2534
|
+
// `</p>` may be omitted if no more content in parent, as long as the parent’s end tag
|
|
2535
|
+
// closes the `p` in browsers (`pClosingParents`)
|
|
2536
|
+
// Except for `</dt>` or `</thead>`, end tags may be omitted if no more content in parent
|
|
2537
|
+
// https://html.spec.whatwg.org/multipage/syntax.html#optional-tags
|
|
2538
|
+
if (tag && optionalEndTag && optionalEndTagEmitted && !trailingElements.has(optionalEndTag) && (optionalEndTag !== 'p' || pClosingParents.has(tag))) {
|
|
2539
|
+
removeEndTag(insideNoTrim);
|
|
1096
2540
|
}
|
|
1097
|
-
optionalEndTag = optionalEndTags(tag) ? tag : '';
|
|
2541
|
+
optionalEndTag = optionalEndTags.has(tag) && !endsImpliedStart && !holdsTable ? tag : '';
|
|
2542
|
+
optionalEndTagEmitted = true;
|
|
1098
2543
|
}
|
|
1099
2544
|
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
2545
|
+
let isElementRemoved = false;
|
|
2546
|
+
// A formatting element closed by a block is reopened after it by browsers, so it has to stay
|
|
2547
|
+
if (options.removeEmptyElements && (isElementEmpty || isElementEmptied) && !options.insideForeignContent && canRemoveElement(tag, attrs) &&
|
|
2548
|
+
!(autoGenerated && formattingElements.has(tag))) {
|
|
2549
|
+
let preserve = false;
|
|
2550
|
+
if (removeEmptyElementsExcept.length) {
|
|
2551
|
+
const normalizedAttrs = attrs.map((/** @type {HTMLAttribute} */ attr) => ({ ...attr, name: resolveName(attr.name) }));
|
|
2552
|
+
preserve = shouldPreserveEmptyElement(tag, normalizedAttrs, removeEmptyElementsExcept);
|
|
2553
|
+
}
|
|
2554
|
+
|
|
2555
|
+
if (!preserve) {
|
|
2556
|
+
removeStartTag();
|
|
2557
|
+
isElementRemoved = true;
|
|
2558
|
+
optionalStartTag = '';
|
|
1108
2559
|
optionalEndTag = '';
|
|
2560
|
+
optionalEndTagEmitted = false;
|
|
2561
|
+
} else {
|
|
2562
|
+
// Preserve the element—add closing tag
|
|
2563
|
+
if (skipsEndTag) {
|
|
2564
|
+
optionalEndTagEmitted = false;
|
|
2565
|
+
} else {
|
|
2566
|
+
bufferPush(endTagText, true);
|
|
2567
|
+
}
|
|
2568
|
+
charsPrevTag = '/' + tag;
|
|
2569
|
+
if (!inlineElements.has(tag)) {
|
|
2570
|
+
currentChars = '';
|
|
2571
|
+
} else if (isElementEmpty) {
|
|
2572
|
+
currentChars += '|';
|
|
2573
|
+
}
|
|
1109
2574
|
}
|
|
1110
|
-
|
|
1111
|
-
|
|
2575
|
+
} else {
|
|
2576
|
+
if (skipsEndTag) {
|
|
2577
|
+
optionalEndTagEmitted = false;
|
|
2578
|
+
} else if (!selfCloses || !closeLastStartTag()) {
|
|
2579
|
+
bufferPush(endTagText, true);
|
|
1112
2580
|
}
|
|
1113
2581
|
charsPrevTag = '/' + tag;
|
|
1114
|
-
if (!
|
|
2582
|
+
if (!inlineElements.has(tag)) {
|
|
1115
2583
|
currentChars = '';
|
|
1116
|
-
}
|
|
1117
|
-
else if (isElementEmpty) {
|
|
2584
|
+
} else if (isElementEmpty) {
|
|
1118
2585
|
currentChars += '|';
|
|
1119
2586
|
}
|
|
1120
2587
|
}
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
nextTag = nextTag === '' ? 'comment' : nextTag;
|
|
1125
|
-
if (options.decodeEntities && text && !specialContentTags(currentTag)) {
|
|
1126
|
-
text = decode(text);
|
|
1127
|
-
}
|
|
1128
|
-
if (options.collapseWhitespace) {
|
|
1129
|
-
if (!stackNoTrimWhitespace.length) {
|
|
1130
|
-
if (prevTag === 'comment') {
|
|
1131
|
-
var prevComment = buffer[buffer.length - 1];
|
|
1132
|
-
if (prevComment.indexOf(uidIgnore) === -1) {
|
|
1133
|
-
if (!prevComment) {
|
|
1134
|
-
prevTag = charsPrevTag;
|
|
1135
|
-
}
|
|
1136
|
-
if (buffer.length > 1 && (!prevComment || !options.conservativeCollapse && / $/.test(currentChars))) {
|
|
1137
|
-
var charsIndex = buffer.length - 2;
|
|
1138
|
-
buffer[charsIndex] = buffer[charsIndex].replace(/\s+$/, function(trailingSpaces) {
|
|
1139
|
-
text = trailingSpaces + text;
|
|
1140
|
-
return '';
|
|
1141
|
-
});
|
|
1142
|
-
}
|
|
1143
|
-
}
|
|
1144
|
-
}
|
|
1145
|
-
if (prevTag) {
|
|
1146
|
-
if (prevTag === '/nobr' || prevTag === 'wbr') {
|
|
1147
|
-
if (/^\s/.test(text)) {
|
|
1148
|
-
var tagIndex = buffer.length - 1;
|
|
1149
|
-
while (tagIndex > 0 && buffer[tagIndex].lastIndexOf('<' + prevTag) !== 0) {
|
|
1150
|
-
tagIndex--;
|
|
1151
|
-
}
|
|
1152
|
-
trimTrailingWhitespace(tagIndex - 1, 'br');
|
|
1153
|
-
}
|
|
1154
|
-
}
|
|
1155
|
-
else if (inlineTextTags(prevTag.charAt(0) === '/' ? prevTag.slice(1) : prevTag)) {
|
|
1156
|
-
text = collapseWhitespace(text, options, /(?:^|\s)$/.test(currentChars));
|
|
1157
|
-
}
|
|
1158
|
-
}
|
|
1159
|
-
if (prevTag || nextTag) {
|
|
1160
|
-
text = collapseWhitespaceSmart(text, prevTag, nextTag, options);
|
|
1161
|
-
}
|
|
1162
|
-
else {
|
|
1163
|
-
text = collapseWhitespace(text, options, true, true);
|
|
1164
|
-
}
|
|
1165
|
-
if (!text && /\s$/.test(currentChars) && prevTag && prevTag.charAt(0) === '/') {
|
|
1166
|
-
trimTrailingWhitespace(buffer.length - 1, nextTag);
|
|
1167
|
-
}
|
|
1168
|
-
}
|
|
1169
|
-
if (!stackNoCollapseWhitespace.length && nextTag !== 'html' && !(prevTag && nextTag)) {
|
|
1170
|
-
text = collapseWhitespace(text, options, false, false, true);
|
|
1171
|
-
}
|
|
1172
|
-
}
|
|
1173
|
-
if (options.processScripts && specialContentTags(currentTag)) {
|
|
1174
|
-
text = processScript(text, options, currentAttrs);
|
|
2588
|
+
|
|
2589
|
+
if (closesNoTrim && !isElementRemoved) {
|
|
2590
|
+
noTrimEndIndex = buffer.length - 1;
|
|
1175
2591
|
}
|
|
1176
|
-
if (
|
|
1177
|
-
|
|
2592
|
+
if (closesNoCollapse && !isElementRemoved) {
|
|
2593
|
+
noCollapseEndIndex = buffer.length - 1;
|
|
1178
2594
|
}
|
|
1179
|
-
|
|
1180
|
-
|
|
2595
|
+
|
|
2596
|
+
// A kept element fills the one it sits in
|
|
2597
|
+
if (openElement && !isElementRemoved) {
|
|
2598
|
+
markOpenElementFilled();
|
|
1181
2599
|
}
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
// </html> or </body> may be omitted if not followed by comment
|
|
1190
|
-
// </head>, </colgroup> or </caption> may be omitted if not followed by space or comment
|
|
1191
|
-
if (compactTags(optionalEndTag) || looseTags(optionalEndTag) && !/^\s/.test(text)) {
|
|
1192
|
-
removeEndTag();
|
|
2600
|
+
|
|
2601
|
+
// SVG subtree capture: Record end position for post-processing with SVGO
|
|
2602
|
+
if (lowerTag === 'svg' && options.minifySVG && svgDepth > 0) {
|
|
2603
|
+
svgDepth--;
|
|
2604
|
+
if (svgDepth === 0 && svgBufferStartIndex >= 0) {
|
|
2605
|
+
svgBlocks.push({ start: svgBufferStartIndex, end: buffer.length });
|
|
2606
|
+
svgBufferStartIndex = -1;
|
|
1193
2607
|
}
|
|
1194
|
-
optionalEndTag = '';
|
|
1195
|
-
}
|
|
1196
|
-
charsPrevTag = /^\s*$/.test(text) ? prevTag : 'comment';
|
|
1197
|
-
if (options.decodeEntities && text && !specialContentTags(currentTag)) {
|
|
1198
|
-
// Escape any `&` symbols that start either:
|
|
1199
|
-
// 1) a legacy named character reference (i.e. one that doesn't end with `;`)
|
|
1200
|
-
// 2) or any other character reference (i.e. one that does end with `;`)
|
|
1201
|
-
// Note that `&` can be escaped as `&`, without the semi-colon.
|
|
1202
|
-
// https://mathiasbynens.be/notes/ambiguous-ampersands
|
|
1203
|
-
text = text.replace(/&((?:Iacute|aacute|uacute|plusmn|Otilde|otilde|agrave|Agrave|Yacute|yacute|Oslash|oslash|atilde|Atilde|brvbar|ccedil|Ccedil|Ograve|curren|divide|eacute|Eacute|ograve|Oacute|egrave|Egrave|Ugrave|frac12|frac14|frac34|ugrave|oacute|iacute|Ntilde|ntilde|Uacute|middot|igrave|Igrave|iquest|Aacute|cedil|laquo|micro|iexcl|Icirc|icirc|acirc|Ucirc|Ecirc|ocirc|Ocirc|ecirc|ucirc|Aring|aring|AElig|aelig|acute|pound|raquo|Acirc|times|THORN|szlig|thorn|COPY|auml|ordf|ordm|Uuml|macr|uuml|Auml|ouml|Ouml|para|nbsp|euml|quot|QUOT|Euml|yuml|cent|sect|copy|sup1|sup2|sup3|iuml|Iuml|ETH|shy|reg|not|yen|amp|AMP|REG|uml|eth|deg|gt|GT|LT|lt)(?!;)|(?:#?[0-9a-zA-Z]+;))/g, '&$1').replace(/</g, '<');
|
|
1204
|
-
}
|
|
1205
|
-
if (uidPattern && options.collapseWhitespace && stackNoTrimWhitespace.length) {
|
|
1206
|
-
text = text.replace(uidPattern, function(match, prefix, index) {
|
|
1207
|
-
return ignoredCustomMarkupChunks[+index][0];
|
|
1208
|
-
});
|
|
1209
2608
|
}
|
|
1210
|
-
currentChars += text;
|
|
1211
|
-
if (text) {
|
|
1212
|
-
hasChars = true;
|
|
1213
|
-
}
|
|
1214
|
-
buffer.push(text);
|
|
1215
2609
|
},
|
|
1216
|
-
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
|
|
2610
|
+
chars: function (/** @type {string} */ text, /** @type {string} */ prevTag, /** @type {string} */ nextTag, /** @type {HTMLAttribute[]} */ prevAttrs, /** @type {HTMLAttribute[]} */ nextAttrs, /** @type {boolean} */ source) {
|
|
2611
|
+
// Publish this node’s context for the `charsCollapse`/`charsFinalize` phases
|
|
2612
|
+
textPrevTag = prevTag === '' ? 'comment' : prevTag;
|
|
2613
|
+
textNextTag = nextTag === '' ? 'comment' : nextTag;
|
|
2614
|
+
textPrevAttrs = prevAttrs || [];
|
|
2615
|
+
textNextAttrs = nextAttrs || [];
|
|
2616
|
+
|
|
2617
|
+
// Whether this text node needs any async work. In foreign content, text reads references
|
|
2618
|
+
// and CDATA sections, and `script` and `style` hold markup, so their text is a source only
|
|
2619
|
+
// where it is all they hold (and in SVG, where they run and apply)
|
|
2620
|
+
const foreign = Boolean(options.insideForeignContent);
|
|
2621
|
+
textForeign = foreign;
|
|
2622
|
+
const holdsSource = !foreign || Boolean(source);
|
|
2623
|
+
const needsProcessScript = holdsSource && specialContentElements.has(currentTag) && (options.processScripts || (options.minifyJSON && currentTag === 'script' && hasJsonScriptType(currentAttrs)));
|
|
2624
|
+
const needsMinifyJS = holdsSource && options.minifyJS !== identity && isExecutableScript(currentTag, currentAttrs);
|
|
2625
|
+
const isModuleScript = needsMinifyJS && currentAttrs.some(
|
|
2626
|
+
a => a.name.toLowerCase() === 'type' && (a.value ?? '').trim().toLowerCase() === 'module'
|
|
2627
|
+
);
|
|
2628
|
+
const needsMinifyCSS = holdsSource && options.minifyCSS !== identity && isStyleElement(currentTag, currentAttrs);
|
|
2629
|
+
textForeignDecoded = foreign && Boolean(options.decodeEntities || needsProcessScript || needsMinifyJS || needsMinifyCSS);
|
|
2630
|
+
const needsDecode = Boolean(text) && (foreign
|
|
2631
|
+
? textForeignDecoded && (text.indexOf('&') !== -1 || text.indexOf('<![CDATA[') !== -1)
|
|
2632
|
+
: options.decodeEntities && text.indexOf('&') !== -1 && !holdsRawText());
|
|
2633
|
+
|
|
2634
|
+
// Fast path: All work is sync—skip async machinery (decoding counts
|
|
2635
|
+
// once `entities` has loaded)
|
|
2636
|
+
if ((!needsDecode || decodeHTMLFn) && !needsProcessScript && !needsMinifyJS && !needsMinifyCSS) {
|
|
2637
|
+
if (needsDecode && decodeHTMLFn) {
|
|
2638
|
+
text = foreign ? decodeForeignText(text, decodeHTMLFn) : decodeHTMLFn(text);
|
|
1228
2639
|
}
|
|
2640
|
+
charsFinalize(charsCollapse(text));
|
|
2641
|
+
return;
|
|
1229
2642
|
}
|
|
1230
|
-
|
|
1231
|
-
|
|
1232
|
-
|
|
1233
|
-
|
|
1234
|
-
|
|
1235
|
-
|
|
1236
|
-
|
|
2643
|
+
|
|
2644
|
+
// Slow path: At least one async step required
|
|
2645
|
+
return (async () => {
|
|
2646
|
+
if (needsDecode) {
|
|
2647
|
+
const decode = /** @type {(text: string) => string} */ (await getDecodeHTML());
|
|
2648
|
+
text = foreign ? decodeForeignText(text, decode) : decode(text);
|
|
2649
|
+
}
|
|
2650
|
+
text = charsCollapse(text);
|
|
2651
|
+
if (needsProcessScript) {
|
|
2652
|
+
text = await processScript(text, options, currentTag, currentAttrs, minifyHTML);
|
|
2653
|
+
}
|
|
2654
|
+
if (needsMinifyJS) {
|
|
2655
|
+
const prewarmed = jsPrewarmed?.get((isModuleScript ? 'm|' : '|') + text);
|
|
2656
|
+
text = prewarmed ? await prewarmed : await options.minifyJS(text, false, isModuleScript);
|
|
2657
|
+
}
|
|
2658
|
+
if (needsMinifyCSS) {
|
|
2659
|
+
text = await options.minifyCSS(text, undefined, options.cssContext);
|
|
2660
|
+
}
|
|
2661
|
+
charsFinalize(text);
|
|
2662
|
+
})();
|
|
2663
|
+
},
|
|
2664
|
+
// `markup` is the comment as written, where it is to stay that way (`<? x>`)
|
|
2665
|
+
comment: function (/** @type {string} */ text, /** @type {boolean} */ nonStandard, /** @type {string | undefined} */ markup) {
|
|
2666
|
+
const prefix = nonStandard ? '<!' : '<!--';
|
|
2667
|
+
const suffix = nonStandard ? '>' : '-->';
|
|
2668
|
+
|
|
2669
|
+
if (options.removeComments && !isIgnoredComment(text, options)) {
|
|
2670
|
+
text = '';
|
|
2671
|
+
removed = true;
|
|
2672
|
+
} else {
|
|
2673
|
+
text = markup ?? prefix + text + suffix;
|
|
1237
2674
|
}
|
|
1238
|
-
|
|
2675
|
+
commentFinalize(text);
|
|
1239
2676
|
},
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
|
|
2677
|
+
// Kept as written, as content may be inserted at one (`<template for>`)
|
|
2678
|
+
processingInstruction: function (/** @type {string} */ text) {
|
|
2679
|
+
hasChars = true;
|
|
2680
|
+
markOpenElementFilled();
|
|
2681
|
+
commentFinalize(text);
|
|
1244
2682
|
},
|
|
1245
|
-
|
|
1246
|
-
|
|
2683
|
+
strayEnd: function () {
|
|
2684
|
+
removed = true;
|
|
2685
|
+
},
|
|
2686
|
+
doctype: function (/** @type {string} */ doctype, /** @type {boolean} */ noQuirksMode) {
|
|
2687
|
+
tableClosesP = noQuirksMode;
|
|
2688
|
+
bufferPush(options.useShortDoctype
|
|
2689
|
+
? '<!doctype' +
|
|
2690
|
+
(options.removeTagWhitespace ? '' : ' ') + 'html>'
|
|
2691
|
+
: collapseWhitespaceAll(doctype), false);
|
|
2692
|
+
}
|
|
1247
2693
|
});
|
|
1248
2694
|
|
|
2695
|
+
await parser.parse();
|
|
2696
|
+
|
|
2697
|
+
// Nothing follows at the end, so pending whitespace goes (before SVG blocks shift the buffer)
|
|
2698
|
+
if (transparentGapIndex !== -1) {
|
|
2699
|
+
settleTransparentGap(Boolean(options.conservativeCollapse));
|
|
2700
|
+
}
|
|
2701
|
+
|
|
2702
|
+
// Post-processing: Optimize SVG blocks with SVGO
|
|
2703
|
+
// Run all SVGO calls in parallel, then splice results in reverse to preserve indices
|
|
2704
|
+
const minifySVG = options.minifySVG;
|
|
2705
|
+
if (minifySVG && svgBlocks.length) {
|
|
2706
|
+
const optimized = await Promise.all(
|
|
2707
|
+
svgBlocks.map(({ start, end }) =>
|
|
2708
|
+
minifySVG(buffer.slice(start, end).join(''))
|
|
2709
|
+
)
|
|
2710
|
+
);
|
|
2711
|
+
for (let i = svgBlocks.length - 1; i >= 0; i--) {
|
|
2712
|
+
const block = svgBlocks[i];
|
|
2713
|
+
if (block) {
|
|
2714
|
+
buffer.splice(block.start, block.end - block.start, optimized[i] ?? '');
|
|
2715
|
+
bufferTags?.splice(block.start, block.end - block.start, false);
|
|
2716
|
+
}
|
|
2717
|
+
}
|
|
2718
|
+
}
|
|
2719
|
+
// Also where an SVG left open never reached SVGO
|
|
2720
|
+
if (svgRawTexts.length) {
|
|
2721
|
+
const pattern = new RegExp('/\\*!' + uidSVGRawText + '([0-9]+)\\*/', 'g');
|
|
2722
|
+
for (let i = 0; i < buffer.length; i++) {
|
|
2723
|
+
const segment = /** @type {string} */ (buffer[i]);
|
|
2724
|
+
if (segment.indexOf(uidSVGRawText) !== -1) {
|
|
2725
|
+
buffer[i] = segment.replace(pattern, (/** @type {string} */ match, /** @type {string} */ index) => svgRawTexts[+index] ?? match);
|
|
2726
|
+
}
|
|
2727
|
+
}
|
|
2728
|
+
}
|
|
2729
|
+
|
|
1249
2730
|
if (options.removeOptionalTags) {
|
|
1250
|
-
//
|
|
1251
|
-
//
|
|
1252
|
-
if (
|
|
2731
|
+
// `<html>` may be omitted if first thing inside is not a comment
|
|
2732
|
+
// `<head>` or `<body>` may be omitted if empty
|
|
2733
|
+
if (topLevelElements.has(optionalStartTag)) {
|
|
1253
2734
|
removeStartTag();
|
|
1254
2735
|
}
|
|
1255
|
-
//
|
|
1256
|
-
if (optionalEndTag && !
|
|
2736
|
+
// Except for `</dt>` or `</thead>`, end tags may be omitted if no more content in parent
|
|
2737
|
+
if (optionalEndTag && optionalEndTagEmitted && !trailingElements.has(optionalEndTag)) {
|
|
1257
2738
|
removeEndTag();
|
|
1258
2739
|
}
|
|
1259
2740
|
}
|
|
2741
|
+
// Collapsing also trims whitespace before trailing inline end tags; trimming the output
|
|
2742
|
+
// catches what it leaves at either end, like a space after a removed leading comment
|
|
1260
2743
|
if (options.collapseWhitespace) {
|
|
1261
2744
|
squashTrailingWhitespace('br');
|
|
1262
2745
|
}
|
|
2746
|
+
trimOutputWhitespace();
|
|
1263
2747
|
|
|
1264
|
-
return joinResultSegments(buffer, options, uidPattern
|
|
1265
|
-
|
|
1266
|
-
|
|
1267
|
-
|
|
1268
|
-
if (
|
|
1269
|
-
|
|
2748
|
+
return joinResultSegments(buffer, bufferTags ?? [], markers, options, uidPattern
|
|
2749
|
+
? function (/** @type {string} */ str) {
|
|
2750
|
+
return str.replace(/** @type {RegExp} */ (uidPattern), function (/** @type {string} */ match, /** @type {string} */ prefix, /** @type {string} */ index, /** @type {string} */ suffix, /** @type {number} */ offset, /** @type {string} */ string) {
|
|
2751
|
+
const chunks = ignoredCustomMarkupChunks[+index];
|
|
2752
|
+
if (!chunks) {
|
|
2753
|
+
return match;
|
|
1270
2754
|
}
|
|
1271
|
-
if (
|
|
1272
|
-
|
|
2755
|
+
if (!options.collapseWhitespace) {
|
|
2756
|
+
// The fragment took the whitespace around it along, so other whitespace here was
|
|
2757
|
+
// written by the minifier (e.g., by sorting) and is needed where the fragment has none
|
|
2758
|
+
return (chunks[1] ? '' : prefix.replace(RE_TABS, '')) + chunks[0] + (chunks[2] ? '' : suffix.replace(RE_TABS, ''));
|
|
1273
2759
|
}
|
|
1274
|
-
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
-
|
|
1279
|
-
|
|
1280
|
-
|
|
1281
|
-
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
|
|
1285
|
-
|
|
2760
|
+
const leading = chunks[1] ?? '';
|
|
2761
|
+
const trailing = chunks[2] ?? '';
|
|
2762
|
+
const fragment = chunks[0].slice(leading.length, chunks[0].length - trailing.length);
|
|
2763
|
+
const inTag = customMarkupInTags.has(+index);
|
|
2764
|
+
const keep = inTag || customMarkupInCode.has(+index) || !options.trimCustomFragments;
|
|
2765
|
+
// The padding tabs are whitespace to the minifier, so where one is gone, the
|
|
2766
|
+
// minifier removed the whitespace, which makes it safe to trim
|
|
2767
|
+
const trimLeading = !keep && prefix === '';
|
|
2768
|
+
const trimTrailing = inTag ? string.charCodeAt(offset + match.length) === 62 /* > */ : !keep && suffix === '';
|
|
2769
|
+
return restoreFragmentSide(prefix.replace(RE_TABS, '') + leading, trimLeading, true) +
|
|
2770
|
+
fragment +
|
|
2771
|
+
restoreFragmentSide(suffix.replace(RE_TABS, '') + trailing, trimTrailing, false);
|
|
2772
|
+
});
|
|
2773
|
+
}
|
|
2774
|
+
: identity, uidIgnore
|
|
2775
|
+
? function (/** @type {string} */ str) {
|
|
2776
|
+
return str.replace(new RegExp('<!--' + uidIgnore + '([0-9]+)-->', 'g'), function (/** @type {string} */ _match, /** @type {string} */ index) {
|
|
2777
|
+
return ignoredMarkupChunks[+index] ?? '';
|
|
2778
|
+
});
|
|
2779
|
+
}
|
|
2780
|
+
: identity);
|
|
1286
2781
|
}
|
|
1287
2782
|
|
|
1288
|
-
|
|
1289
|
-
|
|
1290
|
-
|
|
2783
|
+
/**
|
|
2784
|
+
* @param {string[]} results
|
|
2785
|
+
* @param {boolean[]} resultTags Whether each result belongs to a start or end tag
|
|
2786
|
+
* @param {string[]} markers Custom fragment and `htmlmin:ignore` placeholder markers
|
|
2787
|
+
* @param {ProcessedOptions} options
|
|
2788
|
+
* @param {Function} restoreCustom
|
|
2789
|
+
* @param {Function} restoreIgnore
|
|
2790
|
+
*/
|
|
2791
|
+
function joinResultSegments(results, resultTags, markers, options, restoreCustom, restoreIgnore) {
|
|
2792
|
+
let str;
|
|
2793
|
+
const maxLineLength = options.maxLineLength;
|
|
2794
|
+
|
|
1291
2795
|
if (maxLineLength) {
|
|
1292
|
-
|
|
1293
|
-
|
|
1294
|
-
|
|
1295
|
-
|
|
1296
|
-
|
|
1297
|
-
|
|
1298
|
-
|
|
1299
|
-
|
|
1300
|
-
|
|
1301
|
-
|
|
1302
|
-
|
|
1303
|
-
|
|
1304
|
-
|
|
1305
|
-
|
|
2796
|
+
// Whitespace in tags is the only whitespace that can’t change the output, so lines break
|
|
2797
|
+
// there alone—in place of the space between attributes, or right before a tag’s `>`
|
|
2798
|
+
const noNewlinesBeforeTagClose = options.noNewlinesBeforeTagClose;
|
|
2799
|
+
/** @type {string[]} */
|
|
2800
|
+
const lines = [];
|
|
2801
|
+
let lineLength = 0;
|
|
2802
|
+
// Output since the last break opportunity, and what that opportunity is when not taken;
|
|
2803
|
+
// fragments are restored by chunk, as whitespace around them decides how they come back
|
|
2804
|
+
let chunk = '';
|
|
2805
|
+
/** @type {string | null} */
|
|
2806
|
+
let separator = null;
|
|
2807
|
+
const flush = (/** @type {string | null} */ nextSeparator) => {
|
|
2808
|
+
chunk = restoreIgnore(restoreCustom(chunk));
|
|
2809
|
+
const newline = chunk.indexOf('\n');
|
|
2810
|
+
const head = newline < 0 ? chunk.length : newline;
|
|
2811
|
+
// A line already too long gains nothing from moving a `>` that ends a line of its own
|
|
2812
|
+
const isUseless = separator === '' && head === 1 && lineLength > maxLineLength;
|
|
2813
|
+
if (separator !== null) {
|
|
2814
|
+
if (lineLength > 0 && lineLength + separator.length + head > maxLineLength && !isUseless) {
|
|
2815
|
+
lines.push('\n');
|
|
2816
|
+
lineLength = 0;
|
|
2817
|
+
} else if (separator) {
|
|
2818
|
+
lines.push(separator);
|
|
2819
|
+
lineLength += separator.length;
|
|
2820
|
+
}
|
|
1306
2821
|
}
|
|
1307
|
-
|
|
1308
|
-
|
|
1309
|
-
|
|
2822
|
+
lines.push(chunk);
|
|
2823
|
+
lineLength = newline < 0 ? lineLength + chunk.length : chunk.length - chunk.lastIndexOf('\n') - 1;
|
|
2824
|
+
chunk = '';
|
|
2825
|
+
separator = nextSeparator;
|
|
2826
|
+
};
|
|
2827
|
+
for (let index = 0; index < results.length; index++) {
|
|
2828
|
+
const result = results[index] ?? '';
|
|
2829
|
+
if (!resultTags[index]) {
|
|
2830
|
+
chunk += result;
|
|
2831
|
+
} else if (markers.length && markers.some(marker => result.includes(marker))) {
|
|
2832
|
+
// Whitespace next to a fragment stays as is, as it may belong to it
|
|
2833
|
+
if (!chunk && separator !== null) {
|
|
2834
|
+
chunk = separator;
|
|
2835
|
+
separator = null;
|
|
2836
|
+
}
|
|
2837
|
+
chunk += result;
|
|
2838
|
+
} else if (result.charCodeAt(result.length - 1) === 32 /* space */) {
|
|
2839
|
+
chunk += result.slice(0, -1);
|
|
2840
|
+
flush(' ');
|
|
2841
|
+
} else if (result.charCodeAt(result.length - 1) === 62 /* > */ &&
|
|
2842
|
+
result.charCodeAt(result.length - 2) !== 47 /* / */ &&
|
|
2843
|
+
!(noNewlinesBeforeTagClose && result.startsWith('</'))) {
|
|
2844
|
+
chunk += result.slice(0, -1);
|
|
2845
|
+
flush('');
|
|
2846
|
+
chunk = '>';
|
|
2847
|
+
} else {
|
|
2848
|
+
chunk += result;
|
|
1310
2849
|
}
|
|
1311
2850
|
}
|
|
1312
|
-
|
|
1313
|
-
|
|
1314
|
-
|
|
1315
|
-
str = lines.join('\n');
|
|
1316
|
-
}
|
|
1317
|
-
else {
|
|
2851
|
+
flush(null);
|
|
2852
|
+
str = lines.join('');
|
|
2853
|
+
} else {
|
|
1318
2854
|
str = restoreIgnore(restoreCustom(results.join('')));
|
|
1319
2855
|
}
|
|
1320
2856
|
return options.collapseWhitespace ? collapseWhitespace(str, options, true, true) : str;
|
|
1321
2857
|
}
|
|
1322
2858
|
|
|
1323
|
-
|
|
1324
|
-
|
|
1325
|
-
|
|
1326
|
-
|
|
1327
|
-
|
|
2859
|
+
/**
|
|
2860
|
+
* Initialize minification caches with configurable sizes.
|
|
2861
|
+
*
|
|
2862
|
+
* Important behavior notes:
|
|
2863
|
+
* - Caches are created on the first `minify()` call and persist for the lifetime of the process
|
|
2864
|
+
* - Cache sizes are locked after first initialization—subsequent calls use the same caches
|
|
2865
|
+
* even if different `cacheCSS`/`cacheJS`/`cacheSVG` options are provided
|
|
2866
|
+
* - Invalid values (NaN, Infinity, negative) fall back to the default size (500)
|
|
2867
|
+
*/
|
|
2868
|
+
/**
|
|
2869
|
+
* @param {MinifierOptions} options
|
|
2870
|
+
* @returns {{cssMinifyCache: LRU, jsMinifyCache: LRU, svgMinifyCache: LRU}}
|
|
2871
|
+
*/
|
|
2872
|
+
function initCaches(options) {
|
|
2873
|
+
if (!cssMinifyCache || !jsMinifyCache || !svgMinifyCache) {
|
|
2874
|
+
const defaultSize = 500;
|
|
2875
|
+
|
|
2876
|
+
// Precedence: Options > env > default; non-finite and negative sizes fall back to the default
|
|
2877
|
+
const cacheSize = (/** @type {number | undefined} */ option, /** @type {string | undefined} */ envVar) => {
|
|
2878
|
+
const size = option !== undefined ? option : (envVar !== undefined ? Number(envVar) : defaultSize);
|
|
2879
|
+
return Number.isFinite(size) && size >= 0 ? Math.floor(size) : defaultSize;
|
|
2880
|
+
};
|
|
2881
|
+
|
|
2882
|
+
cssMinifyCache = new LRU(cacheSize(options.cacheCSS, process.env.HM_CACHE_CSS));
|
|
2883
|
+
jsMinifyCache = new LRU(cacheSize(options.cacheJS, process.env.HM_CACHE_JS));
|
|
2884
|
+
svgMinifyCache = new LRU(cacheSize(options.cacheSVG, process.env.HM_CACHE_SVG));
|
|
2885
|
+
}
|
|
2886
|
+
|
|
2887
|
+
return { cssMinifyCache, jsMinifyCache, svgMinifyCache };
|
|
2888
|
+
}
|
|
2889
|
+
|
|
2890
|
+
/**
|
|
2891
|
+
* Get hit/miss statistics for the CSS, JavaScript, and SVG minification caches.
|
|
2892
|
+
*
|
|
2893
|
+
* Each cache is created lazily on the first `minify()` call (see `initCaches`); before
|
|
2894
|
+
* that first call, or for a cache never exercised (e.g., `minifyCSS` disabled), its
|
|
2895
|
+
* entry reports all-zero stats.
|
|
2896
|
+
*
|
|
2897
|
+
* @returns {{ css: CacheStats, js: CacheStats, svg: CacheStats }}
|
|
2898
|
+
*/
|
|
2899
|
+
export function getCacheStats() {
|
|
2900
|
+
return {
|
|
2901
|
+
css: cacheStatsOrEmpty(cssMinifyCache),
|
|
2902
|
+
js: cacheStatsOrEmpty(jsMinifyCache),
|
|
2903
|
+
svg: cacheStatsOrEmpty(svgMinifyCache)
|
|
2904
|
+
};
|
|
2905
|
+
}
|
|
2906
|
+
|
|
2907
|
+
// Memoized options processing: Batch runs pass one options object to many `minify()` calls, and
|
|
2908
|
+
// processing it (regex parsing, closures, option signatures) is expensive. The cache is keyed on
|
|
2909
|
+
// the object and checked against a deep snapshot, so mutating a reused object, nested values
|
|
2910
|
+
// included, still triggers reprocessing. Each call works on a shallow copy, as `minifyHTML`
|
|
2911
|
+
// reassigns top-level keys (wrapped minifiers, sorters, UID comment patterns) per call.
|
|
2912
|
+
/** @type {MinifierOptions} */
|
|
2913
|
+
const EMPTY_OPTIONS = {};
|
|
2914
|
+
/** @type {WeakMap<object, {snapshot: unknown, processed: ProcessedOptions}>} */
|
|
2915
|
+
const processedOptionsCache = new WeakMap();
|
|
2916
|
+
|
|
2917
|
+
// A “plain” value is an array or plain object, which the snapshot copies and
|
|
2918
|
+
// compares structurally; anything else (functions, RegExps, class instances)
|
|
2919
|
+
// is kept and compared by identity—a false mismatch merely reprocesses
|
|
2920
|
+
/** @param {unknown} value */
|
|
2921
|
+
function isPlainValue(value) {
|
|
2922
|
+
if (Array.isArray(value)) return true;
|
|
2923
|
+
if (value === null || typeof value !== 'object') return false;
|
|
2924
|
+
const proto = Object.getPrototypeOf(value);
|
|
2925
|
+
return proto === Object.prototype || proto === null;
|
|
2926
|
+
}
|
|
2927
|
+
|
|
2928
|
+
// Budget for `snapshotValue`: Bounds the work spent on pathological options
|
|
2929
|
+
// objects; when the budget runs out, the object isn’t memoized and falls
|
|
2930
|
+
// back to per-call processing
|
|
2931
|
+
const SNAPSHOT_MAX_NODES = 1024;
|
|
2932
|
+
const SNAPSHOT_UNSAFE = Symbol('snapshot-unsafe');
|
|
2933
|
+
// Skipped memoization is fail-safe but silent—warn once per process
|
|
2934
|
+
let snapshotBudgetWarned = false;
|
|
2935
|
+
|
|
2936
|
+
/**
|
|
2937
|
+
* @param {unknown} value
|
|
2938
|
+
* @param {{budget: number}} state
|
|
2939
|
+
* @returns {unknown} The copy, or `SNAPSHOT_UNSAFE` once the budget is exhausted
|
|
2940
|
+
*/
|
|
2941
|
+
function snapshotValue(value, state) {
|
|
2942
|
+
if (!isPlainValue(value)) return value;
|
|
2943
|
+
if (--state.budget < 0) return SNAPSHOT_UNSAFE;
|
|
2944
|
+
if (Array.isArray(value)) {
|
|
2945
|
+
const copy = new Array(value.length);
|
|
2946
|
+
for (let i = 0; i < value.length; i++) {
|
|
2947
|
+
const child = snapshotValue(value[i], state);
|
|
2948
|
+
if (child === SNAPSHOT_UNSAFE) return SNAPSHOT_UNSAFE;
|
|
2949
|
+
copy[i] = child;
|
|
2950
|
+
}
|
|
2951
|
+
return copy;
|
|
2952
|
+
}
|
|
2953
|
+
/** @type {Record<string, unknown>} */
|
|
2954
|
+
const copy = {};
|
|
2955
|
+
const source = /** @type {Record<string, unknown>} */ (value);
|
|
2956
|
+
for (const key of Object.keys(source)) {
|
|
2957
|
+
const child = snapshotValue(source[key], state);
|
|
2958
|
+
if (child === SNAPSHOT_UNSAFE) return SNAPSHOT_UNSAFE;
|
|
2959
|
+
copy[key] = child;
|
|
2960
|
+
}
|
|
2961
|
+
return copy;
|
|
2962
|
+
}
|
|
2963
|
+
|
|
2964
|
+
/**
|
|
2965
|
+
* @param {unknown} snapshot
|
|
2966
|
+
* @param {unknown} current
|
|
2967
|
+
* @returns {boolean}
|
|
2968
|
+
*/
|
|
2969
|
+
function valueUnchanged(snapshot, current) {
|
|
2970
|
+
if (snapshot === current) return true;
|
|
2971
|
+
if (!isPlainValue(snapshot) || !isPlainValue(current)) return false;
|
|
2972
|
+
if (Array.isArray(snapshot)) {
|
|
2973
|
+
if (!Array.isArray(current) || snapshot.length !== current.length) return false;
|
|
2974
|
+
for (let i = 0; i < snapshot.length; i++) {
|
|
2975
|
+
if (!valueUnchanged(snapshot[i], current[i])) return false;
|
|
2976
|
+
}
|
|
2977
|
+
return true;
|
|
2978
|
+
}
|
|
2979
|
+
if (Array.isArray(current)) return false;
|
|
2980
|
+
const snapshotObj = /** @type {Record<string, unknown>} */ (snapshot);
|
|
2981
|
+
const currentObj = /** @type {Record<string, unknown>} */ (current);
|
|
2982
|
+
const snapshotKeys = Object.keys(snapshotObj);
|
|
2983
|
+
if (snapshotKeys.length !== Object.keys(currentObj).length) return false;
|
|
2984
|
+
for (const key of snapshotKeys) {
|
|
2985
|
+
if (!Object.hasOwn(currentObj, key) || !valueUnchanged(snapshotObj[key], currentObj[key])) {
|
|
2986
|
+
return false;
|
|
2987
|
+
}
|
|
2988
|
+
}
|
|
2989
|
+
return true;
|
|
2990
|
+
}
|
|
2991
|
+
|
|
2992
|
+
/**
|
|
2993
|
+
* @param {string} value
|
|
2994
|
+
* @param {MinifierOptions} [options]
|
|
2995
|
+
* @returns {Promise<string>}
|
|
2996
|
+
*/
|
|
2997
|
+
export const minify = async function (value, options) {
|
|
2998
|
+
const start = Date.now();
|
|
2999
|
+
|
|
3000
|
+
const inputOptions = options || EMPTY_OPTIONS;
|
|
3001
|
+
|
|
3002
|
+
const caches = initCaches(inputOptions);
|
|
3003
|
+
|
|
3004
|
+
// WeakMap keys must be objects
|
|
3005
|
+
const canMemoize = typeof inputOptions === 'object' && inputOptions !== null;
|
|
3006
|
+
const cached = canMemoize ? processedOptionsCache.get(inputOptions) : undefined;
|
|
3007
|
+
/** @type {ProcessedOptions} */
|
|
3008
|
+
let processedBase;
|
|
3009
|
+
// `valueUnchanged` needs no budget of its own: Recursion is bounded by the
|
|
3010
|
+
// stored snapshot, which is a finite tree of at most `SNAPSHOT_MAX_NODES`
|
|
3011
|
+
if (cached && valueUnchanged(cached.snapshot, inputOptions)) {
|
|
3012
|
+
processedBase = cached.processed;
|
|
3013
|
+
} else {
|
|
3014
|
+
processedBase = processOptions(inputOptions, {
|
|
3015
|
+
getLightningCSS,
|
|
3016
|
+
getTerser,
|
|
3017
|
+
getSwc,
|
|
3018
|
+
getSvgo,
|
|
3019
|
+
cssMinifyCache: caches.cssMinifyCache,
|
|
3020
|
+
jsMinifyCache: caches.jsMinifyCache,
|
|
3021
|
+
svgMinifyCache: caches.svgMinifyCache
|
|
3022
|
+
});
|
|
3023
|
+
if (canMemoize) {
|
|
3024
|
+
const snapshot = snapshotValue(inputOptions, { budget: SNAPSHOT_MAX_NODES });
|
|
3025
|
+
if (snapshot !== SNAPSHOT_UNSAFE) {
|
|
3026
|
+
processedOptionsCache.set(inputOptions, { snapshot, processed: processedBase });
|
|
3027
|
+
} else if (!snapshotBudgetWarned) {
|
|
3028
|
+
snapshotBudgetWarned = true;
|
|
3029
|
+
const warn = typeof inputOptions.log === 'function' ? inputOptions.log : console.warn;
|
|
3030
|
+
warn('HTML Minifier: Options object exceeds the memoization complexity limit; options will be processed on every call');
|
|
3031
|
+
}
|
|
3032
|
+
}
|
|
3033
|
+
}
|
|
3034
|
+
// Warnings are deduplicated per document, so the state has to live on the per-call
|
|
3035
|
+
// copy; the `minifyCSS` closure hangs off the memoized base, shared across calls
|
|
3036
|
+
/** @type {NonNullable<ProcessedOptions['cssContext']>} */
|
|
3037
|
+
const cssContext = { warned: new Set() };
|
|
3038
|
+
// What the parse finds out about the document for post-processing, sparing an output scan
|
|
3039
|
+
const documentState = { hasMathML: false };
|
|
3040
|
+
// Whether the document holds a `base` element, looked up in it on the first `target=_self`
|
|
3041
|
+
/** @type {NonNullable<ProcessedOptions['baseState']>} */
|
|
3042
|
+
const baseState = { hasBase: undefined, source: value };
|
|
3043
|
+
// Work on a shallow copy so per-call reassignments don’t reach the cached base. The
|
|
3044
|
+
// per-document state is part of the copy from the start, as properties added to it
|
|
3045
|
+
// later slow down every option lookup.
|
|
3046
|
+
const processedOptions = /** @type {ProcessedOptions} */ ({ ...processedBase, cssContext, documentState, baseState });
|
|
3047
|
+
|
|
3048
|
+
// Unused-CSS removal needs the whole document’s symbols before the first `style` element is
|
|
3049
|
+
// minified, so they are collected upfront from the raw input—where there is a style sheet at all
|
|
3050
|
+
if (processedOptions.removeUnusedCSS && RE_STYLE_ELEMENT.test(value)) {
|
|
3051
|
+
Object.assign(cssContext, collectUsage(
|
|
3052
|
+
value,
|
|
3053
|
+
processedOptions.removeUnusedCSS.scripts,
|
|
3054
|
+
value.indexOf('&') !== -1
|
|
3055
|
+
? /** @type {(text: string) => string} */ (await getDecodeHTML())
|
|
3056
|
+
: undefined
|
|
3057
|
+
));
|
|
3058
|
+
}
|
|
3059
|
+
|
|
3060
|
+
let result = await minifyHTML(value, processedOptions);
|
|
3061
|
+
|
|
3062
|
+
if (processedOptions.mergeScripts) {
|
|
3063
|
+
result = mergeConsecutiveScripts(result, documentState.hasMathML);
|
|
3064
|
+
}
|
|
3065
|
+
|
|
3066
|
+
processedOptions.log('minified in: ' + (Date.now() - start) + 'ms');
|
|
1328
3067
|
return result;
|
|
1329
3068
|
};
|
|
3069
|
+
|
|
3070
|
+
export {
|
|
3071
|
+
presets,
|
|
3072
|
+
getPreset,
|
|
3073
|
+
getPresetNames
|
|
3074
|
+
};
|
|
3075
|
+
|
|
3076
|
+
export default {
|
|
3077
|
+
minify,
|
|
3078
|
+
presets,
|
|
3079
|
+
getPreset,
|
|
3080
|
+
getPresetNames,
|
|
3081
|
+
getCacheStats
|
|
3082
|
+
};
|