From d793f473c432c1868a71f89b46f35f517044373c Mon Sep 17 00:00:00 2001 From: Steve Sanderson Date: Fri, 28 Aug 2020 16:37:37 +0100 Subject: [PATCH] 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 --- .../src/RewriteCssCommand.cs | 68 +++++++++++---- .../test/RewriteCssCommandTest.cs | 83 +++++++++++++++++++ 2 files changed, 133 insertions(+), 18 deletions(-) diff --git a/src/Razor/Microsoft.AspNetCore.Razor.Tools/src/RewriteCssCommand.cs b/src/Razor/Microsoft.AspNetCore.Razor.Tools/src/RewriteCssCommand.cs index 3babba81e0..745bfb0384 100644 --- a/src/Razor/Microsoft.AspNetCore.Razor.Tools/src/RewriteCssCommand.cs +++ b/src/Razor/Microsoft.AspNetCore.Razor.Tools/src/RewriteCssCommand.cs @@ -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" diff --git a/src/Razor/Microsoft.AspNetCore.Razor.Tools/test/RewriteCssCommandTest.cs b/src/Razor/Microsoft.AspNetCore.Razor.Tools/test/RewriteCssCommandTest.cs index 37b1c37a30..76ab33e6ca 100644 --- a/src/Razor/Microsoft.AspNetCore.Razor.Tools/test/RewriteCssCommandTest.cs +++ b/src/Razor/Microsoft.AspNetCore.Razor.Tools/test/RewriteCssCommandTest.cs @@ -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() {