Skip to content

Commit f749cf2

Browse files
fix: support css nesting and scope at-rule (#59)
1 parent 50a2a77 commit f749cf2

File tree

21 files changed

+305
-106
lines changed

21 files changed

+305
-106
lines changed

src/index.js

+56-12
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,23 @@ const selectorParser = require("postcss-selector-parser");
44

55
const hasOwnProperty = Object.prototype.hasOwnProperty;
66

7-
function getSingleLocalNamesForComposes(root) {
7+
function isNestedRule(rule) {
8+
if (!rule.parent || rule.parent.type === "root") {
9+
return false;
10+
}
11+
12+
if (rule.parent.type === "rule") {
13+
return true;
14+
}
15+
16+
return isNestedRule(rule.parent);
17+
}
18+
19+
function getSingleLocalNamesForComposes(root, rule) {
20+
if (isNestedRule(rule)) {
21+
throw new Error(`composition is not allowed in nested rule \n\n${rule}`);
22+
}
23+
824
return root.nodes.map((node) => {
925
if (node.type !== "selector" || node.nodes.length !== 1) {
1026
throw new Error(
@@ -91,7 +107,7 @@ const plugin = (options = {}) => {
91107
Once(root, { rule }) {
92108
const exports = Object.create(null);
93109

94-
function exportScopedName(name, rawName, node) {
110+
function exportScopedName(name, rawName, node, needExport = true) {
95111
const scopedName = generateScopedName(
96112
rawName ? rawName : name,
97113
root.source.input.from,
@@ -107,6 +123,10 @@ const plugin = (options = {}) => {
107123
);
108124
const { key, value } = exportEntry;
109125

126+
if (!needExport) {
127+
return scopedName;
128+
}
129+
110130
exports[key] = exports[key] || [];
111131

112132
if (exports[key].indexOf(value) < 0) {
@@ -116,25 +136,27 @@ const plugin = (options = {}) => {
116136
return scopedName;
117137
}
118138

119-
function localizeNode(node) {
139+
function localizeNode(node, needExport = true) {
120140
switch (node.type) {
121141
case "selector":
122-
node.nodes = node.map(localizeNode);
142+
node.nodes = node.map((item) => localizeNode(item, needExport));
123143
return node;
124144
case "class":
125145
return selectorParser.className({
126146
value: exportScopedName(
127147
node.value,
128148
node.raws && node.raws.value ? node.raws.value : null,
129-
node
149+
node,
150+
needExport
130151
),
131152
});
132153
case "id": {
133154
return selectorParser.id({
134155
value: exportScopedName(
135156
node.value,
136157
node.raws && node.raws.value ? node.raws.value : null,
137-
node
158+
node,
159+
needExport
138160
),
139161
});
140162
}
@@ -144,7 +166,7 @@ const plugin = (options = {}) => {
144166
attribute: node.attribute,
145167
operator: node.operator,
146168
quoteMark: "'",
147-
value: exportScopedName(node.value),
169+
value: exportScopedName(node.value, null, null, needExport),
148170
});
149171
}
150172
}
@@ -155,15 +177,15 @@ const plugin = (options = {}) => {
155177
);
156178
}
157179

158-
function traverseNode(node) {
180+
function traverseNode(node, needExport = true) {
159181
switch (node.type) {
160182
case "pseudo":
161183
if (node.value === ":local") {
162184
if (node.nodes.length !== 1) {
163185
throw new Error('Unexpected comma (",") in :local block');
164186
}
165187

166-
const selector = localizeNode(node.first, node.spaces);
188+
const selector = localizeNode(node.first, needExport);
167189
// move the spaces that were around the pseudo selector to the first
168190
// non-container node
169191
selector.first.spaces = node.spaces;
@@ -186,12 +208,12 @@ const plugin = (options = {}) => {
186208
/* falls through */
187209
case "root":
188210
case "selector": {
189-
node.each(traverseNode);
211+
node.each((item) => traverseNode(item, needExport));
190212
break;
191213
}
192214
case "id":
193215
case "class":
194-
if (exportGlobals) {
216+
if (needExport && exportGlobals) {
195217
exports[node.value] = [node.value];
196218
}
197219
break;
@@ -215,7 +237,10 @@ const plugin = (options = {}) => {
215237
rule.selector = traverseNode(parsedSelector.clone()).toString();
216238

217239
rule.walkDecls(/composes|compose-with/i, (decl) => {
218-
const localNames = getSingleLocalNamesForComposes(parsedSelector);
240+
const localNames = getSingleLocalNamesForComposes(
241+
parsedSelector,
242+
decl.parent
243+
);
219244
const classes = decl.value.split(/\s+/);
220245

221246
classes.forEach((className) => {
@@ -291,6 +316,25 @@ const plugin = (options = {}) => {
291316
atRule.params = exportScopedName(localMatch[1]);
292317
});
293318

319+
root.walkAtRules(/scope$/i, (atRule) => {
320+
atRule.params = atRule.params
321+
.split("to")
322+
.map((item) => {
323+
const selector = item.trim().slice(1, -1).trim();
324+
325+
const localMatch = /^\s*:local\s*\((.+?)\)\s*$/.exec(selector);
326+
327+
if (!localMatch) {
328+
return `(${selector})`;
329+
}
330+
331+
let parsedSelector = selectorParser().astSync(selector);
332+
333+
return `(${traverseNode(parsedSelector, false).toString()})`;
334+
})
335+
.join(" to ");
336+
});
337+
294338
// If we found any :locals, insert an :export rule
295339
const exportedNames = Object.keys(exports);
296340

+35
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
._input__d {
2+
color: red;
3+
}
4+
5+
@scope (._input__a) to (._input__b) {
6+
._input__c {
7+
border: 5px solid black;
8+
background-color: goldenrod;
9+
}
10+
}
11+
12+
@scope (._input__a) {
13+
._input__e {
14+
border: 5px solid black;
15+
}
16+
}
17+
18+
@scope (._input__a) to (img) {
19+
._input__f {
20+
background-color: goldenrod;
21+
}
22+
}
23+
24+
@scope (._input__g) {
25+
img {
26+
backdrop-filter: blur(2px);
27+
}
28+
}
29+
30+
:export {
31+
d: _input__d;
32+
c: _input__c;
33+
e: _input__e;
34+
f: _input__f;
35+
}
+28
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
:local(.d) {
2+
color: red;
3+
}
4+
5+
@scope (:local(.a)) to (:local(.b)) {
6+
:local(.c) {
7+
border: 5px solid black;
8+
background-color: goldenrod;
9+
}
10+
}
11+
12+
@scope (:local(.a)) {
13+
:local(.e) {
14+
border: 5px solid black;
15+
}
16+
}
17+
18+
@scope (:local(.a)) to (img) {
19+
:local(.f) {
20+
background-color: goldenrod;
21+
}
22+
}
23+
24+
@scope (:local(.g)) {
25+
img {
26+
backdrop-filter: blur(2px);
27+
}
28+
}

test/test-cases/at-rule/expected.css

+18
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
._input__otherClass {
2+
background: red;
3+
}
4+
5+
@media screen {
6+
._input__foo {
7+
color: green;
8+
._input__baz {
9+
color: blue;
10+
}
11+
}
12+
}
13+
14+
:export {
15+
otherClass: _input__otherClass;
16+
foo: _input__foo;
17+
baz: _input__baz;
18+
}

test/test-cases/at-rule/source.css

+12
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
:local(.otherClass) {
2+
background: red;
3+
}
4+
5+
@media screen {
6+
:local(.foo) {
7+
color: green;
8+
:local(.baz) {
9+
color: blue;
10+
}
11+
}
12+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
._input__bar {
2+
color: red;
3+
}
4+
5+
._input__foo {
6+
display: grid;
7+
8+
@media (orientation: landscape) {
9+
grid-auto-flow: column;
10+
}
11+
}
12+
13+
:export {
14+
bar: _input__bar;
15+
foo: _input__foo _input__bar;
16+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
:local(.bar) {
2+
color: red;
3+
}
4+
5+
:local(.foo) {
6+
display: grid;
7+
composes: bar;
8+
9+
@media (orientation: landscape) {
10+
grid-auto-flow: column;
11+
}
12+
}
+39
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
._input__otherClass {
2+
background: red;
3+
}
4+
5+
._input__foo {
6+
color: green;
7+
8+
@media (max-width: 520px) {
9+
._input__bar {
10+
color: darkgreen;
11+
}
12+
13+
&._input__baz {
14+
color: blue;
15+
}
16+
}
17+
}
18+
19+
._input__a {
20+
color: red;
21+
22+
&._input__b {
23+
color: green;
24+
}
25+
26+
._input__c {
27+
color: blue;
28+
}
29+
}
30+
31+
:export {
32+
otherClass: _input__otherClass;
33+
foo: _input__foo;
34+
bar: _input__bar;
35+
baz: _input__baz;
36+
a: _input__a;
37+
b: _input__b;
38+
c: _input__c;
39+
}
+29
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
:local(.otherClass) {
2+
background: red;
3+
}
4+
5+
:local(.foo) {
6+
color: green;
7+
8+
@media (max-width: 520px) {
9+
:local(.bar) {
10+
color: darkgreen;
11+
}
12+
13+
&:local(.baz) {
14+
color: blue;
15+
}
16+
}
17+
}
18+
19+
:local(.a) {
20+
color: red;
21+
22+
&:local(.b) {
23+
color: green;
24+
}
25+
26+
:local(.c) {
27+
color: blue;
28+
}
29+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
composition is not allowed in nested rule
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
:local(.otherClassName) {
2+
}
3+
4+
@media (min-width: 1024px) {
5+
:local(.a) {
6+
:local(.b) {
7+
compose-with: otherClassName;
8+
}
9+
}
10+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
composition is not allowed in nested rule
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
:local(.otherClassName) {
2+
}
3+
4+
:local(.a) {
5+
@media (min-width: 1024px) {
6+
:local(.b) {
7+
compose-with: otherClassName;
8+
}
9+
}
10+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
composition is not allowed in nested rule
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
:local(.otherClassName) {
2+
}
3+
4+
:local(.a) {
5+
:local(.b) {
6+
compose-with: otherClassName;
7+
}
8+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
composition is only allowed when selector is single :local class name not in "to", "to" is weird

0 commit comments

Comments
 (0)