Skip to content

Commit adb1b22

Browse files
whorchnerW Hörchnermartinnormark
authored
LinkTagCssSource now also downloads content from @import declarations. (#419)
* LinkTagCssSource now also downloads content from @import declarations. PreMailer: - fixed obsolete DescendentsAndSelf to DescendantsAndSelf * LinkTagCssSource now also downloads content from @import declarations. Extracted the import logic to it's own ImportRuleCssSource class. PreMailer: - fixed obsolete DescendentsAndSelf to DescendantsAndSelf * Revert project file changes * Add an ImportRuleCssSourceTest class * Add an ImportRuleCssSourceTest class * Changed ICssSource and classes based on it to return a IEnumerable<string>. Changed ImportRuleCssSource (and LinkTagCssSource) to return a list of css blocks. * Simplify selection of multiple CSS sources * Added a stylesheet for testing purposes. Can be referenced with a url to github. * Last commit with TestStylesheet.css was not necesarry. Removed it. * Refactor ImportRuleCssSource and related tests for improved clarity and functionality * I first have to commit some changes before I can pull * Because imported stylesheets themselfs can reference the same stylesheet in their import list, checking if the url already exists in the _importList is necessary. * Removed the preparation for another PR. * Add recursive import handling and caching in ImportRuleCssSource tests - Implemented tests for loading CSS imports recursively up to two levels. - Added caching mechanism to prevent multiple downloads of the same stylesheet. - Ensured import order is preserved when processing multiple imports. - Handled circular imports to avoid infinite loops during CSS retrieval. - Enhanced test coverage for ImportRuleCssSource functionality. * Avoid use of deprecated xUnit assert functions * Remove debug Console.WriteLine statements --------- Co-authored-by: W Hörchner <w.horchner@gmail.com> Co-authored-by: Martin Høst Normark <m@martinnormark.com>
1 parent 81693ce commit adb1b22

File tree

9 files changed

+370
-30
lines changed

9 files changed

+370
-30
lines changed

PreMailer.Net/PreMailer.Net.Tests/CssStyleEquivalenceTests.cs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ public void FindEquivalentStyles()
1717

1818
var result = CssStyleEquivalence.FindEquivalent(nodewithoutselector, clazz);
1919

20-
Assert.Equal(1, result.Count);
20+
Assert.Single(result);
2121
}
2222

2323
[Fact]
@@ -32,7 +32,7 @@ public void FindEquivalentStylesNoMatchingStyles()
3232

3333
var result = CssStyleEquivalence.FindEquivalent(nodewithoutselector, clazz);
3434

