├── icon.png ├── PreMailer.Net ├── .editorconfig ├── PreMailer.Net │ ├── PreMailer.Net.snk │ ├── Downloaders │ │ ├── IWebDownloader.cs │ │ └── WebDownloader.cs │ ├── AttributeToCss.cs │ ├── ICssSelectorParser.cs │ ├── Sources │ │ ├── ICssSource.cs │ │ ├── StringCssSource.cs │ │ ├── DocumentStyleTagCssSource.cs │ │ └── LinkTagCssSource.cs │ ├── InlineResult.cs │ ├── CssSelector.cs │ ├── CssAttribute.cs │ ├── CssStyleEquivalence.cs │ ├── CssSpecificity.cs │ ├── PreMailer.Net.csproj │ ├── CssElementStyleResolver.cs │ ├── StyleClassApplier.cs │ ├── StyleClass.cs │ ├── CssParser.cs │ ├── CssSelectorParser.cs │ └── PreMailer.cs ├── nuget.bat ├── PreMailer.Net.Tests │ ├── PreMailer.Net.Tests.csproj │ ├── CssSpecificityTests.cs │ ├── CssElementStyleResolverTests.cs │ ├── CssStyleEquivalenceTests.cs │ ├── CssSelectorTests.cs │ ├── CssAttributeTests.cs │ ├── LinkTagCssSourceTests.cs │ ├── StyleClassApplierTests.cs │ ├── StyleClassTests.cs │ ├── CssSelectorParserTests.cs │ ├── CssParserTests.cs │ └── PreMailerTests.cs ├── PreMailer.Net.sln └── .gitignore ├── LICENSE ├── .gitignore └── README.md /icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CaptainStack/PreMailer.Net/master/icon.png -------------------------------------------------------------------------------- /PreMailer.Net/.editorconfig: -------------------------------------------------------------------------------- 1 | [*.cs] 2 | 3 | #Core editorconfig formatting - indentation 4 | 5 | #use hard tabs for indentation 6 | indent_style = tab -------------------------------------------------------------------------------- /PreMailer.Net/PreMailer.Net/PreMailer.Net.snk: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CaptainStack/PreMailer.Net/master/PreMailer.Net/PreMailer.Net/PreMailer.Net.snk -------------------------------------------------------------------------------- /PreMailer.Net/PreMailer.Net/Downloaders/IWebDownloader.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | 3 | namespace PreMailer.Net.Downloaders 4 | { 5 | public interface IWebDownloader 6 | { 7 | string DownloadString(Uri uri); 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /PreMailer.Net/PreMailer.Net/AttributeToCss.cs: -------------------------------------------------------------------------------- 1 | namespace PreMailer.Net 2 | { 3 | public class AttributeToCss 4 | { 5 | public string AttributeName { get; set; } 6 | public string CssValue { get; set; } 7 | } 8 | } -------------------------------------------------------------------------------- /PreMailer.Net/PreMailer.Net/ICssSelectorParser.cs: -------------------------------------------------------------------------------- 1 | namespace PreMailer.Net 2 | { 3 | public interface ICssSelectorParser 4 | { 5 | int GetSelectorSpecificity(string selector); 6 | bool IsPseudoElement(string selector); 7 | bool IsPseudoClass(string selector); 8 | } 9 | } -------------------------------------------------------------------------------- /PreMailer.Net/PreMailer.Net/Sources/ICssSource.cs: -------------------------------------------------------------------------------- 1 | namespace PreMailer.Net.Sources 2 | { 3 | /// 4 | /// Arbitrary source of CSS code/definitions. 5 | /// 6 | public interface ICssSource 7 | { 8 | string GetCss(); 9 | } 10 | } -------------------------------------------------------------------------------- /PreMailer.Net/nuget.bat: -------------------------------------------------------------------------------- 1 | @echo off 2 | 3 | echo. 4 | echo Restoring project... 5 | 6 | dotnet restore PreMailer.Net\PreMailer.Net.csproj 7 | 8 | echo. 9 | echo Creating NuGet Package... 10 | 11 | dotnet pack PreMailer.Net\PreMailer.Net.csproj --configuration Release --include-symbols -------------------------------------------------------------------------------- /PreMailer.Net/PreMailer.Net/Sources/StringCssSource.cs: -------------------------------------------------------------------------------- 1 | // No usings needed 2 | 3 | namespace PreMailer.Net.Sources 4 | { 5 | public class StringCssSource : ICssSource 6 | { 7 | private readonly string _css; 8 | 9 | public StringCssSource(string css) 10 | { 11 | this._css = css; 12 | } 13 | 14 | public string GetCss() 15 | { 16 | return _css; 17 | } 18 | } 19 | } -------------------------------------------------------------------------------- /PreMailer.Net/PreMailer.Net/Sources/DocumentStyleTagCssSource.cs: -------------------------------------------------------------------------------- 1 | using AngleSharp.Dom; 2 | 3 | namespace PreMailer.Net.Sources 4 | { 5 | public class DocumentStyleTagCssSource : ICssSource 6 | { 7 | private readonly IElement _node; 8 | 9 | public DocumentStyleTagCssSource(IElement node) 10 | { 11 | _node = node; 12 | } 13 | 14 | public string GetCss() 15 | { 16 | return _node.InnerHtml; 17 | } 18 | } 19 | } -------------------------------------------------------------------------------- /PreMailer.Net/PreMailer.Net/InlineResult.cs: -------------------------------------------------------------------------------- 1 | using System.Collections.Generic; 2 | using System.Text; 3 | 4 | namespace PreMailer.Net 5 | { 6 | public class InlineResult 7 | { 8 | public StringBuilder StringBuilder { get; protected set; } 9 | public string Html => StringBuilder.ToString(); 10 | 11 | public List Warnings { get; protected set; } 12 | // TODO: Add plain-text output. 13 | // TODO: Store processing Errors. 14 | 15 | public InlineResult(StringBuilder stringBuilder, List warnings = null) 16 | { 17 | StringBuilder = stringBuilder; 18 | Warnings = warnings ?? new List(); 19 | } 20 | } 21 | } -------------------------------------------------------------------------------- /PreMailer.Net/PreMailer.Net.Tests/PreMailer.Net.Tests.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | netcoreapp2.0 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /PreMailer.Net/PreMailer.Net/CssSelector.cs: -------------------------------------------------------------------------------- 1 | using System.Text.RegularExpressions; 2 | 3 | namespace PreMailer.Net 4 | { 5 | public class CssSelector 6 | { 7 | protected static Regex NotMatcher = new Regex(@":not\((.+)\)", RegexOptions.IgnoreCase | RegexOptions.Compiled); 8 | 9 | public string Selector { get; protected set; } 10 | 11 | public bool HasNotPseudoClass => NotMatcher.IsMatch(Selector); 12 | 13 | public string NotPseudoClassContent 14 | { 15 | get 16 | { 17 | var match = NotMatcher.Match(Selector); 18 | return match.Success ? match.Groups[1].Value : null; 19 | } 20 | } 21 | 22 | public CssSelector(string selector) 23 | { 24 | Selector = selector; 25 | } 26 | 27 | public CssSelector StripNotPseudoClassContent() 28 | { 29 | var stripped = NotMatcher.Replace(Selector, string.Empty); 30 | return new CssSelector(stripped); 31 | } 32 | 33 | public override string ToString() 34 | { 35 | return Selector; 36 | } 37 | } 38 | } -------------------------------------------------------------------------------- /PreMailer.Net/PreMailer.Net.Tests/CssSpecificityTests.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.VisualStudio.TestTools.UnitTesting; 2 | 3 | namespace PreMailer.Net.Tests 4 | { 5 | [TestClass] 6 | public class CssSpecificityTests 7 | { 8 | [TestMethod] 9 | public void PlusOperator_OneIdForBothInstances_ReturnsTwoIds() 10 | { 11 | var first = new CssSpecificity(1, 0, 0); 12 | var second = new CssSpecificity(1, 0, 0); 13 | var result = first + second; 14 | Assert.AreEqual(2, result.Ids); 15 | } 16 | 17 | [TestMethod] 18 | public void PlusOperator_OneClassForBothInstances_ReturnsTwoClasses() 19 | { 20 | var first = new CssSpecificity(0, 1, 0); 21 | var second = new CssSpecificity(0, 1, 0); 22 | var result = first + second; 23 | Assert.AreEqual(2, result.Classes); 24 | } 25 | 26 | [TestMethod] 27 | public void PlusOperator_OneElementForBothInstances_ReturnsTwoElements() 28 | { 29 | var first = new CssSpecificity(0, 0, 1); 30 | var second = new CssSpecificity(0, 0, 1); 31 | var result = first + second; 32 | Assert.AreEqual(2, result.Elements); 33 | } 34 | } 35 | } -------------------------------------------------------------------------------- /PreMailer.Net/PreMailer.Net/CssAttribute.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | 3 | namespace PreMailer.Net 4 | { 5 | public class CssAttribute 6 | { 7 | public string Style { get; set; } 8 | public string Value { get; set; } 9 | public bool Important { get; set; } 10 | 11 | private CssAttribute() { } 12 | 13 | public static CssAttribute FromRule(string rule) 14 | { 15 | var parts = rule.Split(new[] { ':' }, 2); 16 | 17 | if (parts.Length == 1) 18 | { 19 | return null; 20 | } 21 | 22 | var value = parts[1].Trim(); 23 | var important = false; 24 | 25 | if (value.IndexOf("!important", StringComparison.CurrentCultureIgnoreCase) != -1) 26 | { 27 | important = true; 28 | value = value.Replace("!important", "").Trim(); 29 | } 30 | 31 | return new CssAttribute 32 | { 33 | Style = parts[0].Trim(), 34 | Value = value, 35 | Important = important 36 | }; 37 | } 38 | 39 | public override string ToString() 40 | { 41 | return $"{Style}: {Value}{(Important ? " !important" : string.Empty)}"; 42 | } 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /PreMailer.Net/PreMailer.Net/CssStyleEquivalence.cs: -------------------------------------------------------------------------------- 1 | using System.Collections.Generic; 2 | using System.Linq; 3 | using AngleSharp.Dom; 4 | 5 | namespace PreMailer.Net 6 | { 7 | public static class CssStyleEquivalence 8 | { 9 | private static readonly Dictionary _linkedAttributes = new Dictionary 10 | { 11 | {"bgcolor", "background-color"}, 12 | {"width", "width"}, 13 | {"height", "height"} 14 | }; 15 | 16 | 17 | public static IList FindEquivalent(IElement domobject, StyleClass styles) 18 | { 19 | return (from attributeRuleMatch in _linkedAttributes 20 | where domobject.HasAttribute(attributeRuleMatch.Key) && styles.Attributes.ContainsKey(attributeRuleMatch.Value) 21 | select new AttributeToCss 22 | { 23 | AttributeName = attributeRuleMatch.Key, CssValue = styles.Attributes[attributeRuleMatch.Value].Value 24 | }).ToList(); 25 | } 26 | } 27 | } -------------------------------------------------------------------------------- /PreMailer.Net/PreMailer.Net.Tests/CssElementStyleResolverTests.cs: -------------------------------------------------------------------------------- 1 | using System.Linq; 2 | using AngleSharp.Dom; 3 | using AngleSharp.Html.Parser; 4 | using Microsoft.VisualStudio.TestTools.UnitTesting; 5 | 6 | namespace PreMailer.Net.Tests 7 | { 8 | [TestClass] 9 | public class CssElementStyleResolverTests 10 | { 11 | [TestMethod] 12 | public void GetAllStylesForElement() 13 | { 14 | var tableDomObject = new HtmlParser().ParseDocument("
"); 15 | var nodewithoutselector = (IElement)tableDomObject.Body.FirstChild; 16 | 17 | var clazz = new StyleClass(); 18 | clazz.Attributes["background-color"] = CssAttribute.FromRule("background-color: red"); 19 | 20 | var result = CssElementStyleResolver.GetAllStyles(nodewithoutselector, clazz); 21 | 22 | Assert.AreEqual(2, result.Count()); 23 | Assert.AreEqual("style", result.ElementAt(0).AttributeName); 24 | Assert.AreEqual("bgcolor", result.ElementAt(1).AttributeName); 25 | } 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2013 Milkshake Software (http://milkshakesoftware.com) 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is 8 | furnished to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in 11 | all copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 19 | THE SOFTWARE. -------------------------------------------------------------------------------- /PreMailer.Net/PreMailer.Net/CssSpecificity.cs: -------------------------------------------------------------------------------- 1 | namespace PreMailer.Net 2 | { 3 | public class CssSpecificity 4 | { 5 | public int Ids { get; protected set; } 6 | 7 | /// 8 | /// Classes, attributes and pseudo-classes. 9 | /// 10 | public int Classes { get; protected set; } 11 | 12 | /// 13 | /// Elements and pseudo-elements. 14 | /// 15 | public int Elements { get; protected set; } 16 | 17 | public static CssSpecificity None => new CssSpecificity(0, 0, 0); 18 | 19 | public CssSpecificity(int ids, int classes, int elements) 20 | { 21 | Ids = ids; 22 | Classes = classes; 23 | Elements = elements; 24 | } 25 | 26 | public int ToInt() 27 | { 28 | var s = ToString(); 29 | var success = int.TryParse(s, out var result); 30 | return result; 31 | } 32 | 33 | public override string ToString() 34 | { 35 | return $"{Ids}{Classes}{Elements}"; 36 | } 37 | 38 | public static CssSpecificity operator +(CssSpecificity first, CssSpecificity second) 39 | { 40 | return new CssSpecificity( 41 | first.Ids + second.Ids, 42 | first.Classes + second.Classes, 43 | first.Elements + second.Elements); 44 | } 45 | } 46 | } -------------------------------------------------------------------------------- /PreMailer.Net/PreMailer.Net/PreMailer.Net.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | netstandard2.0;net461 5 | 2.1.3 6 | Martin H. Normark 7 | 8 | PreMailer.Net is a C# utility for moving CSS to inline style attributes, to gain maximum E-mail client compatibility. 9 | 10 | Copyright 2016 11 | MIT 12 | https://github.com/milkshakesoftware/PreMailer.Net 13 | icon.png 14 | false 15 | 16 | Target .NETFramework4.6.1 instead of 4.6 https://github.com/milkshakesoftware/PreMailer.Net/pull/187 17 | 18 | email css newsletter html 19 | true 20 | PreMailer.Net.snk 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | -------------------------------------------------------------------------------- /PreMailer.Net/PreMailer.Net/CssElementStyleResolver.cs: -------------------------------------------------------------------------------- 1 | using System.Collections.Generic; 2 | using System.Linq; 3 | using AngleSharp.Dom; 4 | 5 | namespace PreMailer.Net 6 | { 7 | public static class CssElementStyleResolver 8 | { 9 | private const string premailerAttributePrefix = "-premailer-"; 10 | 11 | public static IEnumerable GetAllStyles(IElement domElement, StyleClass styleClass) 12 | { 13 | var attributeCssList = new List(); 14 | 15 | AddSpecialPremailerAttributes(attributeCssList, styleClass); 16 | 17 | if (styleClass.Attributes.Count > 0) 18 | attributeCssList.Add(new AttributeToCss { AttributeName = "style", CssValue = styleClass.ToString() }); 19 | 20 | attributeCssList.AddRange(CssStyleEquivalence.FindEquivalent(domElement, styleClass)); 21 | 22 | return attributeCssList; 23 | } 24 | 25 | private static void AddSpecialPremailerAttributes(List attributeCssList, StyleClass styleClass) 26 | { 27 | while (true) 28 | { 29 | var premailerRuleMatch = styleClass.Attributes.FirstOrDefault(a => a.Key.StartsWith(premailerAttributePrefix)); 30 | 31 | var key = premailerRuleMatch.Key; 32 | var cssAttribute = premailerRuleMatch.Value; 33 | 34 | if (key == null) 35 | break; 36 | 37 | attributeCssList.Add(new AttributeToCss 38 | { 39 | AttributeName = key.Replace(premailerAttributePrefix, ""), 40 | CssValue = cssAttribute.Value 41 | }); 42 | 43 | styleClass.Attributes.Remove(key); 44 | } 45 | } 46 | } 47 | } -------------------------------------------------------------------------------- /PreMailer.Net/PreMailer.Net.Tests/CssStyleEquivalenceTests.cs: -------------------------------------------------------------------------------- 1 | using AngleSharp.Dom; 2 | using AngleSharp.Html.Parser; 3 | using Microsoft.VisualStudio.TestTools.UnitTesting; 4 | 5 | namespace PreMailer.Net.Tests 6 | { 7 | [TestClass] 8 | public class CssStyleEquivalenceTests 9 | { 10 | [TestMethod] 11 | public void FindEquivalentStyles() 12 | { 13 | var tableDomObject = new HtmlParser().ParseDocument("
"); 14 | var nodewithoutselector = (IElement)tableDomObject.Body.FirstChild; 15 | 16 | var clazz = new StyleClass(); 17 | clazz.Attributes["background-color"] = CssAttribute.FromRule("background-color: red"); 18 | 19 | var result = CssStyleEquivalence.FindEquivalent(nodewithoutselector, clazz); 20 | 21 | Assert.AreEqual(1, result.Count); 22 | } 23 | 24 | [TestMethod] 25 | public void FindEquivalentStylesNoMatchingStyles() 26 | { 27 | var tableDomObject = new HtmlParser().ParseDocument("
"); 28 | 29 | var clazz = new StyleClass(); 30 | clazz.Attributes["border"] = CssAttribute.FromRule("border: 1px"); 31 | 32 | var nodewithoutselector = (IElement)tableDomObject.Body.FirstChild; 33 | 34 | var result = CssStyleEquivalence.FindEquivalent(nodewithoutselector, clazz); 35 | 36 | Assert.AreEqual(0, result.Count); 37 | } 38 | } 39 | } -------------------------------------------------------------------------------- /PreMailer.Net/PreMailer.Net/Sources/LinkTagCssSource.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Linq; 3 | using System.Net; 4 | using AngleSharp.Dom; 5 | using PreMailer.Net.Downloaders; 6 | 7 | namespace PreMailer.Net.Sources 8 | { 9 | public class LinkTagCssSource : ICssSource 10 | { 11 | private readonly Uri _downloadUri; 12 | private string _cssContents; 13 | 14 | public LinkTagCssSource(IElement node, Uri baseUri) 15 | { 16 | // There must be an href 17 | var href = node.Attributes.First(a => a.Name.Equals("href", StringComparison.OrdinalIgnoreCase)).Value; 18 | 19 | if (Uri.IsWellFormedUriString(href, UriKind.Relative) && baseUri != null) 20 | { 21 | _downloadUri = new Uri(baseUri, href); 22 | } 23 | else 24 | { 25 | // Assume absolute 26 | _downloadUri = new Uri(href); 27 | } 28 | } 29 | 30 | public string GetCss() 31 | { 32 | if (IsSupported(_downloadUri.Scheme)) 33 | { 34 | try 35 | { 36 | return _cssContents ?? (_cssContents = WebDownloader.SharedDownloader.DownloadString(_downloadUri)); 37 | } catch (WebException) 38 | { 39 | throw new WebException($"PreMailer.Net is unable to fetch the requested URL: {_downloadUri}"); 40 | } 41 | } 42 | return string.Empty; 43 | } 44 | 45 | private static bool IsSupported(string scheme) 46 | { 47 | return 48 | scheme == "http" || 49 | scheme == "https" || 50 | scheme == "ftp" || 51 | scheme == "file"; 52 | } 53 | } 54 | } -------------------------------------------------------------------------------- /PreMailer.Net/PreMailer.Net/Downloaders/WebDownloader.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.IO; 3 | using System.Net; 4 | using System.Text; 5 | 6 | namespace PreMailer.Net.Downloaders 7 | { 8 | public class WebDownloader : IWebDownloader 9 | { 10 | private static IWebDownloader _sharedDownloader; 11 | 12 | public static IWebDownloader SharedDownloader 13 | { 14 | get 15 | { 16 | if (_sharedDownloader == null) 17 | { 18 | _sharedDownloader = new WebDownloader(); 19 | } 20 | 21 | return _sharedDownloader; 22 | } 23 | set 24 | { 25 | _sharedDownloader = value; 26 | } 27 | } 28 | 29 | public string DownloadString(Uri uri) 30 | { 31 | var request = WebRequest.Create(uri); 32 | using (var response = request.GetResponse()) 33 | { 34 | switch (response) 35 | { 36 | case HttpWebResponse httpWebResponse: 37 | { 38 | var charset = httpWebResponse.CharacterSet; 39 | var encoding = Encoding.GetEncoding(charset); 40 | using (var stream = httpWebResponse.GetResponseStream()) 41 | using (var reader = new StreamReader(stream, encoding)) 42 | { 43 | return reader.ReadToEnd(); 44 | } 45 | } 46 | 47 | case FileWebResponse fileWebResponse: 48 | { 49 | using (var stream = fileWebResponse.GetResponseStream()) 50 | using (var reader = new StreamReader(stream)) 51 | { 52 | return reader.ReadToEnd(); 53 | } 54 | } 55 | 56 | default: 57 | throw new NotSupportedException($"The Uri type is giving a response in unsupported type '{response.GetType()}'."); 58 | } 59 | } 60 | } 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /PreMailer.Net/PreMailer.Net.Tests/CssSelectorTests.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.VisualStudio.TestTools.UnitTesting; 2 | 3 | namespace PreMailer.Net.Tests 4 | { 5 | [TestClass] 6 | public class CssSelectorTests 7 | { 8 | [TestMethod] 9 | public void ContainsNotPseudoClass_ElementWithPseudoClass_ReturnsFalse() 10 | { 11 | var selector = new CssSelector("li:first-child"); 12 | Assert.IsFalse(selector.HasNotPseudoClass); 13 | } 14 | 15 | [TestMethod] 16 | public void ContainsNotPseudoClass_ElementWithNotPseudoClass_ReturnsTrue() 17 | { 18 | var selector = new CssSelector("li:not(.ignored)"); 19 | Assert.IsTrue(selector.HasNotPseudoClass); 20 | } 21 | 22 | [TestMethod] 23 | public void NotPseudoClassContent_ElementWithPseudoClass_ReturnsNull() 24 | { 25 | var selector = new CssSelector("li:first-child"); 26 | Assert.IsNull(selector.NotPseudoClassContent); 27 | } 28 | 29 | [TestMethod] 30 | public void NotPseudoClassContent_ElementWithNotPseudoClass_ReturnsContent() 31 | { 32 | var selector = new CssSelector("li:not(.ignored)"); 33 | Assert.AreEqual(".ignored", selector.NotPseudoClassContent); 34 | } 35 | 36 | [TestMethod] 37 | public void StripNotPseudoClassContent_ElementWithPseudoClass_ReturnsOriginalSelector() 38 | { 39 | var expected = "li:first-child"; 40 | var selector = new CssSelector(expected); 41 | Assert.AreEqual(expected, selector.StripNotPseudoClassContent().ToString()); 42 | } 43 | 44 | [TestMethod] 45 | public void StripNotPseudoClassContent_ElementWithNotPseudoClass_ReturnsSelectorWithoutNot() 46 | { 47 | var selector = new CssSelector("li:not(.ignored)"); 48 | Assert.AreEqual("li", selector.StripNotPseudoClassContent().ToString()); 49 | } 50 | } 51 | } -------------------------------------------------------------------------------- /PreMailer.Net/PreMailer.Net.sln: -------------------------------------------------------------------------------- 1 | 2 | Microsoft Visual Studio Solution File, Format Version 12.00 3 | # Visual Studio 15 4 | VisualStudioVersion = 15.0.27703.2035 5 | MinimumVisualStudioVersion = 10.0.40219.1 6 | Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "PreMailer.Net", "PreMailer.Net\PreMailer.Net.csproj", "{7B4A85FA-FA98-40FD-83B7-5E84C8853736}" 7 | EndProject 8 | Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "PreMailer.Net.Tests", "PreMailer.Net.Tests\PreMailer.Net.Tests.csproj", "{F5A0FED0-4A6C-49AA-B49E-C47A034D8098}" 9 | EndProject 10 | Global 11 | GlobalSection(SolutionConfigurationPlatforms) = preSolution 12 | Debug|Any CPU = Debug|Any CPU 13 | Release|Any CPU = Release|Any CPU 14 | EndGlobalSection 15 | GlobalSection(ProjectConfigurationPlatforms) = postSolution 16 | {7B4A85FA-FA98-40FD-83B7-5E84C8853736}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 17 | {7B4A85FA-FA98-40FD-83B7-5E84C8853736}.Debug|Any CPU.Build.0 = Debug|Any CPU 18 | {7B4A85FA-FA98-40FD-83B7-5E84C8853736}.Release|Any CPU.ActiveCfg = Release|Any CPU 19 | {7B4A85FA-FA98-40FD-83B7-5E84C8853736}.Release|Any CPU.Build.0 = Release|Any CPU 20 | {F5A0FED0-4A6C-49AA-B49E-C47A034D8098}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 21 | {F5A0FED0-4A6C-49AA-B49E-C47A034D8098}.Debug|Any CPU.Build.0 = Debug|Any CPU 22 | {F5A0FED0-4A6C-49AA-B49E-C47A034D8098}.Release|Any CPU.ActiveCfg = Release|Any CPU 23 | {F5A0FED0-4A6C-49AA-B49E-C47A034D8098}.Release|Any CPU.Build.0 = Release|Any CPU 24 | EndGlobalSection 25 | GlobalSection(SolutionProperties) = preSolution 26 | HideSolutionNode = FALSE 27 | EndGlobalSection 28 | GlobalSection(ExtensibilityGlobals) = postSolution 29 | SolutionGuid = {FBC52989-0CC3-40E9-8DB3-109FBC0B6C49} 30 | EndGlobalSection 31 | EndGlobal 32 | -------------------------------------------------------------------------------- /PreMailer.Net/PreMailer.Net/StyleClassApplier.cs: -------------------------------------------------------------------------------- 1 | using System.Collections.Generic; 2 | using AngleSharp.Dom; 3 | 4 | namespace PreMailer.Net 5 | { 6 | public static class StyleClassApplier 7 | { 8 | public static Dictionary ApplyAllStyles(Dictionary elementDictionary) 9 | { 10 | foreach (var styleClass in elementDictionary) 11 | { 12 | ApplyStyles(styleClass.Key, styleClass.Value); 13 | } 14 | 15 | return elementDictionary; 16 | } 17 | 18 | private static IElement ApplyStyles(IElement domElement, StyleClass clazz) 19 | { 20 | var styles = CssElementStyleResolver.GetAllStyles(domElement, clazz); 21 | 22 | foreach (var attributeToCss in styles) 23 | { 24 | PrepareAttribute(domElement, attributeToCss); 25 | //domElement.SetAttribute(attributeToCss.AttributeName, attributeToCss.CssValue); 26 | } 27 | 28 | var styleAttr = domElement.Attributes["style"]; 29 | if (styleAttr == null || string.IsNullOrEmpty(styleAttr.Value)) 30 | { 31 | domElement.RemoveAttribute("style"); 32 | } 33 | 34 | return domElement; 35 | } 36 | 37 | private static void PrepareAttribute(IElement domElement, AttributeToCss attributeToCss) 38 | { 39 | string name = attributeToCss.AttributeName; 40 | string value = attributeToCss.CssValue; 41 | 42 | //When rendering images, we need to prevent breaking the WIDTH and HEIGHT attributes. See PreMailerTests.MoveCssInline_HasStyle_DoesNotBreakImageWidthAttribute(). 43 | //The old code could end up writing an image tag like which violates the HTML spec. It should render . 44 | if (domElement.NodeName == @"IMG" 45 | && (name == "width" || name == "height") 46 | && value.EndsWith("px")) 47 | { 48 | value = value.Replace("px", string.Empty); 49 | } 50 | 51 | domElement.SetAttribute(name, value); 52 | } 53 | } 54 | } -------------------------------------------------------------------------------- /PreMailer.Net/PreMailer.Net/StyleClass.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | 4 | namespace PreMailer.Net { 5 | public class StyleClass { 6 | /// 7 | /// Initializes a new instance of the class. 8 | /// 9 | public StyleClass() 10 | { 11 | Attributes = new Dictionary(StringComparer.CurrentCultureIgnoreCase); 12 | } 13 | 14 | /// 15 | /// Gets or sets the name. 16 | /// 17 | /// The name. 18 | public string Name { get; set; } 19 | 20 | /// 21 | /// The position, relative to other style classes. 22 | /// 23 | public int Position { get; set; } 24 | 25 | /// 26 | /// Gets or sets the attributes. 27 | /// 28 | /// The attributes. 29 | public Dictionary Attributes { get; set; } 30 | 31 | /// 32 | /// Merges the specified style class, with this instance. Styles on this instance are not overriden by duplicates in the specified styleClass. 33 | /// 34 | /// The style class. 35 | /// if set to true [can overwrite]. 36 | public void Merge(StyleClass styleClass, bool canOverwrite) { 37 | foreach (var item in styleClass.Attributes) { 38 | if (!Attributes.TryGetValue(item.Key, out var existing) || 39 | canOverwrite && (!existing.Important || item.Value.Important)) 40 | { 41 | Attributes[item.Key] = item.Value; 42 | } 43 | } 44 | } 45 | 46 | /// 47 | /// Returns a that represents this instance. 48 | /// 49 | /// 50 | /// A that represents this instance. 51 | /// 52 | public override string ToString() { 53 | return string.Join(";", Attributes.Values); 54 | } 55 | } 56 | } -------------------------------------------------------------------------------- /PreMailer.Net/PreMailer.Net.Tests/CssAttributeTests.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.VisualStudio.TestTools.UnitTesting; 2 | 3 | namespace PreMailer.Net.Tests 4 | { 5 | [TestClass] 6 | public class CssAttributeTests 7 | { 8 | [TestMethod] 9 | public void StandardUnimportantRule_ReturnsAttribute() 10 | { 11 | var attribute = CssAttribute.FromRule("color: red"); 12 | 13 | Assert.AreEqual("color", attribute.Style); 14 | Assert.AreEqual("red", attribute.Value); 15 | } 16 | 17 | [TestMethod] 18 | public void MixedCaseRuleValue_RetainsCasing() 19 | { 20 | var attribute = CssAttribute.FromRule(" color: rED"); 21 | 22 | Assert.AreEqual("color", attribute.Style); 23 | Assert.AreEqual("rED", attribute.Value); 24 | } 25 | 26 | [TestMethod] 27 | public void MixedCaseRule_RetainsCasing() 28 | { 29 | var attribute = CssAttribute.FromRule("Margin-bottom: 10px"); 30 | 31 | Assert.AreEqual("Margin-bottom", attribute.Style); 32 | Assert.AreEqual("10px", attribute.Value); 33 | } 34 | 35 | [TestMethod] 36 | public void ImportantRule_ReturnsImportantAttribute() 37 | { 38 | var attribute = CssAttribute.FromRule("color: red !important"); 39 | 40 | Assert.AreEqual("color", attribute.Style); 41 | Assert.AreEqual("red", attribute.Value); 42 | Assert.IsTrue(attribute.Important); 43 | } 44 | 45 | [TestMethod] 46 | public void ImportantRule_ReturnsValidCssWithoutWhitespaces() 47 | { 48 | var attribute = CssAttribute.FromRule("color:red!important"); 49 | 50 | Assert.AreEqual("color", attribute.Style); 51 | Assert.AreEqual("red", attribute.Value); 52 | Assert.IsTrue(attribute.Important); 53 | } 54 | 55 | [TestMethod] 56 | public void NonRule_ReturnsNull() 57 | { 58 | var attribute = CssAttribute.FromRule(" "); 59 | 60 | Assert.IsNull(attribute); 61 | } 62 | 63 | [TestMethod] 64 | public void FromRule_OnlySplitsTheRuleAtTheFirstColonToSupportUrls() 65 | { 66 | var attribute = CssAttribute.FromRule("background: url('http://my.web.site.com/Content/email/content.png') repeat-y"); 67 | 68 | Assert.AreEqual("url('http://my.web.site.com/Content/email/content.png') repeat-y", attribute.Value); 69 | } 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /PreMailer.Net/.gitignore: -------------------------------------------------------------------------------- 1 | /TestResults 2 | 3 | ## Ignore Visual Studio temporary files, build results, and 4 | ## files generated by popular Visual Studio add-ons. 5 | 6 | # User-specific files 7 | *.suo 8 | *.user 9 | *.sln.docstates 10 | 11 | # Build results 12 | 13 | [Dd]ebug/ 14 | [Rr]elease/ 15 | x64/ 16 | build/ 17 | [Bb]in/ 18 | [Oo]bj/ 19 | .vs/ 20 | 21 | !Libraries/Redis/bin/ 22 | !Libraries/Mongo/bin/ 23 | 24 | # MSTest test Results 25 | [Tt]est[Rr]esult*/ 26 | [Bb]uild[Ll]og.* 27 | 28 | *_i.c 29 | *_p.c 30 | *.ilk 31 | *.meta 32 | *.obj 33 | *.pch 34 | *.pdb 35 | *.pgc 36 | *.pgd 37 | *.rsp 38 | *.sbr 39 | *.tlb 40 | *.tli 41 | *.tlh 42 | *.tmp 43 | *.tmp_proj 44 | *.log 45 | *.vspscc 46 | *.vssscc 47 | .builds 48 | *.pidb 49 | *.log 50 | *.scc 51 | 52 | # Visual C++ cache files 53 | ipch/ 54 | *.aps 55 | *.ncb 56 | *.opensdf 57 | *.sdf 58 | *.cachefile 59 | 60 | # Visual Studio profiler 61 | *.psess 62 | *.vsp 63 | *.vspx 64 | 65 | # Guidance Automation Toolkit 66 | *.gpState 67 | 68 | # ReSharper is a .NET coding add-in 69 | _ReSharper*/ 70 | *.[Rr]e[Ss]harper 71 | 72 | # TeamCity is a build add-in 73 | _TeamCity* 74 | 75 | # DotCover is a Code Coverage Tool 76 | *.dotCover 77 | 78 | # NCrunch 79 | *.ncrunch* 80 | .*crunch*.local.xml 81 | 82 | # Installshield output folder 83 | [Ee]xpress/ 84 | 85 | # DocProject is a documentation generator add-in 86 | DocProject/buildhelp/ 87 | DocProject/Help/*.HxT 88 | DocProject/Help/*.HxC 89 | DocProject/Help/*.hhc 90 | DocProject/Help/*.hhk 91 | DocProject/Help/*.hhp 92 | DocProject/Help/Html2 93 | DocProject/Help/html 94 | 95 | # Click-Once directory 96 | publish/ 97 | 98 | # Publish Web Output 99 | *.Publish.xml 100 | *.pubxml 101 | 102 | # NuGet Packages Directory 103 | packages/ 104 | 105 | # Windows Azure Build Output 106 | csx 107 | *.build.csdef 108 | 109 | # Windows Store app package directory 110 | AppPackages/ 111 | 112 | # Others 113 | sql/ 114 | *.Cache 115 | ClientBin/ 116 | [Ss]tyle[Cc]op.* 117 | ~$* 118 | *~ 119 | *.dbmdl 120 | *.[Pp]ublish.xml 121 | *.pfx 122 | *.publishsettings 123 | 124 | # RIA/Silverlight projects 125 | Generated_Code/ 126 | 127 | # Backup & report files from converting an old project file to a newer 128 | # Visual Studio version. Backup files are not needed, because we have git ;-) 129 | _UpgradeReport_Files/ 130 | Backup*/ 131 | UpgradeLog*.XML 132 | UpgradeLog*.htm 133 | 134 | # SQL Server files 135 | App_Data/*.mdf 136 | App_Data/*.ldf 137 | 138 | # ========================= 139 | # Windows detritus 140 | # ========================= 141 | 142 | # Windows image file caches 143 | Thumbs.db 144 | ehthumbs.db 145 | 146 | # Folder config file 147 | Desktop.ini 148 | 149 | # Recycle Bin used on file shares 150 | $RECYCLE.BIN/ 151 | 152 | # Mac crap 153 | .DS_Store 154 | 155 | .vs/ 156 | -------------------------------------------------------------------------------- /PreMailer.Net/PreMailer.Net.Tests/LinkTagCssSourceTests.cs: -------------------------------------------------------------------------------- 1 | using AngleSharp.Html.Parser; 2 | using Microsoft.VisualStudio.TestTools.UnitTesting; 3 | using Moq; 4 | using PreMailer.Net.Downloaders; 5 | using PreMailer.Net.Sources; 6 | using System; 7 | 8 | namespace PreMailer.Net.Tests 9 | { 10 | [TestClass] 11 | public class LinkTagCssSourceTests 12 | { 13 | private readonly Mock _webDownloader = new Mock(); 14 | 15 | public LinkTagCssSourceTests() 16 | { 17 | WebDownloader.SharedDownloader = _webDownloader.Object; 18 | } 19 | 20 | [TestMethod] 21 | public void ImplementsInterface() 22 | { 23 | LinkTagCssSource sut = CreateSUT(); 24 | 25 | Assert.IsInstanceOfType(sut, typeof(ICssSource)); 26 | } 27 | 28 | [TestMethod] 29 | public void GetCSS_CallsWebDownloader_WithSpecifiedDomain() 30 | { 31 | string baseUrl = "http://a.co"; 32 | 33 | LinkTagCssSource sut = CreateSUT(baseUrl: baseUrl); 34 | sut.GetCss(); 35 | 36 | _webDownloader.Verify(w => w.DownloadString(It.Is(u => u.Scheme == "http" && u.Host == "a.co"))); 37 | } 38 | 39 | [TestMethod] 40 | public void GetCSS_CallsWebDownloader_WithSpecifiedPath() 41 | { 42 | string path = "b.css"; 43 | 44 | LinkTagCssSource sut = CreateSUT(path: path); 45 | sut.GetCss(); 46 | 47 | _webDownloader.Verify(w => w.DownloadString(It.Is(u => u.PathAndQuery == "/" + path))); 48 | } 49 | 50 | [TestMethod] 51 | public void GetCSS_CallsWebDownloader_WithSpecifiedBundle() 52 | { 53 | string path = "/Content/css?v=7V7TZzP9Wo7LiH9_q-r5mRBdC_N0lA_YJpRL_1V424E1"; 54 | 55 | LinkTagCssSource sut = CreateSUT(path: path, link: ""); 56 | sut.GetCss(); 57 | 58 | _webDownloader.Verify(w => w.DownloadString(It.Is(u => u.PathAndQuery == path))); 59 | } 60 | 61 | [TestMethod] 62 | public void GetCSS_AbsoluteUrlInHref_CallsWebDownloader_WithSpecifiedPath() 63 | { 64 | string path = "http://b.co/a.css"; 65 | 66 | LinkTagCssSource sut = CreateSUT(path: path); 67 | sut.GetCss(); 68 | 69 | _webDownloader.Verify(w => w.DownloadString(new Uri(path))); 70 | } 71 | 72 | [TestMethod] 73 | public void GetCSS_DoesNotCallWebDownloader_WhenSchemeNotSupported() 74 | { 75 | string path = "chrome-extension://fcdjadjbdihbaodagojiomdljhjhjfho/css/atd.css"; 76 | 77 | LinkTagCssSource sut = CreateSUT(path: path); 78 | sut.GetCss(); 79 | 80 | _webDownloader.Verify(w => w.DownloadString(new Uri(path)), Times.Never); 81 | } 82 | 83 | private LinkTagCssSource CreateSUT(string baseUrl = "http://a.com", string path = "a.css", string link = "") 84 | { 85 | var node = new HtmlParser().ParseDocument(String.Format(link, path)); 86 | var sut = new LinkTagCssSource(node.Head.FirstElementChild, new Uri(baseUrl)); 87 | 88 | return sut; 89 | } 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | ## Ignore Visual Studio temporary files, build results, and 2 | ## files generated by popular Visual Studio add-ons. 3 | 4 | # User-specific files 5 | *.suo 6 | *.user 7 | *.sln.docstates 8 | 9 | # Build results 10 | 11 | [Dd]ebug/ 12 | [Rr]elease/ 13 | x64/ 14 | build/ 15 | [Bb]in/ 16 | [Oo]bj/ 17 | 18 | # Visual Studio cache/options directory 19 | .vs/ 20 | 21 | !Libraries/Redis/bin/ 22 | !Libraries/Mongo/bin/ 23 | 24 | # MSTest test Results 25 | [Tt]est[Rr]esult*/ 26 | [Bb]uild[Ll]og.* 27 | 28 | *_i.c 29 | *_p.c 30 | *.ilk 31 | *.meta 32 | *.obj 33 | *.pch 34 | *.pdb 35 | *.pgc 36 | *.pgd 37 | *.rsp 38 | *.sbr 39 | *.tlb 40 | *.tli 41 | *.tlh 42 | *.tmp 43 | *.tmp_proj 44 | *.log 45 | *.vspscc 46 | *.vssscc 47 | .builds 48 | *.pidb 49 | *.log 50 | *.scc 51 | 52 | # Visual C++ cache files 53 | ipch/ 54 | *.aps 55 | *.ncb 56 | *.opensdf 57 | *.sdf 58 | *.cachefile 59 | 60 | # Visual Studio profiler 61 | *.psess 62 | *.vsp 63 | *.vspx 64 | 65 | # Guidance Automation Toolkit 66 | *.gpState 67 | 68 | # ReSharper is a .NET coding add-in 69 | _ReSharper*/ 70 | *.[Rr]e[Ss]harper 71 | 72 | # TeamCity is a build add-in 73 | _TeamCity* 74 | 75 | # DotCover is a Code Coverage Tool 76 | *.dotCover 77 | 78 | # NCrunch 79 | *.ncrunch* 80 | .*crunch*.local.xml 81 | 82 | # Installshield output folder 83 | [Ee]xpress/ 84 | 85 | # DocProject is a documentation generator add-in 86 | DocProject/buildhelp/ 87 | DocProject/Help/*.HxT 88 | DocProject/Help/*.HxC 89 | DocProject/Help/*.hhc 90 | DocProject/Help/*.hhk 91 | DocProject/Help/*.hhp 92 | DocProject/Help/Html2 93 | DocProject/Help/html 94 | 95 | # Click-Once directory 96 | publish/ 97 | 98 | # Publish Web Output 99 | *.Publish.xml 100 | *.pubxml 101 | 102 | # NuGet Packages Directory 103 | packages/ 104 | 105 | /TestResults 106 | /PreMailer.Net/packages 107 | 108 | # Windows Azure Build Output 109 | csx 110 | *.build.csdef 111 | 112 | # Windows Store app package directory 113 | AppPackages/ 114 | 115 | # Others 116 | sql/ 117 | *.Cache 118 | ClientBin/ 119 | [Ss]tyle[Cc]op.* 120 | ~$* 121 | *~ 122 | *.dbmdl 123 | *.[Pp]ublish.xml 124 | *.pfx 125 | *.publishsettings 126 | 127 | # RIA/Silverlight projects 128 | Generated_Code/ 129 | 130 | # Backup & report files from converting an old project file to a newer 131 | # Visual Studio version. Backup files are not needed, because we have git ;-) 132 | _UpgradeReport_Files/ 133 | Backup*/ 134 | UpgradeLog*.XML 135 | UpgradeLog*.htm 136 | 137 | # SQL Server files 138 | App_Data/*.mdf 139 | App_Data/*.ldf 140 | 141 | # ========================= 142 | # Windows detritus 143 | # ========================= 144 | 145 | # Windows image file caches 146 | Thumbs.db 147 | ehthumbs.db 148 | 149 | # Folder config file 150 | Desktop.ini 151 | 152 | # Recycle Bin used on file shares 153 | $RECYCLE.BIN/ 154 | 155 | # Mac crap 156 | .DS_Store 157 | PreMailer.Net/coverage.xml -------------------------------------------------------------------------------- /PreMailer.Net/PreMailer.Net.Tests/StyleClassApplierTests.cs: -------------------------------------------------------------------------------- 1 | using System.Collections.Generic; 2 | using System.Linq; 3 | using AngleSharp.Dom; 4 | using AngleSharp.Html.Parser; 5 | using Microsoft.VisualStudio.TestTools.UnitTesting; 6 | 7 | namespace PreMailer.Net.Tests 8 | { 9 | [TestClass] 10 | public class StyleClassApplierTests 11 | { 12 | [TestMethod] 13 | public void ApplyStylesToAllElements() 14 | { 15 | var elementDictionary = new Dictionary(); 16 | 17 | var tableDomObject1 = new HtmlParser().ParseDocument("
"); 18 | var tableDomObject2 = new HtmlParser().ParseDocument("
"); 19 | var tableDomObject3 = new HtmlParser().ParseDocument("
"); 20 | var tableDomObject4 = new HtmlParser().ParseDocument("
"); 21 | 22 | var styleClassBgColor = new StyleClass(); 23 | styleClassBgColor.Attributes["background-color"] = CssAttribute.FromRule("background-color: #008001"); 24 | 25 | var styleClassWidth = new StyleClass(); 26 | styleClassWidth.Attributes["width"] = CssAttribute.FromRule("width: 10px"); 27 | 28 | var styleClassHeight = new StyleClass(); 29 | styleClassHeight.Attributes["height"] = CssAttribute.FromRule("height: 10px"); 30 | 31 | var styleClassBgAndWidth = new StyleClass(); 32 | styleClassBgAndWidth.Attributes["background-color"] = CssAttribute.FromRule("background-color: #008003"); 33 | styleClassBgAndWidth.Attributes["width"] = CssAttribute.FromRule("width: 10px"); 34 | 35 | elementDictionary.Add(tableDomObject1.Body.FirstElementChild, styleClassBgColor); 36 | elementDictionary.Add(tableDomObject2.Body.FirstElementChild, styleClassWidth); 37 | elementDictionary.Add(tableDomObject3.Body.FirstElementChild, styleClassHeight); 38 | elementDictionary.Add(tableDomObject4.Body.FirstElementChild, styleClassBgAndWidth); 39 | 40 | var result = StyleClassApplier.ApplyAllStyles(elementDictionary); 41 | 42 | Assert.AreEqual("
", result.ElementAt(0).Key.OuterHtml); 43 | Assert.AreEqual("
", result.ElementAt(1).Key.OuterHtml); 44 | Assert.AreEqual("
", result.ElementAt(2).Key.OuterHtml); 45 | Assert.AreEqual("
", result.ElementAt(3).Key.OuterHtml); 46 | } 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /PreMailer.Net/PreMailer.Net/CssParser.cs: -------------------------------------------------------------------------------- 1 | using System.Collections.Generic; 2 | using System.Linq; 3 | using System.Text.RegularExpressions; 4 | 5 | namespace PreMailer.Net 6 | { 7 | public class CssParser 8 | { 9 | private readonly List _styleSheets; 10 | private int styleCount = 0; 11 | 12 | public SortedList Styles { get; set; } 13 | 14 | public CssParser() 15 | { 16 | _styleSheets = new List(); 17 | Styles = new SortedList(); 18 | } 19 | 20 | public void AddStyleSheet(string styleSheetContent) 21 | { 22 | _styleSheets.Add(styleSheetContent); 23 | ProcessStyleSheet(styleSheetContent); 24 | } 25 | 26 | public string GetStyleSheet(int index) 27 | { 28 | return _styleSheets[index]; 29 | } 30 | 31 | public StyleClass ParseStyleClass(string className, string style) 32 | { 33 | var sc = new StyleClass { Name = className }; 34 | 35 | FillStyleClass(sc, className, style); 36 | 37 | return sc; 38 | } 39 | 40 | private void ProcessStyleSheet(string styleSheetContent) 41 | { 42 | string content = CleanUp(styleSheetContent); 43 | string[] parts = content.Split('}'); 44 | 45 | foreach (string s in parts) 46 | { 47 | if (s.IndexOf('{') > -1) 48 | { 49 | FillStyleClassFromBlock(s); 50 | } 51 | } 52 | } 53 | 54 | /// 55 | /// Fills the style class. 56 | /// 57 | /// The style block. 58 | private void FillStyleClassFromBlock(string s) 59 | { 60 | string[] parts = s.Split('{'); 61 | var cleaned = parts[0].Trim(); 62 | var styleNames = cleaned.Split(',').Select(x => x.Trim()); 63 | 64 | foreach (var styleName in styleNames) 65 | { 66 | StyleClass sc; 67 | if (Styles.ContainsKey(styleName)) 68 | { 69 | sc = Styles[styleName]; 70 | Styles.Remove(styleName); 71 | } 72 | else 73 | { 74 | sc = new StyleClass(); 75 | } 76 | 77 | sc.Position = ++styleCount; 78 | 79 | FillStyleClass(sc, styleName, parts[1]); 80 | 81 | Styles.Add(sc.Name, sc); 82 | } 83 | } 84 | 85 | /// 86 | /// Fills the style class. 87 | /// 88 | /// The style class. 89 | /// Name of the style. 90 | /// The styles. 91 | private static void FillStyleClass(StyleClass sc, string styleName, string style) 92 | { 93 | sc.Name = styleName; 94 | 95 | //string[] atrs = style.Split(';'); 96 | //string[] atrs = CleanUp(style).Split(';'); 97 | string[] atrs = FillStyleClassRegex.Split(CleanUp(style)); 98 | 99 | foreach (string a in atrs) 100 | { 101 | var attribute = CssAttribute.FromRule(a); 102 | 103 | if (attribute != null) sc.Attributes[attribute.Style] = attribute; 104 | } 105 | } 106 | 107 | private static Regex FillStyleClassRegex = new Regex(@"(;)(?=(?:[^""']|""[^""]*""|'[^']*')*$)", RegexOptions.Multiline | RegexOptions.Compiled); 108 | private static Regex CssCommentRegex = new Regex(@"(?:/\*(.|[\r\n])*?\*/)|(?:(?", ""); 118 | return temp; 119 | } 120 | 121 | public static Regex SupportedMediaQueriesRegex = new Regex(@"^(?:\s*(?:only\s+)?(?:screen|projection|all),\s*)*(?:(?:only\s+)?(?:screen|projection|all))$", RegexOptions.IgnoreCase | RegexOptions.Compiled); 122 | private static Regex MediaQueryRegex = new Regex(@"@media\s*(?[^{]*){(?(?>[^{}]+|{(?)|}(?<-DEPTH>))*(?(DEPTH)(?!)))}", RegexOptions.Compiled); 123 | 124 | private static string CleanupMediaQueries(string s) 125 | { 126 | return MediaQueryRegex.Replace(s, m => SupportedMediaQueriesRegex.IsMatch(m.Groups["query"].Value.Trim()) ? m.Groups["styles"].Value.Trim() : string.Empty); 127 | } 128 | } 129 | } 130 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # PreMailer.Net [![Build status](https://ci.appveyor.com/api/projects/status/github/milkshakesoftware/PreMailer.Net?branch=master&svg=true)](https://ci.appveyor.com/project/milkshakesoftware/premailer-net) [![Coverage Status](https://coveralls.io/repos/github/milkshakesoftware/PreMailer.Net/badge.svg?branch=master)](https://coveralls.io/github/milkshakesoftware/PreMailer.Net?branch=master) [![Nuget count](https://img.shields.io/nuget/v/PreMailer.Net.svg?style=flat-square)](https://www.nuget.org/packages/PreMailer.Net/) 2 | 3 | C# Library for moving CSS to inline style attributes, to gain maximum E-mail client compatibility. 4 | 5 | ## Usage 6 | 7 | ### Static method on `PreMailer` class 8 | ```csharp 9 | string htmlSource = File.ReadAllText(@"C:\Workspace\testmail.html"); 10 | 11 | var result = PreMailer.MoveCssInline(htmlSource); 12 | 13 | result.Html // Resultant HTML, with CSS in-lined. 14 | result.Warnings // string[] of any warnings that occurred during processing. 15 | ``` 16 | 17 | ### Set up `PreMailer` instance 18 | ```csharp 19 | string htmlSource = File.ReadAllText(@"C:\Workspace\testmail.html"); 20 | 21 | var pm = new PreMailer(htmlSource); 22 | pm.AddAnalyticsTags(source, medium, campaign, content, domain = null); // Optional to add analytics tags 23 | 24 | var result = pm.MoveCssInline(...); 25 | 26 | result.Html // Resultant HTML, with CSS in-lined. 27 | result.Warnings // string[] of any warnings that occurred during processing. 28 | ``` 29 | 30 | ### Options 31 | The following options can be passed to the `PreMailer.MoveCssInline` method to configure it's behaviour: 32 | 33 | - `baseUri(Uri = null)` - Base URL to apply to `link` elements with `href` values ending with `.css`. 34 | - `removeStyleElements(bool = false)` - Removes elements that were used to source CSS (currently, only `style` is supported). 35 | - `ignoreElements(string = null)` - CSS selector of element(s) _not_ to inline. Useful for mobile styles (see below). 36 | - `css(string = null)` - A string containing a style-sheet for inlining. 37 | - `stripIdAndClassAttributes(bool = false)` - True to strip ID and class attributes. 38 | - `removeComments(bool = false)` - True to remove comments, false to leave them intact. 39 | 40 | ### External style sheets 41 | Sometimes it's handy to reference external style sheets with a `` element. PreMailer will download and use external style sheets as long as the value of `href` ends with `.css`. 42 | 43 | Both absolute and relative URLs are supported. If the URL is relative, you must specify the `baseUri` parameter in either the constructor, or when calling the static `MoveCssInline` method. 44 | 45 | `` elements that match the `ignoreElements` selector won't be downloaded. 46 | 47 | ### Media queries 48 | If you want to [apply mobile styles to your e-mail](http://help.campaignmonitor.com/topic.aspx?t=164), you should put your 49 | mobile specific styles in its own `style` block that targets the appropriate devices using media queries. 50 | 51 | But since you cannot know by the time of sending an e-mail whether or not it will be viewed on a mobile device, the `style` 52 | block that targets mobile devices should not be inlined! 53 | 54 | To ignore a `style` block, you need to specify an ignore selector when calling the `MoveCssInline` method, like this: 55 | 56 | ```csharp 57 | var result = PreMailer.MoveCssInline(input, false, ignoreElements: "#ignore"); 58 | ``` 59 | 60 | And your mobile specific `style` block should have an ID of `ignore`: 61 | 62 | ```html 63 | 64 | ``` 65 | 66 | ### Premailer specific CSS becomes HTML attributes 67 | Premailer looks for the use of CSS attributes prefixed with `-premailer` and will proxy the value through to the DOM element as an attribute. 68 | 69 | For example 70 | 71 | ```css 72 | table { 73 | -premailer-cellspacing: 5; 74 | -premailer-width: 500; 75 | } 76 | ``` 77 | 78 | will make a `table` element render as 79 | 80 | ```html 81 | 82 | ``` 83 | 84 | ### Custom DOM Processing 85 | ```csharp 86 | using(var pm = new PreMailer(html)){ 87 | 88 | var document = pm.Document; 89 | 90 | // use AngleSharp to process document before moving css inline ... 91 | 92 | var result = pm.MoveCssInline(); 93 | } 94 | ``` 95 | 96 | ### Notes 97 | 98 | - Pseudo classes/elements which not supported by external dependencies, or doesn't make sense in email, will be ignored and logged to the `InlineResult.Warnings` collection. 99 | 100 | ## Installation 101 | **NuGet**: [PreMailer.Net](http://nuget.org/List/Packages/PreMailer.Net) 102 | 103 | ## Contributors 104 | 105 | * [martinnormark](https://github.com/martinnormark) 106 | * [robcthegeek](https://github.com/robcthegeek) 107 | 108 | [Among others](https://github.com/milkshakesoftware/PreMailer.Net/graphs/contributors) 109 | 110 | ## License 111 | 112 | PreMailer.Net is available under the MIT license. See the [LICENSE](https://github.com/milkshakesoftware/PreMailer.Net/blob/master/LICENSE) file for more info. 113 | -------------------------------------------------------------------------------- /PreMailer.Net/PreMailer.Net.Tests/StyleClassTests.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.VisualStudio.TestTools.UnitTesting; 2 | 3 | namespace PreMailer.Net.Tests 4 | { 5 | [TestClass] 6 | public class StyleClassTests 7 | { 8 | [TestMethod] 9 | public void SingleAttribute_ShouldNotAppendSemiColonToString() 10 | { 11 | var clazz = new StyleClass(); 12 | clazz.Attributes["color"] = CssAttribute.FromRule("color: red"); 13 | 14 | Assert.AreEqual("color: red", clazz.ToString()); 15 | } 16 | 17 | [TestMethod] 18 | public void MultipleAttributes_ShouldJoinAttributesWithSemiColonInString() 19 | { 20 | var clazz = new StyleClass(); 21 | clazz.Attributes["color"] = CssAttribute.FromRule("color: red"); 22 | clazz.Attributes["height"] = CssAttribute.FromRule("height: 100%"); 23 | 24 | Assert.AreEqual("color: red;height: 100%", clazz.ToString()); 25 | } 26 | 27 | [TestMethod] 28 | public void Merge_ShouldAddNonExistingAttributesToClass() 29 | { 30 | var target = new StyleClass(); 31 | var donator = new StyleClass(); 32 | 33 | target.Attributes["color"] = CssAttribute.FromRule("color: red"); 34 | donator.Attributes["height"] = CssAttribute.FromRule("height: 100%"); 35 | 36 | target.Merge(donator, true); 37 | 38 | Assert.IsTrue(target.Attributes.ContainsKey("height")); 39 | Assert.AreEqual("100%", target.Attributes["height"].Value); 40 | } 41 | 42 | [TestMethod] 43 | public void Merge_ShouldOverrideExistingEntriesIfSpecified() 44 | { 45 | var target = new StyleClass(); 46 | var donator = new StyleClass(); 47 | 48 | target.Attributes["color"] = CssAttribute.FromRule("color: red"); 49 | target.Attributes["height"] = CssAttribute.FromRule("height: 50%"); 50 | donator.Attributes["height"] = CssAttribute.FromRule("height: 100%"); 51 | 52 | target.Merge(donator, true); 53 | 54 | Assert.IsTrue(target.Attributes.ContainsKey("height")); 55 | Assert.AreEqual("100%", target.Attributes["height"].Value); 56 | } 57 | 58 | [TestMethod] 59 | public void Merge_ShouldNotOverrideExistingEntriesIfNotSpecified() 60 | { 61 | var target = new StyleClass(); 62 | var donator = new StyleClass(); 63 | 64 | target.Attributes["color"] = CssAttribute.FromRule("color: red"); 65 | target.Attributes["height"] = CssAttribute.FromRule("height: 50%"); 66 | donator.Attributes["height"] = CssAttribute.FromRule("height: 100%"); 67 | 68 | target.Merge(donator, false); 69 | 70 | Assert.IsTrue(target.Attributes.ContainsKey("height")); 71 | Assert.AreEqual("50%", target.Attributes["height"].Value); 72 | } 73 | 74 | [TestMethod] 75 | public void Merge_ShouldNotOverrideExistingImportantEntriesIfNewEntryIsNotImportant() 76 | { 77 | var target = new StyleClass(); 78 | var donator = new StyleClass(); 79 | 80 | target.Attributes["color"] = CssAttribute.FromRule("color: red"); 81 | target.Attributes["height"] = CssAttribute.FromRule("height: 50% !important"); 82 | donator.Attributes["height"] = CssAttribute.FromRule("height: 100%"); 83 | 84 | target.Merge(donator, true); 85 | 86 | Assert.IsTrue(target.Attributes.ContainsKey("height")); 87 | Assert.AreEqual("50%", target.Attributes["height"].Value); 88 | } 89 | 90 | [TestMethod] 91 | public void Merge_ShouldOverrideExistingImportantEntriesIfNewEntryIsImportant() 92 | { 93 | var target = new StyleClass(); 94 | var donator = new StyleClass(); 95 | 96 | target.Attributes["color"] = CssAttribute.FromRule("color: red"); 97 | target.Attributes["height"] = CssAttribute.FromRule("height: 50% !important"); 98 | donator.Attributes["height"] = CssAttribute.FromRule("height: 100% !important"); 99 | 100 | target.Merge(donator, true); 101 | 102 | Assert.IsTrue(target.Attributes.ContainsKey("height")); 103 | Assert.AreEqual("100%", target.Attributes["height"].Value); 104 | } 105 | 106 | [TestMethod] 107 | public void Merge_ShouldOverrideExistingEntriesIfSpecifiedIgnoringCasing() 108 | { 109 | var target = new StyleClass(); 110 | var donator = new StyleClass(); 111 | 112 | target.Attributes["color"] = CssAttribute.FromRule("color: red"); 113 | target.Attributes["HEight"] = CssAttribute.FromRule("height: 50%"); 114 | donator.Attributes["height"] = CssAttribute.FromRule("height: 100%"); 115 | 116 | target.Merge(donator, true); 117 | 118 | Assert.IsTrue(target.Attributes.ContainsKey("height")); 119 | Assert.AreEqual("100%", target.Attributes["height"].Value); 120 | } 121 | } 122 | } -------------------------------------------------------------------------------- /PreMailer.Net/PreMailer.Net.Tests/CssSelectorParserTests.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.VisualStudio.TestTools.UnitTesting; 2 | 3 | namespace PreMailer.Net.Tests 4 | { 5 | [TestClass] 6 | public class CssSelectorParserTests 7 | { 8 | private ICssSelectorParser _parser; 9 | 10 | [TestInitialize] 11 | public void TestInitialize() 12 | { 13 | _parser = new CssSelectorParser(); 14 | } 15 | 16 | [TestMethod] 17 | public void GetSelectorSpecificity_Null_Returns0() 18 | { 19 | var result = _parser.GetSelectorSpecificity(null); 20 | Assert.AreEqual(0, result); 21 | } 22 | 23 | [TestMethod] 24 | public void GetSelectorSpecificity_Empty_Returns0() 25 | { 26 | var result = _parser.GetSelectorSpecificity(string.Empty); 27 | Assert.AreEqual(0, result); 28 | } 29 | 30 | [TestMethod] 31 | public void GetSelectorSpecificity_Wildcard_Returns0() 32 | { 33 | var result = _parser.GetSelectorSpecificity("*"); 34 | Assert.AreEqual(0, result); 35 | } 36 | 37 | // Examples from http://www.w3.org/TR/2001/CR-css3-selectors-20011113/#specificity 38 | [TestMethod] 39 | public void GetSelectorSpecificity_SingleElementName_Returns1() 40 | { 41 | var result = _parser.GetSelectorSpecificity("LI"); 42 | Assert.AreEqual(1, result); 43 | } 44 | 45 | [TestMethod] 46 | public void GetSelectorSpecificity_TwoElementNames_Returns2() 47 | { 48 | var result = _parser.GetSelectorSpecificity("UL LI"); 49 | Assert.AreEqual(2, result); 50 | } 51 | 52 | [TestMethod] 53 | public void GetSelectorSpecificity_ThreeElementNames_Returns3() 54 | { 55 | var result = _parser.GetSelectorSpecificity("UL OL+LI"); 56 | Assert.AreEqual(3, result); 57 | } 58 | 59 | [TestMethod] 60 | public void GetSelectorSpecificity_ElementNameAndAttribute_Returns11() 61 | { 62 | var result = _parser.GetSelectorSpecificity("H1 + *[REL=up]"); 63 | Assert.AreEqual(11, result); 64 | } 65 | 66 | [TestMethod] 67 | public void GetSelectorSpecificity_AttributeVariants_AllEqual() 68 | { 69 | var result1 = _parser.GetSelectorSpecificity("[REL ~= \"up\"]"); 70 | var result2 = _parser.GetSelectorSpecificity("[REL ~= 'up']"); 71 | var result3 = _parser.GetSelectorSpecificity("[REL|=up]"); 72 | var result4 = _parser.GetSelectorSpecificity("[ REL^=up ]"); 73 | var result5 = _parser.GetSelectorSpecificity("[REL$=up]"); 74 | var result6 = _parser.GetSelectorSpecificity("[REL*=up]"); 75 | 76 | Assert.IsTrue( 77 | result1 == result2 && 78 | result2 == result3 && 79 | result3 == result4 && 80 | result4 == result5 && 81 | result5 == result6 82 | ); 83 | } 84 | 85 | [TestMethod] 86 | public void GetSelectorSpecificity_ThreeElementNamesAndOneClass_Returns13() 87 | { 88 | var result = _parser.GetSelectorSpecificity("UL OL LI.red"); 89 | Assert.AreEqual(13, result); 90 | } 91 | 92 | [TestMethod] 93 | public void GetSelectorSpecificity_OneElementNameAndTwoClasses_Returns21() 94 | { 95 | var result = _parser.GetSelectorSpecificity("LI.red.level"); 96 | Assert.AreEqual(21, result); 97 | } 98 | 99 | [TestMethod] 100 | public void GetSelectorSpecificity_OneId_Returns100() 101 | { 102 | var result = _parser.GetSelectorSpecificity("#x34y"); 103 | Assert.AreEqual(100, result); 104 | } 105 | 106 | [TestMethod] 107 | public void GetSelectorSpecificity_OneIdAndElementInPseudoElement_Returns101() 108 | { 109 | var result = _parser.GetSelectorSpecificity("#s12:after"); 110 | Assert.AreEqual(101, result); 111 | } 112 | 113 | [TestMethod] 114 | public void GetSelectorSpecificity_OneIdAndElementInNotPseudoClass_Returns101() 115 | { 116 | var result = _parser.GetSelectorSpecificity("#s12:not(FOO)"); 117 | Assert.AreEqual(101, result); 118 | } 119 | 120 | [TestMethod] 121 | public void GetSelectorSpecificity_OneIdAndElementWithHyphenInNotPseudoClass_Returns101() 122 | { 123 | var result = _parser.GetSelectorSpecificity("#s12:not(FOO-BAR)"); 124 | Assert.AreEqual(101, result); 125 | } 126 | 127 | [TestMethod] 128 | public void GetSelectorSpecificity_OneIdTenClassesOneElement_Returns1101() 129 | { 130 | var result = _parser.GetSelectorSpecificity("#id .class .class .class .class .class .class .class .class .class .class element"); 131 | Assert.AreEqual(1101, result); 132 | } 133 | 134 | [TestMethod] 135 | public void GetSelectorSpecificity_TenIdsOneClassOneElement_Returns1011() 136 | { 137 | var result = _parser.GetSelectorSpecificity("#id #id #id #id #id #id #id #id #id #id .class element"); 138 | Assert.AreEqual(1011, result); 139 | } 140 | 141 | [TestMethod] 142 | public void GetSelectorSpecificity_ElementWithPseudoClass_Returns11() 143 | { 144 | var result = _parser.GetSelectorSpecificity("li:first-child"); 145 | Assert.AreEqual(11, result); 146 | } 147 | 148 | [TestMethod] 149 | public void GetSelectorSpecificity_OneIdOneClassTenElements_Returns1110() 150 | { 151 | var result = _parser.GetSelectorSpecificity("#id .class element element element element element element element element element element"); 152 | Assert.AreEqual(1110, result); 153 | } 154 | 155 | [TestMethod] 156 | public void GetSelectorSpecificity_OneIdOneClassOneElementWithHyphens_Returns111() 157 | { 158 | var result = _parser.GetSelectorSpecificity("my-element#my-id.my-class"); 159 | Assert.AreEqual(111, result); 160 | } 161 | 162 | [TestMethod] 163 | public void GetSelectorSpecificity_OneIdOneClassOneElementWithUnderscores_Returns111() 164 | { 165 | var result = _parser.GetSelectorSpecificity("my_element#my_id.my_class"); 166 | Assert.AreEqual(111, result); 167 | } 168 | 169 | [TestMethod] 170 | public void GetSelectorSpecificity_OneIdOneClassOneElementWithNonAsciiBeginnings_Returns111() 171 | { 172 | var result = _parser.GetSelectorSpecificity("ɟmyelement#ʇmyid.ɹmyclass"); 173 | Assert.AreEqual(111, result); 174 | } 175 | 176 | [TestMethod] 177 | public void GetSelectorSpecificity_OneIdOneClassOneElementWithNonAsciiMiddles_Returns111() 178 | { 179 | var result = _parser.GetSelectorSpecificity("my™element#myǝid.myɐclass"); 180 | Assert.AreEqual(111, result); 181 | } 182 | 183 | [TestMethod] 184 | public void GetSelectorSpecificity_OneIdOneClassOneElementWithNonAsciiEndings_Returns111() 185 | { 186 | var result = _parser.GetSelectorSpecificity("myelement♫#myid♫.myclass♫"); 187 | Assert.AreEqual(111, result); 188 | } 189 | 190 | [TestMethod] 191 | public void IsPseudoClass_SelectorWithoutPseudoClass_ReturnsFalse() 192 | { 193 | var result = _parser.IsPseudoClass("a"); 194 | Assert.IsFalse(result); 195 | } 196 | 197 | [TestMethod] 198 | public void IsPseudoClass_SelectorWithPseudoClass_ReturnsTrue() 199 | { 200 | var result = _parser.IsPseudoClass("a:active"); 201 | Assert.IsTrue(result); 202 | } 203 | 204 | [TestMethod] 205 | public void IsPseudoElement_SelectorWithoutPseudoElement_ReturnsFalse() 206 | { 207 | var result = _parser.IsPseudoElement("p"); 208 | Assert.IsFalse(result); 209 | } 210 | 211 | [TestMethod] 212 | public void IsPseudoElement_SelectorWithPseudoElement_ReturnsTrue() 213 | { 214 | var result = _parser.IsPseudoElement("p:first-line"); 215 | Assert.IsTrue(result); 216 | } 217 | } 218 | } -------------------------------------------------------------------------------- /PreMailer.Net/PreMailer.Net/CssSelectorParser.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Linq; 3 | using System.Text; 4 | using System.Text.RegularExpressions; 5 | 6 | namespace PreMailer.Net 7 | { 8 | public class CssSelectorParser : ICssSelectorParser 9 | { 10 | #region "CSS definitions" 11 | // These definitions have been taken from https://www.w3.org/TR/css3-selectors/#lex 12 | private static readonly string Css_NonAscii = @"[^\0-\177]"; 13 | private static readonly string Css_Unicode = @"(\\[0-9a-f]{1,6}(\r\n|[ \t\r\n\f])?)"; 14 | private static readonly string Css_Escape = $@"({Css_Unicode}|\\[^\r\n\f0-9a-f])"; 15 | private static readonly string Css_NmStart = $@"([_a-z]|{Css_NonAscii}|{Css_Escape})"; 16 | private static readonly string Css_NmChar = $@"([_a-z0-9-]|{Css_NonAscii}|{Css_Escape})"; 17 | 18 | private static readonly string Css_Ident = $@"(-?{Css_NmStart}{Css_NmChar}*)"; 19 | 20 | private static readonly string Css_Nl = @"(\n|\r\n|\r|\f)"; 21 | private static readonly string Css_String1 = $@"(""([^\n\r\f\\""]|\\{Css_Nl}|{Css_NonAscii}|{Css_Escape})*"")"; 22 | private static readonly string Css_String2 = $@"('([^\n\r\f\\']|\\{Css_Nl}|{Css_NonAscii}|{Css_Escape})*')"; 23 | private static readonly string Css_String = $@"({Css_String1}|{Css_String2})"; 24 | #endregion 25 | 26 | // These definitions have been taken from https://www.w3.org/TR/css3-selectors/#grammar 27 | private static readonly Regex IdMatcher = new Regex($@"#{Css_Ident}", RegexOptions.IgnoreCase | RegexOptions.Compiled); 28 | private static readonly Regex AttribMatcher = new Regex(String.Format(@"\[\s*{0}\s*(([$*^~|]?=)\s*({0}|{1})\s*)?\]", Css_Ident, Css_String), RegexOptions.IgnoreCase | RegexOptions.Compiled); 29 | private static readonly Regex ClassMatcher = new Regex($@"\.{Css_Ident}", RegexOptions.IgnoreCase | RegexOptions.Compiled); 30 | private static readonly Regex ElemMatcher = new Regex(Css_Ident, RegexOptions.IgnoreCase | RegexOptions.Compiled); 31 | private static readonly Regex PseudoClassMatcher = BuildOrRegex(PseudoClasses, ":", x => x.Replace("()", $@"\({Css_Ident}\)")); 32 | private static readonly Regex PseudoElemMatcher = BuildOrRegex(PseudoElements, "::?"); 33 | private static readonly Regex PseudoUnimplemented = BuildOrRegex(UnimplementedPseudoSelectors, "::?"); 34 | 35 | /// 36 | /// Static method to quickly find the specificity of a single CSS selector. 37 | /// Don't use this when parsing a lot of selectors, create an instance of and use that instead. 38 | /// 39 | /// CSS Selector 40 | /// Specificity score of the given selector. 41 | public static int SelectorSpecificity(string selector) 42 | { 43 | var instance = new CssSelectorParser(); 44 | 45 | return instance.GetSelectorSpecificity(selector); 46 | } 47 | 48 | /// 49 | /// Finds the specificity of a CSS selector. 50 | /// Using this instance method is more performant for checking many selectors since the Regex's are compiled. 51 | /// 52 | /// CSS Selector 53 | /// Specificity score of the given selector. 54 | public int GetSelectorSpecificity(string selector) 55 | { 56 | return CalculateSpecificity(selector).ToInt(); 57 | } 58 | 59 | public CssSpecificity CalculateSpecificity(string selector) 60 | { 61 | if (string.IsNullOrWhiteSpace(selector) || selector == "*") 62 | return CssSpecificity.None; 63 | 64 | var cssSelector = new CssSelector(selector); 65 | 66 | var result = CssSpecificity.None; 67 | if (cssSelector.HasNotPseudoClass) 68 | { 69 | result += CalculateSpecificity(cssSelector.NotPseudoClassContent); 70 | } 71 | 72 | var buffer = cssSelector.StripNotPseudoClassContent().ToString(); 73 | 74 | var ids = MatchCountAndStrip(IdMatcher, buffer, out buffer); 75 | var attributes = MatchCountAndStrip(AttribMatcher, buffer, out buffer); 76 | var classes = MatchCountAndStrip(ClassMatcher, buffer, out buffer); 77 | var pseudoClasses = MatchCountAndStrip(PseudoClassMatcher, buffer, out buffer); 78 | var elementNames = MatchCountAndStrip(ElemMatcher, buffer, out buffer); 79 | var pseudoElements = MatchCountAndStrip(PseudoElemMatcher, buffer, out buffer); 80 | 81 | var specificity = new CssSpecificity(ids, (classes + attributes + pseudoClasses), (elementNames + pseudoElements)); 82 | 83 | return result + specificity; 84 | } 85 | 86 | public bool IsPseudoClass(string selector) 87 | { 88 | return PseudoClassMatcher.IsMatch(selector); 89 | } 90 | 91 | public bool IsPseudoElement(string selector) 92 | { 93 | return PseudoElemMatcher.IsMatch(selector); 94 | } 95 | 96 | /// 97 | /// Determines if the given CSS selector is supported. This is basically determined by what supports. 98 | /// 99 | /// 100 | /// See https://github.com/jamietre/CsQuery#features for more information. 101 | public bool IsSupportedSelector(string key) 102 | { 103 | return !PseudoUnimplemented.IsMatch(key); 104 | } 105 | 106 | private static int MatchCountAndStrip(Regex regex, string selector, out string result) 107 | { 108 | var matches = regex.Matches(selector); 109 | 110 | result = regex.Replace(selector, string.Empty); 111 | 112 | return matches.Count; 113 | } 114 | 115 | private static string[] PseudoClasses 116 | { 117 | get 118 | { 119 | // Taken from https://developer.mozilla.org/en-US/docs/Web/CSS/Pseudo-classes 120 | return new[] 121 | { 122 | "active", 123 | "checked", 124 | "default", 125 | "dir()", 126 | "disabled", 127 | "empty", 128 | "enabled", 129 | "first", 130 | "first-child", 131 | "first-of-type", 132 | "fullscreen", 133 | "focus", 134 | "hover", 135 | "indeterminate", 136 | "in-range", 137 | "invalid", 138 | "lang()", 139 | "last-child", 140 | "last-of-type", 141 | "left", 142 | "link", 143 | "not()", 144 | "nth-child()", 145 | "nth-last-child()", 146 | "nth-last-of-type()", 147 | "nth-of-type()", 148 | "only-child", 149 | "only-of-type", 150 | "optional", 151 | "out-of-range", 152 | "read-only", 153 | "read-write", 154 | "required", 155 | "right", 156 | "root", 157 | "scope", 158 | "target", 159 | "valid", 160 | "visited" 161 | } 162 | .Reverse().ToArray(); // NOTE: Reversal is important to ensure 'first-line' is processed before 'first'. 163 | } 164 | } 165 | 166 | private static string[] PseudoElements 167 | { 168 | get 169 | { 170 | // Taken from: https://developer.mozilla.org/en-US/docs/Web/CSS/Pseudo-elements 171 | return new[] 172 | { 173 | "after", 174 | "before", 175 | "first-letter", 176 | "first-line", 177 | "selection" 178 | }; 179 | } 180 | } 181 | 182 | private static string[] UnimplementedPseudoSelectors 183 | { 184 | get 185 | { 186 | // Based on: https://github.com/jamietre/CsQuery#missing-css-selectors 187 | return new[] 188 | { 189 | "link", 190 | "hover", 191 | "active", 192 | "focus", 193 | "visited", 194 | "target", 195 | "first-letter", 196 | "first-line", 197 | "before", 198 | "after" 199 | }; 200 | } 201 | } 202 | 203 | private static Regex BuildOrRegex(string[] items, string prefix, Func mutator = null) 204 | { 205 | var sb = new StringBuilder(); 206 | sb.Append(prefix); 207 | sb.Append("("); 208 | 209 | for (var i = 0; i < items.Length; i++) 210 | { 211 | var @class = items[i]; 212 | 213 | if (mutator != null) 214 | { 215 | @class = mutator(@class); 216 | } 217 | 218 | sb.Append(@class); 219 | 220 | if (i < (items.Length - 1)) 221 | sb.Append("|"); 222 | } 223 | 224 | sb.Append(")"); 225 | return new Regex(sb.ToString(), RegexOptions.IgnoreCase | RegexOptions.Compiled); 226 | } 227 | } 228 | } 229 | -------------------------------------------------------------------------------- /PreMailer.Net/PreMailer.Net.Tests/CssParserTests.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.VisualStudio.TestTools.UnitTesting; 2 | 3 | namespace PreMailer.Net.Tests 4 | { 5 | [TestClass] 6 | public class CssParserTests 7 | { 8 | [TestMethod] 9 | public void AddStylesheet_ContainsAtCharsetRule_ShouldStripRuleAndParseStylesheet() 10 | { 11 | var stylesheet = "@charset utf-8; div { width: 100% }"; 12 | 13 | var parser = new CssParser(); 14 | parser.AddStyleSheet(stylesheet); 15 | 16 | Assert.IsTrue(parser.Styles.ContainsKey("div")); 17 | } 18 | 19 | [TestMethod] 20 | public void AddStylesheet_ContainsAtPageSection_ShouldStripRuleAndParseStylesheet() 21 | { 22 | var stylesheet = "@page :first { margin: 2in 3in; } div { width: 100% }"; 23 | 24 | var parser = new CssParser(); 25 | parser.AddStyleSheet(stylesheet); 26 | 27 | Assert.AreEqual(1, parser.Styles.Count); 28 | Assert.IsTrue(parser.Styles.ContainsKey("div")); 29 | } 30 | 31 | [TestMethod] 32 | public void AddStylesheet_ContainsUnsupportedMediaQuery_ShouldStrip() 33 | { 34 | var stylesheet = "@media print { div { width: 90%; } }"; 35 | 36 | var parser = new CssParser(); 37 | parser.AddStyleSheet(stylesheet); 38 | 39 | Assert.AreEqual(0, parser.Styles.Count); 40 | } 41 | 42 | [TestMethod] 43 | public void AddStylesheet_ContainsUnsupportedMediaQueryAndNormalRules_ShouldStripMediaQueryAndParseRules() 44 | { 45 | var stylesheet = "div { width: 600px; } @media only screen and (max-width:620px) { div { width: 100% } } p { font-family: serif; }"; 46 | 47 | var parser = new CssParser(); 48 | parser.AddStyleSheet(stylesheet); 49 | 50 | Assert.AreEqual(2, parser.Styles.Count); 51 | 52 | Assert.IsTrue(parser.Styles.ContainsKey("div")); 53 | Assert.AreEqual("600px", parser.Styles["div"].Attributes["width"].Value); 54 | 55 | Assert.IsTrue(parser.Styles.ContainsKey("p")); 56 | Assert.AreEqual("serif", parser.Styles["p"].Attributes["font-family"].Value); 57 | } 58 | 59 | [TestMethod] 60 | public void AddStylesheet_ContainsSupportedMediaQuery_ShouldParseQueryRules() 61 | { 62 | var stylesheet = "@media only screen { div { width: 600px; } }"; 63 | 64 | var parser = new CssParser(); 65 | parser.AddStyleSheet(stylesheet); 66 | 67 | Assert.AreEqual(1, parser.Styles.Count); 68 | 69 | Assert.IsTrue(parser.Styles.ContainsKey("div")); 70 | Assert.AreEqual("600px", parser.Styles["div"].Attributes["width"].Value); 71 | } 72 | 73 | [TestMethod] 74 | public void AddStylesheet_ContainsImportStatement_ShouldStripOutImportStatement() 75 | { 76 | var stylesheet = "@import url(http://google.com/stylesheet); div { width : 600px; }"; 77 | var parser = new CssParser(); 78 | parser.AddStyleSheet(stylesheet); 79 | Assert.AreEqual(1, parser.Styles.Count); 80 | 81 | Assert.IsTrue(parser.Styles.ContainsKey("div")); 82 | Assert.AreEqual("600px", parser.Styles["div"].Attributes["width"].Value); 83 | } 84 | 85 | 86 | [TestMethod] 87 | public void AddStylesheet_ContainsImportStatementTest_ShouldStripOutImportStatement() 88 | { 89 | var stylesheet = "@import 'stylesheet.css'; div { width : 600px; }"; 90 | var parser = new CssParser(); 91 | parser.AddStyleSheet(stylesheet); 92 | Assert.AreEqual(1, parser.Styles.Count); 93 | 94 | Assert.IsTrue(parser.Styles.ContainsKey("div")); 95 | Assert.AreEqual("600px", parser.Styles["div"].Attributes["width"].Value); 96 | } 97 | 98 | [TestMethod] 99 | public void AddStylesheet_ContainsMinifiedImportStatement_ShouldStripOutImportStatement() 100 | { 101 | var stylesheet = "@import url(http://google.com/stylesheet);div{width:600px;}"; 102 | var parser = new CssParser(); 103 | parser.AddStyleSheet(stylesheet); 104 | Assert.AreEqual(1, parser.Styles.Count); 105 | 106 | Assert.IsTrue(parser.Styles.ContainsKey("div")); 107 | Assert.AreEqual("600px", parser.Styles["div"].Attributes["width"].Value); 108 | } 109 | 110 | [TestMethod] 111 | public void AddStylesheet_ContainsMultipleImportStatement_ShouldStripOutImportStatements() 112 | { 113 | var stylesheet = "@import url(http://google.com/stylesheet); @import url(http://jquery.com/stylesheet1); @import url(http://amazon.com/stylesheet2); div { width : 600px; }"; 114 | var parser = new CssParser(); 115 | parser.AddStyleSheet(stylesheet); 116 | Assert.AreEqual(1, parser.Styles.Count); 117 | 118 | Assert.IsTrue(parser.Styles.ContainsKey("div")); 119 | Assert.AreEqual("600px", parser.Styles["div"].Attributes["width"].Value); 120 | } 121 | 122 | [TestMethod] 123 | public void AddStylesheet_ContainsImportStatementWithMediaQuery_ShouldStripOutImportStatements() 124 | { 125 | var stylesheet = "@import url(http://google.com/stylesheet) mobile; div { width : 600px; }"; 126 | var parser = new CssParser(); 127 | parser.AddStyleSheet(stylesheet); 128 | Assert.AreEqual(1, parser.Styles.Count); 129 | 130 | Assert.IsTrue(parser.Styles.ContainsKey("div")); 131 | Assert.AreEqual("600px", parser.Styles["div"].Attributes["width"].Value); 132 | } 133 | 134 | [TestMethod] 135 | public void AddStylesheet_ContainsMultipleImportStatementWithMediaQueries_ShouldStripOutImportStatements() 136 | { 137 | var stylesheet = "@import url(http://google.com/stylesheet) mobile; @import url(http://google.com/stylesheet) mobile; @import url(http://google.com/stylesheet) mobile; div { width : 600px; }"; 138 | var parser = new CssParser(); 139 | parser.AddStyleSheet(stylesheet); 140 | Assert.AreEqual(1, parser.Styles.Count); 141 | 142 | Assert.IsTrue(parser.Styles.ContainsKey("div")); 143 | Assert.AreEqual("600px", parser.Styles["div"].Attributes["width"].Value); 144 | } 145 | 146 | [TestMethod] 147 | public void AddStylesheet_ContainsEncodedImage() 148 | { 149 | var stylesheet = @"#logo 150 | { 151 | content: url('data:image/jpeg; base64,R0lGODlhAQABAIAAAAUEBAAAACwAAAAAAQABAAACAkQBADs='); 152 | max-width: 200px; 153 | height: auto; 154 | }"; 155 | var parser = new CssParser(); 156 | parser.AddStyleSheet(stylesheet); 157 | var attributes = parser.Styles["#logo"].Attributes; 158 | } 159 | 160 | [TestMethod] 161 | public void AddStylesheet_ShouldSetStyleClassPositions() 162 | { 163 | var stylesheet1 = "#id .class1 element { color: #fff; } #id .class2 element { color: #aaa; }"; 164 | var stylesheet2 = "#id .class3 element { color: #000; } #id .class2 element { color: #bbb; }"; 165 | var parser = new CssParser(); 166 | 167 | parser.AddStyleSheet(stylesheet1); 168 | parser.AddStyleSheet(stylesheet2); 169 | 170 | Assert.AreEqual(1, parser.Styles.Values[0].Position); 171 | Assert.AreEqual(4, parser.Styles.Values[1].Position); 172 | Assert.AreEqual(3, parser.Styles.Values[2].Position); 173 | } 174 | 175 | [TestMethod] 176 | public void AddStylesheet_ContainsSingleQuotes_ShouldParseStylesheet() 177 | { 178 | var stylesheet = "a { color: #fff; font-family: 'Segoe UI', Verdana, Arial, sans-serif; text-decoration: underline; }"; 179 | var parser = new CssParser(); 180 | 181 | parser.AddStyleSheet(stylesheet); 182 | 183 | Assert.IsNotNull(parser.Styles["a"]); 184 | Assert.IsNotNull(parser.Styles["a"].Attributes["font-family"]); 185 | Assert.AreEqual(3, parser.Styles["a"].Attributes.Count); 186 | } 187 | 188 | [TestMethod] 189 | public void AddStylesheet_ContainsDoubleQuotes_ShouldParseStylesheet() 190 | { 191 | var stylesheet = "a { color: #fff; font-family: \"Segoe UI\", Verdana, Arial, sans-serif; text-decoration: underline; }"; 192 | var parser = new CssParser(); 193 | 194 | parser.AddStyleSheet(stylesheet); 195 | 196 | Assert.IsNotNull(parser.Styles["a"]); 197 | Assert.IsNotNull(parser.Styles["a"].Attributes["font-family"]); 198 | Assert.AreEqual(3, parser.Styles["a"].Attributes.Count); 199 | } 200 | } 201 | } -------------------------------------------------------------------------------- /PreMailer.Net/PreMailer.Net/PreMailer.cs: -------------------------------------------------------------------------------- 1 | using AngleSharp; 2 | using AngleSharp.Dom; 3 | using AngleSharp.Html; 4 | using AngleSharp.Html.Dom; 5 | using AngleSharp.Html.Parser; 6 | using AngleSharp.Xhtml; 7 | using PreMailer.Net.Sources; 8 | using System; 9 | using System.Collections.Generic; 10 | using System.Collections.Specialized; 11 | using System.IO; 12 | using System.Linq; 13 | 14 | namespace PreMailer.Net 15 | { 16 | public class PreMailer : IDisposable 17 | { 18 | private readonly IHtmlDocument _document; 19 | private bool _removeStyleElements; 20 | private bool _stripIdAndClassAttributes; 21 | private string _ignoreElements; 22 | private string _css; 23 | private readonly Uri _baseUri; 24 | private readonly CssParser _cssParser; 25 | private readonly CssSelectorParser _cssSelectorParser; 26 | private readonly List _warnings; 27 | 28 | /// 29 | /// 30 | /// Constructor for the PreMailer class 31 | /// 32 | /// The HTML input. 33 | /// Url that all relative urls will be off of 34 | public PreMailer(string html, Uri baseUri = null) 35 | : this(new HtmlParser().ParseDocument(html), baseUri) 36 | { 37 | } 38 | 39 | /// 40 | /// Constructor for the PreMailer class 41 | /// 42 | /// The HtmlDocument input. 43 | /// Url that all relative urls will be off of 44 | public PreMailer(IHtmlDocument htmlDocument, Uri baseUri = null) 45 | { 46 | _baseUri = baseUri; 47 | _document = htmlDocument; 48 | _warnings = new List(); 49 | _cssParser = new CssParser(); 50 | _cssSelectorParser = new CssSelectorParser(); 51 | } 52 | 53 | /// 54 | /// Constructor for the PreMailer class 55 | /// 56 | /// The HTML stream. 57 | /// Url that all relative urls will be off of 58 | public PreMailer(Stream stream, Uri baseUri = null) 59 | { 60 | _baseUri = baseUri; 61 | _document = new HtmlParser().ParseDocument(stream); 62 | _warnings = new List(); 63 | _cssParser = new CssParser(); 64 | _cssSelectorParser = new CssSelectorParser(); 65 | } 66 | 67 | /// 68 | /// In-lines the CSS within the HTML given. 69 | /// 70 | /// The HTML input. 71 | /// If set to true the style elements are removed. 72 | /// CSS selector for STYLE elements to ignore (e.g. mobile-specific styles etc.) 73 | /// A string containing a style-sheet for inlining. 74 | /// True to strip ID and class attributes 75 | /// True to remove comments, false to leave them intact 76 | /// Returns the html input, with styles moved to inline attributes. 77 | public static InlineResult MoveCssInline(string html, bool removeStyleElements = false, string ignoreElements = null, string css = null, bool stripIdAndClassAttributes = false, bool removeComments = false, IMarkupFormatter customFormatter = null) 78 | { 79 | return new PreMailer(html).MoveCssInline(removeStyleElements, ignoreElements, css, stripIdAndClassAttributes, removeComments, customFormatter); 80 | } 81 | 82 | /// 83 | /// In-lines the CSS within the HTML given. 84 | /// 85 | /// The Stream input. 86 | /// If set to true the style elements are removed. 87 | /// CSS selector for STYLE elements to ignore (e.g. mobile-specific styles etc.) 88 | /// A string containing a style-sheet for inlining. 89 | /// True to strip ID and class attributes 90 | /// True to remove comments, false to leave them intact 91 | /// Returns the html input, with styles moved to inline attributes. 92 | public static InlineResult MoveCssInline(Stream stream, bool removeStyleElements = false, string ignoreElements = null, string css = null, bool stripIdAndClassAttributes = false, bool removeComments = false, IMarkupFormatter customFormatter = null) 93 | { 94 | return new PreMailer(stream).MoveCssInline(removeStyleElements, ignoreElements, css, stripIdAndClassAttributes, removeComments, customFormatter); 95 | } 96 | 97 | /// 98 | /// In-lines the CSS within the HTML given. 99 | /// 100 | /// /// The base url that will be used to resolve any relative urls 101 | /// The Url that all relative urls will be off of. 102 | /// The HTML input. 103 | /// If set to true the style elements are removed. 104 | /// CSS selector for STYLE elements to ignore (e.g. mobile-specific styles etc.) 105 | /// A string containing a style-sheet for inlining. 106 | /// True to strip ID and class attributes 107 | /// True to remove comments, false to leave them intact 108 | /// Returns the html input, with styles moved to inline attributes. 109 | public static InlineResult MoveCssInline(Uri baseUri, string html, bool removeStyleElements = false, string ignoreElements = null, string css = null, bool stripIdAndClassAttributes = false, bool removeComments = false, IMarkupFormatter customFormatter = null) 110 | { 111 | return new PreMailer(html, baseUri).MoveCssInline(removeStyleElements, ignoreElements, css, stripIdAndClassAttributes, removeComments, customFormatter); 112 | } 113 | 114 | /// 115 | /// In-lines the CSS within the HTML given. 116 | /// 117 | /// /// The base url that will be used to resolve any relative urls 118 | /// The Url that all relative urls will be off of. 119 | /// The HTML input. 120 | /// If set to true the style elements are removed. 121 | /// CSS selector for STYLE elements to ignore (e.g. mobile-specific styles etc.) 122 | /// A string containing a style-sheet for inlining. 123 | /// True to strip ID and class attributes 124 | /// True to remove comments, false to leave them intact 125 | /// Returns the html input, with styles moved to inline attributes. 126 | public static InlineResult MoveCssInline(Uri baseUri, Stream stream, bool removeStyleElements = false, string ignoreElements = null, string css = null, bool stripIdAndClassAttributes = false, bool removeComments = false, IMarkupFormatter customFormatter = null) 127 | { 128 | return new PreMailer(stream, baseUri).MoveCssInline(removeStyleElements, ignoreElements, css, stripIdAndClassAttributes, removeComments, customFormatter); 129 | } 130 | 131 | /// 132 | /// In-lines the CSS for the current HTML 133 | /// 134 | /// If set to true the style elements are removed. 135 | /// CSS selector for STYLE elements to ignore (e.g. mobile-specific styles etc.) 136 | /// A string containing a style-sheet for inlining. 137 | /// True to strip ID and class attributes 138 | /// True to remove comments, false to leave them intact 139 | /// Returns the html input, with styles moved to inline attributes. 140 | public InlineResult MoveCssInline(bool removeStyleElements = false, string ignoreElements = null, string css = null, bool stripIdAndClassAttributes = false, bool removeComments = false, IMarkupFormatter customFormatter = null) 141 | { 142 | // Store the variables used for inlining the CSS 143 | _removeStyleElements = removeStyleElements; 144 | _stripIdAndClassAttributes = stripIdAndClassAttributes; 145 | _ignoreElements = ignoreElements; 146 | _css = css; 147 | 148 | // Gather all of the CSS that we can work with. 149 | var cssSourceNodes = CssSourceNodes(); 150 | var cssLinkNodes = CssLinkNodes(); 151 | var cssSources = new List(ConvertToStyleSources(cssSourceNodes)); 152 | cssSources.AddRange(ConvertToStyleSources(cssLinkNodes)); 153 | 154 | var cssBlocks = GetCssBlocks(cssSources); 155 | 156 | if (_removeStyleElements) 157 | { 158 | RemoveStyleElements(cssSourceNodes); 159 | RemoveStyleElements(cssLinkNodes); 160 | } 161 | 162 | var joinedBlocks = Join(cssBlocks); 163 | var validSelectors = CleanUnsupportedSelectors(joinedBlocks); 164 | var elementsWithStyles = FindElementsWithStyles(validSelectors); 165 | var mergedStyles = MergeStyleClasses(elementsWithStyles); 166 | 167 | StyleClassApplier.ApplyAllStyles(mergedStyles); 168 | 169 | if (_stripIdAndClassAttributes) 170 | StripElementAttributes("id", "class"); 171 | 172 | if (removeComments) 173 | { 174 | var comments = _document.Descendents().ToList(); 175 | 176 | foreach (var comment in comments) 177 | { 178 | comment.Remove(); 179 | } 180 | } 181 | 182 | IMarkupFormatter markupFormatter = customFormatter ?? GetMarkupFormatterForDocType(); 183 | 184 | using (var sw = new StringWriter()) 185 | { 186 | _document.ToHtml(sw, markupFormatter); 187 | 188 | return new InlineResult(sw.GetStringBuilder(), _warnings); 189 | } 190 | } 191 | 192 | /// 193 | /// Function to add Google analytics tracking tags to the HTML document 194 | /// 195 | /// Source tracking tag 196 | /// Medium tracking tag 197 | /// Campaign tracking tag 198 | /// Content tracking tag 199 | /// Optional domain check; if it does not match the URL will be skipped 200 | /// Reference to the instance so you can chain calls. 201 | public PreMailer AddAnalyticsTags(string source, string medium, string campaign, string content, string domain = null) 202 | { 203 | var tracking = $"utm_source={source}&utm_medium={medium}&utm_campaign={campaign}&utm_content={content}"; 204 | foreach (var tag in _document.QuerySelectorAll("a[href]")) 205 | { 206 | var href = tag.Attributes["href"]; 207 | if (href.Value.StartsWith("http", StringComparison.OrdinalIgnoreCase) && (domain == null || DomainMatch(domain, href.Value))) 208 | { 209 | tag.SetAttribute("href", href.Value + (href.Value.IndexOf("?", StringComparison.Ordinal) >= 0 ? "&" : "?") + tracking); 210 | } 211 | } 212 | 213 | return this; 214 | } 215 | 216 | /// 217 | /// Function to check if the domain in a URL matches 218 | /// 219 | /// Domain to check 220 | /// URL to parse 221 | /// True if the domain matches, false if not 222 | private bool DomainMatch(string domain, string url) 223 | { 224 | if (url.Contains(@"://")) 225 | { 226 | url = url.Split(new[] { @"://" }, 2, StringSplitOptions.None)[1]; 227 | } 228 | url = url.Split('/')[0]; 229 | return string.Compare(domain, url, StringComparison.OrdinalIgnoreCase) == 0; 230 | } 231 | 232 | /// 233 | /// Returns the blocks of CSS within the documents supported CSS sources. 234 | /// Blocks are returned in the order they are declared within the document. 235 | /// 236 | private IEnumerable GetCssBlocks(IEnumerable cssSources) 237 | { 238 | return cssSources.Select(styleSource => styleSource.GetCss()).ToList(); 239 | } 240 | 241 | /// 242 | /// Returns a list of CSS sources ('style', 'link' tags etc.) based on the elements given. 243 | /// These will be returned in their order of definition. 244 | /// 245 | private IEnumerable ConvertToStyleSources(IEnumerable nodesWithStyles) 246 | { 247 | var result = new List(); 248 | var nodes = nodesWithStyles; 249 | foreach (var node in nodes) 250 | { 251 | switch (node.NodeName) 252 | { 253 | case "STYLE": 254 | result.Add(new DocumentStyleTagCssSource(node)); 255 | break; 256 | 257 | case "LINK": 258 | result.Add(new LinkTagCssSource(node, _baseUri)); 259 | break; 260 | } 261 | } 262 | 263 | if (!String.IsNullOrWhiteSpace(_css)) 264 | { 265 | result.Add(new StringCssSource(_css)); 266 | } 267 | 268 | return result; 269 | } 270 | 271 | /// 272 | /// Returns a collection of CQ 'style' nodes that can be used to source CSS content. 273 | /// 274 | private IEnumerable CssSourceNodes() 275 | { 276 | IEnumerable elements = _document.QuerySelectorAll("style"); 277 | 278 | if (!String.IsNullOrEmpty(_ignoreElements)) 279 | { 280 | elements = elements.Not(_ignoreElements); 281 | } 282 | 283 | elements = elements.Where(elem => 284 | { 285 | var mediaAttribute = elem.GetAttribute("media"); 286 | 287 | return string.IsNullOrWhiteSpace(mediaAttribute) || CssParser.SupportedMediaQueriesRegex.IsMatch(mediaAttribute); 288 | }); 289 | 290 | return elements; 291 | } 292 | 293 | /// 294 | /// Returns a collection of CQ 'link' nodes that can be used to source CSS content. 295 | /// 296 | private IEnumerable CssLinkNodes() 297 | { 298 | IEnumerable elements = _document.QuerySelectorAll("link"); 299 | 300 | if (!String.IsNullOrEmpty(_ignoreElements)) 301 | { 302 | elements = elements.Not(_ignoreElements); 303 | } 304 | 305 | return elements.Where(e => e.Attributes 306 | .Any(a => a.Name.Equals("href", StringComparison.OrdinalIgnoreCase) && 307 | (a.Value.EndsWith(".css", StringComparison.OrdinalIgnoreCase) || 308 | (e.Attributes.Any(r => r.Name.Equals("rel", StringComparison.OrdinalIgnoreCase) && 309 | r.Value.Equals("stylesheet", StringComparison.OrdinalIgnoreCase)))))); 310 | } 311 | 312 | 313 | private void RemoveStyleElements(IEnumerable cssSourceNodes) 314 | { 315 | foreach (var node in cssSourceNodes) 316 | { 317 | node.Remove(); 318 | } 319 | } 320 | 321 | private static SortedList Join(IEnumerable cssBlocks) 322 | { 323 | var parser = new CssParser(); 324 | 325 | foreach (var block in cssBlocks) 326 | { 327 | parser.AddStyleSheet(block); 328 | } 329 | 330 | return parser.Styles; 331 | } 332 | 333 | private SortedList CleanUnsupportedSelectors(SortedList selectors) 334 | { 335 | var result = new SortedList(); 336 | var failedSelectors = new List(); 337 | 338 | foreach (var selector in selectors) 339 | { 340 | if (_cssSelectorParser.IsSupportedSelector(selector.Key)) 341 | result.Add(selector.Key, selector.Value); 342 | else 343 | failedSelectors.Add(selector.Value); 344 | } 345 | 346 | if (!failedSelectors.Any()) 347 | return selectors; 348 | 349 | foreach (var failedSelector in failedSelectors) 350 | { 351 | _warnings.Add($"PreMailer.Net is unable to process the pseudo class/element '{failedSelector.Name}' due to a limitation in CsQuery."); 352 | } 353 | 354 | return result; 355 | } 356 | 357 | private Dictionary> FindElementsWithStyles( 358 | SortedList stylesToApply) 359 | { 360 | var result = new Dictionary>(); 361 | 362 | foreach (var style in stylesToApply) 363 | { 364 | try 365 | { 366 | var elementsForSelector = _document.QuerySelectorAll(style.Value.Name); 367 | 368 | foreach (var el in elementsForSelector) 369 | { 370 | var existing = result.ContainsKey(el) ? result[el] : new List(); 371 | existing.Add(style.Value); 372 | result[el] = existing; 373 | } 374 | } 375 | catch (DomException ex) 376 | { 377 | _warnings.Add($"Error finding element with selector: '{style.Value.Name}: {ex.Message}"); 378 | } 379 | } 380 | 381 | return result; 382 | } 383 | 384 | private Dictionary> SortBySpecificity( 385 | Dictionary> styles) 386 | { 387 | var result = new Dictionary>(); 388 | var specificityCache = new Dictionary(); 389 | 390 | foreach (var style in styles) 391 | { 392 | if (style.Key.Attributes != null) 393 | { 394 | var sortedStyles = style.Value.OrderBy(x => GetSelectorSpecificity(specificityCache, x.Name)).ThenBy(x => x.Position).ToList(); 395 | var styleAttr = style.Key.Attributes["style"]; 396 | 397 | if (styleAttr == null || String.IsNullOrWhiteSpace(styleAttr.Value)) 398 | { 399 | style.Key.SetAttribute("style", String.Empty); 400 | } 401 | else // Ensure that existing inline styles always win. 402 | { 403 | sortedStyles.Add(_cssParser.ParseStyleClass("inline", styleAttr.Value)); 404 | } 405 | 406 | result[style.Key] = sortedStyles; 407 | } 408 | } 409 | 410 | return result; 411 | } 412 | 413 | private Dictionary MergeStyleClasses( 414 | Dictionary> styles) 415 | { 416 | var result = new Dictionary(); 417 | var stylesBySpecificity = SortBySpecificity(styles); 418 | 419 | foreach (var elemStyle in stylesBySpecificity) 420 | { 421 | // CSS Classes are assumed to be sorted by specifity now, so we can just merge these up. 422 | var merged = new StyleClass(); 423 | foreach (var style in elemStyle.Value) 424 | { 425 | merged.Merge(style, true); 426 | } 427 | 428 | result[elemStyle.Key] = merged; 429 | } 430 | 431 | return result; 432 | } 433 | 434 | private void StripElementAttributes(params string[] attributeNames) 435 | { 436 | StringCollection selectors = new StringCollection(); 437 | 438 | foreach (string attribute in attributeNames) 439 | { 440 | selectors.Add($"*[{attribute}]"); 441 | } 442 | 443 | var elementsWithAttributes = _document.QuerySelectorAll(String.Join(",", selectors.Cast().ToList())); 444 | foreach (var item in elementsWithAttributes) 445 | { 446 | foreach (var attribute in attributeNames) 447 | { 448 | item.RemoveAttribute(attribute); 449 | } 450 | } 451 | } 452 | 453 | private IMarkupFormatter GetMarkupFormatterForDocType() 454 | { 455 | if (_document != null && _document.Doctype != null && _document.Doctype.PublicIdentifier != null && _document.Doctype.PublicIdentifier.Contains("XHTML")) 456 | { 457 | return XhtmlMarkupFormatter.Instance; 458 | } 459 | 460 | return HtmlMarkupFormatter.Instance; 461 | } 462 | 463 | private int GetSelectorSpecificity(Dictionary cache, string selector) 464 | { 465 | selector = selector ?? ""; 466 | int specificity; 467 | 468 | if (!cache.TryGetValue(selector, out specificity)) 469 | { 470 | specificity = _cssSelectorParser.GetSelectorSpecificity(selector); 471 | cache[selector] = specificity; 472 | } 473 | 474 | return specificity; 475 | } 476 | 477 | /// 478 | /// Access underlying IHTMLDocument 479 | /// 480 | public IHtmlDocument Document => _document; 481 | 482 | /// 483 | /// Dispose underlying document 484 | /// 485 | public void Dispose() 486 | { 487 | _document.Dispose(); 488 | } 489 | } 490 | } -------------------------------------------------------------------------------- /PreMailer.Net/PreMailer.Net.Tests/PreMailerTests.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.VisualStudio.TestTools.UnitTesting; 2 | using Moq; 3 | using PreMailer.Net.Downloaders; 4 | using System; 5 | using System.IO; 6 | 7 | namespace PreMailer.Net.Tests 8 | { 9 | [TestClass] 10 | public class PreMailerTests 11 | { 12 | [TestMethod] 13 | public void MoveCssInline_HasStyle_DoesNotBreakImageWidthAttribute() 14 | { 15 | string input = "" + 16 | ""; 17 | 18 | var premailedOutput = PreMailer.MoveCssInline(input); 19 | 20 | Assert.IsFalse(premailedOutput.Html.Contains("width=\"206px\"")); 21 | Assert.IsTrue(premailedOutput.Html.Contains("width=\"206\"")); 22 | } 23 | 24 | [TestMethod] 25 | public void MoveCssInline_NoStyle_DoesNotBreakImageWidthAttribute() 26 | { 27 | string input = "" + 28 | ""; 29 | 30 | var premailedOutput = PreMailer.MoveCssInline(input); 31 | 32 | Assert.IsFalse(premailedOutput.Html.Contains("width=\"206px\"")); 33 | Assert.IsTrue(premailedOutput.Html.Contains("width=\"206\"")); 34 | } 35 | 36 | [TestMethod] 37 | public void MoveCssInline_RespectExistingStyleElement() 38 | { 39 | string input = "
test
"; 40 | 41 | var premailedOutput = PreMailer.MoveCssInline(input, false); 42 | 43 | Assert.IsTrue(premailedOutput.Html.Contains("
.test { width: 150px; }
test
"; 50 | 51 | var premailedOutput = PreMailer.MoveCssInline(input, false); 52 | 53 | Assert.IsTrue(premailedOutput.Html.Contains("
#high-imp.test { width: 42px; } .test { width: 150px; }
test
"; 60 | 61 | var premailedOutput = PreMailer.MoveCssInline(input, false); 62 | 63 | Assert.IsTrue(premailedOutput.Html.Contains("style=\"width: 42px\"")); 64 | } 65 | 66 | [TestMethod] 67 | public void MoveCssInline_CssWithHigherSpecificityInSeparateStyleTag_AppliesMoreSpecificCss() 68 | { 69 | string input = "
test
"; 70 | 71 | var premailedOutput = PreMailer.MoveCssInline(input, false); 72 | 73 | Assert.IsTrue(premailedOutput.Html.Contains("style=\"width: 1337px\"")); 74 | } 75 | 76 | [TestMethod] 77 | public void MoveCssInline_IgnoreStyleElement_DoesntApplyCss() 78 | { 79 | string input = "
test
"; 80 | 81 | var premailedOutput = PreMailer.MoveCssInline(input, false, ignoreElements: "#ignore"); 82 | 83 | Assert.IsTrue(premailedOutput.Html.Contains("style=\"width: 42px\"")); 84 | } 85 | 86 | [TestMethod] 87 | public void MoveCssInline_SupportedPseudoSelector_AppliesCss() 88 | { 89 | string input = "
  • target
  • blargh>
"; 90 | 91 | var premailedOutput = PreMailer.MoveCssInline(input); 92 | 93 | Assert.IsTrue(premailedOutput.Html.Contains("
  • ")); 94 | } 95 | 96 | [TestMethod] 97 | public void MoveCssInline_CrazyCssSelector_DoesNotThrowError() 98 | { 99 | string input = "
    • target
    • blargh>
    "; 100 | 101 | try 102 | { 103 | PreMailer.MoveCssInline(input); 104 | } 105 | catch (Exception ex) 106 | { 107 | Assert.Fail(ex.Message); 108 | } 109 | } 110 | 111 | [TestMethod] 112 | public void MoveCssInline_SupportedjQuerySelector_AppliesCss() 113 | { 114 | string input = "
    • target
    • blargh>
    "; 115 | 116 | var premailedOutput = PreMailer.MoveCssInline(input); 117 | 118 | Assert.IsTrue(premailedOutput.Html.Contains("
  • target
  • ")); 119 | } 120 | 121 | [TestMethod] 122 | public void MoveCssInline_UnsupportedSelector_AppliesCss() 123 | { 124 | string input = "

    target

    "; 125 | 126 | var premailedOutput = PreMailer.MoveCssInline(input); 127 | 128 | Assert.IsTrue(premailedOutput.Html.Contains("

    target

    ")); 129 | } 130 | 131 | [TestMethod] 132 | public void MoveCssInline_KeepStyleElementsIgnoreElementsMatchesStyleElement_DoesntRemoveScriptTag() 133 | { 134 | string input = "
    test
    "; 135 | 136 | var premailedOutput = PreMailer.MoveCssInline(input, removeStyleElements: false, ignoreElements: "#ignore"); 137 | 138 | Assert.IsTrue(premailedOutput.Html.Contains("
    "; 145 | 146 | var premailedOutput = PreMailer.MoveCssInline(input); 147 | 148 | Assert.IsTrue(premailedOutput.Html.Contains("

    ")); 149 | } 150 | 151 | [TestMethod] 152 | public void MoveCssInline_ImportantFlag_HonorsImportantFlagInStylesheet() 153 | { 154 | string input = "
    Red
    "; 155 | 156 | var premailedOutput = PreMailer.MoveCssInline(input); 157 | 158 | Assert.IsTrue(premailedOutput.Html.Contains("
    Red
    "; 165 | 166 | var premailedOutput = PreMailer.MoveCssInline(input); 167 | 168 | Assert.IsTrue(premailedOutput.Html.Contains("
    ")); 179 | } 180 | 181 | public void MoveCssInline_SupportedMediaAttribute_InlinesAsNormal() 182 | { 183 | string input = "
    Target
    "; 184 | 185 | var premailedOutput = PreMailer.MoveCssInline(input); 186 | 187 | Assert.IsTrue(premailedOutput.Html.Contains("
    Target
    ")); 188 | } 189 | 190 | [TestMethod] 191 | public void MoveCssInline_UnsupportedMediaAttribute_IgnoresStyles() 192 | { 193 | string input = "
    Target
    "; 194 | 195 | var premailedOutput = PreMailer.MoveCssInline(input); 196 | 197 | Assert.IsTrue(premailedOutput.Html.Contains("
    Target
    ")); 198 | } 199 | 200 | [TestMethod] 201 | public void MoveCssInline_AddBgColorStyle() 202 | { 203 | string input = "

    "; 204 | 205 | var premailedOutput = PreMailer.MoveCssInline(input, false); 206 | 207 | Assert.IsTrue(premailedOutput.Html.Contains("")); 208 | } 209 | 210 | [TestMethod] 211 | public void MoveCssInline_AddSpecial() 212 | { 213 | string input = "
    "; 214 | 215 | var premailedOutput = PreMailer.MoveCssInline(input, false); 216 | 217 | Assert.IsTrue(premailedOutput.Html.Contains(""), "Actual: " + premailedOutput.Html); 218 | } 219 | 220 | [TestMethod] 221 | public void MoveCssInline_AddSpecial_RemoveEmptyStyle() 222 | { 223 | string input = "
    "; 224 | 225 | var premailedOutput = PreMailer.MoveCssInline(input, false); 226 | 227 | Assert.IsTrue(premailedOutput.Html.Contains(""), "Actual: " + premailedOutput.Html); 228 | } 229 | 230 | [TestMethod] 231 | public void MoveCssInline_AddBgColorStyle_IgnoreElementWithBackgroundColorAndNoBgColor() 232 | { 233 | string input = "
    "; 234 | 235 | var premailedOutput = PreMailer.MoveCssInline(input, false); 236 | 237 | Assert.IsTrue(premailedOutput.Html.Contains(""; 244 | 245 | var premailedOutput = PreMailer.MoveCssInline(input, false, css: ".test { background-color:#f1f1f1; }"); 246 | 247 | Assert.IsTrue(premailedOutput.Html.Contains(""; 254 | 255 | var premailedOutput = PreMailer.MoveCssInline(input, false, css: ".test { background-color:#f1f1f1; }", stripIdAndClassAttributes: true); 256 | 257 | Assert.IsTrue(premailedOutput.Html.Contains("#high-imp.test { width: 42px; } .test { width: 150px; }
    test
    "; 264 | 265 | var premailedOutput = PreMailer.MoveCssInline(input, false, stripIdAndClassAttributes: true); 266 | 267 | Assert.IsTrue(premailedOutput.Html.Contains("
    ")); 268 | } 269 | 270 | [TestMethod] 271 | public void MoveCssInline_StripsComments() 272 | { 273 | string input = ""; 274 | string expected = ""; 275 | 276 | var premailedOutput = PreMailer.MoveCssInline(input, removeComments: true); 277 | 278 | Assert.IsTrue(expected == premailedOutput.Html); 279 | } 280 | 281 | [TestMethod] 282 | public void MoveCssInline_LaterPositionStylesWithEqualSpecificityHasPrecedence_InSameBlock() 283 | { 284 | string input1 = "
    test
    "; 285 | string input2 = "
    test
    "; 286 | 287 | var premailedOutput1 = PreMailer.MoveCssInline(input1, false); 288 | var premailedOutput2 = PreMailer.MoveCssInline(input2, false); 289 | 290 | Assert.IsTrue(premailedOutput1.Html.Contains("test")); 291 | Assert.IsTrue(premailedOutput2.Html.Contains("test")); 292 | } 293 | 294 | [TestMethod] 295 | public void MoveCssInline_LaterPositionStylesWithEqualSpecificityHasPrecedence_Nested_InSameBlock() 296 | { 297 | string input1 = "
    test
    "; 298 | string input2 = "
    test
    "; 299 | 300 | var premailedOutput1 = PreMailer.MoveCssInline(input1, false); 301 | var premailedOutput2 = PreMailer.MoveCssInline(input2, false); 302 | 303 | Assert.IsTrue(premailedOutput1.Html.Contains("test")); 304 | Assert.IsTrue(premailedOutput2.Html.Contains("test")); 305 | } 306 | 307 | [TestMethod] 308 | public void MoveCssInline_LaterPositionStylesWithEqualSpecificityHasPrecedence_InSeparateBlocks() 309 | { 310 | string input1 = "
    test
    "; 311 | string input2 = "
    test
    "; 312 | 313 | var premailedOutput1 = PreMailer.MoveCssInline(input1, false); 314 | var premailedOutput2 = PreMailer.MoveCssInline(input2, false); 315 | 316 | Assert.IsTrue(premailedOutput1.Html.Contains("test")); 317 | Assert.IsTrue(premailedOutput2.Html.Contains("test")); 318 | } 319 | 320 | [TestMethod] 321 | public void MoveCssInline_LaterPositionStylesWithEqualSpecificityHasPrecedence_Nested_InSeparateBlocks() 322 | { 323 | string input1 = "
    test
    "; 324 | string input2 = "
    test
    "; 325 | 326 | var premailedOutput1 = PreMailer.MoveCssInline(input1, false); 327 | var premailedOutput2 = PreMailer.MoveCssInline(input2, false); 328 | 329 | Assert.IsTrue(premailedOutput1.Html.Contains("test")); 330 | Assert.IsTrue(premailedOutput2.Html.Contains("test")); 331 | } 332 | 333 | [TestMethod] 334 | public void AddAnalyticsTags_AddsTags() 335 | { 336 | const string input = @""; 337 | const string expected = @""; 338 | var premailedOutput = new PreMailer(input) 339 | .AddAnalyticsTags("source", "medium", "campaign", "content") 340 | .MoveCssInline(); 341 | Assert.IsTrue(expected == premailedOutput.Html); 342 | } 343 | 344 | [TestMethod] 345 | public void AddAnalyticsTags_AddsTagsAndExcludesDomain() 346 | { 347 | const string input = @""; 348 | const string expected = @""; 349 | var premailedOutput = new PreMailer(input) 350 | .AddAnalyticsTags("source", "medium", "campaign", "content", "www.Blah.com") 351 | .MoveCssInline(); 352 | Assert.IsTrue(expected == premailedOutput.Html); 353 | } 354 | 355 | [TestMethod] 356 | public void ContainsLinkCssElement_DownloadsCss() 357 | { 358 | var mockDownloader = new Mock(); 359 | mockDownloader.Setup(d => d.DownloadString(It.IsAny())).Returns(".a { display: block; }"); 360 | WebDownloader.SharedDownloader = mockDownloader.Object; 361 | 362 | Uri baseUri = new Uri("http://a.com"); 363 | Uri fullUrl = new Uri(baseUri, "b.css"); 364 | string input = $"
    test
    "; 365 | 366 | PreMailer sut = new PreMailer(input, baseUri); 367 | sut.MoveCssInline(); 368 | 369 | mockDownloader.Verify(d => d.DownloadString(fullUrl)); 370 | } 371 | 372 | [TestMethod] 373 | public void ContainsLinkCssElement_Bundle_DownloadsCss() 374 | { 375 | var mockDownloader = new Mock(); 376 | mockDownloader.Setup(d => d.DownloadString(It.IsAny())).Returns(".a { display: block; }"); 377 | WebDownloader.SharedDownloader = mockDownloader.Object; 378 | 379 | Uri baseUri = new Uri("http://a.com"); 380 | Uri fullUrl = new Uri(baseUri, "/Content/css?v=7V7TZzP9Wo7LiH9_q-r5mRBdC_N0lA_YJpRL_1V424E1"); 381 | string input = $"
    test
    "; 382 | 383 | PreMailer sut = new PreMailer(input, baseUri); 384 | sut.MoveCssInline(); 385 | 386 | mockDownloader.Verify(d => d.DownloadString(fullUrl)); 387 | } 388 | 389 | [TestMethod] 390 | public void ContainsLinkCssElement_NotCssFile_DoNotDownload() 391 | { 392 | var mockDownloader = new Mock(); 393 | mockDownloader.Setup(d => d.DownloadString(It.IsAny())).Returns(".a { display: block; }"); 394 | WebDownloader.SharedDownloader = mockDownloader.Object; 395 | 396 | Uri baseUri = new Uri("http://a.com"); 397 | Uri fullUrl = new Uri(baseUri, "b.bs"); 398 | string input = $"
    test
    "; 399 | 400 | PreMailer sut = new PreMailer(input, baseUri); 401 | sut.MoveCssInline(); 402 | 403 | mockDownloader.Verify(d => d.DownloadString(It.IsAny()), Times.Never()); 404 | } 405 | 406 | [TestMethod] 407 | public void ContainsLinkCssElement_DownloadsCss_InlinesContent() 408 | { 409 | var mockDownloader = new Mock(); 410 | mockDownloader.Setup(d => d.DownloadString(It.IsAny())).Returns(".test { width: 150px; }"); 411 | WebDownloader.SharedDownloader = mockDownloader.Object; 412 | 413 | string input = "
    test
    "; 414 | 415 | PreMailer sut = new PreMailer(input, new Uri("http://a.com")); 416 | var premailedOutput = sut.MoveCssInline(); 417 | 418 | Assert.IsTrue(premailedOutput.Html.Contains("
    ")); 419 | } 420 | 421 | [TestMethod] 422 | public void ContainsKeyframeCSS_InlinesCSSWithOutError() 423 | { 424 | string keyframeAnimation = @" 425 | @keyframes mymove { 426 | 0% {top: 0px;} 427 | 25% {top: 200px;} 428 | 75% {top: 50px} 429 | 100% {top: 100px;} 430 | } 431 | "; 432 | 433 | string input = "
    test
    "; 434 | 435 | var premailedOutput = PreMailer.MoveCssInline(input, false); 436 | 437 | Assert.IsTrue(premailedOutput.Html.Contains("
    "; 444 | 445 | var premailedOutput = PreMailer.MoveCssInline(input, false); 446 | 447 | Assert.IsTrue(premailedOutput.Html.StartsWith("")); 448 | } 449 | 450 | [TestMethod] 451 | public void MoveCSSInline_PreservesDocType_HTML5() 452 | { 453 | string docType = ""; 454 | string input = $"{docType}"; 455 | 456 | var premailedOutput = PreMailer.MoveCssInline(input, false); 457 | 458 | Assert.IsTrue(premailedOutput.Html.StartsWith($"{docType}")); 459 | } 460 | 461 | [TestMethod] 462 | public void MoveCSSInline_PreservesDocType_HTML401_Strict() 463 | { 464 | string docType = ""; 465 | string input = $"{docType}"; 466 | 467 | var premailedOutput = PreMailer.MoveCssInline(input, false); 468 | 469 | Assert.IsTrue(premailedOutput.Html.StartsWith($"{docType}")); 470 | } 471 | 472 | [TestMethod] 473 | public void MoveCSSInline_PreservesDocType_HTML401_Transitional() 474 | { 475 | string docType = ""; 476 | string input = $"{docType}"; 477 | 478 | var premailedOutput = PreMailer.MoveCssInline(input, false); 479 | 480 | Assert.IsTrue(premailedOutput.Html.StartsWith($"{docType}")); 481 | } 482 | 483 | [TestMethod] 484 | public void MoveCSSInline_PreservesDocType_HTML401_Frameset() 485 | { 486 | string docType = ""; 487 | string input = $"{docType}"; 488 | 489 | var premailedOutput = PreMailer.MoveCssInline(input, false); 490 | 491 | Assert.IsTrue(premailedOutput.Html.StartsWith($"{docType}")); 492 | } 493 | 494 | [TestMethod] 495 | public void MoveCSSInline_PreservesDocType_XHTML10_Strict() 496 | { 497 | string docType = ""; 498 | string input = $"{docType}"; 499 | 500 | var premailedOutput = PreMailer.MoveCssInline(input, false); 501 | 502 | Assert.IsTrue(premailedOutput.Html.StartsWith($"{docType}")); 503 | } 504 | 505 | [TestMethod] 506 | public void MoveCSSInline_PreservesDocType_XHTML10_Transitional() 507 | { 508 | string docType = ""; 509 | string input = $"{docType}"; 510 | 511 | var premailedOutput = PreMailer.MoveCssInline(input, false); 512 | 513 | Assert.IsTrue(premailedOutput.Html.StartsWith($"{docType}")); 514 | } 515 | 516 | [TestMethod] 517 | public void MoveCSSInline_PreservesDocType_XHTML10_Frameset() 518 | { 519 | string docType = ""; 520 | string input = $"{docType}"; 521 | 522 | var premailedOutput = PreMailer.MoveCssInline(input, false); 523 | 524 | Assert.IsTrue(premailedOutput.Html.StartsWith($"{docType}")); 525 | } 526 | 527 | [TestMethod] 528 | public void MoveCSSInline_PreservesDocType_XHTML11() 529 | { 530 | string docType = ""; 531 | string input = $"{docType}"; 532 | 533 | var premailedOutput = PreMailer.MoveCssInline(input, false); 534 | 535 | Assert.IsTrue(premailedOutput.Html.StartsWith($"{docType}")); 536 | } 537 | 538 | [TestMethod] 539 | public void MoveCSSInline_PreservesXMLNamespace() 540 | { 541 | string input = ""; 542 | 543 | var premailedOutput = PreMailer.MoveCssInline(input, false); 544 | 545 | Assert.IsTrue(premailedOutput.Html.StartsWith("")); 546 | } 547 | 548 | [TestMethod] 549 | public void MoveCSSInline_MergingTwoValidCssRules() 550 | { 551 | string input = @" 552 | 553 | 560 | 561 | 562 |
    563 |

    Line1

    564 |
    565 | 566 | "; 567 | 568 | var premailedOutput = PreMailer.MoveCssInline(input, true, null, null); 569 | 570 | Assert.IsTrue(premailedOutput.Html.Contains("style=\"mso-style-priority: 99;margin: 0cm\"")); 571 | } 572 | 573 | [TestMethod] 574 | public void MoveCSSInline_AcceptsStream() 575 | { 576 | string input = "
    Target
    "; 577 | using (var stream = new MemoryStream()) 578 | { 579 | using (var writer = new StreamWriter(stream)) 580 | { 581 | writer.Write(input); 582 | writer.Flush(); 583 | stream.Position = 0; 584 | var premailedOutput = PreMailer.MoveCssInline(stream); 585 | 586 | Assert.IsTrue(premailedOutput.Html.Contains("
    Target
    ")); 587 | } 588 | } 589 | } 590 | } 591 | } 592 | --------------------------------------------------------------------------------