Skip to content

Commit d793f47

Browse files
Support pseudoelements in CSS scoping (#25270)
* Support pseudoelements in CSS scoping. Fixes #25268 * CR: More test cases * Another pseudoelement case * Case insensitivity for single-colon pseudoelements Not that anybody should ever want to do this * Avoid an allocation
1 parent ebaaa0f commit d793f47

File tree

2 files changed

+133
-18
lines changed

2 files changed

+133
-18
lines changed

src/Razor/Microsoft.AspNetCore.Razor.Tools/src/RewriteCssCommand.cs

Lines changed: 50 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -187,7 +187,7 @@ protected override void VisitSelector(Selector selector)
187187
var lastSimpleSelector = allSimpleSelectors.TakeWhile(s => s != firstDeepCombinator).LastOrDefault();
188188
if (lastSimpleSelector != null)
189189
{
190-
Edits.Add(new InsertSelectorScopeEdit { Position = FindPositionBeforeTrailingCombinator(lastSimpleSelector) });
190+
Edits.Add(new InsertSelectorScopeEdit { Position = FindPositionToInsertInSelector(lastSimpleSelector) });
191191
}
192192
else if (firstDeepCombinator != null)
193193
{
@@ -203,30 +203,62 @@ protected override void VisitSelector(Selector selector)
203203
}
204204
}
205205

206-
private int FindPositionBeforeTrailingCombinator(SimpleSelector lastSimpleSelector)
206+
private int FindPositionToInsertInSelector(SimpleSelector lastSimpleSelector)
207207
{
208-
// For a selector like "a > ::deep b", the parser splits it as "a >", "::deep", "b".
209-
// The place we want to insert the scope is right after "a", hence we need to detect
210-
// if the simple selector ends with " >" or similar, and if so, insert before that.
211-
var text = lastSimpleSelector.Text;
212-
var lastChar = text.Length > 0 ? text[^1] : default;
213-
switch (lastChar)
208+
var children = lastSimpleSelector.Children;
209+
for (var i = 0; i < children.Count; i++)
214210
{
215-
case '>':
216-
case '+':
217-
case '~':
218-
var trailingCombinatorMatch = _trailingCombinatorRegex.Match(text);
219-
if (trailingCombinatorMatch.Success)
220-
{
221-
var trailingCombinatorLength = trailingCombinatorMatch.Length;
222-
return lastSimpleSelector.AfterEnd - trailingCombinatorLength;
223-
}
224-
break;
211+
switch (children[i])
212+
{
213+
// Selectors like "a > ::deep b" get parsed as [[a][>]][::deep][b], and we want to
214+
// insert right after the "a". So if we're processing a SimpleSelector like [[a][>]],
215+
// consider the ">" to signal the "insert before" position.
216+
case TokenItem t when IsTrailingCombinator(t.TokenType):
217+
218+
// Similarly selectors like "a::before" get parsed as [[a][::before]], and we want to
219+
// insert right after the "a". So if we're processing a SimpleSelector like [[a][::before]],
220+
// consider the pseudoelement to signal the "insert before" position.
221+
case PseudoElementSelector:
222+
case PseudoElementFunctionSelector:
223+
case PseudoClassSelector s when IsSingleColonPseudoElement(s):
224+
// Insert after the previous token if there is one, otherwise before the whole thing
225+
return i > 0 ? children[i - 1].AfterEnd : lastSimpleSelector.Start;
226+
}
225227
}
226228

229+
// Since we didn't find any children that signal the insert-before position,
230+
// insert after the whole thing
227231
return lastSimpleSelector.AfterEnd;
228232
}
229233

