From eb8764e80f6c7a69e09503ab2433091fb39baea0 Mon Sep 17 00:00:00 2001 From: Steve Sanderson Date: Fri, 24 Jul 2020 22:51:30 +0100 Subject: [PATCH] CSS scoping deep combinators (#24289) * Support deep combinators with CSS scoping * Update CSS scope computation --- .../src/RewriteCssCommand.cs | 86 +++++++++++++------ .../test/RewriteCssCommandTest.cs | 50 ++++++++++- .../src/ComputeCssScope.cs | 16 ++-- 3 files changed, 119 insertions(+), 33 deletions(-) diff --git a/src/Razor/Microsoft.AspNetCore.Razor.Tools/src/RewriteCssCommand.cs b/src/Razor/Microsoft.AspNetCore.Razor.Tools/src/RewriteCssCommand.cs index 70f57167eb..6369d3bc93 100644 --- a/src/Razor/Microsoft.AspNetCore.Razor.Tools/src/RewriteCssCommand.cs +++ b/src/Razor/Microsoft.AspNetCore.Razor.Tools/src/RewriteCssCommand.cs @@ -1,4 +1,4 @@ -// Copyright (c) .NET Foundation. All rights reserved. +// Copyright (c) .NET Foundation. All rights reserved. // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. using System; @@ -74,26 +74,27 @@ namespace Microsoft.AspNetCore.Razor.Tools var resultBuilder = new StringBuilder(); var previousInsertionPosition = 0; - var scopeInsertionPositionsVisitor = new FindScopeInsertionPositionsVisitor(stylesheet); + var scopeInsertionPositionsVisitor = new FindScopeInsertionEdits(stylesheet); scopeInsertionPositionsVisitor.Visit(); - foreach (var (currentInsertionPosition, insertionType) in scopeInsertionPositionsVisitor.InsertionPositions) + foreach (var edit in scopeInsertionPositionsVisitor.Edits) { - resultBuilder.Append(inputText.Substring(previousInsertionPosition, currentInsertionPosition - previousInsertionPosition)); - - switch (insertionType) + resultBuilder.Append(inputText.Substring(previousInsertionPosition, edit.Position - previousInsertionPosition)); + previousInsertionPosition = edit.Position; + + switch (edit) { - case ScopeInsertionType.Selector: + case InsertSelectorScopeEdit _: resultBuilder.AppendFormat("[{0}]", cssScope); break; - case ScopeInsertionType.KeyframesName: + case InsertKeyframesNameScopeEdit _: resultBuilder.AppendFormat("-{0}", cssScope); break; + case DeleteContentEdit deleteContentEdit: + previousInsertionPosition += deleteContentEdit.DeleteLength; + break; default: - throw new NotImplementedException($"Unknown insertion type: '{insertionType}'"); + throw new NotImplementedException($"Unknown edit type: '{edit}'"); } - - - previousInsertionPosition = currentInsertionPosition; } resultBuilder.Append(inputText.Substring(previousInsertionPosition)); @@ -118,19 +119,13 @@ namespace Microsoft.AspNetCore.Razor.Tools return false; } - private enum ScopeInsertionType + private class FindScopeInsertionEdits : Visitor { - Selector, - KeyframesName, - } - - private class FindScopeInsertionPositionsVisitor : Visitor - { - public List<(int, ScopeInsertionType)> InsertionPositions { get; } = new List<(int, ScopeInsertionType)>(); + public List Edits { get; } = new List(); private readonly HashSet _keyframeIdentifiers; - public FindScopeInsertionPositionsVisitor(ComplexItem root) : base(root) + public FindScopeInsertionEdits(ComplexItem root) : base(root) { // Before we start, we need to know the full set of keyframe names declared in this document var keyframesIdentifiersVisitor = new FindKeyframesIdentifiersVisitor(root); @@ -146,11 +141,34 @@ namespace Microsoft.AspNetCore.Razor.Tools // ".first child," containing two simple selectors: ".first" and "child" // ".second", containing one simple selector: ".second" // Our goal is to insert immediately after the final simple selector within each selector - var lastSimpleSelector = selector.Children.OfType().LastOrDefault(); + + // If there's a deep combinator among the sequence of simple selectors, we consider that to signal + // the end of the set of simple selectors for us to look at, plus we strip it out + var allSimpleSelectors = selector.Children.OfType(); + var firstDeepCombinator = allSimpleSelectors.FirstOrDefault(s => IsDeepCombinator(s.Text)); + + var lastSimpleSelector = allSimpleSelectors.TakeWhile(s => s != firstDeepCombinator).LastOrDefault(); if (lastSimpleSelector != null) { - InsertionPositions.Add((lastSimpleSelector.AfterEnd, ScopeInsertionType.Selector)); + Edits.Add(new InsertSelectorScopeEdit { Position = lastSimpleSelector.AfterEnd }); } + else if (firstDeepCombinator != null) + { + // For a leading deep combinator, we want to insert the scope attribute at the start + // Otherwise the result would be a CSS rule that isn't scoped at all + Edits.Add(new InsertSelectorScopeEdit { Position = firstDeepCombinator.Start }); + } + + // Also remove the deep combinator if we matched one + if (firstDeepCombinator != null) + { + Edits.Add(new DeleteContentEdit { Position = firstDeepCombinator.Start, DeleteLength = firstDeepCombinator.Length }); + } + } + + private static bool IsDeepCombinator(string simpleSelectorText) + { + return string.Equals(simpleSelectorText, "::deep", StringComparison.Ordinal); } protected override void VisitAtDirective(AtDirective item) @@ -158,7 +176,7 @@ namespace Microsoft.AspNetCore.Razor.Tools // Whenever we see "@keyframes something { ... }", we want to insert right after "something" if (TryFindKeyframesIdentifier(item, out var identifier)) { - InsertionPositions.Add((identifier.AfterEnd, ScopeInsertionType.KeyframesName)); + Edits.Add(new InsertKeyframesNameScopeEdit { Position = identifier.AfterEnd }); } else { @@ -183,7 +201,7 @@ namespace Microsoft.AspNetCore.Razor.Tools .Where(x => x.TokenType == CssTokenType.Identifier && _keyframeIdentifiers.Contains(x.Text)); foreach (var token in animationNameTokens) { - InsertionPositions.Add((token.AfterEnd, ScopeInsertionType.KeyframesName)); + Edits.Add(new InsertKeyframesNameScopeEdit { Position = token.AfterEnd }); } break; default: @@ -273,5 +291,23 @@ namespace Microsoft.AspNetCore.Razor.Tools } } } + + private abstract class CssEdit + { + public int Position { get; set; } + } + + private class InsertSelectorScopeEdit : CssEdit + { + } + + private class InsertKeyframesNameScopeEdit : CssEdit + { + } + + private class DeleteContentEdit : CssEdit + { + public int DeleteLength { get; set; } + } } } diff --git a/src/Razor/Microsoft.AspNetCore.Razor.Tools/test/RewriteCssCommandTest.cs b/src/Razor/Microsoft.AspNetCore.Razor.Tools/test/RewriteCssCommandTest.cs index 7a5b112233..57d8830a52 100644 --- a/src/Razor/Microsoft.AspNetCore.Razor.Tools/test/RewriteCssCommandTest.cs +++ b/src/Razor/Microsoft.AspNetCore.Razor.Tools/test/RewriteCssCommandTest.cs @@ -1,4 +1,4 @@ -// Copyright (c) .NET Foundation. All rights reserved. +// Copyright (c) .NET Foundation. All rights reserved. // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. using Xunit; @@ -75,6 +75,54 @@ namespace Microsoft.AspNetCore.Razor.Tools ", result); } + [Fact] + public void RespectsDeepCombinator() + { + // Arrange/act + var result = RewriteCssCommand.AddScopeToSelectors(@" + .first ::deep .second { color: red; } + a ::deep b, c ::deep d { color: blue; } +", "TestScope"); + + // Assert + Assert.Equal(@" + .first[TestScope] .second { color: red; } + a[TestScope] b, c[TestScope] d { color: blue; } +", result); + } + + [Fact] + public void IgnoresMultipleDeepCombinators() + { + // Arrange/act + var result = RewriteCssCommand.AddScopeToSelectors(@" + .first ::deep .second ::deep .third { color:red; } +", "TestScope"); + + // Assert + Assert.Equal(@" + .first[TestScope] .second ::deep .third { color:red; } +", result); + } + + [Fact] + public void RespectsDeepCombinatorWithSpacesAndComments() + { + // Arrange/act + var result = RewriteCssCommand.AddScopeToSelectors(@" + .a .b /* comment ::deep 1 */ ::deep /* comment ::deep 2 */ .c /* ::deep */ .d { color: red; } + ::deep * { color: blue; } /* Leading deep combinator */ + another ::deep { color: green } /* Trailing deep combinator */ +", "TestScope"); + + // Assert + Assert.Equal(@" + .a .b[TestScope] /* comment ::deep 1 */ /* comment ::deep 2 */ .c /* ::deep */ .d { color: red; } + [TestScope] * { color: blue; } /* Leading deep combinator */ + another[TestScope] { color: green } /* Trailing deep combinator */ +", result); + } + [Fact] public void HandlesAtBlocks() { diff --git a/src/Razor/Microsoft.NET.Sdk.Razor/src/ComputeCssScope.cs b/src/Razor/Microsoft.NET.Sdk.Razor/src/ComputeCssScope.cs index bd5b974289..d5301d8f9f 100644 --- a/src/Razor/Microsoft.NET.Sdk.Razor/src/ComputeCssScope.cs +++ b/src/Razor/Microsoft.NET.Sdk.Razor/src/ComputeCssScope.cs @@ -55,17 +55,19 @@ namespace Microsoft.AspNetCore.Razor.Tasks return builder.ToString(); } - private string ToBase36(byte[] hash) + private static string ToBase36(byte[] hash) { - var builder = new StringBuilder(); - const string chars = "abcdefghijklmnopqrstuvwxyz0123456789"; - var dividend = new BigInteger(hash.AsSpan().Slice(0,8).ToArray()); - while (dividend > 36) + const string chars = "0123456789abcdefghijklmnopqrstuvwxyz"; + + var result = new char[10]; + var dividend = BigInteger.Abs(new BigInteger(hash.AsSpan().Slice(0, 9).ToArray())); + for (var i = 0; i < 10; i++) { dividend = BigInteger.DivRem(dividend, 36, out var remainder); - builder.Insert(0, chars[Math.Abs(((int)remainder))]); + result[i] = chars[(int)remainder]; } - return builder.ToString(); + + return new string(result); } } }