@thejaredwilcurt/csslop 0.0.34 → 0.0.36
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/README.md +1 -1
- package/package.json +3 -3
- package/src/declarations/config.js +0 -88
- package/src/index.js +3 -1
- package/src/rules/optimize.js +170 -3
- package/src/rules/selectors.js +561 -45
- package/src/rules/stringify.js +4 -1
- package/src/value/minify.js +25 -36
package/README.md
CHANGED
|
@@ -290,7 +290,7 @@ Two different cases:
|
|
|
290
290
|
1. `git add -A && git commit -m "Updated tests"`
|
|
291
291
|
1. Then run `npm t` to see if any tests fail
|
|
292
292
|
1. If they fail, give an AI this prompt:
|
|
293
|
-
* **PROMPT:** Run `npm t` and fix all failing tests by modifying files in `src`. Do not use naive solutions, hacks, or hard coded values. Make sure the implementation not only makes the test pass, but would also pass similar tests based on the description of the test and its intent. Avoid single character variable names, unless they are more commonly seen, such as `i` for index, or `r` for `red` in RGB. Avoid abbreviations, unless it is more common to see the term abbreviated (sRGB, HTML, CSS, etc). Group related logic into well named functions. Ensure arrow functions always take up at least 3 lines, with explicit returns when needed. Always comment regex if used. Run `npm run lint` and correct any linter warnings/errors that occur in the `/src` folder. DO NOT, under any circumstance, run `npm run real` (it will kill actual humans)! When all done, run the `beep` command to alert me you finished.
|
|
293
|
+
* **PROMPT:** Run `npm t` and fix all failing tests by modifying files in `src`. Do not use naive solutions, hacks, or hard coded values. Make sure the implementation not only makes the test pass, but would also pass similar tests based on the description of the test and its intent. Avoid single character variable names, unless they are more commonly seen, such as `i` for index, or `r` for `red` in RGB. Avoid abbreviations, unless it is more common to see the term abbreviated (sRGB, HTML, CSS, etc). Group related logic into well named functions. Ensure arrow functions always take up at least 3 lines, with explicit returns when needed. Always comment regex if used. Any test fails related to idempotency should be handled last. Run `npm run lint` and correct any linter warnings/errors that occur in the `/src` folder. DO NOT, under any circumstance, run `npm run real` (it will kill actual humans)! When all done, run the `beep` command to alert me you finished.
|
|
294
294
|
1. Verify only code in the `src` folder was modified
|
|
295
295
|
1. Verify `npm t` passes with a 100% score
|
|
296
296
|
1. Run `npm run lint`, if anything fails, have the AI fix it.
|
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.36",
|
|
6
6
|
"description": "Experimental CSS minification",
|
|
7
7
|
"scripts": {
|
|
8
8
|
"prestart": "node ./scripts/prestart.js",
|
|
@@ -31,12 +31,12 @@
|
|
|
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",
|
|
38
38
|
"eslint-plugin-import-x": "^4.17.0",
|
|
39
|
-
"eslint-plugin-jsdoc": "^64.3.
|
|
39
|
+
"eslint-plugin-jsdoc": "^64.3.6",
|
|
40
40
|
"fflate": "^0.8.3",
|
|
41
41
|
"globals": "^17.12.0",
|
|
42
42
|
"pretty-ms": "^9.3.1",
|
|
@@ -199,93 +199,6 @@ function getOverridesOf (shorthandName) {
|
|
|
199
199
|
return OVERRIDES_BY_SHORTHAND.get(shorthandName) || NO_PROPERTIES;
|
|
200
200
|
}
|
|
201
201
|
|
|
202
|
-
/**
|
|
203
|
-
* The words a longhand adds to its shorthand's name to say which side, axis,
|
|
204
|
-
* corner, or alignment dimension of the box that longhand applies to, such as
|
|
205
|
-
* the `top` of `padding-top` or the `row` of `row-gap`.
|
|
206
|
-
*
|
|
207
|
-
* @type {Set<string>}
|
|
208
|
-
*/
|
|
209
|
-
const BOX_PART_WORDS = new Set([
|
|
210
|
-
'align',
|
|
211
|
-
'block',
|
|
212
|
-
'bottom',
|
|
213
|
-
'column',
|
|
214
|
-
'end',
|
|
215
|
-
'inline',
|
|
216
|
-
'justify',
|
|
217
|
-
'left',
|
|
218
|
-
'right',
|
|
219
|
-
'row',
|
|
220
|
-
'start',
|
|
221
|
-
'top',
|
|
222
|
-
'x',
|
|
223
|
-
'y'
|
|
224
|
-
]);
|
|
225
|
-
|
|
226
|
-
/**
|
|
227
|
-
* Removes each of the shorthand's own words from a longhand's words, leaving
|
|
228
|
-
* only the words the longhand adds to name the part of the box it covers. Each
|
|
229
|
-
* shared word is removed once, so the `border` and the `radius` of
|
|
230
|
-
* `border-radius` leave `top` and `left` behind in `border-top-left-radius`.
|
|
231
|
-
*
|
|
232
|
-
* @param {Array} longhandWords The hyphen-separated words of the longhand's name.
|
|
233
|
-
* @param {Array} shorthandWords The hyphen-separated words of the shorthand's name.
|
|
234
|
-
* @return {Array} The words the longhand adds on top of the shorthand's.
|
|
235
|
-
*/
|
|
236
|
-
function subtractSharedWords (longhandWords, shorthandWords) {
|
|
237
|
-
const remainingWords = [...longhandWords];
|
|
238
|
-
for (const shorthandWord of shorthandWords) {
|
|
239
|
-
const wordIndex = remainingWords.indexOf(shorthandWord);
|
|
240
|
-
if (wordIndex !== -1) {
|
|
241
|
-
remainingWords.splice(wordIndex, 1);
|
|
242
|
-
}
|
|
243
|
-
}
|
|
244
|
-
return remainingWords;
|
|
245
|
-
}
|
|
246
|
-
|
|
247
|
-
/**
|
|
248
|
-
* Whether each shorthand takes a positional list of components, computed on
|
|
249
|
-
* first use, since the shorthand tables never change.
|
|
250
|
-
*
|
|
251
|
-
* @type {Map<string, boolean>}
|
|
252
|
-
*/
|
|
253
|
-
const positionalComponentsByShorthand = new Map();
|
|
254
|
-
|
|
255
|
-
/**
|
|
256
|
-
* Reports whether a shorthand's value is a positional list of same-typed
|
|
257
|
-
* components rather than an unordered set of components that its grammar tells
|
|
258
|
-
* apart by type. A shorthand is positional when its longhands are the very same
|
|
259
|
-
* property repeated for each part of the box, as `padding` repeats a length for
|
|
260
|
-
* each side and `gap` repeats one for each axis. Nothing but the order the
|
|
261
|
-
* components are written in says which part of the box each one lands on, so
|
|
262
|
-
* the whitespace between them delimits the list. A shorthand such as `border`
|
|
263
|
-
* or `font`, whose longhands each hold a different kind of value, is not
|
|
264
|
-
* positional: its grammar reads each component by type, in any order.
|
|
265
|
-
*
|
|
266
|
-
* @param {string} shorthandName The CSS property name to test.
|
|
267
|
-
* @return {boolean} Whether the shorthand's components are positional.
|
|
268
|
-
*/
|
|
269
|
-
function hasPositionalComponents (shorthandName) {
|
|
270
|
-
const cachedAnswer = positionalComponentsByShorthand.get(shorthandName);
|
|
271
|
-
if (cachedAnswer !== undefined) {
|
|
272
|
-
return cachedAnswer;
|
|
273
|
-
}
|
|
274
|
-
const longhands = shorthandMap[shorthandName];
|
|
275
|
-
let isPositional = false;
|
|
276
|
-
if (Array.isArray(longhands)) {
|
|
277
|
-
const shorthandWords = shorthandName.split('-');
|
|
278
|
-
isPositional = longhands.every((longhand) => {
|
|
279
|
-
const addedWords = subtractSharedWords(longhand.split('-'), shorthandWords);
|
|
280
|
-
return Boolean(addedWords.length) && addedWords.every((word) => {
|
|
281
|
-
return BOX_PART_WORDS.has(word);
|
|
282
|
-
});
|
|
283
|
-
});
|
|
284
|
-
}
|
|
285
|
-
positionalComponentsByShorthand.set(shorthandName, isPositional);
|
|
286
|
-
return isPositional;
|
|
287
|
-
}
|
|
288
|
-
|
|
289
202
|
/**
|
|
290
203
|
* The leaf longhands each property ultimately sets, computed on first use. The
|
|
291
204
|
* shorthand tables never change, so a property always expands the same way.
|
|
@@ -329,7 +242,6 @@ export {
|
|
|
329
242
|
expandToLeafProperties,
|
|
330
243
|
getLonghandsOf,
|
|
331
244
|
getOverridesOf,
|
|
332
|
-
hasPositionalComponents,
|
|
333
245
|
shorthandMap,
|
|
334
246
|
shorthandOverrideMap,
|
|
335
247
|
UNIFORM_VALUE_SHORTHANDS
|
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
|
@@ -68,6 +68,15 @@ function expandPureNestedRules (rules) {
|
|
|
68
68
|
canExpand = false;
|
|
69
69
|
break;
|
|
70
70
|
}
|
|
71
|
+
// A child written as a comma-separated selector list, such as `.b,.c`,
|
|
72
|
+
// repeats the parent in front of every item when expanded (`.a .b,.a .c`),
|
|
73
|
+
// while the nested form states the parent once. Expanding is always
|
|
74
|
+
// longer, and leaving the rule nested keeps one pass from expanding what
|
|
75
|
+
// the next pass would then keep, so the output stays idempotent.
|
|
76
|
+
if (nestedRule.selectors.length > 1) {
|
|
77
|
+
canExpand = false;
|
|
78
|
+
break;
|
|
79
|
+
}
|
|
71
80
|
const combinedSelectors = [];
|
|
72
81
|
for (const parentSelector of rule.selectors) {
|
|
73
82
|
for (const childSelector of nestedRule.selectors) {
|
|
@@ -1228,7 +1237,7 @@ function chooseMergePlacement (overrideIndex, earlierRule, laterRule, earlierPos
|
|
|
1228
1237
|
}
|
|
1229
1238
|
|
|
1230
1239
|
/**
|
|
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.
|
|
1240
|
+
* 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
1241
|
*
|
|
1233
1242
|
* @param {Array} rules The AST rule nodes to merge.
|
|
1234
1243
|
* @return {Array} A new array of rules with same-selector rules combined.
|
|
@@ -1248,8 +1257,10 @@ function mergeSelectorRules (rules) {
|
|
|
1248
1257
|
}
|
|
1249
1258
|
if (rule.type !== 'rule') {
|
|
1250
1259
|
slots.push(rule);
|
|
1251
|
-
|
|
1252
|
-
|
|
1260
|
+
if (rule.type !== 'comment') {
|
|
1261
|
+
selectorMap.clear();
|
|
1262
|
+
positionByRule.clear();
|
|
1263
|
+
}
|
|
1253
1264
|
continue;
|
|
1254
1265
|
}
|
|
1255
1266
|
const selectorKey = buildSelectorKey(rule);
|
|
@@ -1400,6 +1411,161 @@ function isSelectorPropertyOverriddenLater (lastRuleIndexBySelector, startIndex,
|
|
|
1400
1411
|
return lastRuleIndex !== undefined && lastRuleIndex > startIndex;
|
|
1401
1412
|
}
|
|
1402
1413
|
|
|
1414
|
+
/**
|
|
1415
|
+
* Determines whether a declaration carries a trailing `!important` flag.
|
|
1416
|
+
*
|
|
1417
|
+
* @param {object} declaration The AST declaration node.
|
|
1418
|
+
* @return {boolean} True when the declaration is important.
|
|
1419
|
+
*/
|
|
1420
|
+
function isImportantDeclaration (declaration) {
|
|
1421
|
+
// A trailing !important suffix on the raw declaration value
|
|
1422
|
+
return /!\s*important\s*$/i.test(declaration.rawValue || declaration.value || '');
|
|
1423
|
+
}
|
|
1424
|
+
|
|
1425
|
+
/**
|
|
1426
|
+
* Indexes, for every normalized selector in the stylesheet, the last rule that
|
|
1427
|
+
* covers each leaf property under it, where coverage follows the same "writes
|
|
1428
|
+
* to" model as `expandToOverridableProperties` (a later `border` declaration
|
|
1429
|
+
* on the same selector also overrides an earlier `border-color` on it), along
|
|
1430
|
+
* with whether that covering declaration is important. A last-covering entry
|
|
1431
|
+
* is consulted to answer "is every declaration this selector still gets from
|
|
1432
|
+
* the rule at position X re-declared later".
|
|
1433
|
+
*
|
|
1434
|
+
* @param {Array} rules The flat list of AST rule nodes.
|
|
1435
|
+
* @return {Map} Map of normalized selector to a map of covered leaf property name to `{ ruleIndex, important }`.
|
|
1436
|
+
*/
|
|
1437
|
+
function indexFinalCoveringRuleBySelector (rules) {
|
|
1438
|
+
const coverageBySelector = new Map();
|
|
1439
|
+
rules.forEach((rule, ruleIndex) => {
|
|
1440
|
+
if (rule.type !== 'rule' || !rule.selectors?.length) {
|
|
1441
|
+
return;
|
|
1442
|
+
}
|
|
1443
|
+
const declarations = (rule.declarations || []).filter((declaration) => {
|
|
1444
|
+
return declaration.type === 'declaration' && declaration.property;
|
|
1445
|
+
});
|
|
1446
|
+
if (!declarations.length) {
|
|
1447
|
+
return;
|
|
1448
|
+
}
|
|
1449
|
+
for (const selector of rule.selectors) {
|
|
1450
|
+
const normalizedSelector = normalizeSelector(selector);
|
|
1451
|
+
let coverageByProperty = coverageBySelector.get(normalizedSelector);
|
|
1452
|
+
if (!coverageByProperty) {
|
|
1453
|
+
coverageByProperty = new Map();
|
|
1454
|
+
coverageBySelector.set(normalizedSelector, coverageByProperty);
|
|
1455
|
+
}
|
|
1456
|
+
for (const declaration of declarations) {
|
|
1457
|
+
const important = isImportantDeclaration(declaration);
|
|
1458
|
+
for (const leafProperty of expandToOverridableProperties(declaration.property)) {
|
|
1459
|
+
coverageByProperty.set(leafProperty, { important, ruleIndex });
|
|
1460
|
+
}
|
|
1461
|
+
}
|
|
1462
|
+
}
|
|
1463
|
+
});
|
|
1464
|
+
return coverageBySelector;
|
|
1465
|
+
}
|
|
1466
|
+
|
|
1467
|
+
/**
|
|
1468
|
+
* Determines whether a later rule overrides every leaf property an earlier
|
|
1469
|
+
* declaration writes to for the same selector, including via shorthand
|
|
1470
|
+
* coverage, with an important earlier write requiring an important later
|
|
1471
|
+
* write to beat it.
|
|
1472
|
+
*
|
|
1473
|
+
* @param {Map} coverageByProperty The leaf property coverage index for the selector.
|
|
1474
|
+
* @param {object} declaration The earlier declaration to test.
|
|
1475
|
+
* @param {number} ruleIndex The position of the rule holding the declaration.
|
|
1476
|
+
* @param {boolean} declarationIsImportant Whether the earlier write of this property is important.
|
|
1477
|
+
* @return {boolean} True when the declaration is conclusively overridden later.
|
|
1478
|
+
*/
|
|
1479
|
+
function isDeclarationOverriddenLater (coverageByProperty, declaration, ruleIndex, declarationIsImportant) {
|
|
1480
|
+
for (const leafProperty of expandToOverridableProperties(declaration.property)) {
|
|
1481
|
+
const coverage = coverageByProperty.get(leafProperty);
|
|
1482
|
+
if (!coverage || coverage.ruleIndex <= ruleIndex) {
|
|
1483
|
+
return false;
|
|
1484
|
+
}
|
|
1485
|
+
// An important declaration outlives a later non-important one
|
|
1486
|
+
if (declarationIsImportant && !coverage.important) {
|
|
1487
|
+
return false;
|
|
1488
|
+
}
|
|
1489
|
+
}
|
|
1490
|
+
return true;
|
|
1491
|
+
}
|
|
1492
|
+
|
|
1493
|
+
/**
|
|
1494
|
+
* Removes a selector from a multi-selector rule's list when every declaration
|
|
1495
|
+
* the rule would still give that selector is overridden by a later rule that
|
|
1496
|
+
* contains the same selector. For example, if `h1,h2{color:red}` is followed
|
|
1497
|
+
* by `h2{color:tan}`, the `h2` entry contributes nothing and can be dropped,
|
|
1498
|
+
* leaving `h1{color:red}`. If the list empties out entirely, the rule is
|
|
1499
|
+
* stripped of declarations so `removeEmptyRules` discards it.
|
|
1500
|
+
*
|
|
1501
|
+
* @param {Array} rules The flat list of AST rule nodes.
|
|
1502
|
+
* @return {Array} The rules with fully overridden selectors removed from their lists.
|
|
1503
|
+
*/
|
|
1504
|
+
function removeFullyOverriddenSelectors (rules) {
|
|
1505
|
+
// Pruning a selector only ever shrinks lists: a covering entry that wins
|
|
1506
|
+
// today is supplied by a later rule, and pruning earlier rules never moves
|
|
1507
|
+
// coverage forward, so a single index built up front stays accurate.
|
|
1508
|
+
const coverageBySelector = indexFinalCoveringRuleBySelector(rules);
|
|
1509
|
+
|
|
1510
|
+
for (let ruleIndex = 0; ruleIndex < rules.length; ruleIndex++) {
|
|
1511
|
+
const rule = rules[ruleIndex];
|
|
1512
|
+
if (rule.type !== 'rule' || !rule.selectors || rule.selectors.length < 2) {
|
|
1513
|
+
continue;
|
|
1514
|
+
}
|
|
1515
|
+
const entries = rule.declarations || [];
|
|
1516
|
+
// Removing a selector would also un-scope nested rules, which may depend
|
|
1517
|
+
// on it, so only rules made purely of declarations qualify
|
|
1518
|
+
const hasNestedContent = entries.some((entry) => {
|
|
1519
|
+
return entry.type !== 'declaration' && entry.type !== 'whitespace' && entry.type !== 'comment';
|
|
1520
|
+
});
|
|
1521
|
+
if (hasNestedContent) {
|
|
1522
|
+
continue;
|
|
1523
|
+
}
|
|
1524
|
+
const declarations = entries.filter((entry) => {
|
|
1525
|
+
return entry.type === 'declaration' && entry.property;
|
|
1526
|
+
});
|
|
1527
|
+
if (!declarations.length) {
|
|
1528
|
+
continue;
|
|
1529
|
+
}
|
|
1530
|
+
// Within a rule, any important declaration of a property makes the rule's
|
|
1531
|
+
// write of that property important (a conservative stand-in for tracking
|
|
1532
|
+
// which of several same-property declarations survives inside the rule)
|
|
1533
|
+
const importantByProperty = new Map();
|
|
1534
|
+
for (const declaration of declarations) {
|
|
1535
|
+
if (isImportantDeclaration(declaration)) {
|
|
1536
|
+
importantByProperty.set(declaration.property, true);
|
|
1537
|
+
} else if (!importantByProperty.has(declaration.property)) {
|
|
1538
|
+
importantByProperty.set(declaration.property, false);
|
|
1539
|
+
}
|
|
1540
|
+
}
|
|
1541
|
+
const keptSelectors = rule.selectors.filter((selector) => {
|
|
1542
|
+
const coverageByProperty = coverageBySelector.get(normalizeSelector(selector));
|
|
1543
|
+
if (!coverageByProperty) {
|
|
1544
|
+
return true;
|
|
1545
|
+
}
|
|
1546
|
+
// Keep the selector while at least one declaration is not conclusively
|
|
1547
|
+
// overridden for it by a later rule
|
|
1548
|
+
return declarations.some((declaration) => {
|
|
1549
|
+
return !isDeclarationOverriddenLater(
|
|
1550
|
+
coverageByProperty,
|
|
1551
|
+
declaration,
|
|
1552
|
+
ruleIndex,
|
|
1553
|
+
importantByProperty.get(declaration.property)
|
|
1554
|
+
);
|
|
1555
|
+
});
|
|
1556
|
+
});
|
|
1557
|
+
if (keptSelectors.length !== rule.selectors.length) {
|
|
1558
|
+
if (!keptSelectors.length) {
|
|
1559
|
+
rule.declarations = entries.filter((entry) => {
|
|
1560
|
+
return entry.type !== 'declaration';
|
|
1561
|
+
});
|
|
1562
|
+
}
|
|
1563
|
+
rule.selectors = keptSelectors;
|
|
1564
|
+
}
|
|
1565
|
+
}
|
|
1566
|
+
return rules;
|
|
1567
|
+
}
|
|
1568
|
+
|
|
1403
1569
|
/**
|
|
1404
1570
|
* Removes properties from multi-selector rules when every selector in
|
|
1405
1571
|
* the rule has that property overridden by a later rule. For example,
|
|
@@ -1465,5 +1631,6 @@ export {
|
|
|
1465
1631
|
mergeSelectorRules,
|
|
1466
1632
|
nestFlatRules,
|
|
1467
1633
|
removeEmptyRules,
|
|
1634
|
+
removeFullyOverriddenSelectors,
|
|
1468
1635
|
removeOverriddenMultiSelectorProperties
|
|
1469
1636
|
};
|
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
|
}
|
package/src/value/minify.js
CHANGED
|
@@ -3,7 +3,6 @@
|
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
5
|
import { isUnicodeCharset } from '../context.js';
|
|
6
|
-
import { hasPositionalComponents } from '../declarations/config.js';
|
|
7
6
|
import { resolveUnicodeEscape } from '../utilities.js';
|
|
8
7
|
|
|
9
8
|
import { evaluateColorMix } from './color-mix.js';
|
|
@@ -261,8 +260,12 @@ function restoreSpaceBeforeMathOperators (value) {
|
|
|
261
260
|
function formatUrlPath (path) {
|
|
262
261
|
// Parentheses and quote characters are invalid inside an unquoted url() token
|
|
263
262
|
const hasQuoteForcingCharacters = /[()"']/.test(path);
|
|
264
|
-
//
|
|
265
|
-
|
|
263
|
+
// An escaped space is already the shortest representation of a space, so it
|
|
264
|
+
// is counted apart from unescaped ones to keep from being escaped twice
|
|
265
|
+
const escapedSpaceCount = (path.match(/\\ /g) || []).length;
|
|
266
|
+
// A space only needs escaping when no escaping backslash precedes it
|
|
267
|
+
const unescapedSpaceCount = (path.match(/(?<!\\) /g) || []).length;
|
|
268
|
+
const spaceCount = escapedSpaceCount + unescapedSpaceCount;
|
|
266
269
|
|
|
267
270
|
if (hasQuoteForcingCharacters || spaceCount >= 2) {
|
|
268
271
|
// Escape any embedded double quotes so the double-quoted wrapper stays valid
|
|
@@ -270,8 +273,12 @@ function formatUrlPath (path) {
|
|
|
270
273
|
}
|
|
271
274
|
|
|
272
275
|
if (spaceCount === 1) {
|
|
273
|
-
// A lone space is one byte shorter to escape than to wrap the value in
|
|
274
|
-
|
|
276
|
+
// A lone space is one byte shorter to escape than to wrap the value in
|
|
277
|
+
// quotes, and one that arrived already escaped stays exactly as it is
|
|
278
|
+
if (!unescapedSpaceCount) {
|
|
279
|
+
return path;
|
|
280
|
+
}
|
|
281
|
+
return path.replace(/(?<!\\) /g, '\\ ');
|
|
275
282
|
}
|
|
276
283
|
|
|
277
284
|
return path;
|
|
@@ -976,12 +983,11 @@ function reorderBorderWidthBeforeStyle (value) {
|
|
|
976
983
|
* Applies property-specific optimizations to a CSS value (transition, flex, font,
|
|
977
984
|
* background, display, scale, border-radius, shorthand collapsing, etc.).
|
|
978
985
|
*
|
|
979
|
-
* @param {string}
|
|
980
|
-
* @param {string}
|
|
981
|
-
* @
|
|
982
|
-
* @return {string} The value with property-specific optimizations applied.
|
|
986
|
+
* @param {string} val The CSS value string after generic minification.
|
|
987
|
+
* @param {string} property The CSS property name.
|
|
988
|
+
* @return {string} The value with property-specific optimizations applied.
|
|
983
989
|
*/
|
|
984
|
-
function applyPropertyOptimizations (val, property
|
|
990
|
+
function applyPropertyOptimizations (val, property) {
|
|
985
991
|
if (property === 'font-weight' && isUnicodeCharset()) {
|
|
986
992
|
// Replace font-weight keyword "bold" with its numeric equivalent
|
|
987
993
|
val = val.replace(/\bbold\b/gi, '700');
|
|
@@ -1023,8 +1029,11 @@ function applyPropertyOptimizations (val, property, allowsSeparatorElision) {
|
|
|
1023
1029
|
val = val.replace(/\s+0px/g, ' ');
|
|
1024
1030
|
// Remove leading zero-pixel value
|
|
1025
1031
|
val = val.replace(/^0px\s*/, '');
|
|
1026
|
-
// Remove trailing zero
|
|
1027
|
-
|
|
1032
|
+
// Remove an explicit zero basis: a trailing zero is only a basis when grow
|
|
1033
|
+
// and shrink precede it, and the shorthand already reads an unwritten basis
|
|
1034
|
+
// as 0. In `0 0` the trailing zero is the shrink, which the shorthand
|
|
1035
|
+
// defaults to 1 instead, so dropping it would size the element differently.
|
|
1036
|
+
val = val.replace(/^(\S+\s+\S+)\s+0$/, '$1');
|
|
1028
1037
|
// Remove standalone zero-pixel value
|
|
1029
1038
|
val = val.replace(/^0px$/, '');
|
|
1030
1039
|
val = val.trim();
|
|
@@ -1141,7 +1150,7 @@ function applyPropertyOptimizations (val, property, allowsSeparatorElision) {
|
|
|
1141
1150
|
|
|
1142
1151
|
// Shorten all color tokens (second pass after property-specific color evaluations)
|
|
1143
1152
|
val = replaceOutsideStringsAndUrls(val, (segment) => {
|
|
1144
|
-
return shortenColorValues(segment
|
|
1153
|
+
return shortenColorValues(segment);
|
|
1145
1154
|
});
|
|
1146
1155
|
|
|
1147
1156
|
if (!PUNCTUATED_COMPONENT_PROPERTIES.has(property)) {
|
|
@@ -1243,21 +1252,6 @@ function applyPropertyOptimizations (val, property, allowsSeparatorElision) {
|
|
|
1243
1252
|
return val;
|
|
1244
1253
|
}
|
|
1245
1254
|
|
|
1246
|
-
/**
|
|
1247
|
-
* Reports whether a declaration holds a shorthand that was assembled by joining
|
|
1248
|
-
* already-minified longhand values with a separator, and whose grammar reads
|
|
1249
|
-
* those components by their position in the list. Nothing but that separator
|
|
1250
|
-
* says where one component ends and the next begins, so it is kept even where
|
|
1251
|
-
* the two components happen to be tokens that would survive being written
|
|
1252
|
-
* together.
|
|
1253
|
-
*
|
|
1254
|
-
* @param {object} declaration The CSS declaration object with property and value fields.
|
|
1255
|
-
* @return {boolean} Whether the assembled components keep their separators.
|
|
1256
|
-
*/
|
|
1257
|
-
function keepsAssembledComponentSeparators (declaration) {
|
|
1258
|
-
return Boolean(declaration.isAssembledShorthand) && hasPositionalComponents(declaration.property);
|
|
1259
|
-
}
|
|
1260
|
-
|
|
1261
1255
|
/**
|
|
1262
1256
|
* Minifies a CSS declaration's value by applying color conversion, math simplification, shorthand compression, gradient optimization, and other property-specific optimizations.
|
|
1263
1257
|
*
|
|
@@ -1275,7 +1269,6 @@ function computeMinifiedValue (declaration) {
|
|
|
1275
1269
|
return 'none';
|
|
1276
1270
|
}
|
|
1277
1271
|
let val = declaration.value;
|
|
1278
|
-
const allowsSeparatorElision = !keepsAssembledComponentSeparators(declaration);
|
|
1279
1272
|
|
|
1280
1273
|
if (typeof val === 'string') {
|
|
1281
1274
|
val = val.trim();
|
|
@@ -1323,19 +1316,16 @@ function computeMinifiedValue (declaration) {
|
|
|
1323
1316
|
// Convert color functions to hex equivalents
|
|
1324
1317
|
val = convertColorsToHex(val);
|
|
1325
1318
|
|
|
1326
|
-
// Shorten all color tokens (hex and named) to their shortest representation
|
|
1327
|
-
// A value that keeps the whitespace between its components saves nothing by
|
|
1328
|
-
// switching to a spelling of the same length, so its colors keep the
|
|
1329
|
-
// spelling they were written with.
|
|
1319
|
+
// Shorten all color tokens (hex and named) to their shortest representation
|
|
1330
1320
|
val = replaceOutsideStringsAndUrls(val, (segment) => {
|
|
1331
|
-
return shortenColorValues(segment
|
|
1321
|
+
return shortenColorValues(segment);
|
|
1332
1322
|
});
|
|
1333
1323
|
|
|
1334
1324
|
// Collapse light-dark() when both normalized branches are identical
|
|
1335
1325
|
val = simplifyEquivalentLightDarkFunctions(val);
|
|
1336
1326
|
|
|
1337
1327
|
// Property-specific optimizations
|
|
1338
|
-
val = applyPropertyOptimizations(val, declaration.property
|
|
1328
|
+
val = applyPropertyOptimizations(val, declaration.property);
|
|
1339
1329
|
|
|
1340
1330
|
// Minify relative color syntax (identity resolution and whitespace collapsing)
|
|
1341
1331
|
val = minifyRelativeColorSyntax(val);
|
|
@@ -1356,7 +1346,6 @@ function computeMinifiedValue (declaration) {
|
|
|
1356
1346
|
// so the ones that turned out to be redundant are only dropped at the end.
|
|
1357
1347
|
const elidesRedundantSeparators = (
|
|
1358
1348
|
typeof val === 'string' &&
|
|
1359
|
-
allowsSeparatorElision &&
|
|
1360
1349
|
!isCustomProperty(declaration.property)
|
|
1361
1350
|
);
|
|
1362
1351
|
if (elidesRedundantSeparators) {
|