rollup-plugin-react-scoped-css 1.2.6 → 1.3.0

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.
Files changed (2) hide show
  1. package/dist/css/scope-css.js +299 -50
  2. package/package.json +1 -1
@@ -1,41 +1,110 @@
1
- import { parse, generate, } from "css-tree";
1
+ import { parse, generate, List, } from "css-tree";
2
2
  /**
3
3
  * Keyframe selectors (`from`, `to`, `50%`) are also parsed as `Rule` nodes with
4
4
  * a `SelectorList` prelude. They need to be left alone, otherwise animations
5
5
  * break by becoming e.g. `0%[data-hash]`.
6
6
  */
7
7
  const isKeyframesAtRule = (name) => /^(-[a-z0-9]+-)?keyframes$/i.test(name);
8
- const getSelectors = (items) => {
9
- const selectorLists = [];
10
- items.forEach((node) => {
11
- if (node.type === "Rule" && node.prelude?.type === "SelectorList") {
12
- selectorLists.push(node.prelude.children);
13
- }
14
- else if (node.type === "Atrule" &&
15
- node.block &&
16
- !isKeyframesAtRule(node.name)) {
17
- // Recurse into conditional/group at-rules (`@media`, `@container`,
18
- // `@supports`, `@layer`, `@scope`, etc.) so their rules get scoped too.
19
- selectorLists.push(...getSelectors(node.block.children));
8
+ const isScopePiercingPseudoSelector = (node) => node.type === "PseudoElementSelector" &&
9
+ (node.name === "v-deep" || node.name === "deep");
10
+ /**
11
+ * CSS-tree parses the contents of `:deep(...)` as a `Raw` node, unlike
12
+ * `:is()`/`:where()` which it parses as selectors.
13
+ */
14
+ const isDeepFunctionSelector = (node) => node.type === "PseudoClassSelector" &&
15
+ (node.name === "deep" || node.name === "v-deep") &&
16
+ !!node.children;
17
+ /**
18
+ * Pseudo selectors that refer to the document root. They cannot be scoped
19
+ * because the root element never receives the scope attribute, so adding the
20
+ * attribute would only turn working rules into dead ones.
21
+ */
22
+ const isRootLikePseudoSelector = (node) => node.type === "PseudoClassSelector" &&
23
+ (node.name.toLowerCase() === "root" || node.name.toLowerCase() === "scope");
24
+ /**
25
+ * The `::view-transition-*` tree is rooted at the document root, which never
26
+ * receives the scope attribute. A bare occurrence can therefore never match
27
+ * once scoped, so it is left global just like `:root`.
28
+ */
29
+ const isViewTransitionPseudoElement = (node) => node.type === "PseudoElementSelector" &&
30
+ node.name.toLowerCase().startsWith("view-transition");
31
+ const isCombinator = (node) => node.type === "Combinator";
32
+ const isNestingSelector = (node) => node.type === "NestingSelector";
33
+ /**
34
+ * Simple selectors that can receive the scope attribute. `&` is excluded
35
+ * because the parent rule is already scoped, so `&[hash]` would be redundant.
36
+ */
37
+ const isHashableSelector = (node) => node.type === "TypeSelector" ||
38
+ node.type === "ClassSelector" ||
39
+ node.type === "IdSelector" ||
40
+ node.type === "AttributeSelector";
41
+ const getDeepFunctionSelectorText = (node) => {
42
+ const children = node.children;
43
+ if (!children || children.isEmpty) {
44
+ return null;
45
+ }
46
+ let text = "";
47
+ children.forEach((child) => {
48
+ if (child.type === "Raw") {
49
+ text += child.value;
20
50
  }
21
51
  });
22
- return selectorLists;
52
+ return text || null;
23
53
  };
24
- const isScopePiercingPseudoSelector = (item) => {
25
- return (item?.data?.type === "PseudoElementSelector" &&
26
- (item?.data?.name === "v-deep" || item?.data?.name === "deep"));
27
- };
28
- const isPseudoSelector = (item) => {
29
- return (item?.data?.type === "PseudoClassSelector" ||
30
- item?.data?.type === "PseudoElementSelector");
31
- };
32
- const isCombinator = (item) => {
33
- return item?.data?.type === "Combinator";
54
+ /**
55
+ * Splits a raw selector prelude on top-level `||` (column combinator) while
56
+ * ignoring `||` inside attribute selectors, functional pseudos, or strings.
57
+ */
58
+ const splitOnColumnCombinator = (value) => {
59
+ const parts = [];
60
+ let start = 0;
61
+ let parenthesisDepth = 0;
62
+ let bracketDepth = 0;
63
+ let quote = null;
64
+ for (let index = 0; index < value.length; index += 1) {
65
+ const char = value[index];
66
+ if (quote) {
67
+ if (char === "\\") {
68
+ index += 1;
69
+ }
70
+ else if (char === quote) {
71
+ quote = null;
72
+ }
73
+ continue;
74
+ }
75
+ if (char === '"' || char === "'") {
76
+ quote = char;
77
+ }
78
+ else if (char === "\\") {
79
+ index += 1;
80
+ }
81
+ else if (char === "(") {
82
+ parenthesisDepth += 1;
83
+ }
84
+ else if (char === ")") {
85
+ parenthesisDepth -= 1;
86
+ }
87
+ else if (char === "[") {
88
+ bracketDepth += 1;
89
+ }
90
+ else if (char === "]") {
91
+ bracketDepth -= 1;
92
+ }
93
+ else if (char === "|" &&
94
+ value[index + 1] === "|" &&
95
+ parenthesisDepth === 0 &&
96
+ bracketDepth === 0) {
97
+ parts.push(value.slice(start, index));
98
+ index += 1;
99
+ start = index + 1;
100
+ }
101
+ }
102
+ parts.push(value.slice(start));
103
+ return parts;
34
104
  };
35
105
  export function scopeCss(css, filename, hash, source = filename) {
36
106
  try {
37
107
  const ast = parse(css, { positions: true, filename: source });
38
- const selectorLists = getSelectors(ast.children);
39
108
  const attributeSelector = {
40
109
  type: "AttributeSelector",
41
110
  name: { type: "Identifier", name: hash },
@@ -43,40 +112,220 @@ export function scopeCss(css, filename, hash, source = filename) {
43
112
  value: null,
44
113
  flags: null,
45
114
  };
46
- selectorLists.forEach((selectorList) => {
47
- selectorList.forEach((selector) => {
48
- if (selector.type !== "Selector") {
115
+ const scopeSelectorList = (selectorLists) => {
116
+ selectorLists.forEach((selector) => {
117
+ if (selector.type === "Selector") {
118
+ scopeSelector(selector);
119
+ }
120
+ });
121
+ };
122
+ /**
123
+ * Inserts the scope attribute after every consecutive run of simple
124
+ * selectors. Pseudo selectors break a run, which preserves the previous
125
+ * behaviour of `a:hover.b` becoming `a[hash]:hover.b[hash]`.
126
+ */
127
+ const scopeRuns = (items, selectorChildren) => {
128
+ let run = [];
129
+ const flushRun = () => {
130
+ if (run.length === 0) {
49
131
  return;
50
132
  }
51
- let item = selector.children.head;
52
- const itemsToInsertScopeBefore = [];
53
- let currentChunkIndex = 0;
54
- while (item !== null) {
55
- if (isScopePiercingPseudoSelector(item)) {
56
- Object.assign(item.data, attributeSelector);
57
- break;
58
- }
59
- else if (isCombinator(item) || isPseudoSelector(item)) {
60
- currentChunkIndex += 1;
61
- }
62
- else if (item.next) {
63
- itemsToInsertScopeBefore[currentChunkIndex] = item.next;
133
+ const last = run[run.length - 1];
134
+ if (last.next) {
135
+ selectorChildren.insertData(attributeSelector, last.next);
136
+ }
137
+ else {
138
+ selectorChildren.appendData(attributeSelector);
139
+ }
140
+ run = [];
141
+ };
142
+ items.forEach((item) => {
143
+ if (isHashableSelector(item.data)) {
144
+ run.push(item);
145
+ }
146
+ else {
147
+ flushRun();
148
+ }
149
+ });
150
+ flushRun();
151
+ };
152
+ const scopeCompound = (items, selectorChildren) => {
153
+ if (items.length === 0) {
154
+ return;
155
+ }
156
+ if (items.some((item) => isHashableSelector(item.data))) {
157
+ scopeRuns(items, selectorChildren);
158
+ return;
159
+ }
160
+ // `&` is already scoped through its parent rule.
161
+ if (items.some((item) => isNestingSelector(item.data))) {
162
+ return;
163
+ }
164
+ // `:root` / `:scope` refer to the document root and can never match the
165
+ // scope attribute; leave them global.
166
+ if (items.some((item) => isRootLikePseudoSelector(item.data))) {
167
+ return;
168
+ }
169
+ // Bare view-transition pseudo-elements are also rooted at the document
170
+ // root, so scoping them would only produce an unmatchable selector.
171
+ if (items.some((item) => isViewTransitionPseudoElement(item.data))) {
172
+ return;
173
+ }
174
+ if (items.some((item) => item.data.type === "PseudoElementSelector")) {
175
+ // An attribute cannot follow a pseudo-element, so it has to be
176
+ // prepended: `::before` -> `[hash]::before`.
177
+ selectorChildren.insertData(attributeSelector, items[0]);
178
+ return;
179
+ }
180
+ if (!items.some((item) => item.data.type === "PseudoClassSelector")) {
181
+ return;
182
+ }
183
+ // `:hover` -> `:hover[hash]`
184
+ const last = items[items.length - 1];
185
+ if (last.next) {
186
+ selectorChildren.insertData(attributeSelector, last.next);
187
+ }
188
+ else {
189
+ selectorChildren.appendData(attributeSelector);
190
+ }
191
+ };
192
+ const replaceDeepFunction = (selectorChildren, item) => {
193
+ const node = item.data;
194
+ const selectorText = getDeepFunctionSelectorText(node);
195
+ if (!selectorText) {
196
+ return;
197
+ }
198
+ let parsed;
199
+ try {
200
+ parsed = parse(selectorText, {
201
+ context: "selectorList",
202
+ });
203
+ }
204
+ catch {
205
+ return;
206
+ }
207
+ const selectors = parsed.children;
208
+ if (selectors.size === 1) {
209
+ // Splice the argument selector in place of `:deep(...)`.
210
+ const replacement = new List();
211
+ selectors.first.children.forEach((child) => {
212
+ replacement.appendData(child);
213
+ });
214
+ selectorChildren.replace(item, replacement);
215
+ }
216
+ else {
217
+ // A selector list cannot be spliced into a single selector, but
218
+ // `:is(...)` has the same matching semantics here.
219
+ node.name = "is";
220
+ node.children = new List();
221
+ node.children.appendData(parsed);
222
+ }
223
+ };
224
+ const scopeSelector = (selector) => {
225
+ const selectorChildren = selector.children;
226
+ const compounds = [];
227
+ let compound = [];
228
+ for (let item = selectorChildren.head; item !== null; item = item.next) {
229
+ if (isCombinator(item.data)) {
230
+ compounds.push(compound);
231
+ compound = [];
232
+ }
233
+ else {
234
+ compound.push(item);
235
+ }
236
+ }
237
+ compounds.push(compound);
238
+ for (const items of compounds) {
239
+ const piercingIndex = items.findIndex((item) => isScopePiercingPseudoSelector(item.data) ||
240
+ isDeepFunctionSelector(item.data));
241
+ if (piercingIndex !== -1) {
242
+ const piercingItem = items[piercingIndex];
243
+ if (isDeepFunctionSelector(piercingItem.data)) {
244
+ // Scope everything before `:deep(...)` in the same compound,
245
+ // then splice in the (unscoped) argument selector.
246
+ scopeRuns(items.slice(0, piercingIndex), selectorChildren);
247
+ replaceDeepFunction(selectorChildren, piercingItem);
64
248
  }
65
249
  else {
66
- selector.children.appendData(attributeSelector);
67
- break;
250
+ // Replace `::deep` / `::v-deep` with the scope attribute.
251
+ Object.assign(piercingItem.data, attributeSelector);
68
252
  }
69
- item = item?.next ?? null;
253
+ // Everything after a deep selector stays global.
254
+ return;
70
255
  }
71
- // This is true if one of the two break statements above was run. Which means we don't want to add to the last one.
72
- if (itemsToInsertScopeBefore[currentChunkIndex]) {
73
- itemsToInsertScopeBefore.pop();
256
+ scopeCompound(items, selectorChildren);
257
+ }
258
+ };
259
+ /**
260
+ * CSS-tree parses implicit nesting as a `Raw` node inside the parent
261
+ * rule. Re-parse it as a rule so its selector can be scoped and write the
262
+ * transformed rule back.
263
+ */
264
+ const scopeRawNestedRule = (node) => {
265
+ if (!node.value.includes("{")) {
266
+ return;
267
+ }
268
+ try {
269
+ const rule = parse(node.value, { context: "rule" });
270
+ if (rule.type !== "Rule") {
271
+ return;
74
272
  }
75
- itemsToInsertScopeBefore.forEach((scopePositionItem) => {
76
- selector.children.insertData(attributeSelector, scopePositionItem);
273
+ scopeRule(rule);
274
+ node.value = generate(rule);
275
+ }
276
+ catch {
277
+ // Leave constructs css-tree cannot parse untouched.
278
+ }
279
+ };
280
+ const scopeRawSelectorPrelude = (prelude) => {
281
+ const parts = splitOnColumnCombinator(prelude.value);
282
+ if (parts.length === 1) {
283
+ return;
284
+ }
285
+ try {
286
+ const scopedParts = parts.map((part) => {
287
+ const selectorList = parse(part.trim(), {
288
+ context: "selectorList",
289
+ });
290
+ scopeSelectorList(selectorList.children);
291
+ return generate(selectorList);
77
292
  });
293
+ prelude.value = scopedParts.join(" || ");
294
+ }
295
+ catch {
296
+ // Leave constructs css-tree cannot parse untouched.
297
+ }
298
+ };
299
+ const scopeNodes = (children, insideRule) => {
300
+ children.forEach((node) => {
301
+ if (node.type === "Rule") {
302
+ scopeRule(node);
303
+ }
304
+ else if (node.type === "Atrule" &&
305
+ node.block &&
306
+ !isKeyframesAtRule(node.name)) {
307
+ // Recurse into conditional/group at-rules (`@media`, `@container`,
308
+ // `@supports`, `@layer`, `@scope`, etc.) so their rules get scoped.
309
+ scopeNodes(node.block.children, insideRule);
310
+ }
311
+ else if (insideRule && node.type === "Raw") {
312
+ scopeRawNestedRule(node);
313
+ }
78
314
  });
79
- });
315
+ };
316
+ const scopeRule = (rule) => {
317
+ if (rule.prelude?.type === "SelectorList") {
318
+ scopeSelectorList(rule.prelude.children);
319
+ }
320
+ else if (rule.prelude?.type === "Raw") {
321
+ scopeRawSelectorPrelude(rule.prelude);
322
+ }
323
+ if (rule.block) {
324
+ // Nested rules (`& ...`) and nested at-rules live in the rule block.
325
+ scopeNodes(rule.block.children, true);
326
+ }
327
+ };
328
+ scopeNodes(ast.children, false);
80
329
  const { css: scopedCss, map } = generate(ast, {
81
330
  sourceMap: true,
82
331
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "rollup-plugin-react-scoped-css",
3
- "version": "1.2.6",
3
+ "version": "1.3.0",
4
4
  "description": "Bring scoped CSS to React with Vite & Rollup. Lightweight component-level style isolation similar to Vue and Angular.",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",