html-minifier 4.0.0 → 5.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE.txt +23 -0
- package/README.md +851 -110
- package/cli.js +991 -207
- package/dist/types/htmlminifier.d.ts +629 -0
- package/dist/types/htmlminifier.d.ts.map +1 -0
- package/dist/types/htmlparser.d.ts +28 -0
- package/dist/types/htmlparser.d.ts.map +1 -0
- package/dist/types/lib/attributes.d.ts +87 -0
- package/dist/types/lib/attributes.d.ts.map +1 -0
- package/dist/types/lib/constants.d.ts +421 -0
- package/dist/types/lib/constants.d.ts.map +1 -0
- package/dist/types/lib/content.d.ts +44 -0
- package/dist/types/lib/content.d.ts.map +1 -0
- package/dist/types/lib/elements.d.ts +48 -0
- package/dist/types/lib/elements.d.ts.map +1 -0
- package/dist/types/lib/fragments.d.ts +47 -0
- package/dist/types/lib/fragments.d.ts.map +1 -0
- package/dist/types/lib/option-definitions.d.ts +33 -0
- package/dist/types/lib/option-definitions.d.ts.map +1 -0
- package/dist/types/lib/options.d.ts +93 -0
- package/dist/types/lib/options.d.ts.map +1 -0
- package/dist/types/lib/unused-css.d.ts +76 -0
- package/dist/types/lib/unused-css.d.ts.map +1 -0
- package/dist/types/lib/urls.d.ts +15 -0
- package/dist/types/lib/urls.d.ts.map +1 -0
- package/dist/types/lib/utils.d.ts +132 -0
- package/dist/types/lib/utils.d.ts.map +1 -0
- package/dist/types/lib/whitespace.d.ts +97 -0
- package/dist/types/lib/whitespace.d.ts.map +1 -0
- package/dist/types/presets.d.ts +50 -0
- package/dist/types/presets.d.ts.map +1 -0
- package/dist/types/tokenchain.d.ts +36 -0
- package/dist/types/tokenchain.d.ts.map +1 -0
- package/html-minifier.schema.json +322 -0
- package/package.json +70 -58
- package/src/htmlminifier.js +2845 -1107
- package/src/htmlparser.js +1163 -452
- package/src/lib/attributes.js +1196 -0
- package/src/lib/constants.js +521 -0
- package/src/lib/content.js +237 -0
- package/src/lib/elements.js +268 -0
- package/src/lib/file-pool.js +193 -0
- package/src/lib/file-write.js +87 -0
- package/src/lib/fragments.js +340 -0
- package/src/lib/option-definitions.js +246 -0
- package/src/lib/options.js +841 -0
- package/src/lib/unused-css.js +499 -0
- package/src/lib/urls.js +146 -0
- package/src/lib/utils.js +1026 -0
- package/src/lib/whitespace.js +431 -0
- package/src/presets.js +58 -0
- package/src/tokenchain.js +142 -56
- package/LICENSE +0 -22
- package/sample-cli-config-file.conf +0 -40
- package/src/utils.js +0 -18
|
@@ -0,0 +1,499 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unused-CSS removal
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { findTagEnd, hashContent } from './utils.js';
|
|
6
|
+
|
|
7
|
+
// Attributes whose values name elements by ID or hold space-separated ID lists
|
|
8
|
+
const idReferenceAttributes = new Set([
|
|
9
|
+
'aria-activedescendant',
|
|
10
|
+
'aria-controls',
|
|
11
|
+
'aria-describedby',
|
|
12
|
+
'aria-details',
|
|
13
|
+
'aria-errormessage',
|
|
14
|
+
'aria-flowto',
|
|
15
|
+
'aria-labelledby',
|
|
16
|
+
'aria-owns',
|
|
17
|
+
'commandfor',
|
|
18
|
+
'contextmenu',
|
|
19
|
+
'for',
|
|
20
|
+
'form',
|
|
21
|
+
'headers',
|
|
22
|
+
'itemref',
|
|
23
|
+
'list',
|
|
24
|
+
'popovertarget'
|
|
25
|
+
]);
|
|
26
|
+
|
|
27
|
+
// Attributes whose value may be a same-document fragment URL (`#main`). `:target`
|
|
28
|
+
// rules and SVG sprite references (`<use href="#icon">`) rest on these, so the ID
|
|
29
|
+
// they name counts as used.
|
|
30
|
+
const fragmentReferenceAttributes = new Set([
|
|
31
|
+
'action',
|
|
32
|
+
'cite',
|
|
33
|
+
'data',
|
|
34
|
+
'formaction',
|
|
35
|
+
'href',
|
|
36
|
+
'poster',
|
|
37
|
+
'src',
|
|
38
|
+
'usemap',
|
|
39
|
+
'xlink:href'
|
|
40
|
+
]);
|
|
41
|
+
|
|
42
|
+
const attributePattern = /(?:^|[\s/])([-\w:.]+)\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s"'=<>`]+))/g;
|
|
43
|
+
const identifierPattern = /--[\w-]*|-?[A-Za-z_][\w-]*/g;
|
|
44
|
+
// SVG paints and filters reach elements by ID through `url(#gradient)`, in
|
|
45
|
+
// presentation attributes as well as in `style`
|
|
46
|
+
const fragmentURLPattern = /url\(\s*['"]?#([^)'"\s]+)/gi;
|
|
47
|
+
// Class names routinely carry characters that end a CSS identifier (`md:flex`,
|
|
48
|
+
// `w-1/2`, `p-[3px]`), so scripts and `data-*` values contribute whole tokens
|
|
49
|
+
// besides identifiers; quoted strings are where scripts keep such names
|
|
50
|
+
const stringLiteralPattern = /'((?:[^'\\\n]|\\.)*)'|"((?:[^"\\\n]|\\.)*)"|`((?:[^`\\]|\\.)*)`/g;
|
|
51
|
+
|
|
52
|
+
// CSS identifiers may contain escapes (`.md\:flex`, `.w-1\/2`), which have to be
|
|
53
|
+
// resolved before comparing them against the plain tokens found in the markup. A
|
|
54
|
+
// symbol may follow another name (`p.note`, `.a.b`); matches outside selectors
|
|
55
|
+
// (`#fff`, `a.png`) are harmless, as only selectors are checked against the list.
|
|
56
|
+
const cssIdentifierPattern = /(?<!\\)[.#]((?:[-_a-zA-Z]|\\[0-9a-fA-F]{1,6}[ \t\n]?|\\[^\n0-9a-fA-F])(?:[-\w]|\\[0-9a-fA-F]{1,6}[ \t\n]?|\\[^\n0-9a-fA-F])*)/g;
|
|
57
|
+
// `unusedSymbols` also drops `@keyframes` and `@counter-style` rules by name, so any
|
|
58
|
+
// name used there is off limits even when no element carries it as a class or ID
|
|
59
|
+
const reservedAtRulePattern = /@(?:-\w+-)?(?:keyframes|counter-style)\s+(-?[_a-zA-Z][\w-]*)/gi;
|
|
60
|
+
const escapePattern = /\\(?:([0-9a-fA-F]{1,6})[ \t\n]?|(.))/g;
|
|
61
|
+
// Run over case-folded markup; matches inside comments and scripts only add names
|
|
62
|
+
const startTagPattern = /<([a-z][^\s/>]*)/g;
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Resolve CSS escape sequences in an identifier.
|
|
66
|
+
* @param {string} identifier
|
|
67
|
+
* @returns {string}
|
|
68
|
+
*/
|
|
69
|
+
function unescapeIdentifier(identifier) {
|
|
70
|
+
if (identifier.indexOf('\\') === -1) {
|
|
71
|
+
return identifier;
|
|
72
|
+
}
|
|
73
|
+
return identifier.replace(escapePattern, (_match, hex, literal) => {
|
|
74
|
+
if (!hex) {
|
|
75
|
+
return literal;
|
|
76
|
+
}
|
|
77
|
+
// Per CSS Syntax spec, a null, surrogate, or out-of-range escape becomes `U+FFFD`
|
|
78
|
+
const code = parseInt(hex, 16);
|
|
79
|
+
return (code === 0 || code > 0x10FFFF || (code >= 0xD800 && code <= 0xDFFF))
|
|
80
|
+
? '\uFFFD'
|
|
81
|
+
: String.fromCodePoint(code);
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Lowercase for matching without disturbing offsets.
|
|
87
|
+
*
|
|
88
|
+
* `toLowerCase()` can change a string's length—U+0130 becomes two code units—which
|
|
89
|
+
* would misalign every offset taken from the result. Folding just ASCII preserves
|
|
90
|
+
* length, and tag names are ASCII anyway.
|
|
91
|
+
*
|
|
92
|
+
* @param {string} text
|
|
93
|
+
* @returns {string} Same length as `text`
|
|
94
|
+
*/
|
|
95
|
+
function foldCase(text) {
|
|
96
|
+
const lowercased = text.toLowerCase();
|
|
97
|
+
return lowercased.length === text.length
|
|
98
|
+
? lowercased
|
|
99
|
+
: text.replace(/[A-Z]+/g, uppercase => uppercase.toLowerCase());
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Locate the bodies of a raw-text element (`style`, `script`).
|
|
104
|
+
*
|
|
105
|
+
* Scanning beats one regular expression here: A pattern permissive enough for the
|
|
106
|
+
* end tags browsers accept (`</script\t\n bar>`, `</script/>`) backtracks
|
|
107
|
+
* quadratically over a document full of near-matches, and bounding it would make
|
|
108
|
+
* long start tags go unrecognized.
|
|
109
|
+
*
|
|
110
|
+
* @param {string} haystack - Case-folded markup, as returned by `foldCase`
|
|
111
|
+
* @param {string} tagName - Lowercase element name
|
|
112
|
+
* @returns {Array<{start: number, bodyStart: number, bodyEnd: number, end: number, closed: boolean}>}
|
|
113
|
+
*/
|
|
114
|
+
function findRawTextElements(haystack, tagName) {
|
|
115
|
+
/** @type {Array<{start: number, bodyStart: number, bodyEnd: number, end: number, closed: boolean}>} */
|
|
116
|
+
const found = [];
|
|
117
|
+
const openTag = '<' + tagName;
|
|
118
|
+
const closeTag = '</' + tagName;
|
|
119
|
+
// A tag name ends at whitespace, a slash, or the closing bracket—so `<styles>`
|
|
120
|
+
// and `</scriptfoo>` name different elements and must not match
|
|
121
|
+
const endsName = (/** @type {string} */ character) =>
|
|
122
|
+
character === '' || character === '/' || character === '>' || /\s/.test(character);
|
|
123
|
+
|
|
124
|
+
let cursor = 0;
|
|
125
|
+
for (;;) {
|
|
126
|
+
const start = haystack.indexOf(openTag, cursor);
|
|
127
|
+
if (start === -1) {
|
|
128
|
+
break;
|
|
129
|
+
}
|
|
130
|
+
if (!endsName(haystack.charAt(start + openTag.length))) {
|
|
131
|
+
cursor = start + openTag.length;
|
|
132
|
+
continue;
|
|
133
|
+
}
|
|
134
|
+
// A quoted attribute value may hold a `>`, so the tag ends where the parser
|
|
135
|
+
// says it does, not at the next bracket
|
|
136
|
+
const startTagEnd = findTagEnd(haystack, start + openTag.length);
|
|
137
|
+
if (startTagEnd === -1) {
|
|
138
|
+
break;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const bodyStart = startTagEnd + 1;
|
|
142
|
+
let search = bodyStart;
|
|
143
|
+
let bodyEnd = -1;
|
|
144
|
+
let end = -1;
|
|
145
|
+
for (;;) {
|
|
146
|
+
const candidate = haystack.indexOf(closeTag, search);
|
|
147
|
+
if (candidate === -1) {
|
|
148
|
+
break;
|
|
149
|
+
}
|
|
150
|
+
if (endsName(haystack.charAt(candidate + closeTag.length))) {
|
|
151
|
+
const closeEnd = findTagEnd(haystack, candidate + closeTag.length);
|
|
152
|
+
if (closeEnd !== -1) {
|
|
153
|
+
bodyEnd = candidate;
|
|
154
|
+
end = closeEnd + 1;
|
|
155
|
+
}
|
|
156
|
+
break;
|
|
157
|
+
}
|
|
158
|
+
search = candidate + closeTag.length;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const closed = bodyEnd !== -1;
|
|
162
|
+
found.push({
|
|
163
|
+
start,
|
|
164
|
+
bodyStart,
|
|
165
|
+
bodyEnd: closed ? bodyEnd : haystack.length,
|
|
166
|
+
end: closed ? end : haystack.length,
|
|
167
|
+
closed
|
|
168
|
+
});
|
|
169
|
+
cursor = closed ? end : haystack.length;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
return found;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Collect the class names and IDs a document references.
|
|
177
|
+
*
|
|
178
|
+
* Style sheet contents are excluded, so that a style sheet never counts as
|
|
179
|
+
* evidence for its own selectors. An `iframe srcdoc` value is not scanned,
|
|
180
|
+
* either: It holds a document of its own, minified against its own symbol set.
|
|
181
|
+
* Over-collecting is safe here (a symbol wrongly considered used is merely
|
|
182
|
+
* kept), under-collecting is not.
|
|
183
|
+
*
|
|
184
|
+
* @param {string} html - Raw document markup
|
|
185
|
+
* @param {boolean} includeScripts - Also treat identifiers inside inline `script` elements as used
|
|
186
|
+
* @param {((text: string) => string)} [decode] - Resolves character references in attribute values and foreign script contents
|
|
187
|
+
* @param {Set<string>} [elements] - Receives the element names the markup uses, lowercased, and the identifiers found in scripts, as possible element names
|
|
188
|
+
* @returns {Set<string>} Symbols to keep
|
|
189
|
+
*/
|
|
190
|
+
function collectUsedSymbols(html, includeScripts, decode, elements) {
|
|
191
|
+
const used = new Set();
|
|
192
|
+
const haystack = foldCase(html);
|
|
193
|
+
|
|
194
|
+
// Style sheets are skipped rather than cut out, so both scans share one folded
|
|
195
|
+
// copy and offsets keep pointing into `html`. Only the body is skipped—the start
|
|
196
|
+
// tag carries ordinary attributes, and `<style id="theme">` could be what
|
|
197
|
+
// `#theme` refers to. An unclosed element is not skipped: Reading its contents
|
|
198
|
+
// as markup can only add symbols, whereas ignoring the rest of the document
|
|
199
|
+
// would lose them.
|
|
200
|
+
const skipped = findRawTextElements(haystack, 'style')
|
|
201
|
+
.filter(element => element.closed)
|
|
202
|
+
.map(element => ({ start: element.bodyStart, end: element.bodyEnd }));
|
|
203
|
+
|
|
204
|
+
if (elements) {
|
|
205
|
+
collectUsedElements(haystack, skipped, elements);
|
|
206
|
+
}
|
|
207
|
+
|
|
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
|
+
// Both loops below walk forward, so one cursor over the skipped ranges suffices
|
|
226
|
+
let skipCursor = 0;
|
|
227
|
+
const isSkipped = (/** @type {number} */ index) => {
|
|
228
|
+
while (skipCursor < skipped.length && (skipped[skipCursor]?.end ?? 0) <= index) {
|
|
229
|
+
skipCursor++;
|
|
230
|
+
}
|
|
231
|
+
const element = skipped[skipCursor];
|
|
232
|
+
return element !== undefined && index >= element.start;
|
|
233
|
+
};
|
|
234
|
+
|
|
235
|
+
attributePattern.lastIndex = 0;
|
|
236
|
+
let match;
|
|
237
|
+
while ((match = attributePattern.exec(html))) {
|
|
238
|
+
const raw = match[2] ?? match[3] ?? match[4] ?? '';
|
|
239
|
+
if (!raw || isSkipped(match.index)) {
|
|
240
|
+
continue;
|
|
241
|
+
}
|
|
242
|
+
const name = (match[1] ?? '').toLowerCase();
|
|
243
|
+
// `class="used"` names the class `used`, so compare against the decoded value
|
|
244
|
+
const value = (decode && raw.indexOf('&') !== -1) ? decode(raw) : raw;
|
|
245
|
+
if (name === 'class' || name === 'id' || idReferenceAttributes.has(name)) {
|
|
246
|
+
addTokens(value);
|
|
247
|
+
} else if (name.startsWith('data-')) {
|
|
248
|
+
// Class names are commonly parked in `data-*` attributes for scripts to apply later
|
|
249
|
+
addIdentifiers(value);
|
|
250
|
+
addTokens(value);
|
|
251
|
+
} else if (fragmentReferenceAttributes.has(name) && value.charAt(0) === '#') {
|
|
252
|
+
// Only a leading `#`: `href="/page#sec"` names a section of another document
|
|
253
|
+
addTokens(value.slice(1));
|
|
254
|
+
}
|
|
255
|
+
if (value.indexOf('(') !== -1) {
|
|
256
|
+
fragmentURLPattern.lastIndex = 0;
|
|
257
|
+
let reference;
|
|
258
|
+
while ((reference = fragmentURLPattern.exec(value))) {
|
|
259
|
+
addTokens(reference[1] ?? '');
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
if (includeScripts) {
|
|
265
|
+
// Script contents are raw text, so character references stay literal
|
|
266
|
+
// except in SVG and MathML, where they are resolved; as the namespace
|
|
267
|
+
// isn’t tracked here, both readings count where they differ. An unclosed
|
|
268
|
+
// `script` runs to the end of the document, as it does in a browser.
|
|
269
|
+
skipCursor = 0;
|
|
270
|
+
for (const element of findRawTextElements(haystack, 'script')) {
|
|
271
|
+
if (isSkipped(element.start)) {
|
|
272
|
+
continue;
|
|
273
|
+
}
|
|
274
|
+
const body = html.slice(element.bodyStart, element.bodyEnd);
|
|
275
|
+
const decoded = (decode && body.indexOf('&') !== -1) ? decode(body) : body;
|
|
276
|
+
for (const text of decoded === body ? [body] : [body, decoded]) {
|
|
277
|
+
addIdentifiers(text, elements);
|
|
278
|
+
stringLiteralPattern.lastIndex = 0;
|
|
279
|
+
let literal;
|
|
280
|
+
while ((literal = stringLiteralPattern.exec(text))) {
|
|
281
|
+
addTokens(literal[1] ?? literal[2] ?? literal[3] ?? '');
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
return used;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/**
|
|
291
|
+
* Determine which class/ID symbols a style sheet defines but the document doesn’t reference.
|
|
292
|
+
* @param {string} css - Style sheet contents
|
|
293
|
+
* @param {Set<string>} used - Symbols the document references
|
|
294
|
+
* @param {Array<string | RegExp>} safelist - Symbols to keep regardless
|
|
295
|
+
* @returns {string[]} Symbols safe to remove
|
|
296
|
+
*/
|
|
297
|
+
function findUnusedSymbols(css, used, safelist) {
|
|
298
|
+
const reserved = new Set();
|
|
299
|
+
reservedAtRulePattern.lastIndex = 0;
|
|
300
|
+
let match;
|
|
301
|
+
while ((match = reservedAtRulePattern.exec(css))) {
|
|
302
|
+
if (match[1]) {
|
|
303
|
+
reserved.add(match[1]);
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
const unused = [];
|
|
308
|
+
const seen = new Set();
|
|
309
|
+
cssIdentifierPattern.lastIndex = 0;
|
|
310
|
+
while ((match = cssIdentifierPattern.exec(css))) {
|
|
311
|
+
const symbol = unescapeIdentifier(match[1] ?? '');
|
|
312
|
+
if (seen.has(symbol)) {
|
|
313
|
+
continue;
|
|
314
|
+
}
|
|
315
|
+
seen.add(symbol);
|
|
316
|
+
if (used.has(symbol) || reserved.has(symbol) || isSafelisted(symbol, safelist)) {
|
|
317
|
+
continue;
|
|
318
|
+
}
|
|
319
|
+
unused.push(symbol);
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
return unused;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/**
|
|
326
|
+
* @param {string} symbol
|
|
327
|
+
* @param {Array<string | RegExp>} safelist
|
|
328
|
+
* @returns {boolean}
|
|
329
|
+
*/
|
|
330
|
+
function isSafelisted(symbol, safelist) {
|
|
331
|
+
return safelist.some(entry => {
|
|
332
|
+
if (!(entry instanceof RegExp)) {
|
|
333
|
+
return entry === symbol;
|
|
334
|
+
}
|
|
335
|
+
// `test()` advances `lastIndex` on global and sticky patterns, which would
|
|
336
|
+
// make a safelist entry match only every other symbol
|
|
337
|
+
entry.lastIndex = 0;
|
|
338
|
+
return entry.test(symbol);
|
|
339
|
+
});
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
/**
|
|
343
|
+
* Collect the element names a document contains, including those the parser
|
|
344
|
+
* supplies when the markup leaves them out.
|
|
345
|
+
* @param {string} haystack - Case-folded markup, as returned by `foldCase`
|
|
346
|
+
* @param {Array<{start: number, end: number}>} skipped - Style sheet bodies, in document order
|
|
347
|
+
* @param {Set<string>} elements - Lowercase element names, added to
|
|
348
|
+
*/
|
|
349
|
+
function collectUsedElements(haystack, skipped, elements) {
|
|
350
|
+
elements.add('html');
|
|
351
|
+
elements.add('head');
|
|
352
|
+
elements.add('body');
|
|
353
|
+
|
|
354
|
+
let skipIndex = 0;
|
|
355
|
+
startTagPattern.lastIndex = 0;
|
|
356
|
+
let match;
|
|
357
|
+
while ((match = startTagPattern.exec(haystack))) {
|
|
358
|
+
while (skipIndex < skipped.length && (skipped[skipIndex]?.end ?? 0) <= match.index) {
|
|
359
|
+
skipIndex++;
|
|
360
|
+
}
|
|
361
|
+
const element = skipped[skipIndex];
|
|
362
|
+
if (element && match.index >= element.start) {
|
|
363
|
+
continue;
|
|
364
|
+
}
|
|
365
|
+
elements.add(match[1] ?? '');
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
/**
|
|
370
|
+
* Build the unused-CSS context for a document.
|
|
371
|
+
* @param {string} html - Raw document markup
|
|
372
|
+
* @param {boolean} includeScripts - Also treat identifiers inside inline `script` elements as used
|
|
373
|
+
* @param {((text: string) => string)} [decode] - Resolves character references in attribute values and foreign script contents
|
|
374
|
+
* @returns {{usedSymbols: Set<string>, usedElements: Set<string>, usedElementsKey: string}}
|
|
375
|
+
*/
|
|
376
|
+
function collectUsage(html, includeScripts, decode) {
|
|
377
|
+
// A script may create the elements a rule is for, so its identifiers count as names, too
|
|
378
|
+
const usedElements = new Set();
|
|
379
|
+
const usedSymbols = collectUsedSymbols(html, includeScripts, decode, usedElements);
|
|
380
|
+
// Elements the parser supplies inside a `table`, added after script identifiers, as a script
|
|
381
|
+
// may create a `table`, too
|
|
382
|
+
if (usedElements.has('table')) {
|
|
383
|
+
usedElements.add('tbody');
|
|
384
|
+
usedElements.add('tr');
|
|
385
|
+
}
|
|
386
|
+
if (usedElements.has('col')) {
|
|
387
|
+
usedElements.add('colgroup');
|
|
388
|
+
}
|
|
389
|
+
return {
|
|
390
|
+
usedSymbols,
|
|
391
|
+
usedElements,
|
|
392
|
+
// The CSS cache outlives a document, so its keys tell element sets apart. The set
|
|
393
|
+
// is in document order, so the same elements in another order only miss the cache.
|
|
394
|
+
usedElementsKey: String(hashContent([...usedElements].join(' ')))
|
|
395
|
+
};
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
/**
|
|
399
|
+
* Create a Lightning CSS `Selector` visitor that drops selectors naming an unused
|
|
400
|
+
* class, ID, or element from their list, so the rule stays for the selectors that
|
|
401
|
+
* remain (`unusedSymbols` alone only removes rules whose selectors are all unused).
|
|
402
|
+
*
|
|
403
|
+
* Only a selector’s own compounds are judged: Names inside pseudo-class and
|
|
404
|
+
* pseudo-element arguments (`:not(.x)`, `:host(.x)`) are left alone.
|
|
405
|
+
*
|
|
406
|
+
* @param {string[]} unusedSymbols - Class/ID symbols safe to remove, from `findUnusedSymbols`
|
|
407
|
+
* @param {Set<string>} usedElements - Lowercase element names the document contains or its scripts name
|
|
408
|
+
* @param {Array<string | RegExp>} safelist - Symbols to keep regardless
|
|
409
|
+
* @returns {(selector: Array<{type: string, name?: string}>) => [] | undefined}
|
|
410
|
+
*/
|
|
411
|
+
function createSelectorPruner(unusedSymbols, usedElements, safelist) {
|
|
412
|
+
const unused = new Set(unusedSymbols);
|
|
413
|
+
/** @type {Map<string, boolean>} */
|
|
414
|
+
const unusedElements = new Map();
|
|
415
|
+
const isUnusedElement = (/** @type {string} */ name) => {
|
|
416
|
+
let result = unusedElements.get(name);
|
|
417
|
+
if (result === undefined) {
|
|
418
|
+
result = !usedElements.has(name.toLowerCase()) && !isSafelisted(name, safelist);
|
|
419
|
+
unusedElements.set(name, result);
|
|
420
|
+
}
|
|
421
|
+
return result;
|
|
422
|
+
};
|
|
423
|
+
|
|
424
|
+
return selector => {
|
|
425
|
+
for (const component of selector) {
|
|
426
|
+
const name = component.name;
|
|
427
|
+
if (name === undefined) {
|
|
428
|
+
continue;
|
|
429
|
+
}
|
|
430
|
+
if (((component.type === 'class' || component.type === 'id') && unused.has(name)) ||
|
|
431
|
+
(component.type === 'type' && isUnusedElement(name))) {
|
|
432
|
+
return [];
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
return undefined;
|
|
436
|
+
};
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
const unusedCSSKeys = new Set(['safelist', 'scripts']);
|
|
440
|
+
|
|
441
|
+
/**
|
|
442
|
+
* Normalize the `removeUnusedCSS` option into a settled configuration.
|
|
443
|
+
*
|
|
444
|
+
* A misspelled key or a safelist that is not an array would otherwise protect
|
|
445
|
+
* nothing, and that only surfaces as a missing rule much later—so every value
|
|
446
|
+
* that gets dropped is reported.
|
|
447
|
+
*
|
|
448
|
+
* @param {boolean | {safelist?: Array<string | RegExp>, scripts?: boolean} | undefined} option
|
|
449
|
+
* @param {(message: string) => unknown} [warn] - Receives one message per ignored value
|
|
450
|
+
* @returns {{safelist: Array<string | RegExp>, scripts: boolean} | null} Null when disabled
|
|
451
|
+
*/
|
|
452
|
+
function normalizeUnusedCSSOptions(option, warn) {
|
|
453
|
+
if (!option) {
|
|
454
|
+
return null;
|
|
455
|
+
}
|
|
456
|
+
const report = warn ?? (() => {});
|
|
457
|
+
const config = /** @type {Record<string, any>} */ (typeof option === 'object' ? option : {});
|
|
458
|
+
|
|
459
|
+
for (const key of Object.keys(config)) {
|
|
460
|
+
if (!unusedCSSKeys.has(key)) {
|
|
461
|
+
report(`Ignoring unknown \`removeUnusedCSS\` key \`${key}\`—expected \`safelist\` or \`scripts\``);
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
/** @type {Array<string | RegExp>} */
|
|
466
|
+
let safelist = [];
|
|
467
|
+
if (config.safelist !== undefined) {
|
|
468
|
+
if (!Array.isArray(config.safelist)) {
|
|
469
|
+
report('Ignoring `removeUnusedCSS.safelist`—it takes an array of strings and regular expressions');
|
|
470
|
+
} else {
|
|
471
|
+
safelist = config.safelist.filter((/** @type {unknown} */ entry) => {
|
|
472
|
+
if (typeof entry === 'string' || entry instanceof RegExp) {
|
|
473
|
+
return true;
|
|
474
|
+
}
|
|
475
|
+
report(`Ignoring \`removeUnusedCSS.safelist\` entry of type ${typeof entry}—entries must be strings or regular expressions`);
|
|
476
|
+
return false;
|
|
477
|
+
});
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
if (config.scripts !== undefined && typeof config.scripts !== 'boolean') {
|
|
482
|
+
report('Ignoring `removeUnusedCSS.scripts`—it takes a boolean');
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
return {
|
|
486
|
+
safelist,
|
|
487
|
+
// Keeping identifiers seen in inline scripts costs a little of the reduction
|
|
488
|
+
// but avoids the most common breakage, so it is the default
|
|
489
|
+
scripts: typeof config.scripts === 'boolean' ? config.scripts : true
|
|
490
|
+
};
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
export {
|
|
494
|
+
collectUsage,
|
|
495
|
+
collectUsedSymbols,
|
|
496
|
+
createSelectorPruner,
|
|
497
|
+
findUnusedSymbols,
|
|
498
|
+
normalizeUnusedCSSOptions
|
|
499
|
+
};
|
package/src/lib/urls.js
ADDED
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* URL minification using Node’s WHATWG URL API
|
|
3
|
+
*
|
|
4
|
+
* Produces the shortest equivalent URL representation:
|
|
5
|
+
* - Same origin: shortest of root-relative or path-relative
|
|
6
|
+
* - Cross-origin, same scheme: scheme-relative (`//host/path`)
|
|
7
|
+
* - Otherwise: normalized absolute URL
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
const REJECTED_SCHEMES = new Set(['data:', 'javascript:', 'mailto:']);
|
|
11
|
+
|
|
12
|
+
const DIRECTORY_INDEXES = ['index.html', 'index.htm'];
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Get the directory portion of a pathname (up to and including the last `/`).
|
|
16
|
+
* @param {string} pathname
|
|
17
|
+
* @returns {string}
|
|
18
|
+
*/
|
|
19
|
+
function getDirectory(pathname) {
|
|
20
|
+
const lastSlash = pathname.lastIndexOf('/');
|
|
21
|
+
return pathname.slice(0, lastSlash + 1);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Compute a path-relative URL from a base directory to a target path.
|
|
26
|
+
* @param {string} baseDir - Base directory path (must end with `/`)
|
|
27
|
+
* @param {string} targetPath - Target pathname
|
|
28
|
+
* @returns {string}
|
|
29
|
+
*/
|
|
30
|
+
function relativize(baseDir, targetPath) {
|
|
31
|
+
const baseSegments = baseDir.split('/').filter(Boolean);
|
|
32
|
+
const targetSegments = targetPath.split('/').filter(Boolean);
|
|
33
|
+
const isDirectoryTarget = targetPath.endsWith('/');
|
|
34
|
+
|
|
35
|
+
let common = 0;
|
|
36
|
+
while (
|
|
37
|
+
common < baseSegments.length &&
|
|
38
|
+
common < targetSegments.length &&
|
|
39
|
+
baseSegments[common] === targetSegments[common]
|
|
40
|
+
) {
|
|
41
|
+
// Stop before the last target segment only for file targets (not directories)
|
|
42
|
+
if (!isDirectoryTarget && common === targetSegments.length - 1) break;
|
|
43
|
+
common++;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const ups = baseSegments.length - common;
|
|
47
|
+
const remaining = targetSegments.slice(common);
|
|
48
|
+
|
|
49
|
+
let relative = '../'.repeat(ups) + remaining.join('/');
|
|
50
|
+
|
|
51
|
+
if (isDirectoryTarget && relative !== '' && !relative.endsWith('/')) {
|
|
52
|
+
relative += '/';
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// For directory targets, empty result means same directory
|
|
56
|
+
if (relative === '' && isDirectoryTarget) {
|
|
57
|
+
return './';
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
return relative;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Remove directory index from the end of a pathname.
|
|
65
|
+
* @param {string} pathname
|
|
66
|
+
* @returns {string}
|
|
67
|
+
*/
|
|
68
|
+
function removeDirectoryIndex(pathname) {
|
|
69
|
+
for (const index of DIRECTORY_INDEXES) {
|
|
70
|
+
if (pathname.endsWith('/' + index)) {
|
|
71
|
+
return pathname.slice(0, pathname.length - index.length);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
return pathname;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Create a URL minifier function for the given site context.
|
|
79
|
+
* @param {string} site - The site base URL (used to compute relative URLs)
|
|
80
|
+
* @returns {(url: string) => string} Minifier function that returns the shortest URL
|
|
81
|
+
*/
|
|
82
|
+
export function createUrlMinifier(site) {
|
|
83
|
+
let baseUrl;
|
|
84
|
+
try {
|
|
85
|
+
baseUrl = site ? new URL(site) : null;
|
|
86
|
+
} catch {
|
|
87
|
+
baseUrl = null;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
return function relate(url) {
|
|
91
|
+
if (!url || !url.trim()) return url;
|
|
92
|
+
|
|
93
|
+
// Preserve fragment-only and query-only URLs as shortest form
|
|
94
|
+
const trimmed = url.trim();
|
|
95
|
+
if (trimmed[0] === '#' || trimmed[0] === '?') return url;
|
|
96
|
+
|
|
97
|
+
let targetUrl;
|
|
98
|
+
try {
|
|
99
|
+
targetUrl = new URL(url);
|
|
100
|
+
} catch {
|
|
101
|
+
// Not a valid absolute URL—try resolving against base
|
|
102
|
+
if (baseUrl) {
|
|
103
|
+
try {
|
|
104
|
+
targetUrl = new URL(url, baseUrl);
|
|
105
|
+
} catch {
|
|
106
|
+
return url;
|
|
107
|
+
}
|
|
108
|
+
} else {
|
|
109
|
+
return url;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
if (REJECTED_SCHEMES.has(targetUrl.protocol)) return url;
|
|
114
|
+
|
|
115
|
+
targetUrl.pathname = removeDirectoryIndex(targetUrl.pathname);
|
|
116
|
+
|
|
117
|
+
const suffix = targetUrl.search + targetUrl.hash;
|
|
118
|
+
|
|
119
|
+
if (!baseUrl) {
|
|
120
|
+
return targetUrl.href;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// Preserve userinfo—non-absolute URLs would drop username/password
|
|
124
|
+
if (targetUrl.username || targetUrl.password) {
|
|
125
|
+
return targetUrl.href;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const sameOrigin =
|
|
129
|
+
targetUrl.protocol === baseUrl.protocol &&
|
|
130
|
+
targetUrl.hostname === baseUrl.hostname &&
|
|
131
|
+
targetUrl.port === baseUrl.port;
|
|
132
|
+
|
|
133
|
+
if (!sameOrigin) {
|
|
134
|
+
if (targetUrl.protocol === baseUrl.protocol) {
|
|
135
|
+
return '//' + targetUrl.host + targetUrl.pathname + suffix;
|
|
136
|
+
}
|
|
137
|
+
return targetUrl.href;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const targetPath = targetUrl.pathname;
|
|
141
|
+
const rootRelative = targetPath + suffix;
|
|
142
|
+
const pathRelative = relativize(getDirectory(baseUrl.pathname), targetPath) + suffix;
|
|
143
|
+
|
|
144
|
+
return pathRelative && pathRelative.length < rootRelative.length ? pathRelative : rootRelative;
|
|
145
|
+
};
|
|
146
|
+
}
|