diff --git a/.github/workflows/dotnetcore.yml b/.github/workflows/dotnetcore.yml index dd078acc..ff19362b 100644 --- a/.github/workflows/dotnetcore.yml +++ b/.github/workflows/dotnetcore.yml @@ -16,7 +16,7 @@ jobs: steps: - uses: actions/checkout@v4.2.2 - name: Setup .NET Core - uses: actions/setup-dotnet@v4.1.0 + uses: actions/setup-dotnet@v4.3.1 with: dotnet-version: 3.1.101 - name: Install dependencies diff --git a/PreMailer.Net/Benchmarks/Benchmarks.csproj b/PreMailer.Net/Benchmarks/Benchmarks.csproj index 4731aa33..22100bf2 100644 --- a/PreMailer.Net/Benchmarks/Benchmarks.csproj +++ b/PreMailer.Net/Benchmarks/Benchmarks.csproj @@ -2,12 +2,12 @@ Exe - net7.0 + net9.0 enable - + diff --git a/PreMailer.Net/Benchmarks/Program.cs b/PreMailer.Net/Benchmarks/Program.cs index f04d91e8..025fffd2 100644 --- a/PreMailer.Net/Benchmarks/Program.cs +++ b/PreMailer.Net/Benchmarks/Program.cs @@ -1,17 +1,30 @@ using AngleSharp; using AngleSharp.Html.Parser; using BenchmarkDotNet.Attributes; +using BenchmarkDotNet.Configs; +using BenchmarkDotNet.Jobs; using BenchmarkDotNet.Running; +using BenchmarkDotNet.Toolchains.InProcess.NoEmit; public static class Program { public static void Main() { - BenchmarkRunner.Run(); + // Some local environments may run into issues with Windows Defender or + // SentinelOne (and others) when running a benchmark. This ensures we + // keep our toolchain within our process and stops the above apps from blocking + // our benchmark process, but can slow the execution time. + var avSafeConfig = DefaultConfig.Instance + .AddJob( + Job.ShortRun + .WithToolchain(InProcessNoEmitToolchain.Instance) + .WithIterationCount(100) + ); + + BenchmarkRunner.Run(avSafeConfig); } } -[SimpleJob(invocationCount: 100, iterationCount: 100)] [MemoryDiagnoser] public class Realistic { @@ -47,7 +60,7 @@ public void MoveCssInline_AllFlags() PreMailer Benchmark - + diff --git a/PreMailer.Net/PreMailer.Net.Tests/CssStyleEquivalenceTests.cs b/PreMailer.Net/PreMailer.Net.Tests/CssStyleEquivalenceTests.cs index b5523773..9665d98b 100644 --- a/PreMailer.Net/PreMailer.Net.Tests/CssStyleEquivalenceTests.cs +++ b/PreMailer.Net/PreMailer.Net.Tests/CssStyleEquivalenceTests.cs @@ -17,7 +17,7 @@ public void FindEquivalentStyles() var result = CssStyleEquivalence.FindEquivalent(nodewithoutselector, clazz); - Assert.Equal(1, result.Count); + Assert.Single(result); } [Fact] @@ -32,7 +32,7 @@ public void FindEquivalentStylesNoMatchingStyles() var result = CssStyleEquivalence.FindEquivalent(nodewithoutselector, clazz); - Assert.Equal(0, result.Count); + Assert.Empty(result); } } } \ No newline at end of file diff --git a/PreMailer.Net/PreMailer.Net.Tests/ImportRuleCssSourceTests.cs b/PreMailer.Net/PreMailer.Net.Tests/ImportRuleCssSourceTests.cs new file mode 100644 index 00000000..0b9d1854 --- /dev/null +++ b/PreMailer.Net/PreMailer.Net.Tests/ImportRuleCssSourceTests.cs @@ -0,0 +1,217 @@ +using Xunit; +using Moq; +using PreMailer.Net.Downloaders; +using PreMailer.Net.Sources; +using System; +using System.Text; +using System.Collections.Generic; +using System.Linq; + +namespace PreMailer.Net.Tests +{ + public class ImportRuleCssSourceTests + { + private readonly Mock _webDownloader = new Mock(); + + public ImportRuleCssSourceTests() + { + WebDownloader.SharedDownloader = _webDownloader.Object; + } + + [Fact] + public void ItShould_DownloadAllImportedUrls_WhenCssContainsImportRules() + { + var baseUri = new Uri("https://a.com"); + var urls = new List() { "variables.css?v234", "/fonts.css", "https://fonts.google.com/css/test-font" }; + + var css = CreateCss(urls); + var sut = new ImportRuleCssSource(); + + sut.GetCss(baseUri, css); + + foreach (var url in urls) + { + _webDownloader.Verify(w => w.DownloadString(It.Is(u => u.Equals(new Uri(baseUri, url))))); + } + } + + [Fact] + public void ItShould_NotDownloadUrls_WhenLevelIsGreaterThanTwo() + { + var baseUri = new Uri("https://a.com"); + var urls = new List() { "variables.css?v234", "/fonts.css", "https://fonts.google.com/css/test-font" }; + + var css = CreateCss(urls); + var sut = new ImportRuleCssSource(); + + sut.GetCss(baseUri, css, 2); + + _webDownloader.Verify(w => w.DownloadString(It.IsAny()), Times.Never()); + } + + [Fact] + public void ItShould_NotDownloadUrls_WhenCssIsEmpty() + { + var baseUri = new Uri("https://a.com"); + var urls = new List() { "variables.css?v234", "/fonts.css", "https://fonts.google.com/css/test-font" }; + + var css = CreateCss(urls); + var sut = new ImportRuleCssSource(); + + sut.GetCss(baseUri, string.Empty); + + _webDownloader.Verify(w => w.DownloadString(It.IsAny()), Times.Never()); + } + + [Fact] + public void ItShould_NotDownloadUrls_WhenCssIsNull() + { + var baseUri = new Uri("https://a.com"); + var urls = new List() { "variables.css?v234", "/fonts.css", "https://fonts.google.com/css/test-font" }; + + var css = CreateCss(urls); + var sut = new ImportRuleCssSource(); + + sut.GetCss(baseUri, null); + + _webDownloader.Verify(w => w.DownloadString(It.IsAny()), Times.Never()); + } + + [Fact] + public void ItShould_NotDownloadUrls_WhenCssDoesNotContainImportRules() + { + var baseUri = new Uri("https://a.com"); + var urls = new List() { "variables.css?v234", "/fonts.css", "https://fonts.google.com/css/test-font" }; + + var css = string.Join(Environment.NewLine, urls); + var sut = new ImportRuleCssSource(); + + sut.GetCss(baseUri, css); + + _webDownloader.Verify(w => w.DownloadString(It.IsAny()), Times.Never()); + } + + [Fact] + public void ItShould_LoadImportsRecursively_UntilLevelTwo() + { + // Arrange + var baseUri = new Uri("https://a.com"); + var level0Css = "@import \"level1.css\";"; + var level1Css = "@import \"level2.css\";"; + var level2Css = "@import \"level3.css\";"; + + _webDownloader + .Setup(w => w.DownloadString(It.Is(u => u.ToString() == "https://a.com/level1.css"))) + .Returns(level1Css); + _webDownloader + .Setup(w => w.DownloadString(It.Is(u => u.ToString() == "https://a.com/level2.css"))) + .Returns(level2Css); + + var sut = new ImportRuleCssSource(); + + // Act + var result = sut.GetCss(baseUri, level0Css).ToList(); + + // Assert + _webDownloader.Verify(w => w.DownloadString(It.Is(u => u.ToString() == "https://a.com/level1.css")), Times.Once); + _webDownloader.Verify(w => w.DownloadString(It.Is(u => u.ToString() == "https://a.com/level2.css")), Times.Once); + _webDownloader.Verify(w => w.DownloadString(It.Is(u => u.ToString() == "https://a.com/level3.css")), Times.Never); + Assert.Equal(2, result.Count); // Should only contain level1.css and level2.css contents + } + + [Fact] + public void ItShould_CacheDownloadedImports_AndNotDownloadTwice() + { + // Arrange + var baseUri = new Uri("https://a.com"); + var css = "@import \"shared.css\"; @import \"also-shared.css\";"; + var secondCss = "@import \"shared.css\";"; // References same file + + _webDownloader + .Setup(w => w.DownloadString(It.Is(u => u.ToString() == "https://a.com/shared.css"))) + .Returns("h1 { color: red; }"); + _webDownloader + .Setup(w => w.DownloadString(It.Is(u => u.ToString() == "https://a.com/also-shared.css"))) + .Returns("h2 { color: blue; }"); + + var sut = new ImportRuleCssSource(); + + // Act + var firstResult = sut.GetCss(baseUri, css); + var secondResult = sut.GetCss(baseUri, secondCss); + + // Assert + _webDownloader.Verify(w => w.DownloadString(It.Is(u => u.ToString() == "https://a.com/shared.css")), Times.Once); + } + + [Fact] + public void ItShould_PreserveImportOrder_WhenProcessingImports() + { + // Arrange + var baseUri = new Uri("https://a.com"); + var css = "@import \"first.css\"; @import \"second.css\"; @import \"third.css\";"; + var firstCss = "first { order: 1; }"; + var secondCss = "second { order: 2; }"; + var thirdCss = "third { order: 3; }"; + + _webDownloader + .Setup(w => w.DownloadString(It.Is(u => u.ToString() == "https://a.com/first.css"))) + .Returns(firstCss); + _webDownloader + .Setup(w => w.DownloadString(It.Is(u => u.ToString() == "https://a.com/second.css"))) + .Returns(secondCss); + _webDownloader + .Setup(w => w.DownloadString(It.Is(u => u.ToString() == "https://a.com/third.css"))) + .Returns(thirdCss); + + var sut = new ImportRuleCssSource(); + + // Act + var result = sut.GetCss(baseUri, css).ToList(); + + // Assert + Assert.Equal(3, result.Count); + Assert.Equal(firstCss, result[0]); + Assert.Equal(secondCss, result[1]); + Assert.Equal(thirdCss, result[2]); + } + + [Fact] + public void ItShould_HandleCircularImports_WithoutInfiniteLoop() + { + // Arrange + var baseUri = new Uri("https://a.com"); + var css = "@import \"circular1.css\";"; + var circular1Css = "@import \"circular2.css\";"; + var circular2Css = "@import \"circular1.css\";"; // Creates a circle + + _webDownloader + .Setup(w => w.DownloadString(It.Is(u => u.ToString() == "https://a.com/circular1.css"))) + .Returns(circular1Css); + _webDownloader + .Setup(w => w.DownloadString(It.Is(u => u.ToString() == "https://a.com/circular2.css"))) + .Returns(circular2Css); + + var sut = new ImportRuleCssSource(); + + // Act + var result = sut.GetCss(baseUri, css).ToList(); + + // Assert + Assert.Equal(2, result.Count); // Should contain both files exactly once + _webDownloader.Verify(w => w.DownloadString(It.Is(u => u.ToString() == "https://a.com/circular1.css")), Times.Once); + _webDownloader.Verify(w => w.DownloadString(It.Is(u => u.ToString() == "https://a.com/circular2.css")), Times.Once); + } + + private string CreateCss(IEnumerable imports) + { + var builder = new StringBuilder(); + foreach (var import in imports) + { + builder.AppendLine($"@import \"{import}\";"); + } + + return builder.ToString(); + } + } +} diff --git a/PreMailer.Net/PreMailer.Net.Tests/PreMailer.Net.Tests.csproj b/PreMailer.Net/PreMailer.Net.Tests/PreMailer.Net.Tests.csproj index 8f975c7e..f6ea2bcd 100644 --- a/PreMailer.Net/PreMailer.Net.Tests/PreMailer.Net.Tests.csproj +++ b/PreMailer.Net/PreMailer.Net.Tests/PreMailer.Net.Tests.csproj @@ -1,17 +1,17 @@ - net7.0 + net9.0 false - - - - - + + + + + diff --git a/PreMailer.Net/PreMailer.Net.Tests/PreMailerTests.cs b/PreMailer.Net/PreMailer.Net.Tests/PreMailerTests.cs index c34e915a..4db0e0e8 100644 --- a/PreMailer.Net/PreMailer.Net.Tests/PreMailerTests.cs +++ b/PreMailer.Net/PreMailer.Net.Tests/PreMailerTests.cs @@ -104,7 +104,7 @@ public void MoveCssInline_CrazyCssSelector_DoesNotThrowError() } catch (Exception ex) { - Assert.True(false, ex.Message); + Assert.Fail(ex.Message); } } diff --git a/PreMailer.Net/PreMailer.Net/Downloaders/WebDownloader.cs b/PreMailer.Net/PreMailer.Net/Downloaders/WebDownloader.cs index 281e7f33..d04de4d7 100644 --- a/PreMailer.Net/PreMailer.Net/Downloaders/WebDownloader.cs +++ b/PreMailer.Net/PreMailer.Net/Downloaders/WebDownloader.cs @@ -1,3 +1,4 @@ +using PreMailer.Net.Extensions; using System; using System.IO; using System.Net; @@ -8,7 +9,7 @@ namespace PreMailer.Net.Downloaders public class WebDownloader : IWebDownloader { private static IWebDownloader _sharedDownloader; - + private const string CssMimeType = "text/css"; public static IWebDownloader SharedDownloader { get @@ -29,8 +30,16 @@ public static IWebDownloader SharedDownloader public string DownloadString(Uri uri) { var request = WebRequest.Create(uri); + request.Headers.Add(HttpRequestHeader.Accept, CssMimeType); + using (var response = request.GetResponse()) { + // We only support this operation for CSS file/content types coming back + // from the response. If we get something different, throw with the unsupported + // content type in the message + if(response.ParseContentType() != CssMimeType) + throw new NotSupportedException($"The Uri type is giving a response in unsupported content type '{response.ContentType}'."); + switch (response) { case HttpWebResponse httpWebResponse: diff --git a/PreMailer.Net/PreMailer.Net/Extensions/WebResponseExtensions.cs b/PreMailer.Net/PreMailer.Net/Extensions/WebResponseExtensions.cs new file mode 100644 index 00000000..5508a35d --- /dev/null +++ b/PreMailer.Net/PreMailer.Net/Extensions/WebResponseExtensions.cs @@ -0,0 +1,24 @@ +using System; +using System.Net; + +namespace PreMailer.Net.Extensions +{ + public static class WebResponseExtensions + { + public static string ParseContentType(this WebResponse response) + { + if(response == null) + throw new NullReferenceException("Malformed response detected when parsing WebResponse Content-Type"); + + if(string.IsNullOrEmpty(response.ContentType)) + throw new NullReferenceException("Malformed Content-Type response detected when parsing WebResponse"); + + var results = response.ContentType.Split(';'); + + if(results.Length == 0) + throw new FormatException("Malformed Content-Type response detected when parsing WebResponse"); + + return results[0]; + } + } +} diff --git a/PreMailer.Net/PreMailer.Net/PreMailer.cs b/PreMailer.Net/PreMailer.Net/PreMailer.cs index 5621249f..34a402c5 100644 --- a/PreMailer.Net/PreMailer.Net/PreMailer.cs +++ b/PreMailer.Net/PreMailer.Net/PreMailer.cs @@ -247,7 +247,7 @@ private bool DomainMatch(string domain, string url) /// private IEnumerable GetCssBlocks(IEnumerable cssSources) { - return cssSources.Select(styleSource => styleSource.GetCss()).ToList(); + return cssSources.SelectMany(styleSource => styleSource.GetCss()).ToList(); } private void RemoveCssComments(IEnumerable cssSourceNodes) @@ -405,7 +405,7 @@ private Dictionary> FindElementsWithStyles( Selector = selectorParser.ParseSelector(x.Value.Name) }).Where(x => x.Selector != null).ToList(); - foreach (var el in _document.DescendentsAndSelf()) + foreach (var el in _document.DescendantsAndSelf()) { foreach (var style in styles) { @@ -516,7 +516,7 @@ private int GetSelectorSpecificity(Dictionary cache, string selecto private void RemoveHtmlComments() { - var comments = _document.Descendents().ToList(); + var comments = _document.Descendants().ToList(); foreach (var comment in comments) { diff --git a/PreMailer.Net/PreMailer.Net/Sources/DocumentStyleTagCssSource.cs b/PreMailer.Net/PreMailer.Net/Sources/DocumentStyleTagCssSource.cs index 4b263b8f..6b3f9861 100644 --- a/PreMailer.Net/PreMailer.Net/Sources/DocumentStyleTagCssSource.cs +++ b/PreMailer.Net/PreMailer.Net/Sources/DocumentStyleTagCssSource.cs @@ -1,4 +1,5 @@ -using AngleSharp.Dom; +using System.Collections.Generic; +using AngleSharp.Dom; using PreMailer.Net.Extensions; namespace PreMailer.Net.Sources @@ -12,9 +13,9 @@ public DocumentStyleTagCssSource(IElement node) _node = node; } - public string GetCss() + public IEnumerable GetCss() { - return _node.GetFirstTextNodeData() ?? ""; + return [_node.GetFirstTextNodeData() ?? ""]; } } } \ No newline at end of file diff --git a/PreMailer.Net/PreMailer.Net/Sources/ICssSource.cs b/PreMailer.Net/PreMailer.Net/Sources/ICssSource.cs index db67824f..43cc1d1c 100644 --- a/PreMailer.Net/PreMailer.Net/Sources/ICssSource.cs +++ b/PreMailer.Net/PreMailer.Net/Sources/ICssSource.cs @@ -1,10 +1,12 @@ -namespace PreMailer.Net.Sources +using System.Collections.Generic; + +namespace PreMailer.Net.Sources { /// /// Arbitrary source of CSS code/definitions. /// public interface ICssSource { - string GetCss(); + IEnumerable GetCss(); } } \ No newline at end of file diff --git a/PreMailer.Net/PreMailer.Net/Sources/ImportRuleCssSource.cs b/PreMailer.Net/PreMailer.Net/Sources/ImportRuleCssSource.cs new file mode 100644 index 00000000..0217f611 --- /dev/null +++ b/PreMailer.Net/PreMailer.Net/Sources/ImportRuleCssSource.cs @@ -0,0 +1,97 @@ +using System; +using System.Collections.Generic; +using System.Net; +using System.Text.RegularExpressions; +using PreMailer.Net.Downloaders; + +namespace PreMailer.Net.Sources +{ + /// + /// This class is used by the LinkTagCssSource class for downloading/fetching import rules. + /// + public class ImportRuleCssSource + { + private Dictionary _importList = new Dictionary(); + private static Regex _importRegex = new Regex("@import.*?[\"'](?[^\"']+)[\"'].*?;", RegexOptions.Multiline | RegexOptions.IgnoreCase); + + public IEnumerable GetCss(Uri linkedStylesheetUrl, string contents, int level = 0) + { + if (level >= 2 || string.IsNullOrEmpty(contents)) + { + return _importList.Values; + } + + var baseUri = GetBaseUri(linkedStylesheetUrl); + var matches = GetMatches(contents); + + foreach (Match match in matches) + { + var href = match.Groups["href"].Value; + Uri url = default; + + if (Uri.IsWellFormedUriString(href, UriKind.Relative)) + { + url = new Uri(baseUri, href); + } + else + { + url = new Uri(href); + } + + if (!_importList.ContainsKey(url)) + { + var content = DownloadContents(url); + + _importList.Add(url, content); + + GetCss(url, content, level + 1); + } + } + + return _importList.Values; + } + + private string DownloadContents(Uri downloadUri) + { + string contents; + + try + { + contents = WebDownloader.SharedDownloader.DownloadString(downloadUri); + } + catch (WebException ex) + { + throw new WebException($"PreMailer.Net is unable to download the requested URL: {downloadUri}", ex); + } + + return contents; + } + + private static bool IsSupported(string scheme) => scheme == "http" || scheme == "https"; + + private static MatchCollection GetMatches(string contents) + { + if (string.IsNullOrEmpty(contents)) + { + return default; + } + + return _importRegex.Matches(contents); + } + + private static Uri GetBaseUri(Uri downloadUri) + { + var baseUrl = new UriBuilder(downloadUri) + { + Port = -1 /* Excludes the port number */, + Query = string.Empty + }; + + // Strip of the css file segment + var path = baseUrl.Path; + baseUrl.Path = path.Substring(0, path.LastIndexOf('/') + 1); + + return baseUrl.Uri; + } + } +} diff --git a/PreMailer.Net/PreMailer.Net/Sources/LinkTagCssSource.cs b/PreMailer.Net/PreMailer.Net/Sources/LinkTagCssSource.cs index 308fe27b..669bdc83 100644 --- a/PreMailer.Net/PreMailer.Net/Sources/LinkTagCssSource.cs +++ b/PreMailer.Net/PreMailer.Net/Sources/LinkTagCssSource.cs @@ -1,4 +1,5 @@ using System; +using System.Collections.Generic; using System.Linq; using System.Net; using AngleSharp.Dom; @@ -9,10 +10,16 @@ namespace PreMailer.Net.Sources public class LinkTagCssSource : ICssSource { private readonly Uri _downloadUri; - private string _cssContents; + private List _cssContents; + private ImportRuleCssSource _importRuleCssSource; + public LinkTagCssSource(IElement node, Uri baseUri) : this(node, baseUri, new ImportRuleCssSource()) + { + } - public LinkTagCssSource(IElement node, Uri baseUri) + public LinkTagCssSource(IElement node, Uri baseUri, ImportRuleCssSource importRuleCssSource) { + _importRuleCssSource = importRuleCssSource; + // There must be an href var href = node.Attributes.First(a => a.Name.Equals("href", StringComparison.OrdinalIgnoreCase)).Value; @@ -27,24 +34,40 @@ public LinkTagCssSource(IElement node, Uri baseUri) } } - public string GetCss() + public IEnumerable GetCss() { - Console.WriteLine($"GetCss scheme: {_downloadUri.Scheme}"); - if (IsSupported(_downloadUri.Scheme)) { - try - { - Console.WriteLine($"Will download from '{_downloadUri}' using {WebDownloader.SharedDownloader.GetType()}"); - - return _cssContents ?? (_cssContents = WebDownloader.SharedDownloader.DownloadString(_downloadUri)); - } - catch (WebException) - { - throw new WebException($"PreMailer.Net is unable to fetch the requested URL: {_downloadUri}"); - } + return _cssContents ?? DownloadContents(); + } + return default; + } + + private List DownloadContents() + { + string content; + _cssContents ??= new(); + + try + { + content = WebDownloader.SharedDownloader.DownloadString(_downloadUri); + } + catch (WebException ex) + { + throw new WebException($"PreMailer.Net is unable to download the requested URL: {_downloadUri}", ex); } - return string.Empty; + + // Fetch possible import rules + var imports = _importRuleCssSource.GetCss(_downloadUri, content); + + if (imports != null) + { + _cssContents.AddRange(imports); + } + + _cssContents.Add(content); + + return _cssContents; } private static bool IsSupported(string scheme) diff --git a/PreMailer.Net/PreMailer.Net/Sources/StringCssSource.cs b/PreMailer.Net/PreMailer.Net/Sources/StringCssSource.cs index ae5df3d8..39fbf163 100644 --- a/PreMailer.Net/PreMailer.Net/Sources/StringCssSource.cs +++ b/PreMailer.Net/PreMailer.Net/Sources/StringCssSource.cs @@ -1,4 +1,4 @@ -// No usings needed +using System.Collections.Generic; namespace PreMailer.Net.Sources { @@ -11,9 +11,9 @@ public StringCssSource(string css) this._css = css; } - public string GetCss() + public IEnumerable GetCss() { - return _css; + return [_css]; } } } \ No newline at end of file