html-minifier 5.0.2 → 5.0.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +8 -5
- package/cli.js +89 -32
- package/dist/types/htmlminifier.d.ts.map +1 -1
- package/dist/types/htmlparser.d.ts.map +1 -1
- package/dist/types/lib/attributes.d.ts +19 -1
- package/dist/types/lib/attributes.d.ts.map +1 -1
- package/dist/types/lib/constants.d.ts +6 -1
- package/dist/types/lib/constants.d.ts.map +1 -1
- package/dist/types/lib/elements.d.ts +3 -1
- package/dist/types/lib/elements.d.ts.map +1 -1
- package/dist/types/lib/fragments.d.ts.map +1 -1
- package/dist/types/lib/options.d.ts +1 -0
- package/dist/types/lib/options.d.ts.map +1 -1
- package/dist/types/lib/unused-css.d.ts.map +1 -1
- package/dist/types/lib/utils.d.ts +8 -1
- package/dist/types/lib/utils.d.ts.map +1 -1
- package/dist/types/lib/whitespace.d.ts.map +1 -1
- package/package.json +5 -3
- package/src/htmlminifier.js +220 -69
- package/src/htmlparser.js +333 -53
- package/src/lib/attributes.js +108 -18
- package/src/lib/constants.js +21 -5
- package/src/lib/elements.js +15 -3
- package/src/lib/fragments.js +12 -3
- package/src/lib/options.js +5 -4
- package/src/lib/unused-css.js +85 -68
- package/src/lib/utils.js +19 -0
- package/src/lib/whitespace.js +2 -4
package/src/lib/unused-css.js
CHANGED
|
@@ -41,6 +41,8 @@ const fragmentReferenceAttributes = new Set([
|
|
|
41
41
|
|
|
42
42
|
const attributePattern = /(?:^|[\s/])([-\w:.]+)\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s"'=<>`]+))/g;
|
|
43
43
|
const identifierPattern = /--[\w-]*|-?[A-Za-z_][\w-]*/g;
|
|
44
|
+
const whitespacePattern = /\s/;
|
|
45
|
+
const whitespaceRunPattern = /\s+/;
|
|
44
46
|
// SVG paints and filters reach elements by ID through `url(#gradient)`, in
|
|
45
47
|
// presentation attributes as well as in `style`
|
|
46
48
|
const fragmentURLPattern = /url\(\s*['"]?#([^)'"\s]+)/gi;
|
|
@@ -58,8 +60,8 @@ const cssIdentifierPattern = /(?<!\\)[.#]((?:[-_a-zA-Z]|\\[0-9a-fA-F]{1,6}[ \t\n
|
|
|
58
60
|
// name used there is off limits even when no element carries it as a class or ID
|
|
59
61
|
const reservedAtRulePattern = /@(?:-\w+-)?(?:keyframes|counter-style)\s+(-?[_a-zA-Z][\w-]*)/gi;
|
|
60
62
|
const escapePattern = /\\(?:([0-9a-fA-F]{1,6})[ \t\n]?|(.))/g;
|
|
61
|
-
//
|
|
62
|
-
const startTagPattern = /<([a-z][^\s/>]*)/
|
|
63
|
+
// Matches inside comments and scripts only add names
|
|
64
|
+
const startTagPattern = /<([a-z][^\s/>]*)/gi;
|
|
63
65
|
|
|
64
66
|
/**
|
|
65
67
|
* Resolve CSS escape sequences in an identifier.
|
|
@@ -83,20 +85,43 @@ function unescapeIdentifier(identifier) {
|
|
|
83
85
|
}
|
|
84
86
|
|
|
85
87
|
/**
|
|
86
|
-
*
|
|
87
|
-
*
|
|
88
|
-
*
|
|
89
|
-
*
|
|
90
|
-
*
|
|
91
|
-
|
|
88
|
+
* Add what `identifierPattern` matches in `text`
|
|
89
|
+
* @param {string} text
|
|
90
|
+
* @param {Set<string>} used
|
|
91
|
+
* @param {Set<string>} [names] - Also receives each identifier lowercased
|
|
92
|
+
* @param {Set<string>} [seen] - Identifiers added before, which are skipped
|
|
93
|
+
*/
|
|
94
|
+
function addIdentifiers(text, used, names, seen) {
|
|
95
|
+
for (const identifier of text.match(identifierPattern) ?? []) {
|
|
96
|
+
if (seen) {
|
|
97
|
+
if (seen.has(identifier)) {
|
|
98
|
+
continue;
|
|
99
|
+
}
|
|
100
|
+
seen.add(identifier);
|
|
101
|
+
}
|
|
102
|
+
used.add(identifier);
|
|
103
|
+
names?.add(identifier.toLowerCase());
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Add what splitting `text` at `/\s+/` yields, but empty strings
|
|
92
109
|
* @param {string} text
|
|
93
|
-
* @
|
|
110
|
+
* @param {Set<string>} used
|
|
94
111
|
*/
|
|
95
|
-
function
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
112
|
+
function addTokens(text, used) {
|
|
113
|
+
// Most values are a single token, which needs no array
|
|
114
|
+
if (!whitespacePattern.test(text)) {
|
|
115
|
+
if (text) {
|
|
116
|
+
used.add(text);
|
|
117
|
+
}
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
for (const token of text.split(whitespaceRunPattern)) {
|
|
121
|
+
if (token) {
|
|
122
|
+
used.add(token);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
100
125
|
}
|
|
101
126
|
|
|
102
127
|
/**
|
|
@@ -107,15 +132,21 @@ function foldCase(text) {
|
|
|
107
132
|
* quadratically over a document full of near-matches, and bounding it would make
|
|
108
133
|
* long start tags go unrecognized.
|
|
109
134
|
*
|
|
110
|
-
*
|
|
135
|
+
* Tag names are matched in ASCII case only, as browsers do, and on the markup itself,
|
|
136
|
+
* so offsets need no mapping back.
|
|
137
|
+
*
|
|
138
|
+
* @param {string} html
|
|
111
139
|
* @param {string} tagName - Lowercase element name
|
|
112
140
|
* @returns {Array<{start: number, bodyStart: number, bodyEnd: number, end: number, closed: boolean}>}
|
|
113
141
|
*/
|
|
114
|
-
function findRawTextElements(
|
|
142
|
+
function findRawTextElements(html, tagName) {
|
|
115
143
|
/** @type {Array<{start: number, bodyStart: number, bodyEnd: number, end: number, closed: boolean}>} */
|
|
116
144
|
const found = [];
|
|
117
|
-
|
|
118
|
-
const
|
|
145
|
+
// Without the `u` flag, `i` folds no non-ASCII character into ASCII
|
|
146
|
+
const openTag = new RegExp('<' + tagName, 'gi');
|
|
147
|
+
const closeTag = new RegExp('</' + tagName, 'gi');
|
|
148
|
+
const lengthOpen = tagName.length + 1;
|
|
149
|
+
const lengthClose = tagName.length + 2;
|
|
119
150
|
// A tag name ends at whitespace, a slash, or the closing bracket—so `<styles>`
|
|
120
151
|
// and `</scriptfoo>` name different elements and must not match
|
|
121
152
|
const endsName = (/** @type {string} */ character) =>
|
|
@@ -123,17 +154,18 @@ function findRawTextElements(haystack, tagName) {
|
|
|
123
154
|
|
|
124
155
|
let cursor = 0;
|
|
125
156
|
for (;;) {
|
|
126
|
-
|
|
157
|
+
openTag.lastIndex = cursor;
|
|
158
|
+
const start = openTag.exec(html)?.index ?? -1;
|
|
127
159
|
if (start === -1) {
|
|
128
160
|
break;
|
|
129
161
|
}
|
|
130
|
-
if (!endsName(
|
|
131
|
-
cursor = start +
|
|
162
|
+
if (!endsName(html.charAt(start + lengthOpen))) {
|
|
163
|
+
cursor = start + lengthOpen;
|
|
132
164
|
continue;
|
|
133
165
|
}
|
|
134
166
|
// A quoted attribute value may hold a `>`, so the tag ends where the parser
|
|
135
167
|
// says it does, not at the next bracket
|
|
136
|
-
const startTagEnd = findTagEnd(
|
|
168
|
+
const startTagEnd = findTagEnd(html, start + lengthOpen);
|
|
137
169
|
if (startTagEnd === -1) {
|
|
138
170
|
break;
|
|
139
171
|
}
|
|
@@ -143,30 +175,31 @@ function findRawTextElements(haystack, tagName) {
|
|
|
143
175
|
let bodyEnd = -1;
|
|
144
176
|
let end = -1;
|
|
145
177
|
for (;;) {
|
|
146
|
-
|
|
178
|
+
closeTag.lastIndex = search;
|
|
179
|
+
const candidate = closeTag.exec(html)?.index ?? -1;
|
|
147
180
|
if (candidate === -1) {
|
|
148
181
|
break;
|
|
149
182
|
}
|
|
150
|
-
if (endsName(
|
|
151
|
-
const closeEnd = findTagEnd(
|
|
183
|
+
if (endsName(html.charAt(candidate + lengthClose))) {
|
|
184
|
+
const closeEnd = findTagEnd(html, candidate + lengthClose);
|
|
152
185
|
if (closeEnd !== -1) {
|
|
153
186
|
bodyEnd = candidate;
|
|
154
187
|
end = closeEnd + 1;
|
|
155
188
|
}
|
|
156
189
|
break;
|
|
157
190
|
}
|
|
158
|
-
search = candidate +
|
|
191
|
+
search = candidate + lengthClose;
|
|
159
192
|
}
|
|
160
193
|
|
|
161
194
|
const closed = bodyEnd !== -1;
|
|
162
195
|
found.push({
|
|
163
196
|
start,
|
|
164
197
|
bodyStart,
|
|
165
|
-
bodyEnd: closed ? bodyEnd :
|
|
166
|
-
end: closed ? end :
|
|
198
|
+
bodyEnd: closed ? bodyEnd : html.length,
|
|
199
|
+
end: closed ? end : html.length,
|
|
167
200
|
closed
|
|
168
201
|
});
|
|
169
|
-
cursor = closed ? end :
|
|
202
|
+
cursor = closed ? end : html.length;
|
|
170
203
|
}
|
|
171
204
|
|
|
172
205
|
return found;
|
|
@@ -189,39 +222,20 @@ function findRawTextElements(haystack, tagName) {
|
|
|
189
222
|
*/
|
|
190
223
|
function collectUsedSymbols(html, includeScripts, decode, elements) {
|
|
191
224
|
const used = new Set();
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
//
|
|
195
|
-
//
|
|
196
|
-
//
|
|
197
|
-
//
|
|
198
|
-
|
|
199
|
-
// would lose them.
|
|
200
|
-
const skipped = findRawTextElements(haystack, 'style')
|
|
225
|
+
|
|
226
|
+
// Style sheets are skipped rather than cut out, so offsets keep pointing into
|
|
227
|
+
// `html`. Only the body is skipped—the start tag carries ordinary attributes,
|
|
228
|
+
// and `<style id="theme">` could be what `#theme` refers to. An unclosed
|
|
229
|
+
// element is not skipped: Reading its contents as markup can only add symbols,
|
|
230
|
+
// whereas ignoring the rest of the document would lose them.
|
|
231
|
+
const skipped = findRawTextElements(html, 'style')
|
|
201
232
|
.filter(element => element.closed)
|
|
202
233
|
.map(element => ({ start: element.bodyStart, end: element.bodyEnd }));
|
|
203
234
|
|
|
204
235
|
if (elements) {
|
|
205
|
-
collectUsedElements(
|
|
236
|
+
collectUsedElements(html, skipped, elements);
|
|
206
237
|
}
|
|
207
238
|
|
|
208
|
-
const addIdentifiers = (/** @type {string} */ text, /** @type {Set<string> | undefined} */ names = undefined) => {
|
|
209
|
-
identifierPattern.lastIndex = 0;
|
|
210
|
-
let identifier;
|
|
211
|
-
while ((identifier = identifierPattern.exec(text))) {
|
|
212
|
-
used.add(identifier[0]);
|
|
213
|
-
names?.add(identifier[0].toLowerCase());
|
|
214
|
-
}
|
|
215
|
-
};
|
|
216
|
-
|
|
217
|
-
const addTokens = (/** @type {string} */ text) => {
|
|
218
|
-
for (const token of text.split(/\s+/)) {
|
|
219
|
-
if (token) {
|
|
220
|
-
used.add(token);
|
|
221
|
-
}
|
|
222
|
-
}
|
|
223
|
-
};
|
|
224
|
-
|
|
225
239
|
// Both loops below walk forward, so one cursor over the skipped ranges suffices
|
|
226
240
|
let skipCursor = 0;
|
|
227
241
|
const isSkipped = (/** @type {number} */ index) => {
|
|
@@ -243,20 +257,20 @@ function collectUsedSymbols(html, includeScripts, decode, elements) {
|
|
|
243
257
|
// `class="used"` names the class `used`, so compare against the decoded value
|
|
244
258
|
const value = (decode && raw.indexOf('&') !== -1) ? decode(raw) : raw;
|
|
245
259
|
if (name === 'class' || name === 'id' || idReferenceAttributes.has(name)) {
|
|
246
|
-
addTokens(value);
|
|
260
|
+
addTokens(value, used);
|
|
247
261
|
} else if (name.startsWith('data-')) {
|
|
248
262
|
// Class names are commonly parked in `data-*` attributes for scripts to apply later
|
|
249
|
-
addIdentifiers(value);
|
|
250
|
-
addTokens(value);
|
|
263
|
+
addIdentifiers(value, used);
|
|
264
|
+
addTokens(value, used);
|
|
251
265
|
} else if (fragmentReferenceAttributes.has(name) && value.charAt(0) === '#') {
|
|
252
266
|
// Only a leading `#`: `href="/page#sec"` names a section of another document
|
|
253
|
-
addTokens(value.slice(1));
|
|
267
|
+
addTokens(value.slice(1), used);
|
|
254
268
|
}
|
|
255
269
|
if (value.indexOf('(') !== -1) {
|
|
256
270
|
fragmentURLPattern.lastIndex = 0;
|
|
257
271
|
let reference;
|
|
258
272
|
while ((reference = fragmentURLPattern.exec(value))) {
|
|
259
|
-
addTokens(reference[1] ?? '');
|
|
273
|
+
addTokens(reference[1] ?? '', used);
|
|
260
274
|
}
|
|
261
275
|
}
|
|
262
276
|
}
|
|
@@ -267,18 +281,21 @@ function collectUsedSymbols(html, includeScripts, decode, elements) {
|
|
|
267
281
|
// isn’t tracked here, both readings count where they differ. An unclosed
|
|
268
282
|
// `script` runs to the end of the document, as it does in a browser.
|
|
269
283
|
skipCursor = 0;
|
|
270
|
-
|
|
284
|
+
// Scripts repeat their identifiers, which need adding only once
|
|
285
|
+
/** @type {Set<string>} */
|
|
286
|
+
const identifiersScript = new Set();
|
|
287
|
+
for (const element of findRawTextElements(html, 'script')) {
|
|
271
288
|
if (isSkipped(element.start)) {
|
|
272
289
|
continue;
|
|
273
290
|
}
|
|
274
291
|
const body = html.slice(element.bodyStart, element.bodyEnd);
|
|
275
292
|
const decoded = (decode && body.indexOf('&') !== -1) ? decode(body) : body;
|
|
276
293
|
for (const text of decoded === body ? [body] : [body, decoded]) {
|
|
277
|
-
addIdentifiers(text, elements);
|
|
294
|
+
addIdentifiers(text, used, elements, identifiersScript);
|
|
278
295
|
stringLiteralPattern.lastIndex = 0;
|
|
279
296
|
let literal;
|
|
280
297
|
while ((literal = stringLiteralPattern.exec(text))) {
|
|
281
|
-
addTokens(literal[1] ?? literal[2] ?? literal[3] ?? '');
|
|
298
|
+
addTokens(literal[1] ?? literal[2] ?? literal[3] ?? '', used);
|
|
282
299
|
}
|
|
283
300
|
}
|
|
284
301
|
}
|
|
@@ -342,11 +359,11 @@ function isSafelisted(symbol, safelist) {
|
|
|
342
359
|
/**
|
|
343
360
|
* Collect the element names a document contains, including those the parser
|
|
344
361
|
* supplies when the markup leaves them out.
|
|
345
|
-
* @param {string}
|
|
362
|
+
* @param {string} html
|
|
346
363
|
* @param {Array<{start: number, end: number}>} skipped - Style sheet bodies, in document order
|
|
347
364
|
* @param {Set<string>} elements - Lowercase element names, added to
|
|
348
365
|
*/
|
|
349
|
-
function collectUsedElements(
|
|
366
|
+
function collectUsedElements(html, skipped, elements) {
|
|
350
367
|
elements.add('html');
|
|
351
368
|
elements.add('head');
|
|
352
369
|
elements.add('body');
|
|
@@ -354,7 +371,7 @@ function collectUsedElements(haystack, skipped, elements) {
|
|
|
354
371
|
let skipIndex = 0;
|
|
355
372
|
startTagPattern.lastIndex = 0;
|
|
356
373
|
let match;
|
|
357
|
-
while ((match = startTagPattern.exec(
|
|
374
|
+
while ((match = startTagPattern.exec(html))) {
|
|
358
375
|
while (skipIndex < skipped.length && (skipped[skipIndex]?.end ?? 0) <= match.index) {
|
|
359
376
|
skipIndex++;
|
|
360
377
|
}
|
|
@@ -362,7 +379,7 @@ function collectUsedElements(haystack, skipped, elements) {
|
|
|
362
379
|
if (element && match.index >= element.start) {
|
|
363
380
|
continue;
|
|
364
381
|
}
|
|
365
|
-
elements.add(match[1] ?? '');
|
|
382
|
+
elements.add((match[1] ?? '').toLowerCase());
|
|
366
383
|
}
|
|
367
384
|
}
|
|
368
385
|
|
package/src/lib/utils.js
CHANGED
|
@@ -133,6 +133,14 @@ function escapeXML(value, attribute = false) {
|
|
|
133
133
|
return value.replace(attribute ? RE_XML_ESCAPE_ATTR : RE_XML_ESCAPE_TEXT, escapeXMLMatch);
|
|
134
134
|
}
|
|
135
135
|
|
|
136
|
+
/**
|
|
137
|
+
* Script and style text as browsers read it, for code minifiers
|
|
138
|
+
* @param {string} text
|
|
139
|
+
*/
|
|
140
|
+
function replaceNUL(text) {
|
|
141
|
+
return text.indexOf('\0') === -1 ? text : text.replace(/\0/g, '\uFFFD');
|
|
142
|
+
}
|
|
143
|
+
|
|
136
144
|
/**
|
|
137
145
|
* Read text in foreign content as HTML does: CDATA sections hold text as written,
|
|
138
146
|
* while what surrounds them reads character references
|
|
@@ -938,6 +946,15 @@ function describeQuantifierRisk(pattern) {
|
|
|
938
946
|
: null;
|
|
939
947
|
}
|
|
940
948
|
|
|
949
|
+
// The characters `\s` matches, by code: tab through carriage return, space, and the
|
|
950
|
+
// Unicode whitespace and line terminators
|
|
951
|
+
/** @param {number} code */
|
|
952
|
+
function isWhitespaceCode(code) {
|
|
953
|
+
return (code >= 9 && code <= 13) || code === 32 || code === 0xA0 || code === 0x1680 ||
|
|
954
|
+
(code >= 0x2000 && code <= 0x200A) || code === 0x2028 || code === 0x2029 ||
|
|
955
|
+
code === 0x202F || code === 0x205F || code === 0x3000 || code === 0xFEFF;
|
|
956
|
+
}
|
|
957
|
+
|
|
941
958
|
/**
|
|
942
959
|
* Find the index of the `>` that closes an opening tag, correctly skipping
|
|
943
960
|
* over quoted attribute values (which may contain `>`).
|
|
@@ -1011,10 +1028,12 @@ export {
|
|
|
1011
1028
|
hashContent,
|
|
1012
1029
|
uniqueId,
|
|
1013
1030
|
escapeXML,
|
|
1031
|
+
replaceNUL,
|
|
1014
1032
|
decodeForeignText,
|
|
1015
1033
|
identity,
|
|
1016
1034
|
isQuirksDoctype,
|
|
1017
1035
|
isThenable,
|
|
1036
|
+
isWhitespaceCode,
|
|
1018
1037
|
lowercase,
|
|
1019
1038
|
paramCase,
|
|
1020
1039
|
replaceAsync,
|
package/src/lib/whitespace.js
CHANGED
|
@@ -18,6 +18,7 @@ import {
|
|
|
18
18
|
formControlElementsEither,
|
|
19
19
|
toEndTags
|
|
20
20
|
} from './constants.js';
|
|
21
|
+
import { isWhitespaceCode } from './utils.js';
|
|
21
22
|
|
|
22
23
|
const RE_ANY_WS_NBSP = /[ \n\r\t\f\xA0]/;
|
|
23
24
|
const RE_TAB_NBSP = /[\t\xA0]/;
|
|
@@ -53,10 +54,7 @@ function isWsOrNbsp(code) {
|
|
|
53
54
|
/** @param {string} str */
|
|
54
55
|
function isAllWhitespace(str) {
|
|
55
56
|
for (let i = 0; i < str.length; i++) {
|
|
56
|
-
|
|
57
|
-
if (!((code >= 9 && code <= 13) || code === 32 || code === 0xA0 || code === 0x1680 ||
|
|
58
|
-
(code >= 0x2000 && code <= 0x200A) || code === 0x2028 || code === 0x2029 ||
|
|
59
|
-
code === 0x202F || code === 0x205F || code === 0x3000 || code === 0xFEFF)) {
|
|
57
|
+
if (!isWhitespaceCode(str.charCodeAt(i))) {
|
|
60
58
|
return false;
|
|
61
59
|
}
|
|
62
60
|
}
|