1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
|
import { Declaration, Plugin } from 'postcss';
interface AstroScopedOptions {
className: string;
}
interface Selector {
start: number;
end: number;
value: string;
}
const CSS_SEPARATORS = new Set([' ', ',', '+', '>', '~']);
const KEYFRAME_PERCENT = /\d+\.?\d*%/;
/** HTML tags that should never get scoped classes */
export const NEVER_SCOPED_TAGS = new Set<string>(['base', 'body', 'font', 'frame', 'frameset', 'head', 'html', 'link', 'meta', 'noframes', 'noscript', 'script', 'style', 'title']);
/**
* Scope Rules
* Given a selector string (`.btn>span,.nav>span`), add an additional CSS class to every selector (`.btn.myClass>span.myClass,.nav.myClass>span.myClass`)
* @param {string} selector The minified selector string to parse. Cannot contain arbitrary whitespace (other than child selector syntax).
* @param {string} className The CSS class to apply.
*/
export function scopeRule(selector: string, className: string) {
// if this is a keyframe keyword, return original selector
if (selector === 'from' || selector === 'to' || KEYFRAME_PERCENT.test(selector)) {
return selector;
}
// For everything else, parse & scope
const c = className.replace(/^\.?/, '.'); // make sure class always has leading '.'
const selectors: Selector[] = [];
let ss = selector; // final output
// Pass 1: parse selector string; extract top-level selectors
{
let start = 0;
let lastValue = '';
let parensOpen = false;
for (let n = 0; n < ss.length; n++) {
const isEnd = n === selector.length - 1;
if (selector[n] === '(') parensOpen = true;
if (selector[n] === ')') parensOpen = false;
if (isEnd || (parensOpen === false && CSS_SEPARATORS.has(selector[n]))) {
lastValue = selector.substring(start, isEnd ? undefined : n);
if (!lastValue) continue;
selectors.push({ start, end: isEnd ? n + 1 : n, value: lastValue });
start = n + 1;
}
}
}
// Pass 2: starting from end, transform selectors w/ scoped class
for (let i = selectors.length - 1; i >= 0; i--) {
const { start, end, value } = selectors[i];
const head = ss.substring(0, start);
const tail = ss.substring(end);
// replace '*' with className
if (value === '*') {
ss = head + c + tail;
continue;
}
// leave :global() alone!
if (value.startsWith(':global(')) {
ss =
head +
ss
.substring(start, end)
.replace(/^:global\(/, '')
.replace(/\)$/, '') +
tail;
continue;
}
// don‘t scope body, title, etc.
if (NEVER_SCOPED_TAGS.has(value)) {
ss = head + value + tail;
continue;
}
// scope everything else
let newSelector = ss.substring(start, end);
const pseudoIndex = newSelector.indexOf(':');
if (pseudoIndex > 0) {
// if there‘s a pseudoclass (:focus)
ss = head + newSelector.substring(start, pseudoIndex) + c + newSelector.substr(pseudoIndex) + tail;
} else {
ss = head + newSelector + c + tail;
}
}
return ss;
}
/** PostCSS Scope plugin */
export default function astroScopedStyles(options: AstroScopedOptions): Plugin {
return {
postcssPlugin: '@astro/postcss-scoped-styles',
Rule(rule) {
rule.selector = scopeRule(rule.selector, options.className);
},
};
}
|