234+
private static bool IsSingleColonPseudoElement(PseudoClassSelector selector)
235+
{
236+
// See https://developer.mozilla.org/en-US/docs/Web/CSS/Pseudo-elements
237+
// Normally, pseudoelements require a double-colon prefix. However the following "original set"
238+
// of pseudoelements also support single-colon prefixes for back-compatibility with older versions
239+
// of the W3C spec. Our CSS parser sees them as pseudoselectors rather than pseudoelements, so
240+
// we have to special-case them. The single-colon option doesn't exist for other more modern
241+
// pseudoelements.
242+
var selectorText = selector.Text;
243+
return string.Equals(selectorText, ":after", StringComparison.OrdinalIgnoreCase)
244+
|| string.Equals(selectorText, ":before", StringComparison.OrdinalIgnoreCase)
245+
|| string.Equals(selectorText, ":first-letter", StringComparison.OrdinalIgnoreCase)
246+
|| string.Equals(selectorText, ":first-line", StringComparison.OrdinalIgnoreCase);
247+
}
248+
249+
private static bool IsTrailingCombinator(CssTokenType tokenType)
250+
{
251+
switch (tokenType)
252+
{
253+
case CssTokenType.Plus:
254+
case CssTokenType.Tilde:
255+
case CssTokenType.Greater:
256+
return true;
257+
default:
258+
return false;
259+
}
260+
}
261+
230262
protected override void VisitAtDirective(AtDirective item)
231263
{
232264
// Whenever we see "@keyframes something { ... }", we want to insert right after "something"

src/Razor/Microsoft.AspNetCore.Razor.Tools/test/RewriteCssCommandTest.cs

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,13 +41,19 @@ public void HandlesMultipleSelectors()
4141
var result = RewriteCssCommand.AddScopeToSelectors("file.css", @"
4242
.first, .second { color: red; }
4343
.third { color: blue; }
44+
:root { color: green; }
45+
* { color: white; }
46+
#some-id { color: yellow; }
4447
", "TestScope", out var diagnostics);
4548

4649
// Assert
4750
Assert.Empty(diagnostics);
4851
Assert.Equal(@"
4952
.first[TestScope], .second[TestScope] { color: red; }
5053
.third[TestScope] { color: blue; }
54+
:root[TestScope] { color: green; }
55+
*[TestScope] { color: white; }
56+
#some-id[TestScope] { color: yellow; }
5157
", result);
5258
}
5359

@@ -81,6 +87,83 @@ public void HandlesSpacesAndCommentsWithinSelectors()
8187
", result);
8288
}
8389

90+
[Fact]
91+
public void HandlesPseudoClasses()
92+
{
93+
// Arrange/act
94+
var result = RewriteCssCommand.AddScopeToSelectors("file.css", @"
95+
a:fake-pseudo-class { color: red; }
96+
a:focus b:hover { color: green; }
97+
tr:nth-child(4n + 1) { color: blue; }
98+
a:has(b > c) { color: yellow; }
99+
a:last-child > ::deep b { color: pink; }
100+
a:not(#something) { color: purple; }
101+
", "TestScope", out var diagnostics);
102+
103+
// Assert
104+
Assert.Empty(diagnostics);
105+
Assert.Equal(@"
106+
a:fake-pseudo-class[TestScope] { color: red; }
107+
a:focus b:hover[TestScope] { color: green; }
108+
tr:nth-child(4n + 1)[TestScope] { color: blue; }
109+
a:has(b > c)[TestScope] { color: yellow; }
110+
a:last-child[TestScope] > b { color: pink; }
111+
a:not(#something)[TestScope] { color: purple; }
112+
", result);
113+
}
114+
115+
[Fact]
116+
public void HandlesPseudoElements()
117+
{
118+
// Arrange/act
119+
var result = RewriteCssCommand.AddScopeToSelectors("file.css", @"
120+
a::before { content: ""✋""; }
121+
a::after::placeholder { content: ""🐯""; }
122+
custom-element::part(foo) { content: ""🤷‍""; }
123+
a::before > ::deep another { content: ""👞""; }
124+
a::fake-PsEuDo-element { content: ""🐔""; }
125+
::selection { content: ""😾""; }
126+
other, ::selection { content: ""👂""; }
127+
", "TestScope", out var diagnostics);
128+
129+
// Assert
130+
Assert.Empty(diagnostics);
131+
Assert.Equal(@"
132+
a[TestScope]::before { content: ""✋""; }
133+
a[TestScope]::after::placeholder { content: ""🐯""; }
134+
custom-element[TestScope]::part(foo) { content: ""🤷‍""; }
135+
a[TestScope]::before > another { content: ""👞""; }
136+
a[TestScope]::fake-PsEuDo-element { content: ""🐔""; }
137+
[TestScope]::selection { content: ""😾""; }
138+
other[TestScope], [TestScope]::selection { content: ""👂""; }
139+
", result);
140+
}
141+
142+
[Fact]
143+
public void HandlesSingleColonPseudoElements()
144+
{
145+
// Arrange/act
146+
var result = RewriteCssCommand.AddScopeToSelectors("file.css", @"
147+
a:after { content: ""x""; }
148+
a:before { content: ""x""; }
149+
a:first-letter { content: ""x""; }
150+
a:first-line { content: ""x""; }
151+
a:AFTER { content: ""x""; }
152+
a:not(something):before { content: ""x""; }
153+
", "TestScope", out var diagnostics);
154+
155+
// Assert
156+
Assert.Empty(diagnostics);
157+
Assert.Equal(@"
158+
a[TestScope]:after { content: ""x""; }
159+
a[TestScope]:before { content: ""x""; }
160+
a[TestScope]:first-letter { content: ""x""; }
161+
a[TestScope]:first-line { content: ""x""; }
162+
a[TestScope]:AFTER { content: ""x""; }
163+
a:not(something)[TestScope]:before { content: ""x""; }
164+
", result);
165+
}
166+
84167
[Fact]
85168
public void RespectsDeepCombinator()
86169
{

0 commit comments

Comments
 (0)