html-minifier 5.0.2 → 5.0.3

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.
@@ -7,6 +7,7 @@ import {
7
7
  RE_CAN_REMOVE_ATTR_QUOTES,
8
8
  RE_AMP_REFERENCE_ATTR,
9
9
  RE_CR,
10
+ RE_ATTR_SELECTOR,
10
11
  RE_BASE_ELEMENT,
11
12
  RE_ATTR_WS_CHECK,
12
13
  RE_ATTR_WS_COLLAPSE,
@@ -29,7 +30,8 @@ import {
29
30
  collapsibleValues,
30
31
  srcsetElements,
31
32
  RE_EMPTY_ATTRIBUTE,
32
- RE_STYLE_ELEMENT
33
+ RE_STYLE_ELEMENT,
34
+ RE_STYLE_TAG
33
35
  } from './constants.js';
34
36
  import { trimWhitespace, collapseWhitespaceAll } from './whitespace.js';
35
37
  import { shouldMinifyInnerHTML } from './options.js';
@@ -197,6 +199,54 @@ function isTargetSelfRedundant(attrValue, options) {
197
199
  return !state || !(state.hasBase ??= RE_BASE_ELEMENT.test(state.source));
198
200
  }
199
201
 
202
+ /**
203
+ * The attribute names `style` elements in `source` select on, in lowercase
204
+ * @param {string} source
205
+ * @returns {Set<string>}
206
+ */
207
+ function findSelectedAttributes(source) {
208
+ /** @type {Set<string>} */
209
+ const names = new Set();
210
+ if (!RE_STYLE_ELEMENT.test(source)) {
211
+ return names;
212
+ }
213
+ const addSelected = (/** @type {number} */ start, /** @type {number} */ end) => {
214
+ for (const selector of source.slice(start, end).toLowerCase().matchAll(RE_ATTR_SELECTOR)) {
215
+ names.add(/** @type {string} */ (selector[1]));
216
+ }
217
+ };
218
+ // Scanned rather than matched, as a lazy pattern is quadratic on `style` tags left open
219
+ let start = -1;
220
+ RE_STYLE_TAG.lastIndex = 0;
221
+ for (let match = RE_STYLE_TAG.exec(source); match; match = RE_STYLE_TAG.exec(source)) {
222
+ if (match[0][1] !== '/') {
223
+ if (start === -1) start = match.index;
224
+ } else if (start !== -1) {
225
+ addSelected(start, match.index);
226
+ start = -1;
227
+ }
228
+ }
229
+ // Browsers apply a `style` element the document ends in, but not one that is script text,
230
+ // with a script’s end tag after it (without, `<script` before it is text of some other kind)
231
+ if (start !== -1) {
232
+ const before = source.slice(0, start).toLowerCase();
233
+ if (before.lastIndexOf('<script') <= before.lastIndexOf('</script') || !source.slice(start).toLowerCase().includes('</script')) {
234
+ addSelected(start, source.length);
235
+ }
236
+ }
237
+ return names;
238
+ }
239
+
240
+ /**
241
+ * Whether a style sheet of the document selects on the attribute, which removing it would break
242
+ * @param {string} attrName
243
+ * @param {ProcessedOptions} options
244
+ */
245
+ function isAttributeSelected(attrName, options) {
246
+ const state = options.baseState;
247
+ return state !== undefined && (state.selectedAttrs ??= findSelectedAttributes(state.source)).has(attrName.toLowerCase());
248
+ }
249
+
200
250
  /**
201
251
  * @param {string} tag - In lowercase
202
252
  * @param {string} attrName - In lowercase
@@ -268,7 +318,18 @@ function isAttributeRedundant(tag, attrName, attrValue, attrs, options) {
268
318
  : undefined;
269
319
  const tagDefault = tagAttrDefaults && Object.hasOwn(tagAttrDefaults, attrName) ? tagAttrDefaults[attrName] : undefined;
270
320
 
271
- const isLegacyAttr = (tag === 'script' && (attrName === 'language' || attrName === 'charset')) || (tag === 'a' && attrName === 'name');
321
+ // As written, for browsers match fragments case-sensitively and without trimming
322
+ if (tag === 'a' && attrName === 'name') {
323
+ const id = attrs.find(attr => attr.name.toLowerCase() === 'id');
324
+ const idValue = id?.value ?? '';
325
+ // With `decodeEntities`, the name is decoded by now, the ID isn’t
326
+ if (id === undefined || (options.decodeEntities && idValue.indexOf('&') !== -1)) {
327
+ return false;
328
+ }
329
+ return options.collapseAttributeWhitespace ? collapseAttributeValue(idValue) === collapseAttributeValue(attrValue) : idValue === attrValue;
330
+ }
331
+
332
+ const isLegacyAttr = tag === 'script' && (attrName === 'language' || attrName === 'charset');
272
333
 
273
334
  // Without a default for this very attribute, nor a legacy rule, the value cannot be redundant
274
335
  if (generalDefault === undefined && tagDefault === undefined && !isLegacyAttr) {
@@ -284,9 +345,6 @@ function isAttributeRedundant(tag, attrName, attrValue, attrs, options) {
284
345
  if (tag === 'script' && attrName === 'charset' && !attributesInclude(attrs, 'src')) {
285
346
  return true;
286
347
  }
287
- if (tag === 'a' && attrName === 'name' && attributesInclude(attrs, 'id')) {
288
- return true;
289
- }
290
348
  if (tag === 'ol' && attrName === 'start' && attributesInclude(attrs, 'reversed')) {
291
349
  return false;
292
350
  }
@@ -658,6 +716,15 @@ const collapseAttributeWhitespaceExempt = new Set(['pattern', 'placeholder', 'ti
658
716
  // `value` whitespace matters only on form-submission and machine-readable elements
659
717
  const valueWhitespaceExemptElements = new Set(['button', 'data', 'input', 'option', 'param']);
660
718
 
719
+ /**
720
+ * @param {string} value
721
+ * @returns {string}
722
+ */
723
+ function collapseAttributeValue(value) {
724
+ // Fast path: Only process if whitespace exists (avoids regex overhead on clean values)
725
+ return RE_ATTR_WS_CHECK.test(value) ? trimWhitespace(value).replace(RE_ATTR_WS_COLLAPSE, ' ') : value;
726
+ }
727
+
661
728
  /**
662
729
  * Drop repeated names from a whitespace-separated class list, keeping the first of each.
663
730
  * @param {string} value
@@ -690,10 +757,7 @@ function cleanAttributeValue(tag, attrName, attrValue, options, attrs, minifyHTM
690
757
  // Special spaces (no-break space, hair space, etc.) stay, for consistency
691
758
  // with `collapseWhitespace`
692
759
  if (options.collapseAttributeWhitespace && !collapseAttributeWhitespaceExempt.has(attrName) && !(attrName === 'value' && valueWhitespaceExemptElements.has(tag)) && !isEventAttr) {
693
- // Fast path: Only process if whitespace exists (avoids regex overhead on clean values)
694
- if (RE_ATTR_WS_CHECK.test(attrValue)) {
695
- attrValue = trimWhitespace(attrValue).replace(RE_ATTR_WS_COLLAPSE, ' ');
696
- }
760
+ attrValue = collapseAttributeValue(attrValue);
697
761
  }
698
762
 
699
763
  if (isEventAttr) {
@@ -1016,12 +1080,12 @@ function normalizeAttr(attr, attrs, tag, tagOut, options, minifyHTML, markers) {
1016
1080
  * @param {string[]} [markers] - What custom fragment and `htmlmin:ignore` placeholders hold
1017
1081
  */
1018
1082
  function normalizeAttrContinue(attrName, attrNameOut, attrValue, attr, attrs, tag, tagOut, options, minifyHTML, markers) {
1019
- if ((options.removeRedundantAttributes &&
1083
+ if (((options.removeRedundantAttributes &&
1020
1084
  isAttributeRedundant(tag, attrName, attrValue ?? '', attrs, options)) ||
1021
1085
  (options.removeDefaultTypeAttributes && attrName === 'type' && (
1022
1086
  ((tag === 'style' || tag === 'link') && isStyleLinkTypeAttribute(attrValue)) ||
1023
1087
  (tag === 'script' && isScriptTypeAttribute(attrValue) && !keepScriptTypeAttribute(attrValue))
1024
- ))) {
1088
+ ))) && !isAttributeSelected(attrName, options)) {
1025
1089
  return;
1026
1090
  }
1027
1091
 
@@ -1048,7 +1112,8 @@ function normalizeAttrContinue(attrName, attrNameOut, attrValue, attr, attrs, ta
1048
1112
  */
1049
1113
  function normalizeAttrFinish(attrName, attrNameOut, attrValue, attr, tag, tagOut, options) {
1050
1114
  if (options.removeEmptyAttributes &&
1051
- canDeleteEmptyAttribute(tag, attrName, attrValue, options, tagOut, attrNameOut)) {
1115
+ canDeleteEmptyAttribute(tag, attrName, attrValue, options, tagOut, attrNameOut) &&
1116
+ !isAttributeSelected(attrName, options)) {
1052
1117
  return;
1053
1118
  }
1054
1119
 
@@ -48,10 +48,17 @@ const RE_HTML_ENCODING = /^(text\/html|application\/xhtml\+xml)$/i;
48
48
  // identifier precedes it, anything but `>` sets quirks mode
49
49
  // https://html.spec.whatwg.org/multipage/parsing.html#doctype-state
50
50
  const RE_DOCTYPE = /^<!doctype[ \n\r\t\f]*([^ \n\r\t\f>]+)[ \n\r\t\f]*(?:public[ \n\r\t\f]*("[^">]*"|'[^'>]*')[ \n\r\t\f]*("[^">]*"|'[^'>]*')?|system[ \n\r\t\f]*("[^">]*"|'[^'>]*'))?([^]*)$/i;
51
+ // A doctype’s quoted identifiers (captured) and what lies between them
52
+ const RE_DOCTYPE_SEGMENT = /("[^"]*(?:"|$)|'[^']*(?:'|$))|[^"']+/g;
51
53
  const RE_ATTR_WS_CHECK = /[ \n\r\t\f]/;
52
54
  const RE_ATTR_WS_COLLAPSE = /[ \n\r\t\f]+/g;
53
55
  const RE_WS_CHAR = /\s/;
54
56
  const RE_STYLE_ELEMENT = /<style[\s/>]/i;
57
+ const RE_STYLE_TAG = /<\/?style/gi;
58
+ // Text that a space after would collapse into (a no-break space does not take it in)
59
+ const RE_ENDS_COLLAPSIBLE = /(?:^|[ \n\r\t\f])$/;
60
+ // An attribute selector’s name, namespace prefix aside, in lowercased CSS
61
+ const RE_ATTR_SELECTOR = /\[\s*(?:[\w-]*\|)?([\w:-]+)\s*[\]=~|^$*]/g;
55
62
  const RE_EMPTY_ATTRIBUTE = new RegExp(
56
63
  '^(?:class|id|style|title|lang|dir|on(?:focus|blur|change|click|dblclick|mouse(' +
57
64
  '?:down|up|over|move|out)|key(?:press|down|up)))$');
@@ -62,11 +69,11 @@ const RE_EMPTY_ATTRIBUTE = new RegExp(
62
69
  const inlineElementsToKeepWhitespaceAround = new Set(['a', 'abbr', 'acronym', 'audio', 'b', 'bdi', 'bdo', 'big', 'button', 'canvas', 'cite', 'code', 'data', 'del', 'dfn', 'em', 'embed', 'font', 'i', 'iframe', 'img', 'input', 'ins', 'kbd', 'label', 'map', 'mark', 'math', 'meter', 'nobr', 'object', 'output', 'picture', 'progress', 'q', 'rb', 'rp', 'rt', 'rtc', 'ruby', 's', 'samp', 'select', 'selectedcontent', 'slot', 'small', 'span', 'strike', 'strong', 'sub', 'sup', 'svg', 'textarea', 'textpath', 'time', 'tspan', 'tt', 'u', 'var', 'video', 'wbr']);
63
70
 
64
71
  // Non-empty elements that will maintain whitespace within them
65
- const inlineElementsToKeepWhitespaceWithin = new Set(['a', 'abbr', 'acronym', 'b', 'big', 'data', 'del', 'em', 'font', 'i', 'ins', 'kbd', 'map', 'mark', 'nobr', 's', 'samp', 'slot', 'small', 'span', 'strike', 'strong', 'sub', 'sup', 'textpath', 'time', 'tspan', 'tt', 'u', 'var']);
72
+ const inlineElementsToKeepWhitespaceWithin = new Set(['a', 'abbr', 'acronym', 'b', 'bdi', 'bdo', 'big', 'cite', 'code', 'data', 'del', 'dfn', 'em', 'font', 'i', 'ins', 'kbd', 'label', 'map', 'mark', 'nobr', 'q', 'ruby', 's', 'samp', 'slot', 'small', 'span', 'strike', 'strong', 'sub', 'sup', 'textpath', 'time', 'tspan', 'tt', 'u', 'var']);
66
73
 
67
74
  // Text-level elements that keep whitespace between them and other elements under
68
75
  // `collapseInlineTagWhitespace`
69
- const inlineElementsToKeepWhitespaceBetween = new Set([...inlineElementsToKeepWhitespaceWithin, 'bdi', 'bdo', 'cite', 'code', 'dfn', 'label', 'math', 'q', 'ruby']);
76
+ const inlineElementsToKeepWhitespaceBetween = new Set([...inlineElementsToKeepWhitespaceWithin, 'math']);
70
77
 
71
78
  // Phrasing elements that don’t render, whose neighbors decide on the whitespace around them
72
79
  const inlineElementsTransparent = new Set(['area', 'datalist', 'link', 'meta', 'noscript', 'script', 'style', 'template']);
@@ -296,7 +303,7 @@ const optionalStartTags = new Set(['html', 'head', 'body', 'colgroup', 'tbody'])
296
303
 
297
304
  const optionalEndTags = new Set(['html', 'head', 'body', 'li', 'dt', 'dd', 'p', 'rb', 'rt', 'rtc', 'rp', 'optgroup', 'option', 'colgroup', 'caption', 'thead', 'tbody', 'tfoot', 'tr', 'td', 'th']);
298
305
 
299
- const headerElements = new Set(['meta', 'link', 'script', 'style', 'template', 'noscript']);
306
+ const headerElements = new Set(['base', 'basefont', 'bgsound', 'link', 'meta', 'noframes', 'noscript', 'script', 'style', 'template', 'title']);
300
307
 
301
308
  const descriptionElements = new Set(['dt', 'dd']);
302
309
 
@@ -432,16 +439,20 @@ export {
432
439
  RE_LINE_BREAK_CR,
433
440
  RE_LEGACY_ENTITIES,
434
441
  RE_ESCAPE_LT,
442
+ RE_ATTR_SELECTOR,
443
+ RE_ENDS_COLLAPSIBLE,
435
444
  RE_BASE_ELEMENT,
436
445
  RE_ESCAPE_LT_RAW_TEXT,
437
446
  RE_XML_ESCAPE_TEXT,
438
447
  RE_XML_ESCAPE_ATTR,
439
448
  RE_HTML_ENCODING,
440
449
  RE_DOCTYPE,
450
+ RE_DOCTYPE_SEGMENT,
441
451
  RE_ATTR_WS_CHECK,
442
452
  RE_ATTR_WS_COLLAPSE,
443
453
  RE_WS_CHAR,
444
454
  RE_STYLE_ELEMENT,
455
+ RE_STYLE_TAG,
445
456
  RE_EMPTY_ATTRIBUTE,
446
457
 
447
458
  // Inline element sets
@@ -124,9 +124,16 @@ function canRemoveElement(tag, attrs) {
124
124
  }
125
125
 
126
126
  switch (tag) {
127
+ // Without a `frameset`, browsers render what follows it
127
128
  case 'canvas':
129
+ case 'frameset':
128
130
  case 'textarea':
129
131
  return false;
132
+ // Browsers create them anyway, but not with their attributes
133
+ case 'body':
134
+ case 'head':
135
+ case 'html':
136
+ return attrs.length === 0;
130
137
  case 'audio':
131
138
  case 'script':
132
139
  case 'video':
@@ -51,7 +51,7 @@ import { optionDefinitions, optionDefaults } from './option-definitions.js';
51
51
  * removeUnusedCSS: {safelist: Array<string | RegExp>, scripts: boolean} | null,
52
52
  * cssContext?: CSSContext,
53
53
  * documentState?: {hasMathML: boolean},
54
- * baseState?: {hasBase: boolean | undefined, source: string},
54
+ * baseState?: {hasBase: boolean | undefined, selectedAttrs?: Set<string>, source: string},
55
55
  * parallelJS?: boolean,
56
56
  * nameParent?: (name: string) => string,
57
57
  * namesAsWritten?: boolean,
@@ -519,14 +519,15 @@ const processOptions = (inputOptions, { getLightningCSS, getTerser, getSwc, getS
519
519
  const outputCSS = unwrapCSS(result.code.toString(), type);
520
520
 
521
521
  // Where Lightning CSS emptied template syntax (`<?php ?>`, `{{ }}`: `<` or `>` without
522
- // `CDATA`) or custom-fragment UIDs (long lowercase alphanumeric runs), keep the
523
- // original; CDATA sections, entities, and other invalid CSS may go
522
+ // `CDATA`), custom-fragment UIDs (long lowercase alphanumeric runs), or a media query
523
+ // (an invalid one matches nothing, an empty one everything), keep the original;
524
+ // CDATA sections, entities, and other invalid CSS may go
524
525
  const isCDATA = text.includes('<![CDATA[');
525
526
  const uidPattern = /[a-z0-9]{10,}/; // UIDs are long alphanumeric strings
526
527
  const hasUID = uidPattern.test(text) && !isCDATA; // Exclude CDATA from UID detection
527
528
  const looksLikeTemplate = (text.includes('<') || text.includes('>')) && !isCDATA;
528
529
 
529
- const css = (text.trim() && !outputCSS.trim() && (looksLikeTemplate || hasUID)) ? text : outputCSS;
530
+ const css = (text.trim() && !outputCSS.trim() && (type === 'media' || looksLikeTemplate || hasUID)) ? text : outputCSS;
530
531
  return /** @type {CSSResult} */ ({ css, warnings });
531
532
  });
532
533
  report(resolved.warnings);