diff --git a/src/RazorSdk/Targets/Sdk.Razor.CurrentVersion.targets b/src/RazorSdk/Targets/Sdk.Razor.CurrentVersion.targets
index f9148b7f1ff4..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
@@ -544,6 +549,26 @@ Copyright (c) .NET Foundation. All rights reserved.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/RazorSdk/Tool/Application.cs b/src/RazorSdk/Tool/Application.cs
index acfa97d0a5d9..107e505a2e06 100644
--- a/src/RazorSdk/Tool/Application.cs
+++ b/src/RazorSdk/Tool/Application.cs
@@ -39,7 +39,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 fe2d14525a3a..000000000000
--- a/src/RazorSdk/Tool/RewriteCssCommand.cs
+++ /dev/null
@@ -1,432 +0,0 @@
-// Licensed to the .NET Foundation under one or more agreements.
-// The .NET Foundation licenses this file to you under the MIT license.
-
-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.Compression.targets b/src/StaticWebAssetsSdk/Targets/Microsoft.NET.Sdk.StaticWebAssets.Compression.targets
index 1d55b60acf10..403f06d1caef 100644
--- a/src/StaticWebAssetsSdk/Targets/Microsoft.NET.Sdk.StaticWebAssets.Compression.targets
+++ b/src/StaticWebAssetsSdk/Targets/Microsoft.NET.Sdk.StaticWebAssets.Compression.targets
@@ -129,7 +129,7 @@ Copyright (c) .NET Foundation. All rights reserved.
-
-
-
+
<_JSModuleCandidates Include="@(StaticWebAsset)" Condition="'%(SourceType)' == 'Discovered'" />
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 af00dd3e415d..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 4c1e862eacbe..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 c8a26616b974..ae3dee42acb3 100644
--- a/src/StaticWebAssetsSdk/Tasks/RewriteCss.cs
+++ b/src/StaticWebAssetsSdk/Tasks/RewriteCss.cs
@@ -1,60 +1,481 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
+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 03bcd211d1ca..000000000000
--- a/src/Tests/Microsoft.NET.Sdk.Razor.Tool.Tests/RewriteCssCommandTest.cs
+++ /dev/null
@@ -1,391 +0,0 @@
-// Licensed to the .NET Foundation under one or more agreements.
-// The .NET Foundation licenses this file to you under the MIT license.
-
-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()));
- }
- }
-}