This commit is contained in:
Steve Sanderson 2020-07-08 12:42:22 +01:00 коммит произвёл GitHub
Родитель b8c5193562
Коммит c5ba43f011
Не найден ключ, соответствующий данной подписи
Идентификатор ключа GPG: 4AEE18F83AFDEB23
6 изменённых файлов: 455 добавлений и 0 удалений

Просмотреть файл

@ -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);
}
}
}