html-minifier 5.0.1 → 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
  }
@@ -342,21 +400,42 @@ function isStyleElement(tag, attrs) {
342
400
  }
343
401
 
344
402
  /**
345
- * Whether an attribute collapses to its name; takes name and value as written, lower-casing
346
- * the value only for the few names that depend on it
403
+ * Whether an attribute collapses to its name; lower-cases the value only for the few names
404
+ * that depend on it
347
405
  *
348
- * @param {string} attrName
406
+ * @param {string} name - In lowercase
349
407
  * @param {string} attrValue
408
+ * @param {string} tag
409
+ * @param {string[]} markers - What custom fragment and `htmlmin:ignore` placeholders hold
350
410
  */
351
- function collapsesToBooleanName(attrName, attrValue) {
352
- const name = attrName.toLowerCase();
353
- if (isSimpleBoolean.has(name)) {
411
+ function collapsesToBooleanName(name, attrValue, tag, markers) {
412
+ const simple = isSimpleBoolean.has(name);
413
+ if (!simple && name !== 'hidden' && name !== 'draggable' && !collapsibleValues.has(name)) {
414
+ return false;
415
+ }
416
+ // Custom elements define what their attributes mean, and `marquee` counts its loops
417
+ if (tag.includes('-') || (name === 'loop' && tag.toLowerCase() === 'marquee')) {
418
+ return false;
419
+ }
420
+ // Template code and ignored markup are not the minifier’s to remove
421
+ if (markers.some(marker => attrValue.indexOf(marker) !== -1)) {
422
+ return false;
423
+ }
424
+ if (simple) {
354
425
  return true;
355
426
  }
356
427
  const values = collapsibleValues.get(name);
357
428
  if (values) {
358
429
  return values.has(attrValue.toLowerCase());
359
430
  }
431
+ // What a reference decodes to, the value as written doesn’t show
432
+ if ((name === 'hidden' || name === 'draggable') && attrValue.indexOf('&') !== -1) {
433
+ return false;
434
+ }
435
+ // Any other value means the hidden state, which the bare attribute does, too
436
+ if (name === 'hidden') {
437
+ return attrValue.toLowerCase() !== 'until-found';
438
+ }
360
439
  return name === 'draggable' && !isBooleanValue.has(attrValue.toLowerCase());
361
440
  }
362
441
 
@@ -637,6 +716,15 @@ const collapseAttributeWhitespaceExempt = new Set(['pattern', 'placeholder', 'ti
637
716
  // `value` whitespace matters only on form-submission and machine-readable elements
638
717
  const valueWhitespaceExemptElements = new Set(['button', 'data', 'input', 'option', 'param']);
639
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
+
640
728
  /**
641
729
  * Drop repeated names from a whitespace-separated class list, keeping the first of each.
642
730
  * @param {string} value
@@ -669,10 +757,7 @@ function cleanAttributeValue(tag, attrName, attrValue, options, attrs, minifyHTM
669
757
  // Special spaces (no-break space, hair space, etc.) stay, for consistency
670
758
  // with `collapseWhitespace`
671
759
  if (options.collapseAttributeWhitespace && !collapseAttributeWhitespaceExempt.has(attrName) && !(attrName === 'value' && valueWhitespaceExemptElements.has(tag)) && !isEventAttr) {
672
- // Fast path: Only process if whitespace exists (avoids regex overhead on clean values)
673
- if (RE_ATTR_WS_CHECK.test(attrValue)) {
674
- attrValue = trimWhitespace(attrValue).replace(RE_ATTR_WS_COLLAPSE, ' ');
675
- }
760
+ attrValue = collapseAttributeValue(attrValue);
676
761
  }
677
762
 
678
763
  if (isEventAttr) {
@@ -995,12 +1080,12 @@ function normalizeAttr(attr, attrs, tag, tagOut, options, minifyHTML, markers) {
995
1080
  * @param {string[]} [markers] - What custom fragment and `htmlmin:ignore` placeholders hold
996
1081
  */
997
1082
  function normalizeAttrContinue(attrName, attrNameOut, attrValue, attr, attrs, tag, tagOut, options, minifyHTML, markers) {
998
- if ((options.removeRedundantAttributes &&
1083
+ if (((options.removeRedundantAttributes &&
999
1084
  isAttributeRedundant(tag, attrName, attrValue ?? '', attrs, options)) ||
1000
1085
  (options.removeDefaultTypeAttributes && attrName === 'type' && (
1001
1086
  ((tag === 'style' || tag === 'link') && isStyleLinkTypeAttribute(attrValue)) ||
1002
1087
  (tag === 'script' && isScriptTypeAttribute(attrValue) && !keepScriptTypeAttribute(attrValue))
1003
- ))) {
1088
+ ))) && !isAttributeSelected(attrName, options)) {
1004
1089
  return;
1005
1090
  }
1006
1091
 
@@ -1027,7 +1112,8 @@ function normalizeAttrContinue(attrName, attrNameOut, attrValue, attr, attrs, ta
1027
1112
  */
1028
1113
  function normalizeAttrFinish(attrName, attrNameOut, attrValue, attr, tag, tagOut, options) {
1029
1114
  if (options.removeEmptyAttributes &&
1030
- canDeleteEmptyAttribute(tag, attrName, attrValue, options, tagOut, attrNameOut)) {
1115
+ canDeleteEmptyAttribute(tag, attrName, attrValue, options, tagOut, attrNameOut) &&
1116
+ !isAttributeSelected(attrName, options)) {
1031
1117
  return;
1032
1118
  }
1033
1119
 
@@ -1051,12 +1137,14 @@ function normalizeAttrFinish(attrName, attrNameOut, attrValue, attr, tag, tagOut
1051
1137
 
1052
1138
  /**
1053
1139
  * @param {{name: string, value: string | undefined, attr: HTMLAttribute}} normalized
1140
+ * @param {string} tag
1054
1141
  * @param {string | boolean | undefined} hasUnarySlash
1055
1142
  * @param {ProcessedOptions} options
1056
1143
  * @param {boolean} isLast
1057
1144
  * @param {string | undefined} uidAttr
1145
+ * @param {string[]} markers - What custom fragment and `htmlmin:ignore` placeholders hold
1058
1146
  */
1059
- function buildAttr(normalized, hasUnarySlash, options, isLast, uidAttr) {
1147
+ function buildAttr(normalized, tag, hasUnarySlash, options, isLast, uidAttr, markers) {
1060
1148
  const attrName = normalized.name;
1061
1149
  let attrValue = normalized.value;
1062
1150
  const attr = normalized.attr;
@@ -1070,7 +1158,7 @@ function buildAttr(normalized, hasUnarySlash, options, isLast, uidAttr) {
1070
1158
 
1071
1159
  // An attribute written as its name alone skips the quoting work below
1072
1160
  if (typeof attrValue === 'undefined' ||
1073
- (options.collapseBooleanAttributes && collapsesToBooleanName(attrName, attrValue ?? '')) ||
1161
+ (options.collapseBooleanAttributes && !options.insideForeignContent && collapsesToBooleanName(options.namesAsWritten ? attrName.toLowerCase() : attrName, attrValue ?? '', tag, markers)) ||
1074
1162
  (options.collapseEmptyAttributes && attrValue === '' && (attr.customAssign ?? '=') === '=')) {
1075
1163
  return attr.customOpen + attrName + (isLast ? '' : ' ') + attr.customClose;
1076
1164
  }
@@ -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)))$');
@@ -59,14 +66,14 @@ const RE_EMPTY_ATTRIBUTE = new RegExp(
59
66
  // Inline element sets for whitespace handling
60
67
 
61
68
  // Non-empty elements that will maintain whitespace around them
62
- 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', 'time', 'tt', 'u', 'var', 'video', 'wbr']);
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', 'time', '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']);
@@ -259,7 +266,7 @@ const keepScriptsMimetypes = new Set([
259
266
 
260
267
  // Boolean attribute sets
261
268
 
262
- const isSimpleBoolean = new Set(['allowfullscreen', 'async', 'autofocus', 'autoplay', 'checked', 'compact', 'controls', 'declare', 'default', 'defaultchecked', 'defaultmuted', 'defaultselected', 'defer', 'disabled', 'enabled', 'formnovalidate', 'hidden', 'indeterminate', 'inert', 'ismap', 'itemscope', 'loop', 'multiple', 'muted', 'nohref', 'noresize', 'noshade', 'novalidate', 'nowrap', 'open', 'pauseonexit', 'readonly', 'required', 'reversed', 'scoped', 'seamless', 'selected', 'sortable', 'truespeed', 'typemustmatch', 'visible']);
269
+ const isSimpleBoolean = new Set(['allowfullscreen', 'alpha', 'async', 'autofocus', 'autoplay', 'checked', 'compact', 'controls', 'declare', 'default', 'defer', 'disabled', 'formnovalidate', 'headingreset', 'inert', 'ismap', 'itemscope', 'loop', 'multiple', 'muted', 'nohref', 'nomodule', 'noresize', 'noshade', 'novalidate', 'nowrap', 'open', 'playsinline', 'readonly', 'required', 'reversed', 'scoped', 'seamless', 'selected', 'shadowrootclonable', 'shadowrootcustomelementregistry', 'shadowrootdelegatesfocus', 'shadowrootserializable', 'sortable', 'truespeed', 'typemustmatch']);
263
270
 
264
271
  const isBooleanValue = new Set(['true', 'false']);
265
272
 
@@ -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':
@@ -11,6 +11,19 @@
11
11
  */
12
12
 
13
13
  const RE_WHITESPACE = /\s/;
14
+ const RE_NAME_CHAR = /[\w$-]/;
15
+
16
+ // Run on at most the eight characters ending in the word
17
+ const RE_BLOCK_KEYWORD = /(?:^|[^\w$.])(?:do|else|finally|try)$/;
18
+
19
+ // The last character before a `/` that starts a regular expression, not a division
20
+ const RE_BEFORE_REGEX = /[(,=:[!&|?{};+*%<>~^}-]/;
21
+
22
+ // Run on at most the eleven characters ending in the word
23
+ const RE_BEFORE_REGEX_KEYWORD = /(?:^|[^\w$.])(?:await|case|delete|do|else|in|instanceof|new|of|return|throw|typeof|void|yield)$/;
24
+
25
+ // Run on at most the eleven characters from the `@`
26
+ const RE_DROPPABLE_AT_RULE = /^@(?:charset|import|namespace)(?![\w-])/i;
14
27
 
15
28
  // Any-character and negated-class bodies; without the `s` flag, `.` is itself a
16
29
  // negated class, excluding line terminators
@@ -333,8 +346,268 @@ function replaceCustomFragments(value, fragments, replacer) {
333
346
  return copied === 0 ? value : out + value.slice(copied);
334
347
  }
335
348
 
349
+ /**
350
+ * Index past the regular expression starting at the `/` at an index
351
+ * @param {string} source
352
+ * @param {number} index
353
+ * @returns {number} The index itself when the line ends before the expression does
354
+ */
355
+ function skipRegex(source, index) {
356
+ let inClass = false;
357
+ for (let position = index + 1; position < source.length; position++) {
358
+ const current = source[position];
359
+ if (current === '\n' || current === '\r') return index;
360
+ if (current === '\\') position++;
361
+ else if (current === '[') inClass = true;
362
+ else if (current === ']') inClass = false;
363
+ else if (current === '/' && !inClass) return position + 1;
364
+ }
365
+ return index;
366
+ }
367
+
368
+ /**
369
+ * Index past the comment, string, or regular expression starting at an index
370
+ * @param {string} source
371
+ * @param {number} index
372
+ * @param {boolean} isJS
373
+ * @returns {number} The index itself when none starts there
374
+ */
375
+ function skipCommentOrString(source, index, isJS) {
376
+ const char = source[index];
377
+ const next = source[index + 1];
378
+
379
+ if (char === '/' && next === '*') {
380
+ const end = source.indexOf('*/', index + 2);
381
+ return end === -1 ? source.length : end + 2;
382
+ }
383
+ if (isJS && char === '/' && next === '/') {
384
+ const end = source.indexOf('\n', index + 2);
385
+ return end === -1 ? source.length : end;
386
+ }
387
+ if (isJS && char === '/') return isRegexStart(source, index) ? skipRegex(source, index) : index;
388
+ if (char !== '"' && char !== '\'' && !(isJS && char === '`')) return index;
389
+
390
+ for (let position = index + 1; position < source.length; position++) {
391
+ const current = source[position];
392
+ if (current === '\\') position++;
393
+ else if (current === char) return position + 1;
394
+ else if (current === '\n' && char !== '`') return position;
395
+ }
396
+ return source.length;
397
+ }
398
+
399
+ /**
400
+ * Index of the last non-whitespace character before an index
401
+ * @param {string} source
402
+ * @param {number} index
403
+ * @returns {number} -1 when there is none
404
+ */
405
+ function findPreviousToken(source, index) {
406
+ let position = index - 1;
407
+ while (position >= 0 && RE_WHITESPACE.test(source[position] ?? '')) position--;
408
+ return position;
409
+ }
410
+
411
+ /**
412
+ * Whether a `/` starts a regular expression rather than a division, going by what precedes
413
+ * it; after a `)`, which `if (a) /b/.test(c)` leaves open, it’s read as a division
414
+ * @param {string} source
415
+ * @param {number} slash
416
+ * @returns {boolean}
417
+ */
418
+ function isRegexStart(source, slash) {
419
+ const before = findPreviousToken(source, slash);
420
+ if (before === -1) return true;
421
+
422
+ const char = source[before] ?? '';
423
+ // `++` and `--` can only end an operand here, as a literal cannot be incremented
424
+ if ((char === '+' || char === '-') && source[before - 1] === char) return false;
425
+ return RE_BEFORE_REGEX.test(char) || RE_BEFORE_REGEX_KEYWORD.test(source.slice(Math.max(before - 10, 0), before + 1));
426
+ }
427
+
428
+ /**
429
+ * Whether a block keyword ends at an index
430
+ * @param {string} source
431
+ * @param {number} index
432
+ * @returns {boolean}
433
+ */
434
+ function isBlockKeywordEnd(source, index) {
435
+ return RE_BLOCK_KEYWORD.test(source.slice(Math.max(index - 7, 0), index + 1));
436
+ }
437
+
438
+ /**
439
+ * Whether a `{` opens a block rather than an object literal
440
+ * @param {string} source
441
+ * @param {number} brace
442
+ * @returns {boolean}
443
+ */
444
+ function isBlockBrace(source, brace) {
445
+ const before = findPreviousToken(source, brace);
446
+ if (before === -1) return true;
447
+
448
+ const char = source[before] ?? '';
449
+ if (char === ';' || char === '{' || char === '}' || char === ')') return true;
450
+ if (char === '>') return source[before - 1] === '=';
451
+ return isBlockKeywordEnd(source, before);
452
+ }
453
+
454
+ /**
455
+ * Whether a colon ends a statement label or `default`, going by what precedes the name.
456
+ * Two expressions cannot follow each other, so a name after one of these tokens cannot
457
+ * be the middle of a ternary
458
+ * @param {string} source
459
+ * @param {number} colon
460
+ * @returns {boolean}
461
+ */
462
+ function isLabelColon(source, colon) {
463
+ const end = findPreviousToken(source, colon);
464
+ let start = end;
465
+ while (start >= 0 && RE_NAME_CHAR.test(source[start] ?? '')) start--;
466
+ if (start === end) return false;
467
+
468
+ const before = findPreviousToken(source, start + 1);
469
+ const char = source[before];
470
+ // After `:`, any other name may be a nested ternary’s branch
471
+ if (char === ':') return source.slice(start + 1, end + 1) === 'default';
472
+ return before === -1 || char === ';' || char === '}' || char === ')' || (char === '{' && isBlockBrace(source, before));
473
+ }
474
+
475
+ /**
476
+ * Whether the `case` keyword starts at an index, rather than a name or property holding it
477
+ * @param {string} source
478
+ * @param {number} index
479
+ * @returns {boolean}
480
+ */
481
+ function isCaseKeyword(source, index) {
482
+ if (!source.startsWith('case', index) || RE_NAME_CHAR.test(source[index + 4] ?? '')) return false;
483
+
484
+ const before = source[index - 1] ?? '';
485
+ if (before === '.' || RE_NAME_CHAR.test(before)) return false;
486
+
487
+ let after = index + 4;
488
+ while (RE_WHITESPACE.test(source[after] ?? '')) after++;
489
+ return source[after] !== ':';
490
+ }
491
+
492
+ /**
493
+ * Indexes of the colons after which a JavaScript statement starts
494
+ * @param {string} source
495
+ * @returns {Set<number>}
496
+ */
497
+ function findStatementColons(source) {
498
+ const colons = new Set();
499
+ // Unpaired `?` in the current `case` expression, -1 outside of one
500
+ let ternaries = -1;
501
+
502
+ for (let i = 0; i < source.length; i++) {
503
+ const end = skipCommentOrString(source, i, true);
504
+ if (end > i) {
505
+ i = end - 1;
506
+ continue;
507
+ }
508
+
509
+ const char = source[i];
510
+ if (ternaries === -1) {
511
+ if (char === ':') {
512
+ if (isLabelColon(source, i)) colons.add(i);
513
+ } else if (char === 'c' && isCaseKeyword(source, i)) {
514
+ ternaries = 0;
515
+ i += 3;
516
+ }
517
+ } else if (char === '?') {
518
+ // `?.` and `??` have no `:` to pair with
519
+ if (source[i + 1] === '.' || source[i + 1] === '?') i++;
520
+ else ternaries++;
521
+ } else if (char === ':') {
522
+ if (ternaries === 0) colons.add(i);
523
+ ternaries--;
524
+ } else if (char === ';' || char === '{' || char === '}') {
525
+ ternaries = -1;
526
+ }
527
+ }
528
+
529
+ return colons;
530
+ }
531
+
532
+ /**
533
+ * Placeholders within `@charset`, `@import`, and `@namespace` statements, which
534
+ * Lightning CSS drops whole where it cannot read them, and `@charset` always
535
+ * @param {string} source
536
+ * @param {RegExp} placeholder
537
+ * @returns {string[]}
538
+ */
539
+ function findDroppableAtRulePlaceholders(source, placeholder) {
540
+ const tokens = [];
541
+ let start = -1;
542
+
543
+ for (let i = 0; i <= source.length; i++) {
544
+ const end = skipCommentOrString(source, i, false);
545
+ if (end > i) {
546
+ i = end - 1;
547
+ continue;
548
+ }
549
+
550
+ const char = source[i];
551
+ if (start === -1) {
552
+ if (char === '@' && RE_DROPPABLE_AT_RULE.test(source.slice(i, i + 11))) start = i;
553
+ } else if (char === ';' || char === '{' || char === '}' || char === undefined) {
554
+ for (const match of source.slice(start, i).matchAll(placeholder)) tokens.push(match[0]);
555
+ start = -1;
556
+ }
557
+ }
558
+
559
+ return tokens;
560
+ }
561
+
562
+ /**
563
+ * Whether what comes before an index allows a statement or rule to start there
564
+ * @param {string} source
565
+ * @param {number} index
566
+ * @param {Set<number>} colons - Colons after which a statement starts
567
+ * @param {boolean} isJS
568
+ * @returns {boolean}
569
+ */
570
+ function isCodeStart(source, index, colons, isJS) {
571
+ let position = index;
572
+ while (position > 0) {
573
+ const char = source[--position] ?? '';
574
+ if (char === '\n' || char === '\r' || char === ';' || char === '{' || char === '}') return true;
575
+ if (!RE_WHITESPACE.test(char)) {
576
+ return char === ')' || (char === '/' && source[position - 1] === '*') || colons.has(position) || (isJS && isBlockKeywordEnd(source, position));
577
+ }
578
+ }
579
+ return true;
580
+ }
581
+
582
+ /**
583
+ * Whether minified CSS or JavaScript can stand in for its source. A fragment starting a
584
+ * statement or rule can render as anything, or as nothing, while engines read it as an
585
+ * identifier or selector and join it with the code around it, or drop it; a fragment in
586
+ * an at-rule Lightning CSS cannot read would go with that at-rule
587
+ * @param {string} source
588
+ * @param {unknown} output - Anything but a string, from a minifier of one’s own, passes
589
+ * @param {RegExp} placeholder - Global pattern matching one fragment placeholder
590
+ * @param {boolean} [isJS] - Whether the code is JavaScript rather than CSS
591
+ * @returns {boolean}
592
+ */
593
+ function isMinifiedCodeSafe(source, output, placeholder, isJS = false) {
594
+ if (typeof output !== 'string' || output === source) return true;
595
+
596
+ const matches = [...source.matchAll(placeholder)];
597
+ if (!matches.length) return true;
598
+
599
+ const colons = isJS ? findStatementColons(source) : new Set();
600
+ const starts = matches.filter(match => isCodeStart(source, match.index, colons, isJS));
601
+ const kept = new Set(Array.from(output.matchAll(placeholder), match => match[0]));
602
+
603
+ // Output of nothing but fragments only holds up where no label or `case` went with the code
604
+ if (starts.length && (colons.size || output.replace(placeholder, '').trim() || !starts.every(match => kept.has(match[0])))) return false;
605
+ return isJS || !source.includes('@') || findDroppableAtRulePlaceholders(source, placeholder).every(token => kept.has(token));
606
+ }
607
+
336
608
  export {
337
609
  toDelimitedFragment,
338
610
  toFragment,
339
- replaceCustomFragments
611
+ replaceCustomFragments,
612
+ isMinifiedCodeSafe
340
613
  };
@@ -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);