Skip to content

Commit 205a266

Browse files
committed
- Plugin
0 parents  commit 205a266

File tree

2 files changed

+119
-0
lines changed

2 files changed

+119
-0
lines changed

index.js

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
/**
2+
* Combines two selectors into a single selector string, accounting for the '&' character, following the rules specified in the CSS
3+
* nesting spec: https://www.w3.org/TR/css-nesting-1/#nested-style-rule
4+
* @param {string} parentSelector The parent selector.
5+
* @param {string} childSelector The child selector.
6+
* @returns A combined selector string.
7+
*/
8+
const combineSelectors = (parentSelector, childSelector) => {
9+
if (childSelector.startsWith('&')) {
10+
return `${parentSelector}${childSelector.slice(1)}`;
11+
}
12+
13+
return `${parentSelector} ${childSelector}`;
14+
};
15+
16+
/**
17+
* Builds a flattened selector for a given rule, by traversing parent rules, and combining their selectors.
18+
*
19+
* This accounts for multiple selectors in both parent/child rules, and uses recursion. Improvements could be made
20+
* to switch this over to a loop, but this is easier to read and understand.
21+
*
22+
* @param {*} rule The rule node, with style declarations, to build a flattened selector for.
23+
* @returns
24+
*/
25+
const buildFlattenedSelectors = (rule) => {
26+
// If we have no rule, or no selector, return an empty array.
27+
if (!rule || !rule.selector) {
28+
return [];
29+
}
30+
31+
// Account for multiple selectors.
32+
const selectors = rule.selector.split(',').map((selector) => selector.trim());
33+
34+
// Get our flattened parent selectors - this will be an array, as one or more parent rules in the tree could have multiple selectors.
35+
const parentSelectors = buildFlattenedSelectors(rule.parent);
36+
37+
// If we have no parent selectors, return just the current selectors (we've reached the root of the tree).
38+
if (!parentSelectors || parentSelectors.length === 0) {
39+
return selectors;
40+
}
41+
42+
// For each possible combination of parent and child selectors, add a new combined selector to the final array.
43+
const combinedSelectors = [];
44+
parentSelectors.forEach((parentSelector) => {
45+
selectors.forEach((childSelector) => {
46+
combinedSelectors.push(combineSelectors(parentSelector, childSelector));
47+
});
48+
});
49+
50+
return combinedSelectors;
51+
};
52+
53+
/**
54+
* A small PostCSS plugin which unwraps nested CSS selectors, so our compiled CSS modules can be handled by older
55+
* browsers that don't support them, as well as JSDOM's crappy CSS parser.
56+
*
57+
* This is a basic implementation which runs once at the end of all other processing.
58+
*
59+
* You may ask why another unwrapping CSS selector plugin. Essentially, it boils down to the fact that this is
60+
* built to work with the PostCSS modules plugin:
61+
* - PostCSS modules plugin is responsible for handling the '@compose' rule, which imports other CSS classes.
62+
* - The PostCSS modules plugin uses the 'OnceExit' hook of the PostCSS pipeline.
63+
* - Other 'unwrap' plugins hook into the 'Once' and 'Rule' hooks, which is run before the 'OnceExit' hook.
64+
* - This means that the 'unwrap' plugins run before the '@compose' rule is handled, and therefore miss
65+
* nested selectors imported from other files.
66+
*
67+
* This plugin essentially flattens nested selectors in a manner similar to SASS.
68+
*/
69+
const unwrapNestedSelectors = () => ({
70+
postcssPlugin: 'unwrap-nested-selectors',
71+
OnceExit(root) {
72+
// Iterate through all style rules in the AST.
73+
root.walkRules((rule) => {
74+
const parentRule = rule.parent;
75+
76+
// Only unwrap rules that are nested inside other rules, and have some style declarations.
77+
if (parentRule.type !== 'root' && rule.nodes.some((node) => node.type === 'decl')) {
78+
const selectorsForRule = buildFlattenedSelectors(rule);
79+
80+
// Clone the node, append it to the root, and remove the original nested rule.
81+
root.append(rule.clone({ selector: selectorsForRule.join(',') }));
82+
rule.remove();
83+
}
84+
85+
// If the remaining parent rule(s) in the tree are now empty, remove them as well.
86+
let currentParent = parentRule;
87+
while (currentParent && currentParent.type === 'rule') {
88+
if (currentParent.nodes.length > 0) {
89+
break;
90+
}
91+
92+
const newParent = currentParent.parent;
93+
currentParent.remove();
94+
currentParent = newParent;
95+
}
96+
});
97+
},
98+
});
99+
100+
module.exports = { unwrapNestedSelectors };

package.json

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
{
2+
"name": "postcss-unwrap-nested-selectors",
3+
"version": "1.0.0",
4+
"description": "A small PostCSS plugin to unwrap nested CSS selectors",
5+
"main": "index.js",
6+
"scripts": {
7+
"test": "echo \"Error: no test specified\" && exit 1"
8+
},
9+
"repository": {
10+
"type": "git",
11+
"url": "git+https://github.com/mattwca/postcss-unwrap-nested-selectors.git"
12+
},
13+
"author": "Matt Carter",
14+
"license": "ISC",
15+
"bugs": {
16+
"url": "https://github.com/mattwca/postcss-unwrap-nested-selectors/issues"
17+
},
18+
"homepage": "https://github.com/mattwca/postcss-unwrap-nested-selectors#readme"
19+
}

0 commit comments

Comments
 (0)