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
This commit is contained in:
Steve Sanderson 2020-08-28 16:37:37 +01:00 committed by GitHub
parent ebaaa0f01d
commit d793f473c4
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
2 changed files with 133 additions and 18 deletions

View File

@ -187,7 +187,7 @@ namespace Microsoft.AspNetCore.Razor.Tools
var lastSimpleSelector = allSimpleSelectors.TakeWhile(s => s != firstDeepCombinator).LastOrDefault();
if (lastSimpleSelector != null)
{
Edits.Add(new InsertSelectorScopeEdit { Position = FindPositionBeforeTrailingCombinator(lastSimpleSelector) });
Edits.Add(new InsertSelectorScopeEdit { Position = FindPositionToInsertInSelector(lastSimpleSelector) });
}
else if (firstDeepCombinator != null)
{
@ -203,30 +203,62 @@ namespace Microsoft.AspNetCore.Razor.Tools
}
}
private int FindPositionBeforeTrailingCombinator(SimpleSelector lastSimpleSelector)
private int FindPositionToInsertInSelector(SimpleSelector lastSimpleSelector)
{
// For a selector like "a > ::deep b", the parser splits it as "a >", "::deep", "b".
// The place we want to insert the scope is right after "a", hence we need to detect
// if the simple selector ends with " >" or similar, and if so, insert before that.
var text = lastSimpleSelector.Text;
var lastChar = text.Length > 0 ? text[^1] : default;
switch (lastChar)
var children = lastSimpleSelector.Children;
for (var i = 0; i < children.Count; i++)
{
case '>':
case '+':
case '~':
var trailingCombinatorMatch = _trailingCombinatorRegex.Match(text);
if (trailingCombinatorMatch.Success)
{
var trailingCombinatorLength = trailingCombinatorMatch.Length;
return lastSimpleSelector.AfterEnd - trailingCombinatorLength;
}
break;
switch (children[i])
{
// Selectors like "a > ::deep b" get parsed as [[a][>]][::deep][b], and we want to
// insert right after the "a". So if we're processing a SimpleSelector like [[a][>]],
// consider the ">" to signal the "insert before" position.
case TokenItem t when IsTrailingCombinator(t.TokenType):
// Similarly selectors like "a::before" get parsed as [[a][::before]], and we want to
// insert right after the "a". So if we're processing a SimpleSelector like [[a][::before]],
// consider the pseudoelement to signal the "insert before" position.
case PseudoElementSelector:
case PseudoElementFunctionSelector:
case PseudoClassSelector s when IsSingleColonPseudoElement(s):
// Insert after the previous token if there is one, otherwise before the whole thing
return i > 0 ? children[i - 1].AfterEnd : lastSimpleSelector.Start;
}
}
// Since we didn't find any children that signal the insert-before position,
// insert after the whole thing
return lastSimpleSelector.AfterEnd;
}
private static bool IsSingleColonPseudoElement(PseudoClassSelector selector)
{
// See https://developer.mozilla.org/en-US/docs/Web/CSS/Pseudo-elements
// Normally, pseudoelements require a double-colon prefix. However the following "original set"
// of pseudoelements also support single-colon prefixes for back-compatibility with older versions
// of the W3C spec. Our CSS parser sees them as pseudoselectors rather than pseudoelements, so
// we have to special-case them. The single-colon option doesn't exist for other more modern
// pseudoelements.
var selectorText = selector.Text;
return string.Equals(selectorText, ":after", StringComparison.OrdinalIgnoreCase)
|| string.Equals(selectorText, ":before", StringComparison.OrdinalIgnoreCase)
|| string.Equals(selectorText, ":first-letter", StringComparison.OrdinalIgnoreCase)
|| string.Equals(selectorText, ":first-line", StringComparison.OrdinalIgnoreCase);
}
private static bool IsTrailingCombinator(CssTokenType tokenType)
{
switch (tokenType)
{
case CssTokenType.Plus:
case CssTokenType.Tilde:
case CssTokenType.Greater:
return true;
default:
return false;
}
}
protected override void VisitAtDirective(AtDirective item)
{
// Whenever we see "@keyframes something { ... }", we want to insert right after "something"

View File

@ -41,6 +41,9 @@ namespace Microsoft.AspNetCore.Razor.Tools
var result = RewriteCssCommand.AddScopeToSelectors("file.css", @"
.first, .second { color: red; }
.third { color: blue; }
:root { color: green; }
* { color: white; }
#some-id { color: yellow; }
", "TestScope", out var diagnostics);
// Assert
@ -48,6 +51,9 @@ namespace Microsoft.AspNetCore.Razor.Tools
Assert.Equal(@"
.first[TestScope], .second[TestScope] { color: red; }
.third[TestScope] { color: blue; }
:root[TestScope] { color: green; }
*[TestScope] { color: white; }
#some-id[TestScope] { color: yellow; }
", result);
}
@ -81,6 +87,83 @@ namespace Microsoft.AspNetCore.Razor.Tools
", result);
}
[Fact]
public void HandlesPseudoClasses()
{
// Arrange/act
var result = RewriteCssCommand.AddScopeToSelectors("file.css", @"
a:fake-pseudo-class { color: red; }
a:focus b:hover { color: green; }
tr:nth-child(4n + 1) { color: blue; }
a:has(b > c) { color: yellow; }
a:last-child > ::deep b { color: pink; }
a:not(#something) { color: purple; }
", "TestScope", out var diagnostics);
// Assert
Assert.Empty(diagnostics);
Assert.Equal(@"
a:fake-pseudo-class[TestScope] { color: red; }
a:focus b:hover[TestScope] { color: green; }
tr:nth-child(4n + 1)[TestScope] { color: blue; }
a:has(b > c)[TestScope] { color: yellow; }
a:last-child[TestScope] > b { color: pink; }
a:not(#something)[TestScope] { color: purple; }
", result);
}
[Fact]
public void HandlesPseudoElements()
{
// Arrange/act
var result = RewriteCssCommand.AddScopeToSelectors("file.css", @"
a::before { content: """"; }
a::after::placeholder { content: ""🐯""; }
custom-element::part(foo) { content: ""🤷""; }
a::before > ::deep another { content: ""👞""; }
a::fake-PsEuDo-element { content: ""🐔""; }
::selection { content: ""😾""; }
other, ::selection { content: ""👂""; }
", "TestScope", out var diagnostics);
// Assert
Assert.Empty(diagnostics);
Assert.Equal(@"
a[TestScope]::before { content: """"; }
a[TestScope]::after::placeholder { content: ""🐯""; }
custom-element[TestScope]::part(foo) { content: ""🤷""; }
a[TestScope]::before > another { content: ""👞""; }
a[TestScope]::fake-PsEuDo-element { content: ""🐔""; }
[TestScope]::selection { content: ""😾""; }
other[TestScope], [TestScope]::selection { content: ""👂""; }
", result);
}
[Fact]
public void HandlesSingleColonPseudoElements()
{
// Arrange/act
var result = RewriteCssCommand.AddScopeToSelectors("file.css", @"
a:after { content: ""x""; }
a:before { content: ""x""; }
a:first-letter { content: ""x""; }
a:first-line { content: ""x""; }
a:AFTER { content: ""x""; }
a:not(something):before { content: ""x""; }
", "TestScope", out var diagnostics);
// Assert
Assert.Empty(diagnostics);
Assert.Equal(@"
a[TestScope]:after { content: ""x""; }
a[TestScope]:before { content: ""x""; }
a[TestScope]:first-letter { content: ""x""; }
a[TestScope]:first-line { content: ""x""; }
a[TestScope]:AFTER { content: ""x""; }
a:not(something)[TestScope]:before { content: ""x""; }
", result);
}
[Fact]
public void RespectsDeepCombinator()
{