35-
Assert.Equal(0, result.Count);
35+
Assert.Empty(result);
3636
}
3737
}
3838
}
Lines changed: 217 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,217 @@
1+
using Xunit;
2+
using Moq;
3+
using PreMailer.Net.Downloaders;
4+
using PreMailer.Net.Sources;
5+
using System;
6+
using System.Text;
7+
using System.Collections.Generic;
8+
using System.Linq;
9+
10+
namespace PreMailer.Net.Tests
11+
{
12+
public class ImportRuleCssSourceTests
13+
{
14+
private readonly Mock<IWebDownloader> _webDownloader = new Mock<IWebDownloader>();
15+
16+
public ImportRuleCssSourceTests()
17+
{
18+
WebDownloader.SharedDownloader = _webDownloader.Object;
19+
}
20+
21+
[Fact]
22+
public void ItShould_DownloadAllImportedUrls_WhenCssContainsImportRules()
23+
{
24+
var baseUri = new Uri("https://a.com");
25+
var urls = new List<string>() { "variables.css?v234", "/fonts.css", "https://fonts.google.com/css/test-font" };
26+
27+
var css = CreateCss(urls);
28+
var sut = new ImportRuleCssSource();
29+
30+
sut.GetCss(baseUri, css);
31+
32+
foreach (var url in urls)
33+
{
34+
_webDownloader.Verify(w => w.DownloadString(It.Is<Uri>(u => u.Equals(new Uri(baseUri, url)))));
35+
}
36+
}
37+
38+
[Fact]
39+
public void ItShould_NotDownloadUrls_WhenLevelIsGreaterThanTwo()
40+
{
41+
var baseUri = new Uri("https://a.com");
42+
var urls = new List<string>() { "variables.css?v234", "/fonts.css", "https://fonts.google.com/css/test-font" };
43+
44+
var css = CreateCss(urls);
45+
var sut = new ImportRuleCssSource();
46+
47+
sut.GetCss(baseUri, css, 2);
48+
49+
_webDownloader.Verify(w => w.DownloadString(It.IsAny<Uri>()), Times.Never());
50+
}
51+
52+
[Fact]
53+
public void ItShould_NotDownloadUrls_WhenCssIsEmpty()
54+
{
55+
var baseUri = new Uri("https://a.com");
56+
var urls = new List<string>() { "variables.css?v234", "/fonts.css", "https://fonts.google.com/css/test-font" };
57+
58+
var css = CreateCss(urls);
59+
var sut = new ImportRuleCssSource();
60+
61+
sut.GetCss(baseUri, string.Empty);
62+
63+
_webDownloader.Verify(w => w.DownloadString(It.IsAny<Uri>()), Times.Never());
64+
}
65+
66+
[Fact]
67+
public void ItShould_NotDownloadUrls_WhenCssIsNull()
68+
{
69+
var baseUri = new Uri("https://a.com");
70+
var urls = new List<string>() { "variables.css?v234", "/fonts.css", "https://fonts.google.com/css/test-font" };
71+
72+
var css = CreateCss(urls);
73+
var sut = new ImportRuleCssSource();
74+
75+
sut.GetCss(baseUri, null);
76+
77+
_webDownloader.Verify(w => w.DownloadString(It.IsAny<Uri>()), Times.Never());
78+
}
79+
80+
[Fact]
81+
public void ItShould_NotDownloadUrls_WhenCssDoesNotContainImportRules()
82+
{
83+
var baseUri = new Uri("https://a.com");
84+
var urls = new List<string>() { "variables.css?v234", "/fonts.css", "https://fonts.google.com/css/test-font" };
85+
86+
var css = string.Join(Environment.NewLine, urls);
87+
var sut = new ImportRuleCssSource();
88+
89+
sut.GetCss(baseUri, css);
90+
91+
_webDownloader.Verify(w => w.DownloadString(It.IsAny<Uri>()), Times.Never());
92+
}
93+
94+
[Fact]
95+
public void ItShould_LoadImportsRecursively_UntilLevelTwo()
96+
{
97+
// Arrange
98+
var baseUri = new Uri("https://a.com");
99+
var level0Css = "@import \"level1.css\";";
100+
var level1Css = "@import \"level2.css\";";
101+
var level2Css = "@import \"level3.css\";";
102+
103+
_webDownloader
104+
.Setup(w => w.DownloadString(It.Is<Uri>(u => u.ToString() == "https://a.com/level1.css")))
105+
.Returns(level1Css);
106+
_webDownloader
107+
.Setup(w => w.DownloadString(It.Is<Uri>(u => u.ToString() == "https://a.com/level2.css")))
108+
.Returns(level2Css);
109+
110+
var sut = new ImportRuleCssSource();
111+
112+
// Act
113+
var result = sut.GetCss(baseUri, level0Css).ToList();
114+
115+
// Assert
116+
_webDownloader.Verify(w => w.DownloadString(It.Is<Uri>(u => u.ToString() == "https://a.com/level1.css")), Times.Once);
117+
_webDownloader.Verify(w => w.DownloadString(It.Is<Uri>(u => u.ToString() == "https://a.com/level2.css")), Times.Once);
118+
_webDownloader.Verify(w => w.DownloadString(It.Is<Uri>(u => u.ToString() == "https://a.com/level3.css")), Times.Never);
119+
Assert.Equal(2, result.Count); // Should only contain level1.css and level2.css contents
120+
}
121+
122+
[Fact]
123+
public void ItShould_CacheDownloadedImports_AndNotDownloadTwice()
124+
{
125+
// Arrange
126+
var baseUri = new Uri("https://a.com");
127+
var css = "@import \"shared.css\"; @import \"also-shared.css\";";
128+
var secondCss = "@import \"shared.css\";"; // References same file
129+
130+
_webDownloader
131+
.Setup(w => w.DownloadString(It.Is<Uri>(u => u.ToString() == "https://a.com/shared.css")))
132+
.Returns("h1 { color: red; }");
133+
_webDownloader
134+
.Setup(w => w.DownloadString(It.Is<Uri>(u => u.ToString() == "https://a.com/also-shared.css")))
135+
.Returns("h2 { color: blue; }");
136+
137+
var sut = new ImportRuleCssSource();
138+
139+
// Act
140+
var firstResult = sut.GetCss(baseUri, css);
141+
var secondResult = sut.GetCss(baseUri, secondCss);
142+
143+
// Assert
144+
_webDownloader.Verify(w => w.DownloadString(It.Is<Uri>(u => u.ToString() == "https://a.com/shared.css")), Times.Once);
145+
}
146+
147+
[Fact]
148+
public void ItShould_PreserveImportOrder_WhenProcessingImports()
149+
{
150+
// Arrange
151+
var baseUri = new Uri("https://a.com");
152+
var css = "@import \"first.css\"; @import \"second.css\"; @import \"third.css\";";
153+
var firstCss = "first { order: 1; }";
154+
var secondCss = "second { order: 2; }";
155+
var thirdCss = "third { order: 3; }";
156+
157+
_webDownloader
158+
.Setup(w => w.DownloadString(It.Is<Uri>(u => u.ToString() == "https://a.com/first.css")))
159+
.Returns(firstCss);
160+
_webDownloader
161+
.Setup(w => w.DownloadString(It.Is<Uri>(u => u.ToString() == "https://a.com/second.css")))
162+
.Returns(secondCss);
163+
_webDownloader
164+
.Setup(w => w.DownloadString(It.Is<Uri>(u => u.ToString() == "https://a.com/third.css")))
165+
.Returns(thirdCss);
166+
167+
var sut = new ImportRuleCssSource();
168+
169+
// Act
170+
var result = sut.GetCss(baseUri, css).ToList();
171+
172+
// Assert
173+
Assert.Equal(3, result.Count);
174+
Assert.Equal(firstCss, result[0]);
175+
Assert.Equal(secondCss, result[1]);
176+
Assert.Equal(thirdCss, result[2]);
177+
}
178+
179+
[Fact]
180+
public void ItShould_HandleCircularImports_WithoutInfiniteLoop()
181+
{
182+
// Arrange
183+
var baseUri = new Uri("https://a.com");
184+
var css = "@import \"circular1.css\";";
185+
var circular1Css = "@import \"circular2.css\";";
186+
var circular2Css = "@import \"circular1.css\";"; // Creates a circle
187+
188+
_webDownloader
189+
.Setup(w => w.DownloadString(It.Is<Uri>(u => u.ToString() == "https://a.com/circular1.css")))
190+
.Returns(circular1Css);
191+
_webDownloader
192+
.Setup(w => w.DownloadString(It.Is<Uri>(u => u.ToString() == "https://a.com/circular2.css")))
193+
.Returns(circular2Css);
194+
195+
var sut = new ImportRuleCssSource();
196+
197+
// Act
198+
var result = sut.GetCss(baseUri, css).ToList();
199+
200+
// Assert
201+
Assert.Equal(2, result.Count); // Should contain both files exactly once
202+
_webDownloader.Verify(w => w.DownloadString(It.Is<Uri>(u => u.ToString() == "https://a.com/circular1.css")), Times.Once);
203+
_webDownloader.Verify(w => w.DownloadString(It.Is<Uri>(u => u.ToString() == "https://a.com/circular2.css")), Times.Once);
204+
}
205+
206+
private string CreateCss(IEnumerable<string> imports)
207+
{
208+
var builder = new StringBuilder();
209+
foreach (var import in imports)
210+
{
211+
builder.AppendLine($"@import \"{import}\";");
212+
}
213+
214+
return builder.ToString();
215+
}
216+
}
217+
}

