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.
Files changed (55) hide show
  1. package/LICENSE.txt +23 -0
  2. package/README.md +851 -110
  3. package/cli.js +991 -207
  4. package/dist/types/htmlminifier.d.ts +629 -0
  5. package/dist/types/htmlminifier.d.ts.map +1 -0
  6. package/dist/types/htmlparser.d.ts +28 -0
  7. package/dist/types/htmlparser.d.ts.map +1 -0
  8. package/dist/types/lib/attributes.d.ts +87 -0
  9. package/dist/types/lib/attributes.d.ts.map +1 -0
  10. package/dist/types/lib/constants.d.ts +421 -0
  11. package/dist/types/lib/constants.d.ts.map +1 -0
  12. package/dist/types/lib/content.d.ts +44 -0
  13. package/dist/types/lib/content.d.ts.map +1 -0
  14. package/dist/types/lib/elements.d.ts +48 -0
  15. package/dist/types/lib/elements.d.ts.map +1 -0
  16. package/dist/types/lib/fragments.d.ts +47 -0
  17. package/dist/types/lib/fragments.d.ts.map +1 -0
  18. package/dist/types/lib/option-definitions.d.ts +33 -0
  19. package/dist/types/lib/option-definitions.d.ts.map +1 -0
  20. package/dist/types/lib/options.d.ts +93 -0
  21. package/dist/types/lib/options.d.ts.map +1 -0
  22. package/dist/types/lib/unused-css.d.ts +76 -0
  23. package/dist/types/lib/unused-css.d.ts.map +1 -0
  24. package/dist/types/lib/urls.d.ts +15 -0
  25. package/dist/types/lib/urls.d.ts.map +1 -0
  26. package/dist/types/lib/utils.d.ts +132 -0
  27. package/dist/types/lib/utils.d.ts.map +1 -0
  28. package/dist/types/lib/whitespace.d.ts +97 -0
  29. package/dist/types/lib/whitespace.d.ts.map +1 -0
  30. package/dist/types/presets.d.ts +50 -0
  31. package/dist/types/presets.d.ts.map +1 -0
  32. package/dist/types/tokenchain.d.ts +36 -0
  33. package/dist/types/tokenchain.d.ts.map +1 -0
  34. package/html-minifier.schema.json +322 -0
  35. package/package.json +70 -58
  36. package/src/htmlminifier.js +2845 -1107
  37. package/src/htmlparser.js +1163 -452
  38. package/src/lib/attributes.js +1196 -0
  39. package/src/lib/constants.js +521 -0
  40. package/src/lib/content.js +237 -0
  41. package/src/lib/elements.js +268 -0
  42. package/src/lib/file-pool.js +193 -0
  43. package/src/lib/file-write.js +87 -0
  44. package/src/lib/fragments.js +340 -0
  45. package/src/lib/option-definitions.js +246 -0
  46. package/src/lib/options.js +841 -0
  47. package/src/lib/unused-css.js +499 -0
  48. package/src/lib/urls.js +146 -0
  49. package/src/lib/utils.js +1026 -0
  50. package/src/lib/whitespace.js +431 -0
  51. package/src/presets.js +58 -0
  52. package/src/tokenchain.js +142 -56
  53. package/LICENSE +0 -22
  54. package/sample-cli-config-file.conf +0 -40
  55. package/src/utils.js +0 -18
@@ -0,0 +1,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="us&#101;d"` 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
+ };
@@ -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
+ }