Skip to content

Commit eb8764e

Browse files
CSS scoping deep combinators (#24289)
* Support deep combinators with CSS scoping * Update CSS scope computation
1 parent 088a6fa commit eb8764e

File tree

3 files changed

+119
-33
lines changed

3 files changed

+119
-33
lines changed

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

Lines changed: 61 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
// Copyright (c) .NET Foundation. All rights reserved.
1+
// Copyright (c) .NET Foundation. All rights reserved.
22
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
33

44
using System;
@@ -74,26 +74,27 @@ public static string AddScopeToSelectors(string inputText, string cssScope)
7474
var resultBuilder = new StringBuilder();
7575
var previousInsertionPosition = 0;
7676

77-
var scopeInsertionPositionsVisitor = new FindScopeInsertionPositionsVisitor(stylesheet);
77+
var scopeInsertionPositionsVisitor = new FindScopeInsertionEdits(stylesheet);
7878
scopeInsertionPositionsVisitor.Visit();
79-
foreach (var (currentInsertionPosition, insertionType) in scopeInsertionPositionsVisitor.InsertionPositions)
79+
foreach (var edit in scopeInsertionPositionsVisitor.Edits)
8080
{
81-
resultBuilder.Append(inputText.Substring(previousInsertionPosition, currentInsertionPosition - previousInsertionPosition));
82-
83-
switch (insertionType)
81+
resultBuilder.Append(inputText.Substring(previousInsertionPosition, edit.Position - previousInsertionPosition));
82+
previousInsertionPosition = edit.Position;
83+
84+
switch (edit)
8485
{
85-
case ScopeInsertionType.Selector:
86+
case InsertSelectorScopeEdit _:
8687
resultBuilder.AppendFormat("[{0}]", cssScope);
8788
break;
88-
case ScopeInsertionType.KeyframesName:
89+
case InsertKeyframesNameScopeEdit _:
8990
resultBuilder.AppendFormat("-{0}", cssScope);
9091
break;
92+
case DeleteContentEdit deleteContentEdit:
93+
previousInsertionPosition += deleteContentEdit.DeleteLength;
94+
break;
9195
default:
92-
throw new NotImplementedException($"Unknown insertion type: '{insertionType}'");
96+
throw new NotImplementedException($"Unknown edit type: '{edit}'");
9397
}
94-
95-
96-
previousInsertionPosition = currentInsertionPosition;
9798
}
9899

99100
resultBuilder.Append(inputText.Substring(previousInsertionPosition));
@@ -118,19 +119,13 @@ private static bool TryFindKeyframesIdentifier(AtDirective atDirective, out Pars
118119
return false;
119120
}
120121

121-
private enum ScopeInsertionType
122+
private class FindScopeInsertionEdits : Visitor
122123
{
123-
Selector,
124-
KeyframesName,
125-
}
126-
127-
private class FindScopeInsertionPositionsVisitor : Visitor
128-
{
129-
public List<(int, ScopeInsertionType)> InsertionPositions { get; } = new List<(int, ScopeInsertionType)>();
124+
public List<CssEdit> Edits { get; } = new List<CssEdit>();
130125

131126
private readonly HashSet<string> _keyframeIdentifiers;
132127

133-
public FindScopeInsertionPositionsVisitor(ComplexItem root) : base(root)
128+
public FindScopeInsertionEdits(ComplexItem root) : base(root)
134129
{
135130
// Before we start, we need to know the full set of keyframe names declared in this document
136131
var keyframesIdentifiersVisitor = new FindKeyframesIdentifiersVisitor(root);
@@ -146,19 +141,42 @@ protected override void VisitSelector(Selector selector)
146141
// ".first child," containing two simple selectors: ".first" and "child"
147142
// ".second", containing one simple selector: ".second"
148143
// Our goal is to insert immediately after the final simple selector within each selector
149-
var lastSimpleSelector = selector.Children.OfType<SimpleSelector>().LastOrDefault();
144+
145+
// If there's a deep combinator among the sequence of simple selectors, we consider that to signal
146+
// the end of the set of simple selectors for us to look at, plus we strip it out
147+
var allSimpleSelectors = selector.Children.OfType<SimpleSelector>();
148+
var firstDeepCombinator = allSimpleSelectors.FirstOrDefault(s => IsDeepCombinator(s.Text));
149+
150+
var lastSimpleSelector = allSimpleSelectors.TakeWhile(s => s != firstDeepCombinator).LastOrDefault();
150151
if (lastSimpleSelector != null)
151152
{
152-
InsertionPositions.Add((lastSimpleSelector.AfterEnd, ScopeInsertionType.Selector));
153+
Edits.Add(new InsertSelectorScopeEdit { Position = lastSimpleSelector.AfterEnd });
154+
}
155+
else if (firstDeepCombinator != null)
156+
{
157+
// For a leading deep combinator, we want to insert the scope attribute at the start
158+
// Otherwise the result would be a CSS rule that isn't scoped at all
159+
Edits.Add(new InsertSelectorScopeEdit { Position = firstDeepCombinator.Start });
153160
}
161+
162+
// Also remove the deep combinator if we matched one
163+
if (firstDeepCombinator != null)
164+
{
165+
Edits.Add(new DeleteContentEdit { Position = firstDeepCombinator.Start, DeleteLength = firstDeepCombinator.Length });
166+
}
167+
}
168+
169+
private static bool IsDeepCombinator(string simpleSelectorText)
170+
{
171+
return string.Equals(simpleSelectorText, "::deep", StringComparison.Ordinal);
154172
}
155173

156174
protected override void VisitAtDirective(AtDirective item)
157175
{
158176
// Whenever we see "@keyframes something { ... }", we want to insert right after "something"
159177
if (TryFindKeyframesIdentifier(item, out var identifier))
160178
{
161-
InsertionPositions.Add((identifier.AfterEnd, ScopeInsertionType.KeyframesName));
179+
Edits.Add(new InsertKeyframesNameScopeEdit { Position = identifier.AfterEnd });
162180
}
163181
else
164182
{
@@ -183,7 +201,7 @@ protected override void VisitDeclaration(Declaration item)
183201
.Where(x => x.TokenType == CssTokenType.Identifier && _keyframeIdentifiers.Contains(x.Text));
184202
foreach (var token in animationNameTokens)
185203
{
186-
InsertionPositions.Add((token.AfterEnd, ScopeInsertionType.KeyframesName));
204+
Edits.Add(new InsertKeyframesNameScopeEdit { Position = token.AfterEnd });
187205
}
188206
break;
189207
default:
@@ -273,5 +291,23 @@ private void VisitDescendants(ComplexItem container)
273291
}
274292
}
275293
}
294+
295+
private abstract class CssEdit
296+
{
297+
public int Position { get; set; }
298+
}
299+
300+
private class InsertSelectorScopeEdit : CssEdit
301+
{
302+
}
303+
304+
private class InsertKeyframesNameScopeEdit : CssEdit
305+
{
306+
}
307+
308+
private class DeleteContentEdit : CssEdit
309+
{
310+
public int DeleteLength { get; set; }
311+
}
276312
}
277313
}

