@thejaredwilcurt/csslop 0.0.34 → 0.0.35
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/package.json +2 -2
- package/src/index.js +3 -1
- package/src/rules/optimize.js +161 -3
- package/src/rules/selectors.js +561 -45
- package/src/rules/stringify.js +4 -1
package/package.json
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"name": "@thejaredwilcurt/csslop",
|
|
3
3
|
"main": "index.js",
|
|
4
4
|
"type": "module",
|
|
5
|
-
"version": "0.0.
|
|
5
|
+
"version": "0.0.35",
|
|
6
6
|
"description": "Experimental CSS minification",
|
|
7
7
|
"scripts": {
|
|
8
8
|
"prestart": "node ./scripts/prestart.js",
|
|
@@ -31,7 +31,7 @@
|
|
|
31
31
|
"@eslint/js": "^10.0.1",
|
|
32
32
|
"@stylistic/eslint-plugin": "^5.10.0",
|
|
33
33
|
"codemirror": "^6.0.2",
|
|
34
|
-
"eslint": "^10.
|
|
34
|
+
"eslint": "^10.10.0",
|
|
35
35
|
"eslint-config-tjw-base": "^5.0.0",
|
|
36
36
|
"eslint-config-tjw-import-x": "^1.0.1",
|
|
37
37
|
"eslint-config-tjw-jsdoc": "^2.0.1",
|
package/src/index.js
CHANGED
|
@@ -36,6 +36,7 @@ import {
|
|
|
36
36
|
mergeSelectorRules,
|
|
37
37
|
nestFlatRules,
|
|
38
38
|
removeEmptyRules,
|
|
39
|
+
removeFullyOverriddenSelectors,
|
|
39
40
|
removeOverriddenMultiSelectorProperties
|
|
40
41
|
} from './rules/optimize.js';
|
|
41
42
|
import {
|
|
@@ -263,7 +264,8 @@ export const minifyCSS = function (input) {
|
|
|
263
264
|
|
|
264
265
|
const mergedRules = mergeSelectorRules(ast.stylesheet.rules);
|
|
265
266
|
const overrideCleanedRules = removeOverriddenMultiSelectorProperties(mergedRules);
|
|
266
|
-
const
|
|
267
|
+
const selectorPrunedRules = removeFullyOverriddenSelectors(overrideCleanedRules);
|
|
268
|
+
const preCleanedRules = removeEmptyRules(selectorPrunedRules);
|
|
267
269
|
const declarationMergedRules = mergeByDeclarations(preCleanedRules);
|
|
268
270
|
const nestedRules = nestFlatRules(declarationMergedRules);
|
|
269
271
|
const nonEmptyRules = removeEmptyRules(nestedRules);
|
package/src/rules/optimize.js
CHANGED
|
@@ -1228,7 +1228,7 @@ function chooseMergePlacement (overrideIndex, earlierRule, laterRule, earlierPos
|
|
|
1228
1228
|
}
|
|
1229
1229
|
|
|
1230
1230
|
/**
|
|
1231
|
-
* Merges rules with identical normalized selectors by combining their declarations, as long as `chooseMergePlacement` finds a spot for the combined rule that the cascade reads the same way. Non-rule entries (like `@media`) break the merge window.
|
|
1231
|
+
* Merges rules with identical normalized selectors by combining their declarations, as long as `chooseMergePlacement` finds a spot for the combined rule that the cascade reads the same way. Non-rule entries that affect the cascade (like `@media`) break the merge window, while comments do not, since comments never influence how declarations apply.
|
|
1232
1232
|
*
|
|
1233
1233
|
* @param {Array} rules The AST rule nodes to merge.
|
|
1234
1234
|
* @return {Array} A new array of rules with same-selector rules combined.
|
|
@@ -1248,8 +1248,10 @@ function mergeSelectorRules (rules) {
|
|
|
1248
1248
|
}
|
|
1249
1249
|
if (rule.type !== 'rule') {
|
|
1250
1250
|
slots.push(rule);
|
|
1251
|
-
|
|
1252
|
-
|
|
1251
|
+
if (rule.type !== 'comment') {
|
|
1252
|
+
selectorMap.clear();
|
|
1253
|
+
positionByRule.clear();
|
|
1254
|
+
}
|
|
1253
1255
|
continue;
|
|
1254
1256
|
}
|
|
1255
1257
|
const selectorKey = buildSelectorKey(rule);
|
|
@@ -1400,6 +1402,161 @@ function isSelectorPropertyOverriddenLater (lastRuleIndexBySelector, startIndex,
|
|
|
1400
1402
|
return lastRuleIndex !== undefined && lastRuleIndex > startIndex;
|
|
1401
1403
|
}
|
|
1402
1404
|
|
|
1405
|
+
/**
|
|
1406
|
+
* Determines whether a declaration carries a trailing `!important` flag.
|
|
1407
|
+
*
|
|
1408
|
+
* @param {object} declaration The AST declaration node.
|
|
1409
|
+
* @return {boolean} True when the declaration is important.
|
|
1410
|
+
*/
|
|
1411
|
+
function isImportantDeclaration (declaration) {
|
|
1412
|
+
// A trailing !important suffix on the raw declaration value
|
|
1413
|
+
return /!\s*important\s*$/i.test(declaration.rawValue || declaration.value || '');
|
|
1414
|
+
}
|
|
1415
|
+
|
|
1416
|
+
/**
|
|
1417
|
+
* Indexes, for every normalized selector in the stylesheet, the last rule that
|
|
1418
|
+
* covers each leaf property under it, where coverage follows the same "writes
|
|
1419
|
+
* to" model as `expandToOverridableProperties` (a later `border` declaration
|
|
1420
|
+
* on the same selector also overrides an earlier `border-color` on it), along
|
|
1421
|
+
* with whether that covering declaration is important. A last-covering entry
|
|
1422
|
+
* is consulted to answer "is every declaration this selector still gets from
|
|
1423
|
+
* the rule at position X re-declared later".
|
|
1424
|
+
*
|
|
1425
|
+
* @param {Array} rules The flat list of AST rule nodes.
|
|
1426
|
+
* @return {Map} Map of normalized selector to a map of covered leaf property name to `{ ruleIndex, important }`.
|
|
1427
|
+
*/
|
|
1428
|
+
function indexFinalCoveringRuleBySelector (rules) {
|
|
1429
|
+
const coverageBySelector = new Map();
|
|
1430
|
+
rules.forEach((rule, ruleIndex) => {
|
|
1431
|
+
if (rule.type !== 'rule' || !rule.selectors?.length) {
|
|
1432
|
+
return;
|
|
1433
|
+
}
|
|
1434
|
+
const declarations = (rule.declarations || []).filter((declaration) => {
|
|
1435
|
+
return declaration.type === 'declaration' && declaration.property;
|
|
1436
|
+
});
|
|
1437
|
+
if (!declarations.length) {
|
|
1438
|
+
return;
|
|
1439
|
+
}
|
|
1440
|
+
for (const selector of rule.selectors) {
|
|
1441
|
+
const normalizedSelector = normalizeSelector(selector);
|
|
1442
|
+
let coverageByProperty = coverageBySelector.get(normalizedSelector);
|
|
1443
|
+
if (!coverageByProperty) {
|
|
1444
|
+
coverageByProperty = new Map();
|
|
1445
|
+
coverageBySelector.set(normalizedSelector, coverageByProperty);
|
|
1446
|
+
}
|
|
1447
|
+
for (const declaration of declarations) {
|
|
1448
|
+
const important = isImportantDeclaration(declaration);
|
|
1449
|
+
for (const leafProperty of expandToOverridableProperties(declaration.property)) {
|
|
1450
|
+
coverageByProperty.set(leafProperty, { important, ruleIndex });
|
|
1451
|
+
}
|
|
1452
|
+
}
|
|
1453
|
+
}
|
|
1454
|
+
});
|
|
1455
|
+
return coverageBySelector;
|
|
1456
|
+
}
|
|
1457
|
+
|
|
1458
|
+
/**
|
|
1459
|
+
* Determines whether a later rule overrides every leaf property an earlier
|
|
1460
|
+
* declaration writes to for the same selector, including via shorthand
|
|
1461
|
+
* coverage, with an important earlier write requiring an important later
|
|
1462
|
+
* write to beat it.
|
|
1463
|
+
*
|
|
1464
|
+
* @param {Map} coverageByProperty The leaf property coverage index for the selector.
|
|
1465
|
+
* @param {object} declaration The earlier declaration to test.
|
|
1466
|
+
* @param {number} ruleIndex The position of the rule holding the declaration.
|
|
1467
|
+
* @param {boolean} declarationIsImportant Whether the earlier write of this property is important.
|
|
1468
|
+
* @return {boolean} True when the declaration is conclusively overridden later.
|
|
1469
|
+
*/
|
|
1470
|
+
function isDeclarationOverriddenLater (coverageByProperty, declaration, ruleIndex, declarationIsImportant) {
|
|
1471
|
+
for (const leafProperty of expandToOverridableProperties(declaration.property)) {
|
|
1472
|
+
const coverage = coverageByProperty.get(leafProperty);
|
|
1473
|
+
if (!coverage || coverage.ruleIndex <= ruleIndex) {
|
|
1474
|
+
return false;
|
|
1475
|
+
}
|
|
1476
|
+
// An important declaration outlives a later non-important one
|
|
1477
|
+
if (declarationIsImportant && !coverage.important) {
|
|
1478
|
+
return false;
|
|
1479
|
+
}
|
|
1480
|
+
}
|
|
1481
|
+
return true;
|
|
1482
|
+
}
|
|
1483
|
+
|
|
1484
|
+
/**
|
|
1485
|
+
* Removes a selector from a multi-selector rule's list when every declaration
|
|
1486
|
+
* the rule would still give that selector is overridden by a later rule that
|
|
1487
|
+
* contains the same selector. For example, if `h1,h2{color:red}` is followed
|
|
1488
|
+
* by `h2{color:tan}`, the `h2` entry contributes nothing and can be dropped,
|
|
1489
|
+
* leaving `h1{color:red}`. If the list empties out entirely, the rule is
|
|
1490
|
+
* stripped of declarations so `removeEmptyRules` discards it.
|
|
1491
|
+
*
|
|
1492
|
+
* @param {Array} rules The flat list of AST rule nodes.
|
|
1493
|
+
* @return {Array} The rules with fully overridden selectors removed from their lists.
|
|
1494
|
+
*/
|
|
1495
|
+
function removeFullyOverriddenSelectors (rules) {
|
|
1496
|
+
// Pruning a selector only ever shrinks lists: a covering entry that wins
|
|
1497
|
+
// today is supplied by a later rule, and pruning earlier rules never moves
|
|
1498
|
+
// coverage forward, so a single index built up front stays accurate.
|
|
1499
|
+
const coverageBySelector = indexFinalCoveringRuleBySelector(rules);
|
|
1500
|
+
|
|
1501
|
+
for (let ruleIndex = 0; ruleIndex < rules.length; ruleIndex++) {
|
|
1502
|
+
const rule = rules[ruleIndex];
|
|
1503
|
+
if (rule.type !== 'rule' || !rule.selectors || rule.selectors.length < 2) {
|
|
1504
|
+
continue;
|
|
1505
|
+
}
|
|
1506
|
+
const entries = rule.declarations || [];
|
|
1507
|
+
// Removing a selector would also un-scope nested rules, which may depend
|
|
1508
|
+
// on it, so only rules made purely of declarations qualify
|
|
1509
|
+
const hasNestedContent = entries.some((entry) => {
|
|
1510
|
+
return entry.type !== 'declaration' && entry.type !== 'whitespace' && entry.type !== 'comment';
|
|
1511
|
+
});
|
|
1512
|
+
if (hasNestedContent) {
|
|
1513
|
+
continue;
|
|
1514
|
+
}
|
|
1515
|
+
const declarations = entries.filter((entry) => {
|
|
1516
|
+
return entry.type === 'declaration' && entry.property;
|
|
1517
|
+
});
|
|
1518
|
+
if (!declarations.length) {
|
|
1519
|
+
continue;
|
|
1520
|
+
}
|
|
1521
|
+
// Within a rule, any important declaration of a property makes the rule's
|
|
1522
|
+
// write of that property important (a conservative stand-in for tracking
|
|
1523
|
+
// which of several same-property declarations survives inside the rule)
|
|
1524
|
+
const importantByProperty = new Map();
|
|
1525
|
+
for (const declaration of declarations) {
|
|
1526
|
+
if (isImportantDeclaration(declaration)) {
|
|
1527
|
+
importantByProperty.set(declaration.property, true);
|
|
1528
|
+
} else if (!importantByProperty.has(declaration.property)) {
|
|
1529
|
+
importantByProperty.set(declaration.property, false);
|
|
1530
|
+
}
|
|
1531
|
+
}
|
|
1532
|
+
const keptSelectors = rule.selectors.filter((selector) => {
|
|
1533
|
+
const coverageByProperty = coverageBySelector.get(normalizeSelector(selector));
|
|
1534
|
+
if (!coverageByProperty) {
|
|
1535
|
+
return true;
|
|
1536
|
+
}
|
|
1537
|
+
// Keep the selector while at least one declaration is not conclusively
|
|
1538
|
+
// overridden for it by a later rule
|
|
1539
|
+
return declarations.some((declaration) => {
|
|
1540
|
+
return !isDeclarationOverriddenLater(
|
|
1541
|
+
coverageByProperty,
|
|
1542
|
+
declaration,
|
|
1543
|
+
ruleIndex,
|
|
1544
|
+
importantByProperty.get(declaration.property)
|
|
1545
|
+
);
|
|
1546
|
+
});
|
|
1547
|
+
});
|
|
1548
|
+
if (keptSelectors.length !== rule.selectors.length) {
|
|
1549
|
+
if (!keptSelectors.length) {
|
|
1550
|
+
rule.declarations = entries.filter((entry) => {
|
|
1551
|
+
return entry.type !== 'declaration';
|
|
1552
|
+
});
|
|
1553
|
+
}
|
|
1554
|
+
rule.selectors = keptSelectors;
|
|
1555
|
+
}
|
|
1556
|
+
}
|
|
1557
|
+
return rules;
|
|
1558
|
+
}
|
|
1559
|
+
|
|
1403
1560
|
/**
|
|
1404
1561
|
* Removes properties from multi-selector rules when every selector in
|
|
1405
1562
|
* the rule has that property overridden by a later rule. For example,
|
|
@@ -1465,5 +1622,6 @@ export {
|
|
|
1465
1622
|
mergeSelectorRules,
|
|
1466
1623
|
nestFlatRules,
|
|
1467
1624
|
removeEmptyRules,
|
|
1625
|
+
removeFullyOverriddenSelectors,
|
|
1468
1626
|
removeOverriddenMultiSelectorProperties
|
|
1469
1627
|
};
|
package/src/rules/selectors.js
CHANGED
|
@@ -30,9 +30,60 @@ function splitParametersByComma (parameterString) {
|
|
|
30
30
|
return parameters;
|
|
31
31
|
}
|
|
32
32
|
|
|
33
|
+
/**
|
|
34
|
+
* Advances past a quoted string in a selector, honoring backslash escapes so a
|
|
35
|
+
* quoted close-quote does not end the skip early.
|
|
36
|
+
*
|
|
37
|
+
* @param {string} text The selector string being scanned.
|
|
38
|
+
* @param {number} quoteIndex The index of the opening quote character.
|
|
39
|
+
* @return {number} The index right after the closing quote, or the end of the string if the quote never closes.
|
|
40
|
+
*/
|
|
41
|
+
function skipQuotedText (text, quoteIndex) {
|
|
42
|
+
const quote = text[quoteIndex];
|
|
43
|
+
let index = quoteIndex + 1;
|
|
44
|
+
while (index < text.length) {
|
|
45
|
+
if (text[index] === '\\') {
|
|
46
|
+
index++;
|
|
47
|
+
} else if (text[index] === quote) {
|
|
48
|
+
return index + 1;
|
|
49
|
+
}
|
|
50
|
+
index++;
|
|
51
|
+
}
|
|
52
|
+
return text.length;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Advances past an attribute selector `[...]` in a selector, skipping quoted
|
|
57
|
+
* values and escaped characters so brackets inside them are not miscounted.
|
|
58
|
+
*
|
|
59
|
+
* @param {string} text The selector string being scanned.
|
|
60
|
+
* @param {number} openIndex The index of the `[` that opens the attribute selector.
|
|
61
|
+
* @return {number} The index right after the closing `]`, or the end of the string if it never closes.
|
|
62
|
+
*/
|
|
63
|
+
function skipAttributeSelector (text, openIndex) {
|
|
64
|
+
let index = openIndex + 1;
|
|
65
|
+
while (index < text.length) {
|
|
66
|
+
const character = text[index];
|
|
67
|
+
if (character === '"' || character === '\'') {
|
|
68
|
+
index = skipQuotedText(text, index);
|
|
69
|
+
continue;
|
|
70
|
+
}
|
|
71
|
+
if (character === '\\') {
|
|
72
|
+
index += 2;
|
|
73
|
+
continue;
|
|
74
|
+
}
|
|
75
|
+
if (character === ']') {
|
|
76
|
+
return index + 1;
|
|
77
|
+
}
|
|
78
|
+
index++;
|
|
79
|
+
}
|
|
80
|
+
return text.length;
|
|
81
|
+
}
|
|
82
|
+
|
|
33
83
|
/**
|
|
34
84
|
* Finds the index of the closing parenthesis that matches the opening
|
|
35
|
-
* parenthesis at the given position in the string
|
|
85
|
+
* parenthesis at the given position in the string, skipping over quoted
|
|
86
|
+
* strings and attribute selectors so parentheses inside them are ignored.
|
|
36
87
|
*
|
|
37
88
|
* @param {string} text The string to search within.
|
|
38
89
|
* @param {number} openIndex The index of the opening parenthesis.
|
|
@@ -40,15 +91,60 @@ function splitParametersByComma (parameterString) {
|
|
|
40
91
|
*/
|
|
41
92
|
function findMatchingCloseParenthesis (text, openIndex) {
|
|
42
93
|
let depth = 0;
|
|
43
|
-
|
|
44
|
-
|
|
94
|
+
let index = openIndex;
|
|
95
|
+
while (index < text.length) {
|
|
96
|
+
const character = text[index];
|
|
97
|
+
if (character === '"' || character === '\'') {
|
|
98
|
+
index = skipQuotedText(text, index);
|
|
99
|
+
continue;
|
|
100
|
+
}
|
|
101
|
+
if (character === '[') {
|
|
102
|
+
index = skipAttributeSelector(text, index);
|
|
103
|
+
continue;
|
|
104
|
+
}
|
|
105
|
+
if (character === '(') {
|
|
45
106
|
depth++;
|
|
46
|
-
} else if (
|
|
107
|
+
} else if (character === ')') {
|
|
47
108
|
depth--;
|
|
48
109
|
if (depth === 0) {
|
|
49
110
|
return index;
|
|
50
111
|
}
|
|
51
112
|
}
|
|
113
|
+
index++;
|
|
114
|
+
}
|
|
115
|
+
return -1;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Finds the next occurrence of a pseudo-class function token (e.g. `:is(`)
|
|
120
|
+
* that sits at the top level of a selector, outside any quoted string or
|
|
121
|
+
* attribute selector.
|
|
122
|
+
*
|
|
123
|
+
* @param {string} text The selector string to scan.
|
|
124
|
+
* @param {string} functionCall The function token to find, including its opening parenthesis.
|
|
125
|
+
* @param {number} start The index to start scanning from.
|
|
126
|
+
* @return {number} The index of the next top-level occurrence, or -1 if none remains.
|
|
127
|
+
*/
|
|
128
|
+
function findNextFunctionCallOutsideStrings (text, functionCall, start) {
|
|
129
|
+
let index = start;
|
|
130
|
+
while (index < text.length) {
|
|
131
|
+
const character = text[index];
|
|
132
|
+
if (character === '"' || character === '\'') {
|
|
133
|
+
index = skipQuotedText(text, index);
|
|
134
|
+
continue;
|
|
135
|
+
}
|
|
136
|
+
if (character === '[') {
|
|
137
|
+
index = skipAttributeSelector(text, index);
|
|
138
|
+
continue;
|
|
139
|
+
}
|
|
140
|
+
if (character === '\\') {
|
|
141
|
+
index += 2;
|
|
142
|
+
continue;
|
|
143
|
+
}
|
|
144
|
+
if (text.startsWith(functionCall, index)) {
|
|
145
|
+
return index;
|
|
146
|
+
}
|
|
147
|
+
index++;
|
|
52
148
|
}
|
|
53
149
|
return -1;
|
|
54
150
|
}
|
|
@@ -174,42 +270,445 @@ function mergeAdjacentWherePseudoClasses (selector) {
|
|
|
174
270
|
}
|
|
175
271
|
|
|
176
272
|
/**
|
|
177
|
-
*
|
|
178
|
-
*
|
|
179
|
-
*
|
|
180
|
-
*
|
|
181
|
-
*
|
|
182
|
-
*
|
|
273
|
+
* Pseudo-classes that every browser has recognized for many years. Wrapping
|
|
274
|
+
* one of these in `:is()` cannot protect a rule from a browser that would not
|
|
275
|
+
* know how to parse it, because every browser does. Keeping the list to
|
|
276
|
+
* long-established pseudo-classes means newer or vendor-specific ones continue
|
|
277
|
+
* to be treated as potentially unknown.
|
|
278
|
+
*
|
|
279
|
+
* @type {Set<string>}
|
|
280
|
+
*/
|
|
281
|
+
const WELL_KNOWN_PSEUDO_CLASSES = new Set([
|
|
282
|
+
'active',
|
|
283
|
+
'any-link',
|
|
284
|
+
'checked',
|
|
285
|
+
'default',
|
|
286
|
+
'dir',
|
|
287
|
+
'disabled',
|
|
288
|
+
'empty',
|
|
289
|
+
'enabled',
|
|
290
|
+
'first-child',
|
|
291
|
+
'first-of-type',
|
|
292
|
+
'focus',
|
|
293
|
+
'focus-visible',
|
|
294
|
+
'focus-within',
|
|
295
|
+
'fullscreen',
|
|
296
|
+
'hover',
|
|
297
|
+
'in-range',
|
|
298
|
+
'indeterminate',
|
|
299
|
+
'invalid',
|
|
300
|
+
'lang',
|
|
301
|
+
'last-child',
|
|
302
|
+
'last-of-type',
|
|
303
|
+
'link',
|
|
304
|
+
'not',
|
|
305
|
+
'nth-child',
|
|
306
|
+
'nth-last-child',
|
|
307
|
+
'nth-last-of-type',
|
|
308
|
+
'nth-of-type',
|
|
309
|
+
'only-child',
|
|
310
|
+
'only-of-type',
|
|
311
|
+
'optional',
|
|
312
|
+
'out-of-range',
|
|
313
|
+
'placeholder-shown',
|
|
314
|
+
'read-only',
|
|
315
|
+
'read-write',
|
|
316
|
+
'required',
|
|
317
|
+
'root',
|
|
318
|
+
'scope',
|
|
319
|
+
'target',
|
|
320
|
+
'valid',
|
|
321
|
+
'visited'
|
|
322
|
+
]);
|
|
323
|
+
|
|
324
|
+
/**
|
|
325
|
+
* The legacy pseudo-elements that browsers accept with a single colon. They
|
|
326
|
+
* are tracked because pseudo-elements inside `:is()` never match (the spec
|
|
327
|
+
* forbids them), so unwrapping such an `:is()` would resurrect a dead rule.
|
|
328
|
+
*
|
|
329
|
+
* @type {Set<string>}
|
|
330
|
+
*/
|
|
331
|
+
const LEGACY_PSEUDO_ELEMENT_NAMES = new Set([
|
|
332
|
+
'after',
|
|
333
|
+
'before',
|
|
334
|
+
'first-letter',
|
|
335
|
+
'first-line'
|
|
336
|
+
]);
|
|
337
|
+
|
|
338
|
+
/**
|
|
339
|
+
* Functional pseudo-classes whose argument is a selector list, such as
|
|
340
|
+
* `:not(.a)`. Their specificity comes from their argument rather than the
|
|
341
|
+
* pseudo-class itself, so a simple token count cannot describe them.
|
|
342
|
+
*
|
|
343
|
+
* @type {Set<string>}
|
|
344
|
+
*/
|
|
345
|
+
const SELECTOR_ARGUMENT_PSEUDO_CLASSES = new Set([
|
|
346
|
+
'is',
|
|
347
|
+
'where',
|
|
348
|
+
'has',
|
|
349
|
+
'not',
|
|
350
|
+
'matches',
|
|
351
|
+
'-webkit-any',
|
|
352
|
+
'-moz-any'
|
|
353
|
+
]);
|
|
354
|
+
|
|
355
|
+
/**
|
|
356
|
+
* Matches a single character allowed inside a selector identifier (letters,
|
|
357
|
+
* digits, hyphens, underscores).
|
|
183
358
|
*
|
|
184
359
|
* @type {RegExp}
|
|
185
360
|
*/
|
|
186
|
-
const
|
|
361
|
+
const IDENTIFIER_CHARACTER = /[a-zA-Z0-9_-]/;
|
|
187
362
|
|
|
188
363
|
/**
|
|
189
|
-
* Matches
|
|
190
|
-
* each one's specificity contribution.
|
|
364
|
+
* Matches a single ASCII letter, which is how a type selector name begins.
|
|
191
365
|
*
|
|
192
366
|
* @type {RegExp}
|
|
193
367
|
*/
|
|
194
|
-
const
|
|
368
|
+
const TYPE_SELECTOR_START = /[a-zA-Z]/;
|
|
369
|
+
|
|
370
|
+
/**
|
|
371
|
+
* Matches a single character that may start a pseudo-class or pseudo-element
|
|
372
|
+
* name (a letter, or a hyphen for vendor-prefixed names).
|
|
373
|
+
*
|
|
374
|
+
* @type {RegExp}
|
|
375
|
+
*/
|
|
376
|
+
const PSEUDO_NAME_CHARACTER = /[a-zA-Z-]/;
|
|
377
|
+
|
|
378
|
+
/**
|
|
379
|
+
* Reads a pseudo-class or pseudo-element name starting at the given index and
|
|
380
|
+
* returns the index right after the final name character.
|
|
381
|
+
*
|
|
382
|
+
* @param {string} selector The selector string being scanned.
|
|
383
|
+
* @param {number} start The index of the name's first character.
|
|
384
|
+
* @return {number} The index immediately after the pseudo name.
|
|
385
|
+
*/
|
|
386
|
+
function readPseudoNameEnd (selector, start) {
|
|
387
|
+
let index = start;
|
|
388
|
+
while (index < selector.length && PSEUDO_NAME_CHARACTER.test(selector[index])) {
|
|
389
|
+
index++;
|
|
390
|
+
}
|
|
391
|
+
return index;
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
/**
|
|
395
|
+
* Result of scanning one compound selector: how many simple selectors of each
|
|
396
|
+
* specificity tier it holds and whether a browser could fail to recognize any
|
|
397
|
+
* of them.
|
|
398
|
+
*
|
|
399
|
+
* @typedef {object} CompoundSelectorSummary
|
|
400
|
+
* @property {number} identifierCount Number of id selectors.
|
|
401
|
+
* @property {number} classLevelCount Number of class, attribute, and pseudo-class selectors.
|
|
402
|
+
* @property {number} typeLevelCount Number of type selectors and pseudo-elements.
|
|
403
|
+
* @property {boolean} hasPseudoElement True when a pseudo-element is present.
|
|
404
|
+
* @property {boolean} recognizable True when every simple selector is universally recognized.
|
|
405
|
+
*/
|
|
406
|
+
|
|
407
|
+
/**
|
|
408
|
+
* Scans a compound selector (one without combinators) and summarizes its
|
|
409
|
+
* simple selectors for specificity comparison and recognizability checks.
|
|
410
|
+
* Returns null when the string is not a plain compound selector, since
|
|
411
|
+
* something else (a combinator, a stray character) was found inside.
|
|
412
|
+
*
|
|
413
|
+
* @param {string} selector The compound selector string to summarize.
|
|
414
|
+
* @return {CompoundSelectorSummary|null} The summary, or null for non-compound input.
|
|
415
|
+
*/
|
|
416
|
+
function summarizeCompoundSelector (selector) {
|
|
417
|
+
if (!selector) {
|
|
418
|
+
return null;
|
|
419
|
+
}
|
|
420
|
+
const summary = {
|
|
421
|
+
identifierCount: 0,
|
|
422
|
+
classLevelCount: 0,
|
|
423
|
+
typeLevelCount: 0,
|
|
424
|
+
hasPseudoElement: false,
|
|
425
|
+
recognizable: true
|
|
426
|
+
};
|
|
427
|
+
let index = 0;
|
|
428
|
+
let tokenCount = 0;
|
|
429
|
+
while (index < selector.length) {
|
|
430
|
+
const character = selector[index];
|
|
431
|
+
if (tokenCount === 0 && (character === '*' || TYPE_SELECTOR_START.test(character))) {
|
|
432
|
+
// The first simple selector of a compound may be a type (`div`) or universal (`*`) selector
|
|
433
|
+
if (character !== '*') {
|
|
434
|
+
let end = index + 1;
|
|
435
|
+
while (end < selector.length && IDENTIFIER_CHARACTER.test(selector[end])) {
|
|
436
|
+
end++;
|
|
437
|
+
}
|
|
438
|
+
index = end;
|
|
439
|
+
summary.typeLevelCount++;
|
|
440
|
+
} else {
|
|
441
|
+
index++;
|
|
442
|
+
}
|
|
443
|
+
tokenCount++;
|
|
444
|
+
continue;
|
|
445
|
+
}
|
|
446
|
+
if (character === '#' || character === '.') {
|
|
447
|
+
// Id and class selectors are an identifier prefixed by '#' or '.'
|
|
448
|
+
const nameStart = index + 1;
|
|
449
|
+
if (nameStart >= selector.length || !IDENTIFIER_CHARACTER.test(selector[nameStart])) {
|
|
450
|
+
return null;
|
|
451
|
+
}
|
|
452
|
+
let end = nameStart + 1;
|
|
453
|
+
while (end < selector.length && IDENTIFIER_CHARACTER.test(selector[end])) {
|
|
454
|
+
end++;
|
|
455
|
+
}
|
|
456
|
+
index = end;
|
|
457
|
+
if (character === '#') {
|
|
458
|
+
summary.identifierCount++;
|
|
459
|
+
} else {
|
|
460
|
+
summary.classLevelCount++;
|
|
461
|
+
}
|
|
462
|
+
} else if (character === '[') {
|
|
463
|
+
// Attribute selector: well-formed input has a closing bracket
|
|
464
|
+
const closeBracketIndex = selector.indexOf(']', index + 1);
|
|
465
|
+
if (closeBracketIndex === -1) {
|
|
466
|
+
return null;
|
|
467
|
+
}
|
|
468
|
+
index = closeBracketIndex + 1;
|
|
469
|
+
summary.classLevelCount++;
|
|
470
|
+
} else if (character === ':') {
|
|
471
|
+
let nameStart = index + 1;
|
|
472
|
+
const isDoubleColon = selector[nameStart] === ':';
|
|
473
|
+
if (isDoubleColon) {
|
|
474
|
+
nameStart++;
|
|
475
|
+
}
|
|
476
|
+
const nameEnd = readPseudoNameEnd(selector, nameStart);
|
|
477
|
+
if (nameEnd === nameStart) {
|
|
478
|
+
return null;
|
|
479
|
+
}
|
|
480
|
+
const pseudoName = selector.slice(nameStart, nameEnd).toLowerCase();
|
|
481
|
+
index = nameEnd;
|
|
482
|
+
if (selector[index] === '(') {
|
|
483
|
+
// Functional pseudo-class: skip past its balanced argument parentheses
|
|
484
|
+
const closeParenthesisIndex = findMatchingCloseParenthesis(selector, index);
|
|
485
|
+
if (closeParenthesisIndex === -1) {
|
|
486
|
+
return null;
|
|
487
|
+
}
|
|
488
|
+
index = closeParenthesisIndex + 1;
|
|
489
|
+
}
|
|
490
|
+
if (isDoubleColon || LEGACY_PSEUDO_ELEMENT_NAMES.has(pseudoName)) {
|
|
491
|
+
summary.hasPseudoElement = true;
|
|
492
|
+
summary.recognizable = false;
|
|
493
|
+
summary.typeLevelCount++;
|
|
494
|
+
} else if (SELECTOR_ARGUMENT_PSEUDO_CLASSES.has(pseudoName)) {
|
|
495
|
+
// Selector-argument pseudo-classes take their specificity from their
|
|
496
|
+
// argument, so simple token counting cannot price them, and their
|
|
497
|
+
// argument may itself contain something unknown to older browsers.
|
|
498
|
+
summary.recognizable = false;
|
|
499
|
+
summary.classLevelCount++;
|
|
500
|
+
} else {
|
|
501
|
+
summary.classLevelCount++;
|
|
502
|
+
if (!WELL_KNOWN_PSEUDO_CLASSES.has(pseudoName)) {
|
|
503
|
+
summary.recognizable = false;
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
} else {
|
|
507
|
+
return null;
|
|
508
|
+
}
|
|
509
|
+
tokenCount++;
|
|
510
|
+
}
|
|
511
|
+
if (tokenCount === 0) {
|
|
512
|
+
return null;
|
|
513
|
+
}
|
|
514
|
+
return summary;
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
/**
|
|
518
|
+
* Splits a complex selector into the compound segments between its top-level
|
|
519
|
+
* combinators, treating anything inside parentheses or attribute brackets as
|
|
520
|
+
* part of the current segment.
|
|
521
|
+
*
|
|
522
|
+
* @param {string} selector The selector string to split.
|
|
523
|
+
* @return {Array} The compound selector segments, combinators excluded.
|
|
524
|
+
*/
|
|
525
|
+
function splitCombinatorSegments (selector) {
|
|
526
|
+
const segments = [];
|
|
527
|
+
let currentSegment = '';
|
|
528
|
+
let depth = 0;
|
|
529
|
+
for (const character of selector) {
|
|
530
|
+
if (character === '(' || character === '[') {
|
|
531
|
+
depth++;
|
|
532
|
+
} else if (character === ')' || character === ']') {
|
|
533
|
+
depth--;
|
|
534
|
+
}
|
|
535
|
+
// A combinator at the top level ends the current compound segment;
|
|
536
|
+
// whitespace runs collapse into a single boundary
|
|
537
|
+
if (depth === 0 && (character === '>' || character === '+' || character === '~' || /\s/.test(character))) {
|
|
538
|
+
if (currentSegment) {
|
|
539
|
+
segments.push(currentSegment);
|
|
540
|
+
currentSegment = '';
|
|
541
|
+
}
|
|
542
|
+
continue;
|
|
543
|
+
}
|
|
544
|
+
currentSegment += character;
|
|
545
|
+
}
|
|
546
|
+
if (currentSegment) {
|
|
547
|
+
segments.push(currentSegment);
|
|
548
|
+
}
|
|
549
|
+
return segments;
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
/**
|
|
553
|
+
* Determines whether a selector is built entirely from simple selectors every
|
|
554
|
+
* browser recognizes. Combinators are fine; only the compound segments between
|
|
555
|
+
* them decide. Unknown pseudo-classes and pseudo-elements fail the check,
|
|
556
|
+
* because a browser that cannot parse a selector discards its whole rule,
|
|
557
|
+
* while `:is()` forgiving parsing would keep the rest of the rule alive.
|
|
558
|
+
*
|
|
559
|
+
* @param {string} selector A minified CSS selector string.
|
|
560
|
+
* @return {boolean} True when the selector is universally recognizable.
|
|
561
|
+
*/
|
|
562
|
+
function isUniversallyRecognizableSelector (selector) {
|
|
563
|
+
const segments = splitCombinatorSegments(selector);
|
|
564
|
+
if (!segments.length) {
|
|
565
|
+
return false;
|
|
566
|
+
}
|
|
567
|
+
return segments.every((segment) => {
|
|
568
|
+
const summary = summarizeCompoundSelector(segment);
|
|
569
|
+
return summary !== null && summary.recognizable && !summary.hasPseudoElement;
|
|
570
|
+
});
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
/**
|
|
574
|
+
* Determines whether a selector contains a pseudo-element anywhere (double
|
|
575
|
+
* colon, or a legacy single-colon pseudo-element name). Pseudo-elements are
|
|
576
|
+
* invalid inside `:is()`, so an `:is()` holding one never matches, and
|
|
577
|
+
* unwrapping it would wrongly bring the selector to life.
|
|
578
|
+
*
|
|
579
|
+
* @param {string} selector The selector string to inspect.
|
|
580
|
+
* @return {boolean} True when a pseudo-element token is present.
|
|
581
|
+
*/
|
|
582
|
+
function containsPseudoElement (selector) {
|
|
583
|
+
let index = 0;
|
|
584
|
+
while (index < selector.length) {
|
|
585
|
+
if (selector[index] !== ':') {
|
|
586
|
+
index++;
|
|
587
|
+
continue;
|
|
588
|
+
}
|
|
589
|
+
if (selector[index + 1] === ':') {
|
|
590
|
+
return true;
|
|
591
|
+
}
|
|
592
|
+
const nameEnd = readPseudoNameEnd(selector, index + 1);
|
|
593
|
+
const pseudoName = selector.slice(index + 1, nameEnd).toLowerCase();
|
|
594
|
+
if (LEGACY_PSEUDO_ELEMENT_NAMES.has(pseudoName)) {
|
|
595
|
+
return true;
|
|
596
|
+
}
|
|
597
|
+
index = nameEnd > index + 1 ? nameEnd : index + 1;
|
|
598
|
+
}
|
|
599
|
+
return false;
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
/**
|
|
603
|
+
* Determines whether a single selector contains a top-level combinator, which
|
|
604
|
+
* would make it a complex selector rather than a single compound selector.
|
|
605
|
+
*
|
|
606
|
+
* @param {string} selector The selector string to inspect.
|
|
607
|
+
* @return {boolean} True when a top-level combinator is present.
|
|
608
|
+
*/
|
|
609
|
+
function hasTopLevelCombinator (selector) {
|
|
610
|
+
let depth = 0;
|
|
611
|
+
for (const character of selector) {
|
|
612
|
+
if (character === '(' || character === '[') {
|
|
613
|
+
depth++;
|
|
614
|
+
} else if (character === ')' || character === ']') {
|
|
615
|
+
depth--;
|
|
616
|
+
} else if (depth === 0 && (character === '>' || character === '+' || character === '~' || /\s/.test(character))) {
|
|
617
|
+
// A combinator or whitespace boundary at the top level joins two compounds
|
|
618
|
+
return true;
|
|
619
|
+
}
|
|
620
|
+
}
|
|
621
|
+
return false;
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
/**
|
|
625
|
+
* The characters that can start a simple selector mid-compound (id, class,
|
|
626
|
+
* attribute selector, pseudo-class) or the nesting selector. Type and
|
|
627
|
+
* universal selectors are excluded on purpose: they may only open a compound,
|
|
628
|
+
* so `div:is(a)` cannot flatten to `diva`.
|
|
629
|
+
*
|
|
630
|
+
* @type {Set<string>}
|
|
631
|
+
*/
|
|
632
|
+
const SIMPLE_SELECTOR_STARTS = new Set(['#', '.', '[', ':', '&']);
|
|
195
633
|
|
|
196
634
|
/**
|
|
197
|
-
*
|
|
198
|
-
*
|
|
635
|
+
* Determines whether a character sits between two selector parts, meaning an
|
|
636
|
+
* `:is()` next to it is its own compound (or its own item in an argument
|
|
637
|
+
* list) rather than fused with neighboring simple selectors.
|
|
199
638
|
*
|
|
200
|
-
* @param {string}
|
|
201
|
-
* @return {
|
|
639
|
+
* @param {string} character The character to classify.
|
|
640
|
+
* @return {boolean} True when the character is a selector part boundary.
|
|
202
641
|
*/
|
|
203
|
-
function
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
642
|
+
function isSelectorPartBoundary (character) {
|
|
643
|
+
// Combinators, whitespace, parentheses, and comma all delimit selector parts
|
|
644
|
+
return /\s/.test(character) || character === '>' || character === '+' || character === '~' || character === '(' || character === ')' || character === ',';
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
/**
|
|
648
|
+
* Unwraps every single-argument `:is()` found within a selector, since such an
|
|
649
|
+
* `:is()` adds neither specificity (a one-argument `:is()` takes its
|
|
650
|
+
* argument's) nor matching behavior of its own. The unwrap is refused when it
|
|
651
|
+
* could change what a browser applies: an `:is()` fused to neighboring
|
|
652
|
+
* compound parts cannot release a complex argument (`div:is(a b)` cannot
|
|
653
|
+
* become `div a b`), and an argument a browser might not recognize must stay
|
|
654
|
+
* inside `:is()` whenever sibling selectors rely on its forgiving parsing.
|
|
655
|
+
*
|
|
656
|
+
* @param {string} selector A minified CSS selector string.
|
|
657
|
+
* @param {boolean} hasSiblingSelectors True when the rule's selector list holds other selectors besides this one.
|
|
658
|
+
* @return {string} The selector with redundant `:is()` wrappers removed.
|
|
659
|
+
*/
|
|
660
|
+
function unwrapSingleArgumentIsFunctions (selector, hasSiblingSelectors) {
|
|
661
|
+
let result = selector;
|
|
662
|
+
let position = 0;
|
|
663
|
+
while (position < result.length) {
|
|
664
|
+
const isIndex = findNextFunctionCallOutsideStrings(result, ':is(', position);
|
|
665
|
+
if (isIndex === -1) {
|
|
666
|
+
break;
|
|
667
|
+
}
|
|
668
|
+
const openParenthesisIndex = isIndex + 3;
|
|
669
|
+
const closeParenthesisIndex = findMatchingCloseParenthesis(result, openParenthesisIndex);
|
|
670
|
+
if (closeParenthesisIndex === -1) {
|
|
671
|
+
break;
|
|
672
|
+
}
|
|
673
|
+
const content = result.slice(openParenthesisIndex + 1, closeParenthesisIndex);
|
|
674
|
+
const innerSelector = content.trim();
|
|
675
|
+
const parts = splitParametersByComma(content);
|
|
676
|
+
if (parts.length !== 1 || !innerSelector) {
|
|
677
|
+
position = closeParenthesisIndex + 1;
|
|
678
|
+
continue;
|
|
679
|
+
}
|
|
680
|
+
const characterBefore = isIndex > 0 ? result[isIndex - 1] : '';
|
|
681
|
+
const characterAfter = closeParenthesisIndex + 1 < result.length ? result[closeParenthesisIndex + 1] : '';
|
|
682
|
+
const fusedOnLeft = characterBefore !== '' && !isSelectorPartBoundary(characterBefore);
|
|
683
|
+
const fusedOnRight = characterAfter !== '' && !isSelectorPartBoundary(characterAfter);
|
|
684
|
+
if ((fusedOnLeft || fusedOnRight) && hasTopLevelCombinator(innerSelector)) {
|
|
685
|
+
position = closeParenthesisIndex + 1;
|
|
686
|
+
continue;
|
|
687
|
+
}
|
|
688
|
+
// A compound selector's simple selectors must not fuse into one another:
|
|
689
|
+
// dropped `:is()` text must still start with something that can continue a
|
|
690
|
+
// compound (`div:is(.a)` → `div.a`) and end before something that can
|
|
691
|
+
// continue one (`:is(.a):hover` → `.a:hover`), or tokens merge wrongly
|
|
692
|
+
// (`div:is(a)` cannot become `diva`)
|
|
693
|
+
if (fusedOnLeft && !SIMPLE_SELECTOR_STARTS.has(innerSelector[0])) {
|
|
694
|
+
position = closeParenthesisIndex + 1;
|
|
695
|
+
continue;
|
|
696
|
+
}
|
|
697
|
+
if (fusedOnRight && !SIMPLE_SELECTOR_STARTS.has(characterAfter)) {
|
|
698
|
+
position = closeParenthesisIndex + 1;
|
|
699
|
+
continue;
|
|
700
|
+
}
|
|
701
|
+
const canUnwrap = hasSiblingSelectors ?
|
|
702
|
+
isUniversallyRecognizableSelector(innerSelector) :
|
|
703
|
+
!containsPseudoElement(innerSelector);
|
|
704
|
+
if (!canUnwrap) {
|
|
705
|
+
position = closeParenthesisIndex + 1;
|
|
706
|
+
continue;
|
|
707
|
+
}
|
|
708
|
+
result = result.slice(0, isIndex) + innerSelector + result.slice(closeParenthesisIndex + 1);
|
|
709
|
+
// Stay at isIndex so a nested :is() exposed by this unwrap is considered next
|
|
710
|
+
}
|
|
711
|
+
return result;
|
|
213
712
|
}
|
|
214
713
|
|
|
215
714
|
/**
|
|
@@ -226,32 +725,45 @@ function canDecomposeIsSelector (parts) {
|
|
|
226
725
|
if (parts.length < 2) {
|
|
227
726
|
return false;
|
|
228
727
|
}
|
|
229
|
-
const
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
728
|
+
const summaries = [];
|
|
729
|
+
for (const part of parts) {
|
|
730
|
+
const trimmedPart = part.trim();
|
|
731
|
+
if (!trimmedPart) {
|
|
732
|
+
return false;
|
|
733
|
+
}
|
|
734
|
+
const summary = summarizeCompoundSelector(trimmedPart);
|
|
735
|
+
if (!summary || !summary.recognizable || summary.hasPseudoElement) {
|
|
736
|
+
return false;
|
|
737
|
+
}
|
|
738
|
+
summaries.push(summary);
|
|
234
739
|
}
|
|
235
|
-
const
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
740
|
+
const firstSummary = summaries[0];
|
|
741
|
+
return summaries.every((summary) => {
|
|
742
|
+
return (
|
|
743
|
+
summary.identifierCount === firstSummary.identifierCount &&
|
|
744
|
+
summary.classLevelCount === firstSummary.classLevelCount &&
|
|
745
|
+
summary.typeLevelCount === firstSummary.typeLevelCount
|
|
746
|
+
);
|
|
240
747
|
});
|
|
241
748
|
}
|
|
242
749
|
|
|
243
750
|
/**
|
|
244
|
-
* Processes a
|
|
245
|
-
*
|
|
246
|
-
*
|
|
751
|
+
* Processes a selector by unwrapping redundant single-argument `:is()`
|
|
752
|
+
* functions within it, then — for bare `:is()` selectors (where `:is()` is the
|
|
753
|
+
* entire selector) — merging `:link`+`:visited` into `:any-link`,
|
|
754
|
+
* de-duplicating, sorting alphabetically, and decomposing into individual
|
|
755
|
+
* selectors when the remaining parts are browser-safe and share one level of
|
|
756
|
+
* specificity.
|
|
247
757
|
*
|
|
248
|
-
* @param {string}
|
|
249
|
-
* @
|
|
758
|
+
* @param {string} selector A minified CSS selector string.
|
|
759
|
+
* @param {boolean} hasSiblingSelectors True when the rule's selector list holds other selectors besides this one.
|
|
760
|
+
* @return {Array} An array of one or more processed selector strings.
|
|
250
761
|
*/
|
|
251
|
-
function processIsSelector (selector) {
|
|
762
|
+
function processIsSelector (selector, hasSiblingSelectors) {
|
|
252
763
|
// Replace :is(:link,:visited) and :is(:visited,:link) with :any-link
|
|
253
764
|
selector = selector.replace(/:is\(:link,:visited\)/g, ':any-link');
|
|
254
765
|
selector = selector.replace(/:is\(:visited,:link\)/g, ':any-link');
|
|
766
|
+
selector = unwrapSingleArgumentIsFunctions(selector, hasSiblingSelectors);
|
|
255
767
|
// Only process bare :is() selectors (where :is() is the entire selector)
|
|
256
768
|
if (!selector.startsWith(':is(')) {
|
|
257
769
|
return [selector];
|
|
@@ -307,7 +819,11 @@ function processIsSelector (selector) {
|
|
|
307
819
|
parts.sort();
|
|
308
820
|
// Unwrap :is() with a single selector
|
|
309
821
|
if (parts.length === 1) {
|
|
310
|
-
|
|
822
|
+
const onlyPart = parts[0].trim();
|
|
823
|
+
const canUnwrap = hasSiblingSelectors ?
|
|
824
|
+
isUniversallyRecognizableSelector(onlyPart) :
|
|
825
|
+
!containsPseudoElement(onlyPart);
|
|
826
|
+
return canUnwrap ? [onlyPart] : [':is(' + parts[0] + ')'];
|
|
311
827
|
}
|
|
312
828
|
// Drop the :is() wrapper when the parts are equivalent as a plain selector list
|
|
313
829
|
if (canDecomposeIsSelector(parts)) {
|
package/src/rules/stringify.js
CHANGED
|
@@ -262,7 +262,10 @@ function stringifyRule (rule, context) {
|
|
|
262
262
|
if (isNestingParent) {
|
|
263
263
|
uniqueSelectors = uniqueSelectors.flatMap(flattenNestingParentIsSelector);
|
|
264
264
|
}
|
|
265
|
-
|
|
265
|
+
const hasSiblingSelectors = uniqueSelectors.length > 1;
|
|
266
|
+
uniqueSelectors = uniqueSelectors.flatMap((selector) => {
|
|
267
|
+
return processIsSelector(selector, hasSiblingSelectors);
|
|
268
|
+
});
|
|
266
269
|
uniqueSelectors = [...new Set(uniqueSelectors)];
|
|
267
270
|
output.push(uniqueSelectors.join(','));
|
|
268
271
|
}
|