├── 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 [](https://ci.appveyor.com/project/milkshakesoftware/premailer-net) [](https://coveralls.io/github/milkshakesoftware/PreMailer.Net?branch=master) [](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