Skip to content

Commit c5ba43f

Browse files
CSS isolation rewriter tool (#23657)
1 parent b8c5193 commit c5ba43f

File tree

6 files changed

+455
-0
lines changed

6 files changed

+455
-0
lines changed

eng/Dependencies.props

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ and are generated based on the last package release.
2121

2222
<ItemGroup Label=".NET team dependencies">
2323
<LatestPackageReference Include="Microsoft.Azure.SignalR" Version="$(MicrosoftAzureSignalRPackageVersion)" />
24+
<LatestPackageReference Include="Microsoft.Css.Parser" Version="$(MicrosoftCssParserPackageVersion)" />
2425
<LatestPackageReference Include="Microsoft.CodeAnalysis.Common" Version="$(MicrosoftCodeAnalysisCommonPackageVersion)" />
2526
<LatestPackageReference Include="Microsoft.CodeAnalysis.CSharp.Workspaces" Version="$(MicrosoftCodeAnalysisCSharpWorkspacesPackageVersion)" />
2627
<LatestPackageReference Include="Microsoft.CodeAnalysis.CSharp" Version="$(MicrosoftCodeAnalysisCSharpPackageVersion)" />

eng/Versions.props

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -188,6 +188,7 @@
188188
<MicrosoftCodeAnalysisCommonPackageVersion>3.7.0-4.20351.7</MicrosoftCodeAnalysisCommonPackageVersion>
189189
<MicrosoftCodeAnalysisCSharpPackageVersion>3.7.0-4.20351.7</MicrosoftCodeAnalysisCSharpPackageVersion>
190190
<MicrosoftCodeAnalysisCSharpWorkspacesPackageVersion>3.7.0-4.20351.7</MicrosoftCodeAnalysisCSharpWorkspacesPackageVersion>
191+
<MicrosoftCssParserPackageVersion>1.0.0-20200708.1</MicrosoftCssParserPackageVersion>
191192
<MicrosoftIdentityModelClientsActiveDirectoryPackageVersion>3.19.8</MicrosoftIdentityModelClientsActiveDirectoryPackageVersion>
192193
<MicrosoftIdentityModelLoggingPackageVersion>5.5.0</MicrosoftIdentityModelLoggingPackageVersion>
193194
<MicrosoftIdentityModelProtocolsOpenIdConnectPackageVersion>5.5.0</MicrosoftIdentityModelProtocolsOpenIdConnectPackageVersion>

src/Razor/Microsoft.AspNetCore.Razor.Tools/src/Application.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ public Application(
4040
Commands.Add(new DiscoverCommand(this));
4141
Commands.Add(new GenerateCommand(this));
4242
Commands.Add(new BrotliCompressCommand(this));
43+
Commands.Add(new RewriteCssCommand(this));
4344
}
4445

4546
public CancellationToken CancellationToken { get; }

src/Razor/Microsoft.AspNetCore.Razor.Tools/src/Microsoft.AspNetCore.Razor.Tools.csproj

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@
3838

3939
<ItemGroup>
4040
<Reference Include="Newtonsoft.Json" Version="$(Razor_NewtonsoftJsonPackageVersion)" />
41+
<Reference Include="Microsoft.Css.Parser" Version="$(MicrosoftCssParserPackageVersion)" />
4142
</ItemGroup>
4243

4344
<ItemGroup>
Lines changed: 277 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,277 @@
1+
// Copyright (c) .NET Foundation. All rights reserved.
2+
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
3+
4+
using System;
5+
using System.Collections.Generic;
6+
using System.IO;
7+
using System.Linq;
8+
using System.Text;
9+
using System.Threading.Tasks;
10+
using Microsoft.Css.Parser.Parser;
11+
using Microsoft.Css.Parser.Tokens;
12+
using Microsoft.Css.Parser.TreeItems;
13+
using Microsoft.Css.Parser.TreeItems.AtDirectives;
14+
using Microsoft.Css.Parser.TreeItems.Selectors;
15+
using Microsoft.Extensions.CommandLineUtils;
16+
17+
namespace Microsoft.AspNetCore.Razor.Tools
18+
{
19+
internal class RewriteCssCommand : CommandBase
20+
{
21+
public RewriteCssCommand(Application parent)
22+
: base(parent, "rewritecss")
23+
{
24+
Sources = Option("-s", "Files to rewrite", CommandOptionType.MultipleValue);
25+
Outputs = Option("-o", "Output file paths", CommandOptionType.MultipleValue);
26+
CssScopes = Option("-c", "CSS scope identifiers", CommandOptionType.MultipleValue);
27+
}
28+
29+
public CommandOption Sources { get; }
30+
31+
public CommandOption Outputs { get; }
32+
33+
public CommandOption CssScopes { get; }
34+
35+
protected override bool ValidateArguments()
36+
{
37+
if (Sources.Values.Count != Outputs.Values.Count)
38+
{
39+
Error.WriteLine($"{Sources.Description} has {Sources.Values.Count}, but {Outputs.Description} has {Outputs.Values.Count} values.");
40+
return false;
41+
}
42+
43+
if (Sources.Values.Count != CssScopes.Values.Count)
44+
{
45+
Error.WriteLine($"{Sources.Description} has {Sources.Values.Count}, but {CssScopes.Description} has {CssScopes.Values.Count} values.");
46+
return false;
47+
}
48+
49+
return true;
50+
}
51+
52+
protected override Task<int> ExecuteCoreAsync()
53+
{
54+
Parallel.For(0, Sources.Values.Count, i =>
55+
{
56+
var source = Sources.Values[i];
57+
var output = Outputs.Values[i];
58+
var cssScope = CssScopes.Values[i];
59+
60+
var inputText = File.ReadAllText(source);
61+
var rewrittenCss = AddScopeToSelectors(inputText, cssScope);
62+
File.WriteAllText(output, rewrittenCss);
63+
});
64+
65+
return Task.FromResult(ExitCodeSuccess);
66+
}
67+
68+
// Public for tests
69+
public static string AddScopeToSelectors(string inputText, string cssScope)
70+
{
71+
var cssParser = new DefaultParserFactory().CreateParser();
72+
var stylesheet = cssParser.Parse(inputText, insertComments: false);
73+
74+
var resultBuilder = new StringBuilder();
75+
var previousInsertionPosition = 0;
76+
77+
var scopeInsertionPositionsVisitor = new FindScopeInsertionPositionsVisitor(stylesheet);
78+
scopeInsertionPositionsVisitor.Visit();
79+
foreach (var (currentInsertionPosition, insertionType) in scopeInsertionPositionsVisitor.InsertionPositions)
80+
{
81+
resultBuilder.Append(inputText.Substring(previousInsertionPosition, currentInsertionPosition - previousInsertionPosition));
82+
83+
switch (insertionType)
84+
{
85+
case ScopeInsertionType.Selector:
86+
resultBuilder.AppendFormat("[{0}]", cssScope);
87+
break;
88+
case ScopeInsertionType.KeyframesName:
89+
resultBuilder.AppendFormat("-{0}", cssScope);
90+
break;
91+
default:
92+
throw new NotImplementedException($"Unknown insertion type: '{insertionType}'");
93+
}
94+
95+
96+
previousInsertionPosition = currentInsertionPosition;
97+
}
98+
99+
resultBuilder.Append(inputText.Substring(previousInsertionPosition));
100+
101+
return resultBuilder.ToString();
102+
}
103+
104+
private static bool TryFindKeyframesIdentifier(AtDirective atDirective, out ParseItem identifier)
105+
{
106+
var keyword = atDirective.Keyword;
107+
if (string.Equals(keyword?.Text, "keyframes", StringComparison.OrdinalIgnoreCase))
108+
{
109+
var nextSiblingText = keyword.NextSibling?.Text;
110+
if (!string.IsNullOrEmpty(nextSiblingText))
111+
{
112+
identifier = keyword.NextSibling;
113+
return true;
114+
}
115+
}
116+
117+
identifier = null;
118+
return false;
119+
}
120+
121+
private enum ScopeInsertionType
122+
{
123+
Selector,
124+
KeyframesName,
125+
}
126+
127+
private class FindScopeInsertionPositionsVisitor : Visitor
128+
{
129+
public List<(int, ScopeInsertionType)> InsertionPositions { get; } = new List<(int, ScopeInsertionType)>();
130+
131+
private readonly HashSet<string> _keyframeIdentifiers;
132+
133+
public FindScopeInsertionPositionsVisitor(ComplexItem root) : base(root)
134+
{
135+
// Before we start, we need to know the full set of keyframe names declared in this document
136+
var keyframesIdentifiersVisitor = new FindKeyframesIdentifiersVisitor(root);
137+
keyframesIdentifiersVisitor.Visit();
138+
_keyframeIdentifiers = keyframesIdentifiersVisitor.KeyframesIdentifiers
139+
.Select(x => x.Text)
140+
.ToHashSet(StringComparer.Ordinal); // Keyframe names are case-sensitive
141+
}
142+
143+
protected override void VisitSelector(Selector selector)
144+
{
145+
// For a ruleset like ".first child, .second { ... }", we'll see two selectors:
146+
// ".first child," containing two simple selectors: ".first" and "child"
147+
// ".second", containing one simple selector: ".second"
148+
// Our goal is to insert immediately after the final simple selector within each selector
149+
var lastSimpleSelector = selector.Children.OfType<SimpleSelector>().LastOrDefault();
150+
if (lastSimpleSelector != null)
151+
{
152+
InsertionPositions.Add((lastSimpleSelector.AfterEnd, ScopeInsertionType.Selector));
153+
}
154+
}
155+
156+
protected override void VisitAtDirective(AtDirective item)
157+
{
158+
// Whenever we see "@keyframes something { ... }", we want to insert right after "something"
159+
if (TryFindKeyframesIdentifier(item, out var identifier))
160+
{
161+
InsertionPositions.Add((identifier.AfterEnd, ScopeInsertionType.KeyframesName));
162+
}
163+
else
164+
{
165+
VisitDefault(item);
166+
}
167+
}
168+
169+
protected override void VisitDeclaration(Declaration item)
170+
{
171+
switch (item.PropertyNameText)
172+
{
173+
case "animation":
174+
case "animation-name":
175+
// The first two tokens are <propertyname> and <colon> (otherwise we wouldn't be here).
176+
// After that, any of the subsequent tokens might be the animation name.
177+
// Unfortunately the rules for determining which token is the animation name are very
178+
// complex - https://developer.mozilla.org/en-US/docs/Web/CSS/animation#Syntax
179+
// Fortunately we only want to rewrite animation names that are explicitly declared in
180+
// the same document (we don't want to add scopes to references to global keyframes)
181+
// so it's sufficient just to match known animation names.
182+
var animationNameTokens = item.Children.Skip(2).OfType<TokenItem>()
183+
.Where(x => x.TokenType == CssTokenType.Identifier && _keyframeIdentifiers.Contains(x.Text));
184+
foreach (var token in animationNameTokens)
185+
{
186+
InsertionPositions.Add((token.AfterEnd, ScopeInsertionType.KeyframesName));
187+
}
188+
break;
189+
default:
190+
// We don't need to do anything else with other declaration types
191+
break;
192+
}
193+
}
194+
}
195+
196+
private class FindKeyframesIdentifiersVisitor : Visitor
197+
{
198+
public FindKeyframesIdentifiersVisitor(ComplexItem root) : base(root)
199+
{
200+
}
201+
202+
public List<ParseItem> KeyframesIdentifiers { get; } = new List<ParseItem>();
203+
204+
protected override void VisitAtDirective(AtDirective item)
205+
{
206+
if (TryFindKeyframesIdentifier(item, out var identifier))
207+
{
208+
KeyframesIdentifiers.Add(identifier);
209+
}
210+
else
211+
{
212+
VisitDefault(item);
213+
}
214+
}
215+
}
216+
217+
private class Visitor
218+
{
219+
private readonly ComplexItem _root;
220+
221+
public Visitor(ComplexItem root)
222+
{
223+
_root = root ?? throw new ArgumentNullException(nameof(root));
224+
}
225+
226+
public void Visit()
227+
{
228+
VisitDefault(_root);
229+
}
230+
231+
protected virtual void VisitSelector(Selector item)
232+
{
233+
VisitDefault(item);
234+
}
235+
236+
protected virtual void VisitAtDirective(AtDirective item)
237+
{
238+
VisitDefault(item);
239+
}
240+
241+
protected virtual void VisitDeclaration(Declaration item)
242+
{
243+
VisitDefault(item);
244+
}
245+
246+
protected virtual void VisitDefault(ParseItem item)
247+
{
248+
if (item is ComplexItem complexItem)
249+
{
250+
VisitDescendants(complexItem);
251+
}
252+
}
253+
254+
private void VisitDescendants(ComplexItem container)
255+
{
256+
foreach (var child in container.Children)
257+
{
258+
switch (child)
259+
{
260+
case Selector selector:
261+
VisitSelector(selector);
262+
break;
263+
case AtDirective atDirective:
264+
VisitAtDirective(atDirective);
265+
break;
266+
case Declaration declaration:
267+
VisitDeclaration(declaration);
268+
break;
269+
default:
270+
VisitDefault(child);
271+
break;
272+
}
273+
}
274+
}
275+
}
276+
}
277+
}

0 commit comments

Comments
 (0)