From c5ba43f0116309295cc66d50ff947269a6a96a5d Mon Sep 17 00:00:00 2001 From: Steve Sanderson Date: Wed, 8 Jul 2020 12:42:22 +0100 Subject: [PATCH] CSS isolation rewriter tool (#23657) --- eng/Dependencies.props | 1 + eng/Versions.props | 1 + .../src/Application.cs | 1 + .../Microsoft.AspNetCore.Razor.Tools.csproj | 1 + .../src/RewriteCssCommand.cs | 277 ++++++++++++++++++ .../test/RewriteCssCommandTest.cs | 174 +++++++++++ 6 files changed, 455 insertions(+) create mode 100644 src/Razor/Microsoft.AspNetCore.Razor.Tools/src/RewriteCssCommand.cs create mode 100644 src/Razor/Microsoft.AspNetCore.Razor.Tools/test/RewriteCssCommandTest.cs diff --git a/eng/Dependencies.props b/eng/Dependencies.props index a17d5470524..51caea76d33 100644 --- a/eng/Dependencies.props +++ b/eng/Dependencies.props @@ -21,6 +21,7 @@ and are generated based on the last package release. + diff --git a/eng/Versions.props b/eng/Versions.props index 87e8509d883..c926d918c76 100644 --- a/eng/Versions.props +++ b/eng/Versions.props @@ -188,6 +188,7 @@ 3.7.0-4.20351.7 3.7.0-4.20351.7 3.7.0-4.20351.7 + 1.0.0-20200708.1 3.19.8 5.5.0 5.5.0 diff --git a/src/Razor/Microsoft.AspNetCore.Razor.Tools/src/Application.cs b/src/Razor/Microsoft.AspNetCore.Razor.Tools/src/Application.cs index 73a05d570f7..d4c57b799d5 100644 --- a/src/Razor/Microsoft.AspNetCore.Razor.Tools/src/Application.cs +++ b/src/Razor/Microsoft.AspNetCore.Razor.Tools/src/Application.cs @@ -40,6 +40,7 @@ namespace Microsoft.AspNetCore.Razor.Tools Commands.Add(new DiscoverCommand(this)); Commands.Add(new GenerateCommand(this)); Commands.Add(new BrotliCompressCommand(this)); + Commands.Add(new RewriteCssCommand(this)); } public CancellationToken CancellationToken { get; } diff --git a/src/Razor/Microsoft.AspNetCore.Razor.Tools/src/Microsoft.AspNetCore.Razor.Tools.csproj b/src/Razor/Microsoft.AspNetCore.Razor.Tools/src/Microsoft.AspNetCore.Razor.Tools.csproj index 1489542b024..a12f1b86246 100644 --- a/src/Razor/Microsoft.AspNetCore.Razor.Tools/src/Microsoft.AspNetCore.Razor.Tools.csproj +++ b/src/Razor/Microsoft.AspNetCore.Razor.Tools/src/Microsoft.AspNetCore.Razor.Tools.csproj @@ -38,6 +38,7 @@ + diff --git a/src/Razor/Microsoft.AspNetCore.Razor.Tools/src/RewriteCssCommand.cs b/src/Razor/Microsoft.AspNetCore.Razor.Tools/src/RewriteCssCommand.cs new file mode 100644 index 00000000000..70f57167eb0 --- /dev/null +++ b/src/Razor/Microsoft.AspNetCore.Razor.Tools/src/RewriteCssCommand.cs @@ -0,0 +1,277 @@ +// 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; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using Microsoft.Css.Parser.Parser; +using Microsoft.Css.Parser.Tokens; +using Microsoft.Css.Parser.TreeItems; +using Microsoft.Css.Parser.TreeItems.AtDirectives; +using Microsoft.Css.Parser.TreeItems.Selectors; +using Microsoft.Extensions.CommandLineUtils; + +namespace Microsoft.AspNetCore.Razor.Tools +{ + internal class RewriteCssCommand : CommandBase + { + public RewriteCssCommand(Application parent) + : base(parent, "rewritecss") + { + Sources = Option("-s", "Files to rewrite", CommandOptionType.MultipleValue); + Outputs = Option("-o", "Output file paths", CommandOptionType.MultipleValue); + CssScopes = Option("-c", "CSS scope identifiers", CommandOptionType.MultipleValue); + } + + public CommandOption Sources { get; } + + public CommandOption Outputs { get; } + + public CommandOption CssScopes { get; } + + protected override bool ValidateArguments() + { + if (Sources.Values.Count != Outputs.Values.Count) + { + Error.WriteLine($"{Sources.Description} has {Sources.Values.Count}, but {Outputs.Description} has {Outputs.Values.Count} values."); + return false; + } + + if (Sources.Values.Count != CssScopes.Values.Count) + { + Error.WriteLine($"{Sources.Description} has {Sources.Values.Count}, but {CssScopes.Description} has {CssScopes.Values.Count} values."); + return false; + } + + return true; + } + + protected override Task ExecuteCoreAsync() + { + Parallel.For(0, Sources.Values.Count, i => + { + var source = Sources.Values[i]; + var output = Outputs.Values[i]; + var cssScope = CssScopes.Values[i]; + + var inputText = File.ReadAllText(source); + var rewrittenCss = AddScopeToSelectors(inputText, cssScope); + File.WriteAllText(output, rewrittenCss); + }); + + return Task.FromResult(ExitCodeSuccess); + } + + // Public for tests + public static string AddScopeToSelectors(string inputText, string cssScope) + { + var cssParser = new DefaultParserFactory().CreateParser(); + var stylesheet = cssParser.Parse(inputText, insertComments: false); + + var resultBuilder = new StringBuilder(); + var previousInsertionPosition = 0; + + var scopeInsertionPositionsVisitor = new FindScopeInsertionPositionsVisitor(stylesheet); + scopeInsertionPositionsVisitor.Visit(); + foreach (var (currentInsertionPosition, insertionType) in scopeInsertionPositionsVisitor.InsertionPositions) + { + resultBuilder.Append(inputText.Substring(previousInsertionPosition, currentInsertionPosition - previousInsertionPosition)); + + switch (insertionType) + { + case ScopeInsertionType.Selector: + resultBuilder.AppendFormat("[{0}]", cssScope); + break; + case ScopeInsertionType.KeyframesName: + resultBuilder.AppendFormat("-{0}", cssScope); + break; + default: + throw new NotImplementedException($"Unknown insertion type: '{insertionType}'"); + } + + + previousInsertionPosition = currentInsertionPosition; + } + + resultBuilder.Append(inputText.Substring(previousInsertionPosition)); + + return resultBuilder.ToString(); + } + + private static bool TryFindKeyframesIdentifier(AtDirective atDirective, out ParseItem identifier) + { + var keyword = atDirective.Keyword; + if (string.Equals(keyword?.Text, "keyframes", StringComparison.OrdinalIgnoreCase)) + { + var nextSiblingText = keyword.NextSibling?.Text; + if (!string.IsNullOrEmpty(nextSiblingText)) + { + identifier = keyword.NextSibling; + return true; + } + } + + identifier = null; + return false; + } + + private enum ScopeInsertionType + { + Selector, + KeyframesName, + } + + private class FindScopeInsertionPositionsVisitor : Visitor + { + public List<(int, ScopeInsertionType)> InsertionPositions { get; } = new List<(int, ScopeInsertionType)>(); + + private readonly HashSet _keyframeIdentifiers; + + public FindScopeInsertionPositionsVisitor(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); + keyframesIdentifiersVisitor.Visit(); + _keyframeIdentifiers = keyframesIdentifiersVisitor.KeyframesIdentifiers + .Select(x => x.Text) + .ToHashSet(StringComparer.Ordinal); // Keyframe names are case-sensitive + } + + protected override void VisitSelector(Selector selector) + { + // For a ruleset like ".first child, .second { ... }", we'll see two selectors: + // ".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 (lastSimpleSelector != null) + { + InsertionPositions.Add((lastSimpleSelector.AfterEnd, ScopeInsertionType.Selector)); + } + } + + protected override void VisitAtDirective(AtDirective item) + { + // Whenever we see "@keyframes something { ... }", we want to insert right after "something" + if (TryFindKeyframesIdentifier(item, out var identifier)) + { + InsertionPositions.Add((identifier.AfterEnd, ScopeInsertionType.KeyframesName)); + } + else + { + VisitDefault(item); + } + } + + protected override void VisitDeclaration(Declaration item) + { + switch (item.PropertyNameText) + { + case "animation": + case "animation-name": + // The first two tokens are and (otherwise we wouldn't be here). + // After that, any of the subsequent tokens might be the animation name. + // Unfortunately the rules for determining which token is the animation name are very + // complex - https://developer.mozilla.org/en-US/docs/Web/CSS/animation#Syntax + // Fortunately we only want to rewrite animation names that are explicitly declared in + // the same document (we don't want to add scopes to references to global keyframes) + // so it's sufficient just to match known animation names. + var animationNameTokens = item.Children.Skip(2).OfType() + .Where(x => x.TokenType == CssTokenType.Identifier && _keyframeIdentifiers.Contains(x.Text)); + foreach (var token in animationNameTokens) + { + InsertionPositions.Add((token.AfterEnd, ScopeInsertionType.KeyframesName)); + } + break; + default: + // We don't need to do anything else with other declaration types + break; + } + } + } + + private class FindKeyframesIdentifiersVisitor : Visitor + { + public FindKeyframesIdentifiersVisitor(ComplexItem root) : base(root) + { + } + + public List KeyframesIdentifiers { get; } = new List(); + + protected override void VisitAtDirective(AtDirective item) + { + if (TryFindKeyframesIdentifier(item, out var identifier)) + { + KeyframesIdentifiers.Add(identifier); + } + else + { + VisitDefault(item); + } + } + } + + private class Visitor + { + private readonly ComplexItem _root; + + public Visitor(ComplexItem root) + { + _root = root ?? throw new ArgumentNullException(nameof(root)); + } + + public void Visit() + { + VisitDefault(_root); + } + + protected virtual void VisitSelector(Selector item) + { + VisitDefault(item); + } + + protected virtual void VisitAtDirective(AtDirective item) + { + VisitDefault(item); + } + + protected virtual void VisitDeclaration(Declaration item) + { + VisitDefault(item); + } + + protected virtual void VisitDefault(ParseItem item) + { + if (item is ComplexItem complexItem) + { + VisitDescendants(complexItem); + } + } + + private void VisitDescendants(ComplexItem container) + { + foreach (var child in container.Children) + { + switch (child) + { + case Selector selector: + VisitSelector(selector); + break; + case AtDirective atDirective: + VisitAtDirective(atDirective); + break; + case Declaration declaration: + VisitDeclaration(declaration); + break; + default: + VisitDefault(child); + break; + } + } + } + } + } +} diff --git a/src/Razor/Microsoft.AspNetCore.Razor.Tools/test/RewriteCssCommandTest.cs b/src/Razor/Microsoft.AspNetCore.Razor.Tools/test/RewriteCssCommandTest.cs new file mode 100644 index 00000000000..7a5b1122338 --- /dev/null +++ b/src/Razor/Microsoft.AspNetCore.Razor.Tools/test/RewriteCssCommandTest.cs @@ -0,0 +1,174 @@ +// 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; + +namespace Microsoft.AspNetCore.Razor.Tools +{ + public class RewriteCssCommandTest + { + [Fact] + public void HandlesEmptyFile() + { + // Arrange/act + var result = RewriteCssCommand.AddScopeToSelectors(string.Empty, "TestScope"); + + // Assert + Assert.Equal(string.Empty, result); + } + + [Fact] + public void AddsScopeAfterSelector() + { + // Arrange/act + var result = RewriteCssCommand.AddScopeToSelectors(@" + .myclass { color: red; } +", "TestScope"); + + // Assert + Assert.Equal(@" + .myclass[TestScope] { color: red; } +", result); + } + + [Fact] + public void HandlesMultipleSelectors() + { + // Arrange/act + var result = RewriteCssCommand.AddScopeToSelectors(@" + .first, .second { color: red; } + .third { color: blue; } +", "TestScope"); + + // Assert + Assert.Equal(@" + .first[TestScope], .second[TestScope] { color: red; } + .third[TestScope] { color: blue; } +", result); + } + + [Fact] + public void HandlesComplexSelectors() + { + // Arrange/act + var result = RewriteCssCommand.AddScopeToSelectors(@" + .first div > li, body .second:not(.fancy)[attr~=whatever] { color: red; } +", "TestScope"); + + // Assert + Assert.Equal(@" + .first div > li[TestScope], body .second:not(.fancy)[attr~=whatever][TestScope] { color: red; } +", result); + } + + [Fact] + public void HandlesSpacesAndCommentsWithinSelectors() + { + // Arrange/act + var result = RewriteCssCommand.AddScopeToSelectors(@" + .first /* space at end {} */ div , .myclass /* comment at end */ { color: red; } +", "TestScope"); + + // Assert + Assert.Equal(@" + .first /* space at end {} */ div[TestScope] , .myclass[TestScope] /* comment at end */ { color: red; } +", result); + } + + [Fact] + public void HandlesAtBlocks() + { + // Arrange/act + var result = RewriteCssCommand.AddScopeToSelectors(@" + .myclass { color: red; } + + @media only screen and (max-width: 600px) { + .another .thing { + content: 'This should not be a selector: .fake-selector { color: red }' + } + } +", "TestScope"); + + // Assert + Assert.Equal(@" + .myclass[TestScope] { color: red; } + + @media only screen and (max-width: 600px) { + .another .thing[TestScope] { + content: 'This should not be a selector: .fake-selector { color: red }' + } + } +", result); + } + + [Fact] + public void AddsScopeToKeyframeNames() + { + // Arrange/act + var result = RewriteCssCommand.AddScopeToSelectors(@" + @keyframes my-animation { /* whatever */ } +", "TestScope"); + + // Assert + Assert.Equal(@" + @keyframes my-animation-TestScope { /* whatever */ } +", result); + } + + [Fact] + public void RewritesAnimationNamesWhenMatchingKnownKeyframes() + { + // Arrange/act + var result = RewriteCssCommand.AddScopeToSelectors(@" + .myclass { + color: red; + animation: /* ignore comment */ my-animation 1s infinite; + } + + .another-thing { animation-name: different-animation; } + + h1 { animation: unknown-animation; } /* Should not be scoped */ + + @keyframes my-animation { /* whatever */ } + @keyframes different-animation { /* whatever */ } + @keyframes unused-animation { /* whatever */ } +", "TestScope"); + + // Assert + Assert.Equal(@" + .myclass[TestScope] { + color: red; + animation: /* ignore comment */ my-animation-TestScope 1s infinite; + } + + .another-thing[TestScope] { animation-name: different-animation-TestScope; } + + h1[TestScope] { animation: unknown-animation; } /* Should not be scoped */ + + @keyframes my-animation-TestScope { /* whatever */ } + @keyframes different-animation-TestScope { /* whatever */ } + @keyframes unused-animation-TestScope { /* whatever */ } +", result); + } + + [Fact] + public void RewritesMultipleAnimationNames() + { + // Arrange/act + var result = RewriteCssCommand.AddScopeToSelectors(@" + .myclass1 { animation-name: my-animation , different-animation } + .myclass2 { animation: 4s linear 0s alternate my-animation infinite, different-animation 0s } + @keyframes my-animation { } + @keyframes different-animation { } +", "TestScope"); + + // Assert + Assert.Equal(@" + .myclass1[TestScope] { animation-name: my-animation-TestScope , different-animation-TestScope } + .myclass2[TestScope] { animation: 4s linear 0s alternate my-animation-TestScope infinite, different-animation-TestScope 0s } + @keyframes my-animation-TestScope { } + @keyframes different-animation-TestScope { } +", result); + } + } +}