src/Razor/Microsoft.AspNetCore.Razor.Tools/test/RewriteCssCommandTest.cs

Lines changed: 49 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
// Copyright (c) .NET Foundation. All rights reserved.
1+
// Copyright (c) .NET Foundation. All rights reserved.
22
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
33

44
using Xunit;
@@ -75,6 +75,54 @@ public void HandlesSpacesAndCommentsWithinSelectors()
7575
", result);
7676
}
7777

78+
[Fact]
79+
public void RespectsDeepCombinator()
80+
{
81+
// Arrange/act
82+
var result = RewriteCssCommand.AddScopeToSelectors(@"
83+
.first ::deep .second { color: red; }
84+
a ::deep b, c ::deep d { color: blue; }
85+
", "TestScope");
86+
87+
// Assert
88+
Assert.Equal(@"
89+
.first[TestScope] .second { color: red; }
90+
a[TestScope] b, c[TestScope] d { color: blue; }
91+
", result);
92+
}
93+
94+
[Fact]
95+
public void IgnoresMultipleDeepCombinators()
96+
{
97+
// Arrange/act
98+
var result = RewriteCssCommand.AddScopeToSelectors(@"
99+
.first ::deep .second ::deep .third { color:red; }
100+
", "TestScope");
101+
102+
// Assert
103+
Assert.Equal(@"
104+
.first[TestScope] .second ::deep .third { color:red; }
105+
", result);
106+
}
107+
108+
[Fact]
109+
public void RespectsDeepCombinatorWithSpacesAndComments()
110+
{
111+
// Arrange/act
112+
var result = RewriteCssCommand.AddScopeToSelectors(@"
113+
.a .b /* comment ::deep 1 */ ::deep /* comment ::deep 2 */ .c /* ::deep */ .d { color: red; }
114+
::deep * { color: blue; } /* Leading deep combinator */
115+
another ::deep { color: green } /* Trailing deep combinator */
116+
", "TestScope");
117+
118+
// Assert
119+
Assert.Equal(@"
120+
.a .b[TestScope] /* comment ::deep 1 */ /* comment ::deep 2 */ .c /* ::deep */ .d { color: red; }
121+
[TestScope] * { color: blue; } /* Leading deep combinator */
122+
another[TestScope] { color: green } /* Trailing deep combinator */
123+
", result);
124+
}
125+
78126
[Fact]
79127
public void HandlesAtBlocks()
80128
{

src/Razor/Microsoft.NET.Sdk.Razor/src/ComputeCssScope.cs

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -55,17 +55,19 @@ private string GenerateScope(string targetName, string relativePath)
5555
return builder.ToString();
5656
}
5757

58-
private string ToBase36(byte[] hash)
58+
private static string ToBase36(byte[] hash)
5959
{
60-
var builder = new StringBuilder();
61-
const string chars = "abcdefghijklmnopqrstuvwxyz0123456789";
62-
var dividend = new BigInteger(hash.AsSpan().Slice(0,8).ToArray());
63-
while (dividend > 36)
60+
const string chars = "0123456789abcdefghijklmnopqrstuvwxyz";
61+
62+
var result = new char[10];
63+
var dividend = BigInteger.Abs(new BigInteger(hash.AsSpan().Slice(0, 9).ToArray()));
64+
for (var i = 0; i < 10; i++)
6465
{
6566
dividend = BigInteger.DivRem(dividend, 36, out var remainder);
66-
builder.Insert(0, chars[Math.Abs(((int)remainder))]);
67+
result[i] = chars[(int)remainder];
6768
}
68-
return builder.ToString();
69+
70+
return new string(result);
6971
}
7072
}
7173
}

0 commit comments

Comments
 (0)