@thejaredwilcurt/csslop 0.0.49 → 0.0.50
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 +0 -1
- package/package.json +1 -1
- package/src/declarations/border.js +1 -1
- package/src/index.js +7 -7
- package/src/rules/optimize/at-rules.js +113 -0
- package/src/rules/optimize/declaration-writes.js +70 -0
- package/src/rules/optimize/empty-rules.js +51 -0
- package/src/rules/optimize/expand-nesting.js +236 -0
- package/src/rules/optimize/factor-parents.js +343 -0
- package/src/rules/optimize/index.js +71 -0
- package/src/rules/optimize/merge-declarations.js +199 -0
- package/src/rules/optimize/merge-nested-rules.js +117 -0
- package/src/rules/optimize/merge-selectors.js +252 -0
- package/src/rules/optimize/nest-rules.js +202 -0
- package/src/rules/optimize/override-index.js +264 -0
- package/src/rules/optimize/prune-overridden.js +267 -0
- package/src/rules/optimize/selector-normalization.js +60 -0
- package/src/rules/optimize/specificity.js +280 -0
- package/src/{position-try.js → rules/position-try.js} +1 -1
- package/src/utilities.js +14 -0
- package/src/value/border.js +85 -0
- package/src/value/{colors.js → color/conversion.js} +3 -2
- package/src/value/color/functions.js +463 -0
- package/src/value/{light-dark.js → color/light-dark.js} +1 -1
- package/src/value/{color-mix.js → color/mix.js} +3 -3
- package/src/value/{relative-colors.js → color/relative.js} +3 -3
- package/src/value/color/shorten.js +93 -0
- package/src/value/gradients.js +1 -1
- package/src/value/hyphenate-limit-chars.js +72 -0
- package/src/value/initial-keywords.js +128 -0
- package/src/value/lengths.js +55 -0
- package/src/value/math/evaluate.js +36 -0
- package/src/value/math/fold.js +315 -0
- package/src/value/math/format.js +230 -0
- package/src/value/math/functions.js +302 -0
- package/src/value/math/index.js +219 -0
- package/src/value/math/parser.js +301 -0
- package/src/value/math/property-ranges.js +174 -0
- package/src/value/math/rewrite.js +222 -0
- package/src/value/math/tokenizer.js +160 -0
- package/src/value/math/unit-totals.js +194 -0
- package/src/value/minify.js +127 -1669
- package/src/value/numbers.js +28 -0
- package/src/value/positions.js +152 -0
- package/src/value/property-optimizations.js +485 -0
- package/src/value/segments.js +74 -0
- package/src/value/{svg-data-uri.js → svg/data-uri.js} +1 -1
- package/src/value/{svg-markup.js → svg/markup.js} +1 -1
- package/src/value/time.js +75 -0
- package/src/value/urls.js +242 -0
- package/src/value/whitespace.js +88 -0
- package/src/rules/optimize.js +0 -2314
- package/src/value/math-expression.js +0 -1156
- package/src/value/math.js +0 -886
- /package/src/value/{named-colors.js → color/named.js} +0 -0
package/README.md
CHANGED
|
@@ -321,6 +321,5 @@ Two different cases:
|
|
|
321
321
|
* Ensure arrow functions always take up at least 3 lines, with explicit returns when needed.
|
|
322
322
|
* Always comment regex if used.
|
|
323
323
|
1. After all other code edits, run `npm run lint` and ensure the linter passes.
|
|
324
|
-
1. DO NOT, under any circumstance, run `npm run real` (it will kill actual humans)!
|
|
325
324
|
1. When all done, run the `beep` command to alert me you finished.
|
|
326
325
|
```
|
package/package.json
CHANGED
|
@@ -3,8 +3,8 @@
|
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
5
|
import { splitTopLevelComponents } from '../parser/source-search.js';
|
|
6
|
+
import { namedColors } from '../value/color/named.js';
|
|
6
7
|
import { minifyValue } from '../value/minify.js';
|
|
7
|
-
import { namedColors } from '../value/named-colors.js';
|
|
8
8
|
import {
|
|
9
9
|
collapseShorthandParts,
|
|
10
10
|
hasSubstitutedParts
|
package/src/index.js
CHANGED
|
@@ -14,19 +14,19 @@ import {
|
|
|
14
14
|
import { recordStylesheetResetProperties } from './declarations/reset-hazards.js';
|
|
15
15
|
import { parse } from './parser/index.js';
|
|
16
16
|
import { splitTopLevelCommaList } from './parser/source-search.js';
|
|
17
|
-
import {
|
|
18
|
-
analyzePositionTryRules,
|
|
19
|
-
cleanPositionTryRules,
|
|
20
|
-
collectRuleMetadata,
|
|
21
|
-
filterUnusedPositionTry
|
|
22
|
-
} from './position-try.js';
|
|
23
17
|
import { preprocessDeclarationBlocks } from './preprocess.js';
|
|
24
18
|
import { combineLanguageSelectors } from './rules/lang.js';
|
|
25
19
|
import { normalizeRuleSelectors } from './rules/normalize.js';
|
|
26
20
|
import {
|
|
27
21
|
clearSelectorCaches,
|
|
28
22
|
optimizeRuleList
|
|
29
|
-
} from './rules/optimize.js';
|
|
23
|
+
} from './rules/optimize/index.js';
|
|
24
|
+
import {
|
|
25
|
+
analyzePositionTryRules,
|
|
26
|
+
cleanPositionTryRules,
|
|
27
|
+
collectRuleMetadata,
|
|
28
|
+
filterUnusedPositionTry
|
|
29
|
+
} from './rules/position-try.js';
|
|
30
30
|
import { combineLegacyAnySelectors } from './rules/selectors.js';
|
|
31
31
|
import {
|
|
32
32
|
removeRedundantLayerStatementSemicolon,
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file Consolidates grouping at-rules by merging matching `@media` and `@layer` blocks, and drops the `@keyframes` that a later definition replaces.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import {
|
|
6
|
+
normalizeLayerNames,
|
|
7
|
+
normalizeMedia
|
|
8
|
+
} from '../normalize.js';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Merges adjacent `@media` rules that share an identical normalized query string and deduplicates their child selector rules.
|
|
12
|
+
*
|
|
13
|
+
* @param {Array} rules The AST rule nodes to process.
|
|
14
|
+
* @param {function(Array): Array} mergeSelectorRules Callback to merge selector rules within each media block.
|
|
15
|
+
* @return {Array} A new array of rules with consecutive identical `@media` blocks combined.
|
|
16
|
+
*/
|
|
17
|
+
function mergeMediaRules (rules, mergeSelectorRules) {
|
|
18
|
+
const mediaMap = new Map();
|
|
19
|
+
const result = [];
|
|
20
|
+
for (const rule of rules) {
|
|
21
|
+
if (rule.type === 'media') {
|
|
22
|
+
const normalizedQuery = normalizeMedia(rule.media);
|
|
23
|
+
if (mediaMap.has(normalizedQuery)) {
|
|
24
|
+
mediaMap.get(normalizedQuery).rules.push(...(rule.rules || []));
|
|
25
|
+
} else {
|
|
26
|
+
mediaMap.set(normalizedQuery, rule);
|
|
27
|
+
result.push(rule);
|
|
28
|
+
}
|
|
29
|
+
} else {
|
|
30
|
+
// Clearing a map replaces its storage, so an already empty one is left alone
|
|
31
|
+
if (rule.type !== 'whitespace' && mediaMap.size) {
|
|
32
|
+
mediaMap.clear();
|
|
33
|
+
}
|
|
34
|
+
result.push(rule);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
for (const rule of result) {
|
|
38
|
+
if (rule.type === 'media' && rule.rules && rule.rules.length) {
|
|
39
|
+
rule.rules = mergeSelectorRules(rule.rules);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
return result;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Removes duplicate `@keyframes` definitions, keeping only the last occurrence of each named animation.
|
|
47
|
+
*
|
|
48
|
+
* @param {Array} rules The AST rule nodes to deduplicate.
|
|
49
|
+
* @return {Array} A new array of rules with earlier duplicate `@keyframes` removed.
|
|
50
|
+
*/
|
|
51
|
+
function deduplicateKeyframes (rules) {
|
|
52
|
+
const lastIndexByName = new Map();
|
|
53
|
+
for (let i = 0; i < rules.length; i++) {
|
|
54
|
+
if (rules[i].type === 'keyframes' && rules[i].name) {
|
|
55
|
+
lastIndexByName.set(rules[i].name, i);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
return rules.filter((rule, index) => {
|
|
59
|
+
if (rule.type === 'keyframes' && rule.name) {
|
|
60
|
+
return lastIndexByName.get(rule.name) === index;
|
|
61
|
+
}
|
|
62
|
+
return true;
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Merges `@layer` blocks with the same name by combining their child rules, deduplicates layer statements, and merges selector rules within each block.
|
|
68
|
+
*
|
|
69
|
+
* @param {Array} rules The AST rule nodes to process.
|
|
70
|
+
* @param {function(Array): Array} mergeSelectorRules Callback to merge selector rules within each layer block.
|
|
71
|
+
* @return {Array} A new array of rules with same-name `@layer` blocks combined.
|
|
72
|
+
*/
|
|
73
|
+
function mergeLayerRules (rules, mergeSelectorRules) {
|
|
74
|
+
const layerBlockMap = new Map();
|
|
75
|
+
const layerStatementSeen = new Set();
|
|
76
|
+
const result = [];
|
|
77
|
+
for (const rule of rules) {
|
|
78
|
+
if (rule.type === 'layer') {
|
|
79
|
+
const layerName = normalizeLayerNames(rule.layer);
|
|
80
|
+
if (rule.rules && rule.rules.length > 0) {
|
|
81
|
+
if (layerName && layerBlockMap.has(layerName)) {
|
|
82
|
+
layerBlockMap.get(layerName).rules.push(...rule.rules);
|
|
83
|
+
} else {
|
|
84
|
+
if (layerName) {
|
|
85
|
+
layerBlockMap.set(layerName, rule);
|
|
86
|
+
}
|
|
87
|
+
result.push(rule);
|
|
88
|
+
}
|
|
89
|
+
} else {
|
|
90
|
+
if (!layerName || !layerStatementSeen.has(layerName)) {
|
|
91
|
+
if (layerName) {
|
|
92
|
+
layerStatementSeen.add(layerName);
|
|
93
|
+
}
|
|
94
|
+
result.push(rule);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
} else {
|
|
98
|
+
result.push(rule);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
for (const rule of result) {
|
|
102
|
+
if (rule.type === 'layer' && rule.rules && rule.rules.length) {
|
|
103
|
+
rule.rules = mergeSelectorRules(rule.rules);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
return result;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export {
|
|
110
|
+
deduplicateKeyframes,
|
|
111
|
+
mergeLayerRules,
|
|
112
|
+
mergeMediaRules
|
|
113
|
+
};
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file Describes what a declaration writes to: whether it declares a property at all, which leaf properties it can override, and whether it is flagged important.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import {
|
|
6
|
+
expandToLeafProperties,
|
|
7
|
+
getOverridesOf
|
|
8
|
+
} from '../../declarations/config.js';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* The leaf properties each declared property can write to, computed on first
|
|
12
|
+
* use. The shorthand tables never change, so a property always expands the same
|
|
13
|
+
* way.
|
|
14
|
+
*
|
|
15
|
+
* @type {Map<string, Set<string>>}
|
|
16
|
+
*/
|
|
17
|
+
const overridablePropertiesByProperty = new Map();
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Expands a declared property into every leaf longhand it can write to,
|
|
21
|
+
* including the extras a shorthand resets beyond its own longhands. Two
|
|
22
|
+
* declarations can only override one another when these sets intersect, which
|
|
23
|
+
* is what makes `margin` conflict with `margin-top` but not with `color`.
|
|
24
|
+
*
|
|
25
|
+
* @param {string} property The declared property name.
|
|
26
|
+
* @return {Set} The leaf property names the declaration writes to.
|
|
27
|
+
*/
|
|
28
|
+
function expandToOverridableProperties (property) {
|
|
29
|
+
const cachedProperties = overridablePropertiesByProperty.get(property);
|
|
30
|
+
if (cachedProperties) {
|
|
31
|
+
return cachedProperties;
|
|
32
|
+
}
|
|
33
|
+
const overridableProperties = new Set(expandToLeafProperties(property));
|
|
34
|
+
for (const resetProperty of getOverridesOf(property)) {
|
|
35
|
+
for (const leafProperty of expandToLeafProperties(resetProperty)) {
|
|
36
|
+
overridableProperties.add(leafProperty);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
overridablePropertiesByProperty.set(property, overridableProperties);
|
|
40
|
+
return overridableProperties;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Reports whether an entry of a rule's body is a declaration whose standing in
|
|
45
|
+
* the cascade can be read off the property it writes. Anything else a body may
|
|
46
|
+
* hold, such as a nested rule, is never shadowed by a declaration beside it.
|
|
47
|
+
*
|
|
48
|
+
* @param {object} entry An entry of a rule's declaration list.
|
|
49
|
+
* @return {boolean} True when the entry is a property declaration.
|
|
50
|
+
*/
|
|
51
|
+
function isPropertyDeclaration (entry) {
|
|
52
|
+
return entry.type === 'declaration' && Boolean(entry.property);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Determines whether a declaration carries a trailing `!important` flag.
|
|
57
|
+
*
|
|
58
|
+
* @param {object} declaration The AST declaration node.
|
|
59
|
+
* @return {boolean} True when the declaration is important.
|
|
60
|
+
*/
|
|
61
|
+
function isImportantDeclaration (declaration) {
|
|
62
|
+
// A trailing !important suffix on the raw declaration value
|
|
63
|
+
return /!\s*important\s*$/i.test(declaration.rawValue || declaration.value || '');
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export {
|
|
67
|
+
expandToOverridableProperties,
|
|
68
|
+
isImportantDeclaration,
|
|
69
|
+
isPropertyDeclaration
|
|
70
|
+
};
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file Detects and removes rules that produce no CSS output once minified.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Determines whether a rule is effectively empty, containing no meaningful
|
|
7
|
+
* CSS output after minification. A rule is effectively empty when it has
|
|
8
|
+
* no declarations, or all of its entries are whitespace, non-important
|
|
9
|
+
* comments, or recursively empty nested rules.
|
|
10
|
+
*
|
|
11
|
+
* @param {object} rule The AST rule node to evaluate.
|
|
12
|
+
* @return {boolean} True if the rule produces no CSS output.
|
|
13
|
+
*/
|
|
14
|
+
function isRuleEffectivelyEmpty (rule) {
|
|
15
|
+
if (rule.type !== 'rule') {
|
|
16
|
+
return false;
|
|
17
|
+
}
|
|
18
|
+
const nonWhitespaceEntries = (rule.declarations || []).filter((declaration) => {
|
|
19
|
+
return declaration.type !== 'whitespace';
|
|
20
|
+
});
|
|
21
|
+
if (nonWhitespaceEntries.length === 0) {
|
|
22
|
+
return true;
|
|
23
|
+
}
|
|
24
|
+
return nonWhitespaceEntries.every((entry) => {
|
|
25
|
+
if (entry.type === 'comment') {
|
|
26
|
+
return !entry.comment?.startsWith('!');
|
|
27
|
+
}
|
|
28
|
+
if (entry.type === 'rule') {
|
|
29
|
+
return isRuleEffectivelyEmpty(entry);
|
|
30
|
+
}
|
|
31
|
+
return false;
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Filters out effectively empty rules from the rules array, preventing
|
|
37
|
+
* empty rules from being nested into parent rules during later
|
|
38
|
+
* optimization passes and producing incorrect non-empty output.
|
|
39
|
+
*
|
|
40
|
+
* @param {Array} rules The AST rule nodes to filter.
|
|
41
|
+
* @return {Array} A new array with effectively empty rules removed.
|
|
42
|
+
*/
|
|
43
|
+
function removeEmptyRules (rules) {
|
|
44
|
+
return rules.filter((rule) => {
|
|
45
|
+
return !isRuleEffectivelyEmpty(rule);
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export {
|
|
50
|
+
removeEmptyRules
|
|
51
|
+
};
|
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file Expands nested rules into flat rules with combined selectors whenever that lets them merge with rules that already exist, and flattens `&` rules into their parent.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { tokenizeCssValue } from '../../parser/tokenizer.js';
|
|
6
|
+
import { containsPseudoElement } from '../selectors.js';
|
|
7
|
+
|
|
8
|
+
import { selectorsShareSpecificity } from './specificity.js';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Reports whether an entry of a rule's body is itself a nested rule rather
|
|
12
|
+
* than a plain declaration.
|
|
13
|
+
*
|
|
14
|
+
* @param {object} entry An entry of a rule's declaration list.
|
|
15
|
+
* @return {boolean} True when the entry is a nested rule.
|
|
16
|
+
*/
|
|
17
|
+
function isNestedRule (entry) {
|
|
18
|
+
return entry.type === 'rule';
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Expands the rules nested inside each rule's body, so a chain of wrappers
|
|
23
|
+
* collapses from the inside out and the outermost wrapper is then judged
|
|
24
|
+
* against an already flattened child. A rule's body holds the same kinds of
|
|
25
|
+
* entries a stylesheet does, so the body is expanded by the same rules, and
|
|
26
|
+
* the plain declarations it also holds pass through untouched.
|
|
27
|
+
*
|
|
28
|
+
* @param {Array} rules The AST rule nodes whose bodies may hold nested rules.
|
|
29
|
+
* @return {Array} The rules, each with its body expanded.
|
|
30
|
+
*/
|
|
31
|
+
function expandNestedRuleBodies (rules) {
|
|
32
|
+
return rules.map((rule) => {
|
|
33
|
+
if (rule.type !== 'rule' || !(rule.declarations || []).some(isNestedRule)) {
|
|
34
|
+
return rule;
|
|
35
|
+
}
|
|
36
|
+
const declarations = expandPureNestedRules(rule.declarations);
|
|
37
|
+
const canFlattenSelf = selectorsShareSpecificity(rule.selectors) && !rule.selectors.some(containsPseudoElement);
|
|
38
|
+
let encounteredNestedContent = false;
|
|
39
|
+
const flattened = declarations.flatMap((entry) => {
|
|
40
|
+
const isSelfRule = entry.type === 'rule' && entry.selectors?.length === 1 && entry.selectors[0].trim() === '&';
|
|
41
|
+
if (canFlattenSelf && isSelfRule && !encounteredNestedContent) {
|
|
42
|
+
return entry.declarations || [];
|
|
43
|
+
}
|
|
44
|
+
if (entry.type !== 'declaration' && entry.type !== 'whitespace' && entry.type !== 'comment') {
|
|
45
|
+
encounteredNestedContent = true;
|
|
46
|
+
}
|
|
47
|
+
if (entry.type === 'rule' && rule.selectors.length === 1) {
|
|
48
|
+
entry.selectors = entry.selectors.map((selector) => {
|
|
49
|
+
return shortenRepeatedNestingSelector(rule.selectors[0], selector);
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
return [entry];
|
|
53
|
+
});
|
|
54
|
+
return {
|
|
55
|
+
...rule,
|
|
56
|
+
declarations: flattened
|
|
57
|
+
};
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Replaces repetitions of a simple class or ID beside a leading nesting
|
|
63
|
+
* selector. Token comparisons keep longer names and escaped text intact.
|
|
64
|
+
*
|
|
65
|
+
* @param {string} parentSelector The sole parent selector.
|
|
66
|
+
* @param {string} childSelector The nested selector.
|
|
67
|
+
* @return {string} The shorter equivalent selector.
|
|
68
|
+
*/
|
|
69
|
+
function shortenRepeatedNestingSelector (parentSelector, childSelector) {
|
|
70
|
+
const parentTokens = tokenizeCssValue(parentSelector);
|
|
71
|
+
const isClass = parentTokens.length === 2 && parentTokens[0].text === '.' && parentTokens[1].type === 'ident';
|
|
72
|
+
const isIdentifier = parentTokens.length === 1 && parentTokens[0].type === 'hash';
|
|
73
|
+
if (!isClass && !isIdentifier) {
|
|
74
|
+
return childSelector;
|
|
75
|
+
}
|
|
76
|
+
const childTokens = tokenizeCssValue(childSelector);
|
|
77
|
+
if (childTokens[0]?.text !== '&') {
|
|
78
|
+
return childSelector;
|
|
79
|
+
}
|
|
80
|
+
let index = 1;
|
|
81
|
+
let result = '&';
|
|
82
|
+
while (index < childTokens.length) {
|
|
83
|
+
const matchesParent = parentTokens.every((token, position) => {
|
|
84
|
+
return childTokens[index + position]?.text === token.text;
|
|
85
|
+
});
|
|
86
|
+
if (!matchesParent) {
|
|
87
|
+
break;
|
|
88
|
+
}
|
|
89
|
+
result += '&';
|
|
90
|
+
index += parentTokens.length;
|
|
91
|
+
}
|
|
92
|
+
return result + childTokens.slice(index).map((token) => {
|
|
93
|
+
return token.text;
|
|
94
|
+
}).join('');
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Reports whether a nested child selector still means the same thing once it
|
|
99
|
+
* is written out in full under its parent.
|
|
100
|
+
*
|
|
101
|
+
* @param {string} childSelector The selector of a rule nested inside another.
|
|
102
|
+
* @return {boolean} True when the child can be written as a flat selector.
|
|
103
|
+
*/
|
|
104
|
+
function canFlattenChildSelector (childSelector) {
|
|
105
|
+
const child = childSelector.trim();
|
|
106
|
+
if (!child) {
|
|
107
|
+
return false;
|
|
108
|
+
}
|
|
109
|
+
// A `&` anywhere but the very start puts the parent where flattening cannot:
|
|
110
|
+
// `.b &` means the parent follows the child rather than leading it
|
|
111
|
+
return !child.slice(1).includes('&');
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Writes a nested child selector out in full, as the selector it stands for
|
|
116
|
+
* when read through its parent.
|
|
117
|
+
*
|
|
118
|
+
* @param {string} parentSelector The selector of the rule the child is nested in.
|
|
119
|
+
* @param {string} childSelector The selector of the nested child rule.
|
|
120
|
+
* @return {string} The flat selector the nested pair stands for.
|
|
121
|
+
*/
|
|
122
|
+
function combineNestedSelector (parentSelector, childSelector) {
|
|
123
|
+
const parent = parentSelector.trim();
|
|
124
|
+
const child = childSelector.trim();
|
|
125
|
+
// A leading `&` names the parent itself, so it is what the parent replaces
|
|
126
|
+
if (child.startsWith('&')) {
|
|
127
|
+
return parent + child.slice(1);
|
|
128
|
+
}
|
|
129
|
+
return parent + ' ' + child;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Expands rules that contain only nested sub-rules into flat rules with combined selectors, enabling further merging when the combined selectors already exist elsewhere.
|
|
134
|
+
*
|
|
135
|
+
* @param {Array} inputRules The AST rule nodes to process.
|
|
136
|
+
* @return {Array} A new array of rules with pure-nested rules expanded.
|
|
137
|
+
*/
|
|
138
|
+
function expandPureNestedRules (inputRules) {
|
|
139
|
+
const rules = expandNestedRuleBodies(inputRules);
|
|
140
|
+
const flatSelectors = new Set();
|
|
141
|
+
for (const rule of rules) {
|
|
142
|
+
if (rule.type !== 'rule' || !rule.selectors?.length) {
|
|
143
|
+
continue;
|
|
144
|
+
}
|
|
145
|
+
const nonWhitespace = (rule.declarations || []).filter((declaration) => {
|
|
146
|
+
return declaration.type !== 'whitespace';
|
|
147
|
+
});
|
|
148
|
+
const hasNonRuleDeclarations = nonWhitespace.some((declaration) => {
|
|
149
|
+
return declaration.type !== 'rule';
|
|
150
|
+
});
|
|
151
|
+
if (hasNonRuleDeclarations) {
|
|
152
|
+
rule.selectors.forEach((selector) => {
|
|
153
|
+
// Normalize selector whitespace to single space for deduplication
|
|
154
|
+
flatSelectors.add(selector.trim().replace(/\s+/g, ' '));
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const result = [];
|
|
160
|
+
for (const rule of rules) {
|
|
161
|
+
if (rule.type !== 'rule' || !rule.selectors?.length) {
|
|
162
|
+
result.push(rule);
|
|
163
|
+
continue;
|
|
164
|
+
}
|
|
165
|
+
const nonWhitespace = (rule.declarations || []).filter((declaration) => {
|
|
166
|
+
return declaration.type !== 'whitespace';
|
|
167
|
+
});
|
|
168
|
+
const isPureNested = nonWhitespace.length > 0 && nonWhitespace.every((declaration) => {
|
|
169
|
+
return declaration.type === 'rule';
|
|
170
|
+
});
|
|
171
|
+
// A comma-separated parent selector list behaves like :is() for specificity,
|
|
172
|
+
// so expanding it into descendant combinations (e.g. #a,.b → #a .c,.b .c)
|
|
173
|
+
// would change specificity. Only single-selector parents can expand safely.
|
|
174
|
+
if (!isPureNested || rule.selectors.length !== 1) {
|
|
175
|
+
result.push(rule);
|
|
176
|
+
continue;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
let anyMatch = false;
|
|
180
|
+
let expandedRules = [];
|
|
181
|
+
let canExpand = true;
|
|
182
|
+
|
|
183
|
+
for (const nestedRule of nonWhitespace) {
|
|
184
|
+
if (!nestedRule.selectors?.length) {
|
|
185
|
+
canExpand = false;
|
|
186
|
+
break;
|
|
187
|
+
}
|
|
188
|
+
// A child written as a comma-separated selector list, such as `.b,.c`,
|
|
189
|
+
// repeats the parent in front of every item when expanded (`.a .b,.a .c`),
|
|
190
|
+
// while the nested form states the parent once. Expanding is always
|
|
191
|
+
// longer, and leaving the rule nested keeps one pass from expanding what
|
|
192
|
+
// the next pass would then keep, so the output stays idempotent.
|
|
193
|
+
if (nestedRule.selectors.length > 1) {
|
|
194
|
+
canExpand = false;
|
|
195
|
+
break;
|
|
196
|
+
}
|
|
197
|
+
if (!nestedRule.selectors.every(canFlattenChildSelector)) {
|
|
198
|
+
canExpand = false;
|
|
199
|
+
break;
|
|
200
|
+
}
|
|
201
|
+
const combinedSelectors = [];
|
|
202
|
+
for (const parentSelector of rule.selectors) {
|
|
203
|
+
for (const childSelector of nestedRule.selectors) {
|
|
204
|
+
const combinedSelector = combineNestedSelector(parentSelector, childSelector);
|
|
205
|
+
combinedSelectors.push(combinedSelector);
|
|
206
|
+
if (flatSelectors.has(combinedSelector)) {
|
|
207
|
+
anyMatch = true;
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
expandedRules.push({ ...nestedRule, selectors: combinedSelectors });
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
const allChildSelectorStrings = expandedRules.map((expandedRule) => {
|
|
215
|
+
return expandedRule.selectors.join(',');
|
|
216
|
+
});
|
|
217
|
+
const allSame = (
|
|
218
|
+
allChildSelectorStrings.length > 0 &&
|
|
219
|
+
allChildSelectorStrings.every((selectorString) => {
|
|
220
|
+
return selectorString === allChildSelectorStrings[0];
|
|
221
|
+
})
|
|
222
|
+
);
|
|
223
|
+
if (canExpand && (allSame || anyMatch)) {
|
|
224
|
+
for (const expandedRule of expandedRules) {
|
|
225
|
+
result.push(expandedRule);
|
|
226
|
+
}
|
|
227
|
+
} else {
|
|
228
|
+
result.push(rule);
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
return result;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
export {
|
|
235
|
+
expandPureNestedRules
|
|
236
|
+
};
|