2
\$\begingroup\$

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 classes 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 classes.


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?

\$\endgroup\$
  • 1
    \$\begingroup\$ Initial observation, assuming CssParser is static (tight coupling to static dependencies), is there any way to abstract that out and have it explicitly injected via constructor? \$\endgroup\$ – Nkosi Feb 24 '18 at 17:14
  • \$\begingroup\$ @Nkosi you're right, it should be injected. I actually have an interface for it, it's the ICssParser and I am able to do it correctly, but I also have this Default property in case I'm too lazy for a full DI ;-) \$\endgroup\$ – t3chb0t Feb 24 '18 at 18:46
  • \$\begingroup\$ The CSS provider can cache the filename mapped to the CSS in a dictionary. The current design uses the same one for all file names after the initial set. \$\endgroup\$ – Nkosi Feb 25 '18 at 5:02
  • \$\begingroup\$ You also need to register the interface. You are only registering the implementation. \$\endgroup\$ – Nkosi Feb 25 '18 at 5:06
  • \$\begingroup\$ @Nkosi I think this is ok because there is only one file per action and each action is one request, right? So for each request there will be only a single fine in the cache. That's what the Scoped service registration does. It is resolved one per request. And within a single request the tag-helper is created everytime it finds the class attribute but still this is the same request. \$\endgroup\$ – t3chb0t Feb 25 '18 at 8:58
2
\$\begingroup\$

Initial observation, assuming CssParser was static (tight coupling to static dependencies), was to abstract that core functionality out into its own concern and have it explicitly injected via constructor.

That was done. However, the extracted helper service implementation was registered like

services.AddScoped<CssProvider>();

While it works, for a more SOLID design, classes should depend on abstractions and not concretions.

An abstraction exists, so I suggest refactoring the helper to explicitly depend on ICssProvider abstraction

//...code removed for brevity

private readonly ICssProvider _cssProvider;

public InlineClassTagHelper(ICssProvider cssProvider) {
    _cssProvider = cssProvider;
}

//...code removed for brevity

and register the abstraction with its implementation in the composition root (Startup) with the following extension

services.AddScoped<ICssProvider, CssProvider>();
\$\endgroup\$
  • \$\begingroup\$ Oh, I don't know how I missed that it takes two generic parameters! :-o \$\endgroup\$ – t3chb0t Feb 27 '18 at 4:58

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service, privacy policy and cookie policy

Not the answer you're looking for? Browse other questions tagged or ask your own question.