I'm building a RESTful service that sends emails with body rendered by partial views. It replaces my old solution that used hardcoded templates of IHtmlElement
inside each application.
One of its features is to inline class
es as style
. I'm using class
because it's easier to design a view this way. Since it's about emails, I'm not expecting any fency style selectors and to make it simple I'm using only class
es.
InlineClassTagHelper
The inlining is driven by the InlineClassTagHelper
. It stops at each element with the class
attribute and checks if there are classes prefixed with m-
(it's my custom prefix that stands for mail). It then looks for a style in the parsed .css
file. Its name is always wwwroot/css/Conroller.Action.css
. When found, it sets the style
attribute and removes the class
one.
[HtmlTargetElement(Attributes = "class")]
public class InlineClassTagHelper : TagHelper
{
private readonly CssProvider _cssProvider;
public InlineClassTagHelper(CssProvider cssProvider)
{
_cssProvider = cssProvider;
}
[HtmlAttributeNotBound, ViewContext]
public ViewContext ViewContext { get; set; }
public override async Task ProcessAsync(TagHelperContext context, TagHelperOutput output)
{
var classNames =
output
.Attributes["class"]
?.Value
.ToString()
.Split(new[] { ' ' }, StringSplitOptions.RemoveEmptyEntries);
if (classNames is null)
{
return;
}
var inlineableClassNames =
(from className in classNames
where className.StartsWith("m-")
select SoftString.Create(className)).ToImmutableHashSet();
if (inlineableClassNames.None())
{
return;
}
var cssFileName =
$"wwwroot/css/" +
$"{ViewContext.RouteData.Values["controller"]}." +
$"{ViewContext.RouteData.Values["action"]}.css";
var css = await _cssProvider.GetCss(cssFileName);
var declarations =
from ruleset in css
from selector in ruleset.Selectors
join className in inlineableClassNames on selector equals className
select ruleset.Declarations.TrimEnd(';');
var style = declarations.Join("; ");
if (style.IsNullOrEmpty())
{
// Make debugging of missing styles easier by highlighting the element with a red border.
output.Attributes.SetAttribute("style", "border: 1px solid #ff6666; border-radius: 3px;");
}
else
{
output.Attributes.SetAttribute("style", style);
output.Attributes.RemoveAll("class");
}
}
}
Caching
To avoid multiple loads (for each class
attribute occurance) the .css
file is cached for the lifetime of a request and loaded by another helper service I call ICssProvider
.
public interface ICssProvider
{
Task<Css> GetCss(string fileName);
}
public class CssProvider : ICssProvider
{
private readonly IFileProvider _fileProvider;
private Css _css;
public CssProvider(IFileProvider fileProvider)
{
_fileProvider = fileProvider;
}
public async Task<Css> GetCss(string fileName)
{
if (_css is null)
{
var cssFile = _fileProvider.GetFileInfo(fileName);
using (var reader = new StreamReader(cssFile.CreateReadStream()))
{
var cssString = await reader.ReadToEndAsync();
_css = CssParser.Default.Parse(cssString);
Debug.WriteLine($"{fileName} loaded.");
}
}
return _css;
}
}
This is registered in Startup
as
services.AddScoped<CssProvider>();
Example
When the tag-helper finds an element like this one:
<h2 class="m-title">@ViewData["Title"]</h2>
it turns it into this:
<h2 style="color: blueviolet;">About</h2>
where the .css
is:
m-title { color: blueviolet; }
Would you say it's a good solution or do you see any room for improvement?
CssParser
is static (tight coupling to static dependencies), is there any way to abstract that out and have it explicitly injected via constructor? \$\endgroup\$ICssParser
and I am able to do it correctly, but I also have thisDefault
property in case I'm too lazy for a full DI ;-) \$\endgroup\$Scoped
service registration does. It is resolved one per request. And within a single request the tag-helper is created everytime it finds theclass
attribute but still this is the same request. \$\endgroup\$