зеркало из https://github.com/dotnet/aspnetcore.git
CSS isolation rewriter tool (#23657)
This commit is contained in:
Родитель
b8c5193562
Коммит
c5ba43f011
|
@ -21,6 +21,7 @@ and are generated based on the last package release.
|
|||
|
||||
<ItemGroup Label=".NET team dependencies">
|
||||
<LatestPackageReference Include="Microsoft.Azure.SignalR" Version="$(MicrosoftAzureSignalRPackageVersion)" />
|
||||
<LatestPackageReference Include="Microsoft.Css.Parser" Version="$(MicrosoftCssParserPackageVersion)" />
|
||||
<LatestPackageReference Include="Microsoft.CodeAnalysis.Common" Version="$(MicrosoftCodeAnalysisCommonPackageVersion)" />
|
||||
<LatestPackageReference Include="Microsoft.CodeAnalysis.CSharp.Workspaces" Version="$(MicrosoftCodeAnalysisCSharpWorkspacesPackageVersion)" />
|
||||
<LatestPackageReference Include="Microsoft.CodeAnalysis.CSharp" Version="$(MicrosoftCodeAnalysisCSharpPackageVersion)" />
|
||||
|
|
|
@ -188,6 +188,7 @@
|
|||
<MicrosoftCodeAnalysisCommonPackageVersion>3.7.0-4.20351.7</MicrosoftCodeAnalysisCommonPackageVersion>
|
||||
<MicrosoftCodeAnalysisCSharpPackageVersion>3.7.0-4.20351.7</MicrosoftCodeAnalysisCSharpPackageVersion>
|
||||
<MicrosoftCodeAnalysisCSharpWorkspacesPackageVersion>3.7.0-4.20351.7</MicrosoftCodeAnalysisCSharpWorkspacesPackageVersion>
|
||||
<MicrosoftCssParserPackageVersion>1.0.0-20200708.1</MicrosoftCssParserPackageVersion>
|
||||
<MicrosoftIdentityModelClientsActiveDirectoryPackageVersion>3.19.8</MicrosoftIdentityModelClientsActiveDirectoryPackageVersion>
|
||||
<MicrosoftIdentityModelLoggingPackageVersion>5.5.0</MicrosoftIdentityModelLoggingPackageVersion>
|
||||
<MicrosoftIdentityModelProtocolsOpenIdConnectPackageVersion>5.5.0</MicrosoftIdentityModelProtocolsOpenIdConnectPackageVersion>
|
||||
|
|
|
@ -40,6 +40,7 @@ namespace Microsoft.AspNetCore.Razor.Tools
|
|||
Commands.Add(new DiscoverCommand(this));
|
||||
Commands.Add(new GenerateCommand(this));
|
||||
Commands.Add(new BrotliCompressCommand(this));
|
||||
Commands.Add(new RewriteCssCommand(this));
|
||||
}
|
||||
|
||||
public CancellationToken CancellationToken { get; }
|
||||
|
|
|
@ -38,6 +38,7 @@
|
|||
|
||||
<ItemGroup>
|
||||
<Reference Include="Newtonsoft.Json" Version="$(Razor_NewtonsoftJsonPackageVersion)" />
|
||||
<Reference Include="Microsoft.Css.Parser" Version="$(MicrosoftCssParserPackageVersion)" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
|
|
@ -0,0 +1,277 @@
|
|||
// Copyright (c) .NET Foundation. All rights reserved.
|
||||
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Css.Parser.Parser;
|
||||
using Microsoft.Css.Parser.Tokens;
|
||||
using Microsoft.Css.Parser.TreeItems;
|
||||
using Microsoft.Css.Parser.TreeItems.AtDirectives;
|
||||
using Microsoft.Css.Parser.TreeItems.Selectors;
|
||||
using Microsoft.Extensions.CommandLineUtils;
|
||||
|
||||
namespace Microsoft.AspNetCore.Razor.Tools
|
||||
{
|
||||
internal class RewriteCssCommand : CommandBase
|
||||
{
|
||||
public RewriteCssCommand(Application parent)
|
||||
: base(parent, "rewritecss")
|
||||
{
|
||||
Sources = Option("-s", "Files to rewrite", CommandOptionType.MultipleValue);
|
||||
Outputs = Option("-o", "Output file paths", CommandOptionType.MultipleValue);
|
||||
CssScopes = Option("-c", "CSS scope identifiers", CommandOptionType.MultipleValue);
|
||||
}
|
||||
|
||||
public CommandOption Sources { get; }
|
||||
|
||||
public CommandOption Outputs { get; }
|
||||
|
||||
public CommandOption CssScopes { get; }
|
||||
|
||||
protected override bool ValidateArguments()
|
||||
{
|
||||
if (Sources.Values.Count != Outputs.Values.Count)
|
||||
{
|
||||
Error.WriteLine($"{Sources.Description} has {Sources.Values.Count}, but {Outputs.Description} has {Outputs.Values.Count} values.");
|
||||
return false;
|
||||
}
|
||||
|
||||
if (Sources.Values.Count != CssScopes.Values.Count)
|
||||
{
|
||||
Error.WriteLine($"{Sources.Description} has {Sources.Values.Count}, but {CssScopes.Description} has {CssScopes.Values.Count} values.");
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
protected override Task<int> ExecuteCoreAsync()
|
||||
{
|
||||
Parallel.For(0, Sources.Values.Count, i =>
|
||||
{
|
||||
var source = Sources.Values[i];
|
||||
var output = Outputs.Values[i];
|
||||
var cssScope = CssScopes.Values[i];
|
||||
|
||||
var inputText = File.ReadAllText(source);
|
||||
var rewrittenCss = AddScopeToSelectors(inputText, cssScope);
|
||||
File.WriteAllText(output, rewrittenCss);
|
||||
});
|
||||
|
||||
return Task.FromResult(ExitCodeSuccess);
|
||||
}
|
||||
|
||||
// Public for tests
|
||||
public static string AddScopeToSelectors(string inputText, string cssScope)
|
||||
{
|
||||
var cssParser = new DefaultParserFactory().CreateParser();
|
||||
var stylesheet = cssParser.Parse(inputText, insertComments: false);
|
||||
|
||||
var resultBuilder = new StringBuilder();
|
||||
var previousInsertionPosition = 0;
|
||||
|
||||
var scopeInsertionPositionsVisitor = new FindScopeInsertionPositionsVisitor(stylesheet);
|
||||
scopeInsertionPositionsVisitor.Visit();
|
||||
foreach (var (currentInsertionPosition, insertionType) in scopeInsertionPositionsVisitor.InsertionPositions)
|
||||
{
|
||||
resultBuilder.Append(inputText.Substring(previousInsertionPosition, currentInsertionPosition - previousInsertionPosition));
|
||||
|
||||
switch (insertionType)
|
||||
{
|
||||
case ScopeInsertionType.Selector:
|
||||
resultBuilder.AppendFormat("[{0}]", cssScope);
|
||||
break;
|
||||
case ScopeInsertionType.KeyframesName:
|
||||
resultBuilder.AppendFormat("-{0}", cssScope);
|
||||
break;
|
||||
default:
|
||||
throw new NotImplementedException($"Unknown insertion type: '{insertionType}'");
|
||||
}
|
||||
|
||||
|
||||
previousInsertionPosition = currentInsertionPosition;
|
||||
}
|
||||
|
||||
resultBuilder.Append(inputText.Substring(previousInsertionPosition));
|
||||
|
||||
return resultBuilder.ToString();
|
||||
}
|
||||
|
||||
private static bool TryFindKeyframesIdentifier(AtDirective atDirective, out ParseItem identifier)
|
||||
{
|
||||
var keyword = atDirective.Keyword;
|
||||
if (string.Equals(keyword?.Text, "keyframes", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
var nextSiblingText = keyword.NextSibling?.Text;
|
||||
if (!string.IsNullOrEmpty(nextSiblingText))
|
||||
{
|
||||
identifier = keyword.NextSibling;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
identifier = null;
|
||||
return false;
|
||||
}
|
||||
|
||||
private enum ScopeInsertionType
|
||||
{
|
||||
Selector,
|
||||
KeyframesName,
|
||||
}
|
||||
|
||||
private class FindScopeInsertionPositionsVisitor : Visitor
|
||||
{
|
||||
public List<(int, ScopeInsertionType)> InsertionPositions { get; } = new List<(int, ScopeInsertionType)>();
|
||||
|
||||
private readonly HashSet<string> _keyframeIdentifiers;
|
||||
|
||||
public FindScopeInsertionPositionsVisitor(ComplexItem root) : base(root)
|
||||
{
|
||||
// Before we start, we need to know the full set of keyframe names declared in this document
|
||||
var keyframesIdentifiersVisitor = new FindKeyframesIdentifiersVisitor(root);
|
||||
keyframesIdentifiersVisitor.Visit();
|
||||
_keyframeIdentifiers = keyframesIdentifiersVisitor.KeyframesIdentifiers
|
||||
.Select(x => x.Text)
|
||||
.ToHashSet(StringComparer.Ordinal); // Keyframe names are case-sensitive
|
||||
}
|
||||
|
||||
protected override void VisitSelector(Selector selector)
|
||||
{
|
||||
// For a ruleset like ".first child, .second { ... }", we'll see two selectors:
|
||||
// ".first child," containing two simple selectors: ".first" and "child"
|
||||
// ".second", containing one simple selector: ".second"
|
||||
// Our goal is to insert immediately after the final simple selector within each selector
|
||||
var lastSimpleSelector = selector.Children.OfType<SimpleSelector>().LastOrDefault();
|
||||
if (lastSimpleSelector != null)
|
||||
{
|
||||
InsertionPositions.Add((lastSimpleSelector.AfterEnd, ScopeInsertionType.Selector));
|
||||
}
|
||||
}
|
||||
|
||||
protected override void VisitAtDirective(AtDirective item)
|
||||
{
|
||||
// Whenever we see "@keyframes something { ... }", we want to insert right after "something"
|
||||
if (TryFindKeyframesIdentifier(item, out var identifier))
|
||||
{
|
||||
InsertionPositions.Add((identifier.AfterEnd, ScopeInsertionType.KeyframesName));
|
||||
}
|
||||
else
|
||||
{
|
||||
VisitDefault(item);
|
||||
}
|
||||
}
|
||||
|
||||
protected override void VisitDeclaration(Declaration item)
|
||||
{
|
||||
switch (item.PropertyNameText)
|
||||
{
|
||||
case "animation":
|
||||
case "animation-name":
|
||||
// The first two tokens are <propertyname> and <colon> (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<TokenItem>()
|
||||
.Where(x => x.TokenType == CssTokenType.Identifier && _keyframeIdentifiers.Contains(x.Text));
|
||||
foreach (var token in animationNameTokens)
|
||||
{
|
||||
InsertionPositions.Add((token.AfterEnd, ScopeInsertionType.KeyframesName));
|
||||
}
|
||||
break;
|
||||
default:
|
||||
// We don't need to do anything else with other declaration types
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private class FindKeyframesIdentifiersVisitor : Visitor
|
||||
{
|
||||
public FindKeyframesIdentifiersVisitor(ComplexItem root) : base(root)
|
||||
{
|
||||
}
|
||||
|
||||
public List<ParseItem> KeyframesIdentifiers { get; } = new List<ParseItem>();
|
||||
|
||||
protected override void VisitAtDirective(AtDirective item)
|
||||
{
|
||||
if (TryFindKeyframesIdentifier(item, out var identifier))
|
||||
{
|
||||
KeyframesIdentifiers.Add(identifier);
|
||||
}
|
||||
else
|
||||
{
|
||||
VisitDefault(item);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private class Visitor
|
||||
{
|
||||
private readonly ComplexItem _root;
|
||||
|
||||
public Visitor(ComplexItem root)
|
||||
{
|
||||
_root = root ?? throw new ArgumentNullException(nameof(root));
|
||||
}
|
||||
|
||||
public void Visit()
|
||||
{
|
||||
VisitDefault(_root);
|
||||
}
|
||||
|
||||
protected virtual void VisitSelector(Selector item)
|
||||
{
|
||||
VisitDefault(item);
|
||||
}
|
||||
|
||||
protected virtual void VisitAtDirective(AtDirective item)
|
||||
{
|
||||
VisitDefault(item);
|
||||
}
|
||||
|
||||
protected virtual void VisitDeclaration(Declaration item)
|
||||
{
|
||||
VisitDefault(item);
|
||||
}
|
||||
|
||||
protected virtual void VisitDefault(ParseItem item)
|
||||
{
|
||||
if (item is ComplexItem complexItem)
|
||||
{
|
||||
VisitDescendants(complexItem);
|
||||
}
|
||||
}
|
||||
|
||||
private void VisitDescendants(ComplexItem container)
|
||||
{
|
||||
foreach (var child in container.Children)
|
||||
{
|
||||
switch (child)
|
||||
{
|
||||
case Selector selector:
|
||||
VisitSelector(selector);
|
||||
break;
|
||||
case AtDirective atDirective:
|
||||
VisitAtDirective(atDirective);
|
||||
break;
|
||||
case Declaration declaration:
|
||||
VisitDeclaration(declaration);
|
||||
break;
|
||||
default:
|
||||
VisitDefault(child);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,174 @@
|
|||
// Copyright (c) .NET Foundation. All rights reserved.
|
||||
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
|
||||
|
||||
using Xunit;
|
||||
|
||||
namespace Microsoft.AspNetCore.Razor.Tools
|
||||
{
|
||||
public class RewriteCssCommandTest
|
||||
{
|
||||
[Fact]
|
||||
public void HandlesEmptyFile()
|
||||
{
|
||||
// Arrange/act
|
||||
var result = RewriteCssCommand.AddScopeToSelectors(string.Empty, "TestScope");
|
||||
|
||||
// Assert
|
||||
Assert.Equal(string.Empty, result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AddsScopeAfterSelector()
|
||||
{
|
||||
// Arrange/act
|
||||
var result = RewriteCssCommand.AddScopeToSelectors(@"
|
||||
.myclass { color: red; }
|
||||
", "TestScope");
|
||||
|
||||
// Assert
|
||||
Assert.Equal(@"
|
||||
.myclass[TestScope] { color: red; }
|
||||
", result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HandlesMultipleSelectors()
|
||||
{
|
||||
// Arrange/act
|
||||
var result = RewriteCssCommand.AddScopeToSelectors(@"
|
||||
.first, .second { color: red; }
|
||||
.third { color: blue; }
|
||||
", "TestScope");
|
||||
|
||||
// Assert
|
||||
Assert.Equal(@"
|
||||
.first[TestScope], .second[TestScope] { color: red; }
|
||||
.third[TestScope] { color: blue; }
|
||||
", result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HandlesComplexSelectors()
|
||||
{
|
||||
// Arrange/act
|
||||
var result = RewriteCssCommand.AddScopeToSelectors(@"
|
||||
.first div > li, body .second:not(.fancy)[attr~=whatever] { color: red; }
|
||||
", "TestScope");
|
||||
|
||||
// Assert
|
||||
Assert.Equal(@"
|
||||
.first div > li[TestScope], body .second:not(.fancy)[attr~=whatever][TestScope] { color: red; }
|
||||
", result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HandlesSpacesAndCommentsWithinSelectors()
|
||||
{
|
||||
// Arrange/act
|
||||
var result = RewriteCssCommand.AddScopeToSelectors(@"
|
||||
.first /* space at end {} */ div , .myclass /* comment at end */ { color: red; }
|
||||
", "TestScope");
|
||||
|
||||
// Assert
|
||||
Assert.Equal(@"
|
||||
.first /* space at end {} */ div[TestScope] , .myclass[TestScope] /* comment at end */ { color: red; }
|
||||
", result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HandlesAtBlocks()
|
||||
{
|
||||
// Arrange/act
|
||||
var result = RewriteCssCommand.AddScopeToSelectors(@"
|
||||
.myclass { color: red; }
|
||||
|
||||
@media only screen and (max-width: 600px) {
|
||||
.another .thing {
|
||||
content: 'This should not be a selector: .fake-selector { color: red }'
|
||||
}
|
||||
}
|
||||
", "TestScope");
|
||||
|
||||
// Assert
|
||||
Assert.Equal(@"
|
||||
.myclass[TestScope] { color: red; }
|
||||
|
||||
@media only screen and (max-width: 600px) {
|
||||
.another .thing[TestScope] {
|
||||
content: 'This should not be a selector: .fake-selector { color: red }'
|
||||
}
|
||||
}
|
||||
", result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AddsScopeToKeyframeNames()
|
||||
{
|
||||
// Arrange/act
|
||||
var result = RewriteCssCommand.AddScopeToSelectors(@"
|
||||
@keyframes my-animation { /* whatever */ }
|
||||
", "TestScope");
|
||||
|
||||
// Assert
|
||||
Assert.Equal(@"
|
||||
@keyframes my-animation-TestScope { /* whatever */ }
|
||||
", result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RewritesAnimationNamesWhenMatchingKnownKeyframes()
|
||||
{
|
||||
// Arrange/act
|
||||
var result = RewriteCssCommand.AddScopeToSelectors(@"
|
||||
.myclass {
|
||||
color: red;
|
||||
animation: /* ignore comment */ my-animation 1s infinite;
|
||||
}
|
||||
|
||||
.another-thing { animation-name: different-animation; }
|
||||
|
||||
h1 { animation: unknown-animation; } /* Should not be scoped */
|
||||
|
||||
@keyframes my-animation { /* whatever */ }
|
||||
@keyframes different-animation { /* whatever */ }
|
||||
@keyframes unused-animation { /* whatever */ }
|
||||
", "TestScope");
|
||||
|
||||
// Assert
|
||||
Assert.Equal(@"
|
||||
.myclass[TestScope] {
|
||||
color: red;
|
||||
animation: /* ignore comment */ my-animation-TestScope 1s infinite;
|
||||
}
|
||||
|
||||
.another-thing[TestScope] { animation-name: different-animation-TestScope; }
|
||||
|
||||
h1[TestScope] { animation: unknown-animation; } /* Should not be scoped */
|
||||
|
||||
@keyframes my-animation-TestScope { /* whatever */ }
|
||||
@keyframes different-animation-TestScope { /* whatever */ }
|
||||
@keyframes unused-animation-TestScope { /* whatever */ }
|
||||
", result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RewritesMultipleAnimationNames()
|
||||
{
|
||||
// Arrange/act
|
||||
var result = RewriteCssCommand.AddScopeToSelectors(@"
|
||||
.myclass1 { animation-name: my-animation , different-animation }
|
||||
.myclass2 { animation: 4s linear 0s alternate my-animation infinite, different-animation 0s }
|
||||
@keyframes my-animation { }
|
||||
@keyframes different-animation { }
|
||||
", "TestScope");
|
||||
|
||||
// Assert
|
||||
Assert.Equal(@"
|
||||
.myclass1[TestScope] { animation-name: my-animation-TestScope , different-animation-TestScope }
|
||||
.myclass2[TestScope] { animation: 4s linear 0s alternate my-animation-TestScope infinite, different-animation-TestScope 0s }
|
||||
@keyframes my-animation-TestScope { }
|
||||
@keyframes different-animation-TestScope { }
|
||||
", result);
|
||||
}
|
||||
}
|
||||
}
|
Загрузка…
Ссылка в новой задаче