From d1883b01ad714497bf284e00982a4330267a53e8 Mon Sep 17 00:00:00 2001 From: Mackinnon Buck Date: Tue, 25 Apr 2023 15:22:02 -0700 Subject: [PATCH 1/2] Fix some dependencies, make Brotli conditional --- .../Targets/Sdk.Razor.CurrentVersion.targets | 18 ++++++++++++++++-- ...NET.Sdk.StaticWebAssets.Compression.targets | 4 ++-- ...t.NET.Sdk.StaticWebAssets.JSModules.targets | 2 +- ...T.Sdk.StaticWebAssets.ScopedCss.5_0.targets | 4 ++-- ...t.NET.Sdk.StaticWebAssets.ScopedCss.targets | 4 ++-- 5 files changed, 23 insertions(+), 9 deletions(-) diff --git a/src/RazorSdk/Targets/Sdk.Razor.CurrentVersion.targets b/src/RazorSdk/Targets/Sdk.Razor.CurrentVersion.targets index f9148b7f1ff4..7132fdf3779f 100644 --- a/src/RazorSdk/Targets/Sdk.Razor.CurrentVersion.targets +++ b/src/RazorSdk/Targets/Sdk.Razor.CurrentVersion.targets @@ -198,6 +198,16 @@ Copyright (c) .NET Foundation. All rights reserved. ResolveRazorComponentInputs; AssignRazorComponentTargetPaths + + + $(AssignRazorComponentTargetPathsDependsOn); + ResolveCssScopes; + + + + $(AssignRazorGenerateTargetPathsDependsOn); + ResolveCssScopes; + - + @@ -159,7 +159,7 @@ Integration with static web assets: - + <_ScopedCssIntermediatePath>$([System.IO.Path]::GetFullPath($(IntermediateOutputPath)scopedcss\)) diff --git a/src/StaticWebAssetsSdk/Targets/Microsoft.NET.Sdk.StaticWebAssets.ScopedCss.targets b/src/StaticWebAssetsSdk/Targets/Microsoft.NET.Sdk.StaticWebAssets.ScopedCss.targets index 4c1e862eacbe..b8bb15f389d8 100644 --- a/src/StaticWebAssetsSdk/Targets/Microsoft.NET.Sdk.StaticWebAssets.ScopedCss.targets +++ b/src/StaticWebAssetsSdk/Targets/Microsoft.NET.Sdk.StaticWebAssets.ScopedCss.targets @@ -122,7 +122,7 @@ Integration with static web assets: - + @@ -137,7 +137,7 @@ Integration with static web assets: - + <_ScopedCssIntermediatePath>$([System.IO.Path]::GetFullPath($(IntermediateOutputPath)scopedcss\)) From a3790afb60f635d29e695af1f2a09fbfc2d37ca7 Mon Sep 17 00:00:00 2001 From: Mackinnon Buck Date: Wed, 26 Apr 2023 15:22:09 -0700 Subject: [PATCH 2/2] Remove Razor tool assembly dependency in SWA --- .../Targets/Sdk.Razor.CurrentVersion.targets | 43 +- src/RazorSdk/Tool/Application.cs | 1 - src/RazorSdk/Tool/RewriteCssCommand.cs | 433 ---------------- ....Sdk.StaticWebAssets.ScopedCss.5_0.targets | 37 +- ....NET.Sdk.StaticWebAssets.ScopedCss.targets | 41 +- ...Sdk.StaticWebAssets.CurrentVersion.targets | 1 - ...osoft.NET.Sdk.StaticWebAssets.Tasks.csproj | 31 +- src/StaticWebAssetsSdk/Tasks/RewriteCss.cs | 475 +++++++++++++++++- .../RewriteCssTest.cs | 392 +++++++++++++++ .../RewriteCssCommandTest.cs | 392 --------------- 10 files changed, 904 insertions(+), 942 deletions(-) delete mode 100644 src/RazorSdk/Tool/RewriteCssCommand.cs create mode 100644 src/Tests/Microsoft.NET.Sdk.BlazorWebAssembly.Tests/RewriteCssTest.cs delete mode 100644 src/Tests/Microsoft.NET.Sdk.Razor.Tool.Tests/RewriteCssCommandTest.cs diff --git a/src/RazorSdk/Targets/Sdk.Razor.CurrentVersion.targets b/src/RazorSdk/Targets/Sdk.Razor.CurrentVersion.targets index 7132fdf3779f..e64b9c255d58 100644 --- a/src/RazorSdk/Targets/Sdk.Razor.CurrentVersion.targets +++ b/src/RazorSdk/Targets/Sdk.Razor.CurrentVersion.targets @@ -174,6 +174,11 @@ Copyright (c) .NET Foundation. All rights reserved. ResolveRazorEmbeddedResources; + + $(ResolveScopedCssOutputsDependsOn); + ResolveCssScopes; + + $(DebugSymbolsProjectOutputGroupDependsOn); _RazorAddDebugSymbolsProjectOutputGroupOutput @@ -198,16 +203,6 @@ Copyright (c) .NET Foundation. All rights reserved. ResolveRazorComponentInputs; AssignRazorComponentTargetPaths - - - $(AssignRazorComponentTargetPathsDependsOn); - ResolveCssScopes; - - - - $(AssignRazorGenerateTargetPathsDependsOn); - ResolveCssScopes; - + + + + + + + + + + + + + @@ -564,9 +579,7 @@ Copyright (c) .NET Foundation. All rights reserved. - + @@ -586,9 +599,7 @@ Copyright (c) .NET Foundation. All rights reserved. - + diff --git a/src/RazorSdk/Tool/Application.cs b/src/RazorSdk/Tool/Application.cs index d1dd2f035527..634fa1c707b8 100644 --- a/src/RazorSdk/Tool/Application.cs +++ b/src/RazorSdk/Tool/Application.cs @@ -40,7 +40,6 @@ public Application( Commands.Add(new ShutdownCommand(this)); Commands.Add(new DiscoverCommand(this)); Commands.Add(new GenerateCommand(this)); - Commands.Add(new RewriteCssCommand(this)); } public CancellationToken CancellationToken { get; } diff --git a/src/RazorSdk/Tool/RewriteCssCommand.cs b/src/RazorSdk/Tool/RewriteCssCommand.cs deleted file mode 100644 index 663fc720b4a7..000000000000 --- a/src/RazorSdk/Tool/RewriteCssCommand.cs +++ /dev/null @@ -1,433 +0,0 @@ -// Copyright (c) .NET Foundation and contributors. All rights reserved. -// Licensed under the MIT license. See LICENSE file in the project root for full license information. -// - -using System; -using System.Collections.Concurrent; -using System.Collections.Generic; -using System.Globalization; -using System.IO; -using System.Linq; -using System.Text; -using System.Text.RegularExpressions; -using System.Threading.Tasks; -using Microsoft.AspNetCore.Razor.Language; -using Microsoft.CodeAnalysis.Text; -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.NET.Sdk.Razor.Tool.CommandLineUtils; - -namespace Microsoft.NET.Sdk.Razor.Tool -{ - internal class RewriteCssCommand : CommandBase - { - private const string DeepCombinatorText = "::deep"; - private readonly static TimeSpan _regexTimeout = TimeSpan.FromSeconds(1); - private readonly static Regex _deepCombinatorRegex = new Regex($@"^{DeepCombinatorText}\s*", RegexOptions.None, _regexTimeout); - private readonly static Regex _trailingCombinatorRegex = new Regex(@"\s+[\>\+\~]$", RegexOptions.None, _regexTimeout); - - 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() - { - var allDiagnostics = new ConcurrentQueue(); - - Parallel.For(0, Sources.Values.Count, i => - { - var source = Sources.Values[i]; - var output = Outputs.Values[i]; - var cssScope = CssScopes.Values[i]; - - using var inputSourceStream = new FileStream(source, FileMode.Open, FileAccess.Read, FileShare.ReadWrite | FileShare.Delete); - var inputSourceText = SourceText.From(inputSourceStream); - - var rewrittenCss = AddScopeToSelectors(source, inputSourceText, cssScope, out var diagnostics); - if (diagnostics.Any()) - { - foreach (var diagnostic in diagnostics) - { - allDiagnostics.Enqueue(diagnostic); - } - } - else - { - File.WriteAllText(output, rewrittenCss); - } - }); - - foreach (var diagnostic in allDiagnostics) - { - Error.WriteLine(diagnostic.ToString()); - } - - return Task.FromResult(allDiagnostics.Any() ? ExitCodeFailure : ExitCodeSuccess); - } - - // Public for tests - public static string AddScopeToSelectors(string filePath, string inputSource, string cssScope, out IEnumerable diagnostics) - => AddScopeToSelectors(filePath, SourceText.From(inputSource), cssScope, out diagnostics); - - private static string AddScopeToSelectors(string filePath, SourceText inputSourceText, string cssScope, out IEnumerable diagnostics) - { - var cssParser = new DefaultParserFactory().CreateParser(); - var inputText = inputSourceText.ToString(); - var stylesheet = cssParser.Parse(inputText, insertComments: false); - - var resultBuilder = new StringBuilder(); - var previousInsertionPosition = 0; - var foundDiagnostics = new List(); - - var ensureNoImportsVisitor = new EnsureNoImports(filePath, inputSourceText, stylesheet, foundDiagnostics); - ensureNoImportsVisitor.Visit(); - - var scopeInsertionPositionsVisitor = new FindScopeInsertionEdits(stylesheet); - scopeInsertionPositionsVisitor.Visit(); - foreach (var edit in scopeInsertionPositionsVisitor.Edits) - { - resultBuilder.Append(inputText.Substring(previousInsertionPosition, edit.Position - previousInsertionPosition)); - previousInsertionPosition = edit.Position; - - switch (edit) - { - case InsertSelectorScopeEdit _: - resultBuilder.AppendFormat(CultureInfo.InvariantCulture, "[{0}]", cssScope); - break; - case InsertKeyframesNameScopeEdit _: - resultBuilder.AppendFormat(CultureInfo.InvariantCulture, "-{0}", cssScope); - break; - case DeleteContentEdit deleteContentEdit: - previousInsertionPosition += deleteContentEdit.DeleteLength; - break; - default: - throw new NotImplementedException($"Unknown edit type: '{edit}'"); - } - } - - resultBuilder.Append(inputText.Substring(previousInsertionPosition)); - - diagnostics = foundDiagnostics; - 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 class FindScopeInsertionEdits : Visitor - { - public List Edits { get; } = new List(); - - private readonly HashSet _keyframeIdentifiers; - - 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); - 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 - - // 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 => _deepCombinatorRegex.IsMatch(s.Text)); - - var lastSimpleSelector = allSimpleSelectors.TakeWhile(s => s != firstDeepCombinator).LastOrDefault(); - if (lastSimpleSelector != null) - { - Edits.Add(new InsertSelectorScopeEdit { Position = FindPositionToInsertInSelector(lastSimpleSelector) }); - } - 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 = DeepCombinatorText.Length }); - } - } - - private int FindPositionToInsertInSelector(SimpleSelector lastSimpleSelector) - { - var children = lastSimpleSelector.Children; - for (var i = 0; i < children.Count; i++) - { - 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" - if (TryFindKeyframesIdentifier(item, out var identifier)) - { - Edits.Add(new InsertKeyframesNameScopeEdit { Position = identifier.AfterEnd }); - } - 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) - { - Edits.Add(new InsertKeyframesNameScopeEdit { Position = token.AfterEnd }); - } - 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 EnsureNoImports : Visitor - { - private readonly string _filePath; - private readonly SourceText _sourceText; - private readonly List _diagnostics; - - public EnsureNoImports(string filePath, SourceText sourceText, ComplexItem root, List diagnostics) : base(root) - { - _filePath = filePath; - _sourceText = sourceText; - _diagnostics = diagnostics; - } - - protected override void VisitAtDirective(AtDirective item) - { - if (item.Children.Count >= 2 - && item.Children[0] is TokenItem firstChild - && firstChild.TokenType == CssTokenType.At - && item.Children[1] is TokenItem secondChild - && string.Equals(secondChild.Text, "import", StringComparison.OrdinalIgnoreCase)) - { - var linePosition = _sourceText.Lines.GetLinePosition(item.Start); - var sourceSpan = new SourceSpan(_filePath, item.Start, linePosition.Line, linePosition.Character, item.Length); - _diagnostics.Add(RazorDiagnosticFactory.CreateCssRewriting_ImportNotAllowed(sourceSpan)); - } - - base.VisitAtDirective(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; - } - } - } - } - - 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/StaticWebAssetsSdk/Targets/Microsoft.NET.Sdk.StaticWebAssets.ScopedCss.5_0.targets b/src/StaticWebAssetsSdk/Targets/Microsoft.NET.Sdk.StaticWebAssets.ScopedCss.5_0.targets index 481197cd3691..f21e819e6113 100644 --- a/src/StaticWebAssetsSdk/Targets/Microsoft.NET.Sdk.StaticWebAssets.ScopedCss.5_0.targets +++ b/src/StaticWebAssetsSdk/Targets/Microsoft.NET.Sdk.StaticWebAssets.ScopedCss.5_0.targets @@ -83,6 +83,10 @@ Integration with static web assets: _AddScopedCssBundles; + + $(ResolveScopedCssOutputsDependsOn); + + @@ -135,31 +139,15 @@ Integration with static web assets: - + - - - - - - - - - - - - - - - + <_ScopedCssIntermediatePath>$([System.IO.Path]::GetFullPath($(IntermediateOutputPath)scopedcss\)) @@ -181,19 +169,14 @@ Integration with static web assets: - + - - + @@ -324,7 +307,7 @@ Integration with static web assets: - + diff --git a/src/StaticWebAssetsSdk/Targets/Microsoft.NET.Sdk.StaticWebAssets.ScopedCss.targets b/src/StaticWebAssetsSdk/Targets/Microsoft.NET.Sdk.StaticWebAssets.ScopedCss.targets index b8bb15f389d8..2b25919ca191 100644 --- a/src/StaticWebAssetsSdk/Targets/Microsoft.NET.Sdk.StaticWebAssets.ScopedCss.targets +++ b/src/StaticWebAssetsSdk/Targets/Microsoft.NET.Sdk.StaticWebAssets.ScopedCss.targets @@ -65,6 +65,10 @@ Integration with static web assets: BundleScopedCssFiles; + + $(ResolveScopedCssOutputsDependsOn); + + @@ -80,6 +84,10 @@ Integration with static web assets: $(GenerateComputedBuildStaticWebAssetsDependsOn); + + $(ResolveScopedCssOutputsDependsOn); + + @@ -113,31 +121,15 @@ Integration with static web assets: - + - - - - - - - - - - - - - - - + <_ScopedCssIntermediatePath>$([System.IO.Path]::GetFullPath($(IntermediateOutputPath)scopedcss\)) @@ -160,19 +152,14 @@ Integration with static web assets: - + - - + @@ -298,7 +285,7 @@ Integration with static web assets: - + <_ScopedCssCandidateFile Include="%(_ScopedCss.OutputFile)" Condition="@(_ScopedCss) != ''"> %(_ScopedCss.RelativePath) diff --git a/src/StaticWebAssetsSdk/Targets/Sdk.StaticWebAssets.CurrentVersion.targets b/src/StaticWebAssetsSdk/Targets/Sdk.StaticWebAssets.CurrentVersion.targets index 1fbe9382d922..e38433b0ee5b 100644 --- a/src/StaticWebAssetsSdk/Targets/Sdk.StaticWebAssets.CurrentVersion.targets +++ b/src/StaticWebAssetsSdk/Targets/Sdk.StaticWebAssets.CurrentVersion.targets @@ -32,7 +32,6 @@ Copyright (c) .NET Foundation. All rights reserved. <_StaticWebAssetsSdkTasksTFM Condition=" '$(_StaticWebAssetsSdkTasksTFM)' == ''">net472 $(StaticWebAssetsSdkBuildTasksDirectoryRoot)$(_StaticWebAssetsSdkTasksTFM)\Microsoft.NET.Sdk.StaticWebAssets.Tasks.dll <_StaticWebAssetsSdkToolAssembly>$(StaticWebAssetsSdkDirectoryRoot)tools\net8.0\Microsoft.NET.Sdk.StaticWebAssets.Tool.dll - <_StaticWebAssetsSdkRazorToolAssembly>$(StaticWebAssetsSdkDirectoryRoot)..\Microsoft.NET.Sdk.Razor\tools\rzc.dll diff --git a/src/StaticWebAssetsSdk/Tasks/Microsoft.NET.Sdk.StaticWebAssets.Tasks.csproj b/src/StaticWebAssetsSdk/Tasks/Microsoft.NET.Sdk.StaticWebAssets.Tasks.csproj index e8541a00012b..295c5c5d6639 100644 --- a/src/StaticWebAssetsSdk/Tasks/Microsoft.NET.Sdk.StaticWebAssets.Tasks.csproj +++ b/src/StaticWebAssetsSdk/Tasks/Microsoft.NET.Sdk.StaticWebAssets.Tasks.csproj @@ -41,28 +41,11 @@ + - - Shared\CommandLine\%(FileName) - - - Shared\ServerProtocol\%(FileName) - - - Shared\PipeName.cs - - - Shared\MutexName.cs - - - Shared\Client.cs - - - Shared\DotnetToolTask.cs - @@ -95,6 +78,18 @@ + + + <_CssParser Include="@(ReferencePath)" Condition="'%(ReferencePath.NuGetPackageId)' == 'Microsoft.Css.Parser'" /> + <_CssParserContent Include ="@(_CssParser)" TargetPath="tasks\$(SdkTargetFramework)\%(_CssParser.Filename)%(_CssParser.Extension)" /> + + + + + + + + diff --git a/src/StaticWebAssetsSdk/Tasks/RewriteCss.cs b/src/StaticWebAssetsSdk/Tasks/RewriteCss.cs index 46326c91ec74..5b74d699de75 100644 --- a/src/StaticWebAssetsSdk/Tasks/RewriteCss.cs +++ b/src/StaticWebAssetsSdk/Tasks/RewriteCss.cs @@ -2,60 +2,481 @@ // Licensed under the MIT license. See LICENSE file in the project root for full license information. // +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Globalization; using System.IO; +using System.Linq; using System.Text; -using Microsoft.AspNetCore.Razor.Tasks; +using System.Text.RegularExpressions; using Microsoft.Build.Framework; +using Microsoft.Build.Utilities; +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; namespace Microsoft.AspNetCore.StaticWebAssets.Tasks { - public class RewriteCss : DotNetToolTask + public class RewriteCss : Task { + // Public for testing. + public const string ImportNotAllowedErrorMessage = + "{0}({1},{2}): @import rules are not supported within scoped CSS files because the loading order would be undefined. " + + "@import may only be placed in non-scoped CSS files."; + private const string DeepCombinatorText = "::deep"; + + private static readonly TimeSpan s_regexTimeout = TimeSpan.FromSeconds(1); + private static readonly Regex s_deepCombinatorRegex = new($@"^{DeepCombinatorText}\s*", RegexOptions.None, s_regexTimeout); + [Required] public ITaskItem[] FilesToTransform { get; set; } public bool SkipIfOutputIsNewer { get; set; } = true; - internal override string Command => "rewritecss"; - - protected override string GenerateResponseFileCommands() + public override bool Execute() { - var builder = new StringBuilder(); + var allDiagnostics = new ConcurrentQueue(); - builder.AppendLine(Command); - - for (var i = 0; i < FilesToTransform.Length; i++) + System.Threading.Tasks.Parallel.For(0, FilesToTransform.Length, i => { var input = FilesToTransform[i]; - var inputFullPath = input.GetMetadata("FullPath"); - var relativePath = input.GetMetadata("RelativePath"); + var inputFile = input.GetMetadata("FullPath"); + var outputFile = input.GetMetadata("OutputFile"); var cssScope = input.GetMetadata("CssScope"); - var outputPath = input.GetMetadata("OutputFile"); - if (SkipIfOutputIsNewer && File.Exists(outputPath) && File.GetLastWriteTimeUtc(inputFullPath) < File.GetLastWriteTimeUtc(outputPath)) + if (SkipIfOutputIsNewer && File.Exists(outputFile) && File.GetLastWriteTimeUtc(inputFile) < File.GetLastWriteTimeUtc(outputFile)) { - Log.LogMessage(MessageImportance.Low, $"Skipping scope transformation for '{input.ItemSpec}' because '{outputPath}' is newer than '{input.ItemSpec}'."); - continue; + Log.LogMessage(MessageImportance.Low, $"Skipping scope transformation for '{input.ItemSpec}' because '{outputFile}' is newer than '{input.ItemSpec}'."); + return; } - builder.AppendLine("-s"); - builder.AppendLine(inputFullPath); + // Create the directory for the output file in case it doesn't exist. + // It's easier to do it here than on MSBuild. + Directory.CreateDirectory(Path.GetDirectoryName(outputFile)); - builder.AppendLine("-o"); - builder.AppendLine(outputPath); + var inputText = File.ReadAllText(inputFile); + var sourceFile = new SourceFile(inputText); - // Create the directory for the output file in case it doesn't exist. - // Its easier to do it here than on MSBuild. Alternatively the tool could have taken care of it. - Directory.CreateDirectory(Path.GetDirectoryName(outputPath)); + var rewrittenCss = AddScopeToSelectors(inputFile, inputText, cssScope, out var errors); + if (errors.Any()) + { + foreach (var error in errors) + { + Log.LogError(error.Message, error.MessageArgs); + } + } + else + { + File.WriteAllText(outputFile, rewrittenCss); + } + }); + + return !Log.HasLoggedErrors; + } + + // Public for testing. + public static string AddScopeToSelectors(string filePath, string text, string cssScope, out IEnumerable errors) + => AddScopeToSelectors(filePath, new SourceFile(text), cssScope, out errors); + + private static string AddScopeToSelectors(string filePath, in SourceFile sourceFile, string cssScope, out IEnumerable errors) + { + var cssParser = new DefaultParserFactory().CreateParser(); + var stylesheet = cssParser.Parse(sourceFile.Text, insertComments: false); + + var resultBuilder = new StringBuilder(); + var previousInsertionPosition = 0; + var foundErrors = new List(); + + var ensureNoImportsVisitor = new EnsureNoImports(filePath, sourceFile, stylesheet, foundErrors); + ensureNoImportsVisitor.Visit(); + + var scopeInsertionPositionsVisitor = new FindScopeInsertionEdits(stylesheet); + scopeInsertionPositionsVisitor.Visit(); + foreach (var edit in scopeInsertionPositionsVisitor.Edits) + { + resultBuilder.Append(sourceFile.Text.Substring(previousInsertionPosition, edit.Position - previousInsertionPosition)); + previousInsertionPosition = edit.Position; + + switch (edit) + { + case InsertSelectorScopeEdit _: + resultBuilder.AppendFormat(CultureInfo.InvariantCulture, "[{0}]", cssScope); + break; + case InsertKeyframesNameScopeEdit _: + resultBuilder.AppendFormat(CultureInfo.InvariantCulture, "-{0}", cssScope); + break; + case DeleteContentEdit deleteContentEdit: + previousInsertionPosition += deleteContentEdit.DeleteLength; + break; + default: + throw new NotImplementedException($"Unknown edit type: '{edit}'"); + } + } + + resultBuilder.Append(sourceFile.Text.Substring(previousInsertionPosition)); + + errors = foundErrors; + return resultBuilder.ToString(); + } - builder.AppendLine("-c"); - builder.AppendLine(cssScope); + 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; + } } - return builder.ToString(); + identifier = null; + return false; } - internal static string CalculateTargetPath(string relativePath, string extension) => - Path.ChangeExtension(relativePath, $"{extension}{Path.GetExtension(relativePath)}"); + private class FindScopeInsertionEdits : Visitor + { + public List Edits { get; } = new List(); + + private readonly HashSet _keyframeIdentifiers; + + 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); + 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 + + // 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 => s_deepCombinatorRegex.IsMatch(s.Text)); + + var lastSimpleSelector = allSimpleSelectors.TakeWhile(s => s != firstDeepCombinator).LastOrDefault(); + if (lastSimpleSelector != null) + { + Edits.Add(new InsertSelectorScopeEdit { Position = FindPositionToInsertInSelector(lastSimpleSelector) }); + } + 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 = DeepCombinatorText.Length }); + } + } + + private int FindPositionToInsertInSelector(SimpleSelector lastSimpleSelector) + { + var children = lastSimpleSelector.Children; + for (var i = 0; i < children.Count; i++) + { + 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" + if (TryFindKeyframesIdentifier(item, out var identifier)) + { + Edits.Add(new InsertKeyframesNameScopeEdit { Position = identifier.AfterEnd }); + } + 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) + { + Edits.Add(new InsertKeyframesNameScopeEdit { Position = token.AfterEnd }); + } + 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 EnsureNoImports : Visitor + { + private readonly string _filePath; + private readonly SourceFile _sourceFile; + private readonly List _diagnostics; + + public EnsureNoImports(string filePath, in SourceFile sourceFile, ComplexItem root, List diagnostics) : base(root) + { + _filePath = filePath; + _sourceFile = sourceFile; + _diagnostics = diagnostics; + } + + protected override void VisitAtDirective(AtDirective item) + { + if (item.Children.Count >= 2 + && item.Children[0] is TokenItem firstChild + && firstChild.TokenType == CssTokenType.At + && item.Children[1] is TokenItem secondChild + && string.Equals(secondChild.Text, "import", StringComparison.OrdinalIgnoreCase)) + { + var location = _sourceFile.GetLocation(item.Start); + _diagnostics.Add(new(ImportNotAllowedErrorMessage, _filePath, location.Line, location.Character)); + } + + base.VisitAtDirective(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; + } + } + } + } + + 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; } + } + + private class SourceFile + { + private List _lineStartIndices; + + public string Text { get; } + + public SourceFile(string text) + { + Text = text; + } + + public SourceLocation GetLocation(int charIndex) + { + if (charIndex < 0) + { + throw new ArgumentOutOfRangeException(nameof(charIndex), charIndex, message: null); + } + + _lineStartIndices ??= GetLineStartIndices(Text); + + var index = _lineStartIndices.BinarySearch(charIndex); + var line = index < 0 ? -index - 1 : index + 1; + var lastLineStart = _lineStartIndices[line - 1]; + var character = charIndex - lastLineStart + 1; + return new(line, character); + } + + private static List GetLineStartIndices(string text) + { + var result = new List() { 0 }; + for (var i = 0; i < text.Length; i++) + { + if (text[i] == '\n') + { + result.Add(i + 1); + } + } + return result; + } + } + + private readonly struct SourceLocation + { + public int Line { get; } + public int Character { get; } + + public SourceLocation(int line, int character) + { + Line = line; + Character = character; + } + } + + // Public for testing. + public readonly struct ErrorMessage + { + public string Message { get; } + + public object[] MessageArgs { get; } + + public ErrorMessage(string message, params object[] messageArgs) + { + Message = message; + MessageArgs = messageArgs; + } + + public override string ToString() => string.Format(Message, MessageArgs); + } } } diff --git a/src/Tests/Microsoft.NET.Sdk.BlazorWebAssembly.Tests/RewriteCssTest.cs b/src/Tests/Microsoft.NET.Sdk.BlazorWebAssembly.Tests/RewriteCssTest.cs new file mode 100644 index 000000000000..fa503982a4a7 --- /dev/null +++ b/src/Tests/Microsoft.NET.Sdk.BlazorWebAssembly.Tests/RewriteCssTest.cs @@ -0,0 +1,392 @@ +// Copyright (c) .NET Foundation and contributors. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. +// + +using System.Linq; +using Microsoft.AspNetCore.StaticWebAssets.Tasks; +using Xunit; + +namespace Microsoft.NET.Sdk.BlazorWebAssembly.Tests; + +public class RewriteCssTest +{ + [Fact] + public void HandlesEmptyFile() + { + // Arrange/act + var result = RewriteCss.AddScopeToSelectors("file.css", string.Empty, "TestScope", out var errors); + + // Assert + Assert.Empty(errors); + Assert.Equal(string.Empty, result); + } + + [Fact] + public void AddsScopeAfterSelector() + { + // Arrange/act + var result = RewriteCss.AddScopeToSelectors("file.css", @" + .myclass { color: red; } +", "TestScope", out var errors); + + // Assert + Assert.Empty(errors); + Assert.Equal(@" + .myclass[TestScope] { color: red; } +", result); + } + + [Fact] + public void HandlesMultipleSelectors() + { + // Arrange/act + var result = RewriteCss.AddScopeToSelectors("file.css", @" + .first, .second { color: red; } + .third { color: blue; } + :root { color: green; } + * { color: white; } + #some-id { color: yellow; } +", "TestScope", out var errors); + + // Assert + Assert.Empty(errors); + 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); + } + + [Fact] + public void HandlesComplexSelectors() + { + // Arrange/act + var result = RewriteCss.AddScopeToSelectors("file.css", @" + .first div > li, body .second:not(.fancy)[attr~=whatever] { color: red; } +", "TestScope", out var errors); + + // Assert + Assert.Empty(errors); + Assert.Equal(@" + .first div > li[TestScope], body .second:not(.fancy)[attr~=whatever][TestScope] { color: red; } +", result); + } + + [Fact] + public void HandlesSpacesAndCommentsWithinSelectors() + { + // Arrange/act + var result = RewriteCss.AddScopeToSelectors("file.css", @" + .first /* space at end {} */ div , .myclass /* comment at end */ { color: red; } +", "TestScope", out var errors); + + // Assert + Assert.Empty(errors); + Assert.Equal(@" + .first /* space at end {} */ div[TestScope] , .myclass[TestScope] /* comment at end */ { color: red; } +", result); + } + + [Fact] + public void HandlesPseudoClasses() + { + // Arrange/act + var result = RewriteCss.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 errors); + + // Assert + Assert.Empty(errors); + 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 = RewriteCss.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 errors); + + // Assert + Assert.Empty(errors); + 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 = RewriteCss.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 errors); + + // Assert + Assert.Empty(errors); + 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() + { + // Arrange/act + var result = RewriteCss.AddScopeToSelectors("file.css", @" + .first ::deep .second { color: red; } + a ::deep b, c ::deep d { color: blue; } +", "TestScope", out var errors); + + // Assert + Assert.Empty(errors); + Assert.Equal(@" + .first[TestScope] .second { color: red; } + a[TestScope] b, c[TestScope] d { color: blue; } +", result); + } + + [Fact] + public void RespectsDeepCombinatorWithDirectDescendant() + { + // Arrange/act + var result = RewriteCss.AddScopeToSelectors("file.css", @" + a > ::deep b { color: red; } + c ::deep > d { color: blue; } +", "TestScope", out var errors); + + // Assert + Assert.Empty(errors); + Assert.Equal(@" + a[TestScope] > b { color: red; } + c[TestScope] > d { color: blue; } +", result); + } + + [Fact] + public void RespectsDeepCombinatorWithAdjacentSibling() + { + // Arrange/act + var result = RewriteCss.AddScopeToSelectors("file.css", @" + a + ::deep b { color: red; } + c ::deep + d { color: blue; } +", "TestScope", out var errors); + + // Assert + Assert.Empty(errors); + Assert.Equal(@" + a[TestScope] + b { color: red; } + c[TestScope] + d { color: blue; } +", result); + } + + [Fact] + public void RespectsDeepCombinatorWithGeneralSibling() + { + // Arrange/act + var result = RewriteCss.AddScopeToSelectors("file.css", @" + a ~ ::deep b { color: red; } + c ::deep ~ d { color: blue; } +", "TestScope", out var errors); + + // Assert + Assert.Empty(errors); + Assert.Equal(@" + a[TestScope] ~ b { color: red; } + c[TestScope] ~ d { color: blue; } +", result); + } + + [Fact] + public void IgnoresMultipleDeepCombinators() + { + // Arrange/act + var result = RewriteCss.AddScopeToSelectors("file.css", @" + .first ::deep .second ::deep .third { color:red; } +", "TestScope", out var errors); + + // Assert + Assert.Empty(errors); + Assert.Equal(@" + .first[TestScope] .second ::deep .third { color:red; } +", result); + } + + [Fact] + public void RespectsDeepCombinatorWithSpacesAndComments() + { + // Arrange/act + var result = RewriteCss.AddScopeToSelectors("file.css", @" + .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", out var errors); + + // Assert + Assert.Empty(errors); + 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() + { + // Arrange/act + var result = RewriteCss.AddScopeToSelectors("file.css", @" + .myclass { color: red; } + + @media only screen and (max-width: 600px) { + .another .thing { + content: 'This should not be a selector: .fake-selector { color: red }' + } + } +", "TestScope", out var errors); + + // Assert + Assert.Empty(errors); + 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 = RewriteCss.AddScopeToSelectors("file.css", @" + @keyframes my-animation { /* whatever */ } +", "TestScope", out var errors); + + // Assert + Assert.Empty(errors); + Assert.Equal(@" + @keyframes my-animation-TestScope { /* whatever */ } +", result); + } + + [Fact] + public void RewritesAnimationNamesWhenMatchingKnownKeyframes() + { + // Arrange/act + var result = RewriteCss.AddScopeToSelectors("file.css", @" + .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", out var errors); + + // Assert + Assert.Empty(errors); + 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 = RewriteCss.AddScopeToSelectors("file.css", @" + .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", out var errors); + + // Assert + Assert.Empty(errors); + 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); + } + + [Fact] + public void RejectsImportStatements() + { + // Arrange/act + RewriteCss.AddScopeToSelectors("file.css", @" + @import ""basic-import.css""; + @import ""import-with-media-type.css"" print; + @import ""import-with-media-query.css"" screen and (orientation:landscape); + @ImPoRt /* comment */ ""scheme://path/to/complex-import"" /* another-comment */ screen; + @otheratrule ""should-not-cause-error.css""; + /* @import ""should-be-ignored-because-it-is-in-a-comment.css""; */ + .myclass { color: red; } +", "TestScope", out var errors); + + // Assert + Assert.Collection(errors, + error => Assert.Equal("file.css(2,5): @import rules are not supported within scoped CSS files because the loading order would be undefined. @import may only be placed in non-scoped CSS files.", error.ToString()), + error => Assert.Equal("file.css(3,5): @import rules are not supported within scoped CSS files because the loading order would be undefined. @import may only be placed in non-scoped CSS files.", error.ToString()), + error => Assert.Equal("file.css(4,5): @import rules are not supported within scoped CSS files because the loading order would be undefined. @import may only be placed in non-scoped CSS files.", error.ToString()), + error => Assert.Equal("file.css(5,5): @import rules are not supported within scoped CSS files because the loading order would be undefined. @import may only be placed in non-scoped CSS files.", error.ToString())); + } +} diff --git a/src/Tests/Microsoft.NET.Sdk.Razor.Tool.Tests/RewriteCssCommandTest.cs b/src/Tests/Microsoft.NET.Sdk.Razor.Tool.Tests/RewriteCssCommandTest.cs deleted file mode 100644 index 5ff24ed9cf72..000000000000 --- a/src/Tests/Microsoft.NET.Sdk.Razor.Tool.Tests/RewriteCssCommandTest.cs +++ /dev/null @@ -1,392 +0,0 @@ -// Copyright (c) .NET Foundation and contributors. All rights reserved. -// Licensed under the MIT license. See LICENSE file in the project root for full license information. -// - -using Microsoft.AspNetCore.Razor.Language; -using Xunit; - -namespace Microsoft.NET.Sdk.Razor.Tool.Tests -{ - public class RewriteCssCommandTest - { - [Fact] - public void HandlesEmptyFile() - { - // Arrange/act - var result = RewriteCssCommand.AddScopeToSelectors("file.css", string.Empty, "TestScope", out var diagnostics); - - // Assert - Assert.Empty(diagnostics); - Assert.Equal(string.Empty, result); - } - - [Fact] - public void AddsScopeAfterSelector() - { - // Arrange/act - var result = RewriteCssCommand.AddScopeToSelectors("file.css", @" - .myclass { color: red; } -", "TestScope", out var diagnostics); - - // Assert - Assert.Empty(diagnostics); - Assert.Equal(@" - .myclass[TestScope] { color: red; } -", result); - } - - [Fact] - public void HandlesMultipleSelectors() - { - // Arrange/act - 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 - Assert.Empty(diagnostics); - 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); - } - - [Fact] - public void HandlesComplexSelectors() - { - // Arrange/act - var result = RewriteCssCommand.AddScopeToSelectors("file.css", @" - .first div > li, body .second:not(.fancy)[attr~=whatever] { color: red; } -", "TestScope", out var diagnostics); - - // Assert - Assert.Empty(diagnostics); - 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("file.css", @" - .first /* space at end {} */ div , .myclass /* comment at end */ { color: red; } -", "TestScope", out var diagnostics); - - // Assert - Assert.Empty(diagnostics); - Assert.Equal(@" - .first /* space at end {} */ div[TestScope] , .myclass[TestScope] /* comment at end */ { color: red; } -", 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() - { - // Arrange/act - var result = RewriteCssCommand.AddScopeToSelectors("file.css", @" - .first ::deep .second { color: red; } - a ::deep b, c ::deep d { color: blue; } -", "TestScope", out var diagnostics); - - // Assert - Assert.Empty(diagnostics); - Assert.Equal(@" - .first[TestScope] .second { color: red; } - a[TestScope] b, c[TestScope] d { color: blue; } -", result); - } - - [Fact] - public void RespectsDeepCombinatorWithDirectDescendant() - { - // Arrange/act - var result = RewriteCssCommand.AddScopeToSelectors("file.css", @" - a > ::deep b { color: red; } - c ::deep > d { color: blue; } -", "TestScope", out var diagnostics); - - // Assert - Assert.Empty(diagnostics); - Assert.Equal(@" - a[TestScope] > b { color: red; } - c[TestScope] > d { color: blue; } -", result); - } - - [Fact] - public void RespectsDeepCombinatorWithAdjacentSibling() - { - // Arrange/act - var result = RewriteCssCommand.AddScopeToSelectors("file.css", @" - a + ::deep b { color: red; } - c ::deep + d { color: blue; } -", "TestScope", out var diagnostics); - - // Assert - Assert.Empty(diagnostics); - Assert.Equal(@" - a[TestScope] + b { color: red; } - c[TestScope] + d { color: blue; } -", result); - } - - [Fact] - public void RespectsDeepCombinatorWithGeneralSibling() - { - // Arrange/act - var result = RewriteCssCommand.AddScopeToSelectors("file.css", @" - a ~ ::deep b { color: red; } - c ::deep ~ d { color: blue; } -", "TestScope", out var diagnostics); - - // Assert - Assert.Empty(diagnostics); - Assert.Equal(@" - a[TestScope] ~ b { color: red; } - c[TestScope] ~ d { color: blue; } -", result); - } - - [Fact] - public void IgnoresMultipleDeepCombinators() - { - // Arrange/act - var result = RewriteCssCommand.AddScopeToSelectors("file.css", @" - .first ::deep .second ::deep .third { color:red; } -", "TestScope", out var diagnostics); - - // Assert - Assert.Empty(diagnostics); - Assert.Equal(@" - .first[TestScope] .second ::deep .third { color:red; } -", result); - } - - [Fact] - public void RespectsDeepCombinatorWithSpacesAndComments() - { - // Arrange/act - var result = RewriteCssCommand.AddScopeToSelectors("file.css", @" - .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", out var diagnostics); - - // Assert - Assert.Empty(diagnostics); - 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() - { - // Arrange/act - var result = RewriteCssCommand.AddScopeToSelectors("file.css", @" - .myclass { color: red; } - - @media only screen and (max-width: 600px) { - .another .thing { - content: 'This should not be a selector: .fake-selector { color: red }' - } - } -", "TestScope", out var diagnostics); - - // Assert - Assert.Empty(diagnostics); - 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("file.css", @" - @keyframes my-animation { /* whatever */ } -", "TestScope", out var diagnostics); - - // Assert - Assert.Empty(diagnostics); - Assert.Equal(@" - @keyframes my-animation-TestScope { /* whatever */ } -", result); - } - - [Fact] - public void RewritesAnimationNamesWhenMatchingKnownKeyframes() - { - // Arrange/act - var result = RewriteCssCommand.AddScopeToSelectors("file.css", @" - .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", out var diagnostics); - - // Assert - Assert.Empty(diagnostics); - 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("file.css", @" - .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", out var diagnostics); - - // Assert - Assert.Empty(diagnostics); - 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); - } - - [Fact] - public void RejectsImportStatements() - { - // Arrange/act - RewriteCssCommand.AddScopeToSelectors("file.css", @" - @import ""basic-import.css""; - @import ""import-with-media-type.css"" print; - @import ""import-with-media-query.css"" screen and (orientation:landscape); - @ImPoRt /* comment */ ""scheme://path/to/complex-import"" /* another-comment */ screen; - @otheratrule ""should-not-cause-error.css""; - /* @import ""should-be-ignored-because-it-is-in-a-comment.css""; */ - .myclass { color: red; } -", "TestScope", out var diagnostics); - - // Assert - Assert.Collection(diagnostics, - diagnostic => Assert.Equal("file.css(2,5): Error RZ5000: @import rules are not supported within scoped CSS files because the loading order would be undefined. @import may only be placed in non-scoped CSS files.", diagnostic.ToString()), - diagnostic => Assert.Equal("file.css(3,5): Error RZ5000: @import rules are not supported within scoped CSS files because the loading order would be undefined. @import may only be placed in non-scoped CSS files.", diagnostic.ToString()), - diagnostic => Assert.Equal("file.css(4,5): Error RZ5000: @import rules are not supported within scoped CSS files because the loading order would be undefined. @import may only be placed in non-scoped CSS files.", diagnostic.ToString()), - diagnostic => Assert.Equal("file.css(5,5): Error RZ5000: @import rules are not supported within scoped CSS files because the loading order would be undefined. @import may only be placed in non-scoped CSS files.", diagnostic.ToString())); - } - } -}