PreMailer.Net/PreMailer.Net.Tests/PreMailerTests.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -104,7 +104,7 @@ public void MoveCssInline_CrazyCssSelector_DoesNotThrowError()
104104
}
105105
catch (Exception ex)
106106
{
107-
Assert.True(false, ex.Message);
107+
Assert.Fail(ex.Message);
108108
}
109109
}
110110

PreMailer.Net/PreMailer.Net/PreMailer.cs

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -247,7 +247,7 @@ private bool DomainMatch(string domain, string url)
247247
/// </summary>
248248
private IEnumerable<string> GetCssBlocks(IEnumerable<ICssSource> cssSources)
249249
{
250-
return cssSources.Select(styleSource => styleSource.GetCss()).ToList();
250+
return cssSources.SelectMany(styleSource => styleSource.GetCss()).ToList();
251251
}
252252

253253
private void RemoveCssComments(IEnumerable<IElement> cssSourceNodes)
@@ -405,7 +405,7 @@ private Dictionary<IElement, List<StyleClass>> FindElementsWithStyles(
405405
Selector = selectorParser.ParseSelector(x.Value.Name)
406406
}).Where(x => x.Selector != null).ToList();
407407

408-
foreach (var el in _document.DescendentsAndSelf<IElement>())
408+
foreach (var el in _document.DescendantsAndSelf<IElement>())
409409
{
410410
foreach (var style in styles)
411411
{
@@ -516,7 +516,7 @@ private int GetSelectorSpecificity(Dictionary<string, int> cache, string selecto
516516

517517
private void RemoveHtmlComments()
518518
{
519-
var comments = _document.Descendents<IComment>().ToList();
519+
var comments = _document.Descendants<IComment>().ToList();
520520

521521
foreach (var comment in comments)
522522
{

PreMailer.Net/PreMailer.Net/Sources/DocumentStyleTagCssSource.cs

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
1-
using AngleSharp.Dom;
1+
using System.Collections.Generic;
2+
using AngleSharp.Dom;
23
using PreMailer.Net.Extensions;
34

45
namespace PreMailer.Net.Sources
@@ -12,9 +13,9 @@ public DocumentStyleTagCssSource(IElement node)
1213
_node = node;
1314
}
1415

15-
public string GetCss()
16+
public IEnumerable<string> GetCss()
1617
{
17-
return _node.GetFirstTextNodeData() ?? "";
18+
return [_node.GetFirstTextNodeData() ?? ""];
1819
}
1920
}
2021
}
Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
1-
namespace PreMailer.Net.Sources
1+
using System.Collections.Generic;
2+
3+
namespace PreMailer.Net.Sources
24
{
35
/// <summary>
46
/// Arbitrary source of CSS code/definitions.
57
/// </summary>
68
public interface ICssSource
79
{
8-
string GetCss();
10+
IEnumerable<string> GetCss();
911
}
1012
}

0 commit comments

Comments
 (0)