Tuesday 28 March 2017

Managing CSS files in Sitecore

This article shows how you can manage CSS files in Sitecore. This is one of those techniques that I don't exactly recommend as it shouldn't really be needed, but sometimes it can be a compromise to allow front-end designers to modify CSS on the fly. One of the advantages of doing this is that having Sitecore drive your CSS means your CSS can also follow the restricted publication process allowing your CSS to change depending on the date. I was working on a site that had a "deal of the day" where each day had a different offer and that offer was reflected on the home page. This technique allowed the designer to version the home page for each day, and also version the css tweaks for each day and set up the site for the whole week in advance. On each morning we simply did a publish and the publish restrictions we'd set up on the versions meant the homepage and css reflected that day's deal.

Note that the css for the majority of the site was still handled in css files, the css that we made driven from Sitecore contained only slight modifications the designer wanted so that buttons looked different, fonts for some components were different and so on.

What we're going to end up doing is to create a <link> tag that refers to a css file in a non-existent folder, and use a custom handler to handle this request and our handler will return css that is defined in Sitecore. First step, as always, is to create a template for our css and call it "Css File".


We have a checkbox that we can use to enabled or disable the css file, a multi-line box to hold the css itself, and a field to allow us to set the media type. Create a folder where you'll store these files, I normally put things like this outside of the Home tree as you don't want to navigate to it directly.  Below is how an example item will look.




I'm going to make a model to store this data as a class

using Sitecore.Data.Items;
using System;

namespace MyNamespace.Models
{
    public class CssFileModel
    {
        public bool Enabled { get; set; }
        public Guid ID { get; set; }
        public string Href { get; set; }
        public string Media { get; set; }

        public static implicit operator CssFileModel(Item item)
        {
            CssFileModel model = new CssFileModel();

            if (item != null)
            {
                model.ID = item.ID.ToGuid();
                model.Enabled = !string.IsNullOrWhiteSpace(item["Css File Enabled"]);
                model.Href = string.Format("{0}.css?id={1}&rev={2}&v={3}",
                    item.Name,
                    model.ID.ToString("N"),
                    item[Sitecore.FieldIDs.Revision],
                    item.Version.ToString());

                if (!Sitecore.Context.PageMode.IsNormal)
                {
                    model.Href = string.Concat(model.Href, "&p=y");
                }
                model.Media = item["Css Media"];
            }

            return model;
        }
    }
}

This doesn't do anything particularly complicated, it just reads the field values and stores them in properties. The main things of interest are the name of the file, and how it handles page modes. The href of the css file is going to be the name of the item with a ".css" extension and it is going to add the Sitecore ID, revision and version number as parameters. This is to ensure that when the Css File item changes we get a new href in the link tag so the browser attempts a new download.  I just want to reiterate here that we're not actually providing a physical CSS file, only a link to a non-existent file and a custom handler is going to intercept that request and return the CSS held in our Sitecore item.  If the page mode is anything other than Normal then we also add an additional "p" parameter. When we come to write the custom handler you'll see how that is used.

Next we need a partial view that will generate our link attributes.

_css.cshtml

@model List<Models.CssFileModel>
@foreach(CssFileModel css in Model)
{
    <link rel="stylesheet" href="/mycss/@css.Href" type="text/css" media="@css.Media" />
}

You'll note that we're prefixing the file with "/mycss/". That that folder doesn't need to exist, it is simply something that we'll use to attach our custom handler to. You can call it anything you want, it could even be a sub-folder like "/css/dynamicsss/" if you wish. We'll use an Action to render this html on our layout, so amend your layout to add this to the head.

<head>
    <!— all your other elements here -->
    @Html.Action("CssFiles", "Header")
</head>

Now we'll write the CssFiles action in our HeaderController (you'll need to create this controller if you don't already have one)

public ActionResult CssFiles()
{
    model = new List<CssFileModel>();

    // I'm hard-coding this path for simplicity, you'll get the path from a config
    // or somewhere else
    Item folder = Sitecore.Context.Database.GetItem("/sitecore/content/Css Files"));

    if (folder != null)
    {
        // the implicit operator on our CssFileModel handles the conversion
        // between the Sitrecore Item and the CssFileModel class
        foreach(CssFileModel cssFile in folder.GetChildren().ToArray())
        {
            if (cssFile.Enabled)
            {
                model.Add(cssFile);
            }
        }
    }

    return View("~/Views/Common/_css.cshtml", model);
}

As this code is going on every page I actually cache the list of active CssFileModel objects (see my previous blog about caching). You could also turn the partial view into an actual Sitecore rendering and use Sitecore's built-in caching framework.

At this point when you view your pages you should now see a link element referring your Css File item.

<link rel="stylesheet" href="/mycss/MyCss.css?id=4cfb9916a2d54ab8a33a65d037cf50fa&rev=20af5a8f-9da1-470f-bba1-9dabd2cc1fcb&v=1" type="text/css" media="all" />

You'll see one of these links for every item in the "/sitecore/content/Css Files" folder.  As it stands, though, this link is simply going to 404 as no such css file exits. The final piece of the puzzle is to create a custom handler that will intercept any request for a css file in the "mycss" folder.

Amend the "system.weberver\handlers" section of web.config to add a new handler

<system.webServer>
  <handlers>
    <add name="CCSHandler" verb="*" path="/mycss/*.css" type="MyNamespace.HttpHandlers.CssHandler, MyAssembly" />

And here is the code for the handler. It's fairly simple, the only real trick to it is to implement the appropriate caching headers so we're not generating and sending the css with every request.

using Sitecore.Data;
using Sitecore.Data.Items;
using Sitecore.Sites;
using System;
using System.Web;

namespace MyNamespace.HttpHandlers
{
    public class CssHandler : IHttpHandler
    {
        public bool IsReusable
        {
            get { return false; }
        }

        public void ProcessRequest(HttpContext context)
        {
            context.Response.ContentType = "text/css";
            TimeSpan expiry = new TimeSpan(1, 0, 0, 0);
            context.Response.Cache.SetExpires(DateTime.Now.Add(expiry));
            context.Response.Cache.SetMaxAge(expiry);
            context.Response.Cache.SetCacheability(HttpCacheability.Public);
            context.Response.Cache.SetValidUntilExpires(true);

            string rawIfModifiedSince = context.Request.Headers.Get("If-Modified-Since");

            if (string.IsNullOrEmpty(rawIfModifiedSince))
            {
                // This is the first request from the browser
                context.Response.Cache.SetLastModified(DateTime.Now);
            }
            else
            {
                // This is a subsequent requested so we will say the file has not changed
                context.Response.StatusCode = 304;
                return;
            }

            bool preview = !string.IsNullOrWhiteSpace(context.Request.QueryString["p"]);
            Guid id;

            if (!Guid.TryParse(context.Request.QueryString["id"], out id))
            {
                return;
            }

            Database db = Sitecore.Configuration.Factory.GetDatabase(preview ? "master" : "web");
            if (db == null)
            {
                return;
            }

            Item item;

            using (new SiteContextSwitcher(Sitecore.Sites.SiteContextFactory.GetSiteContext("website")))
            {
                item = db.GetItem(new ID(id));
            }
           
            if (item == null)
            {
                return;
            }

            context.Response.Write(item["Css Content"]);
        }
    }
}

You'll notice we make the assumption that if the browser already has a cached version of the css we say the file hasn't changed. The reason we can make this assumption is because if the css item has changed the revision will have changed, ergo the href will have changed, ergo the browser will issue a new request. I know this code is a little scary; we're implementing a handler that outputs vital information yet we're returning nothing from it, but you just have to trust that http works.

The second thing you might note is that we seem to be doing a lot of things we don't normally do...we're explicitly accessing databases, site context objects etc, this is because the request is happening outside of the standard Sitecore framework so we don't have access to the contextual things we normally do. You'll also see the "p" parameter come into play here, if the parameter exists we know it's not in Normal mode so we want to show content from the master version, otherwise the web version. Obviously this code assumes you are using the standard database names and site names.

No comments:

Post a Comment