1 Star2 Stars3 Stars4 Stars5 Stars

A theming engine for ASP.NET MVC 2


Share



Get the example code shown in the video here.

I’ve wanted a way to apply custom themes in ASP.NET MVC that “just works” for awhile. Sometimes you want to apply a new theme that switches out just a few CSS elements, or changes the structure of a specific view, or the entire site. There’s no out-of-the-box way to do that, and the existing solutions I’ve seen will only solve the CSS problem, or the views problem, but not both, or they require you to duplicate your views for every theme or use exact file names rather than your own, which is a maintenance nightmare.

I’ve come up with a ThemeViewEngine, based on the current WebFormViewEngine, that allows you to opt-in to only the changes you need, whether that’s a CSS file there, a MasterPage here, a couple of views, etc. Since it’s written for ASP.NET MVC 2, it supports themes within areas as well. The next listing isn’t the whole engine, but it highlights the important points, which are the locations where theme content lives, and finding the theme in the current session or query string. In production, it’s not a great idea to use cookies (or use the URI for cookieless sessions), so you’ll probably want to tie the theme to a user’s session in a different way.

using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using System.Web.Mvc;

namespace MvcTheming
{
    ///
    /// Provides a flexible  for adding theming capabilities to your views.
    /// You have the option of using a theme to override only what you need, whether that's CSS, Javascript,
    /// MasterPages, or specific views. This way both look and feel as well as site structure may be
    /// changed on demand.
    ///
    public class ThemeViewEngine : WebFormViewEngine
    {
        // format is ":ViewCacheEntry:{cacheType}:{prefix}:{name}:{controllerName}:{areaName}:{themeName}"
        private const string CacheKeyFormat = ":ViewCacheEntry:{0}:{1}:{2}:{3}:{4}:{5}";
        private const string CacheKeyPrefixMaster = "Master";
        private const string CacheKeyPrefixPartial = "Partial";
        private const string CacheKeyPrefixView = "View";

        private static readonly string[] _emptyLocations = new string[0];

        public string Theme { get; private set; }

        // {0}name:{1}controllerName:{2}areaName:{3}themeName
        public ThemeViewEngine()
        {
            MasterLocationFormats = new[] {
                                              // Theme-specific locations (opt-in)
                                              "~/Content/{3}/{0}.master",
                                              "~/Content/{3}/Views/{1}/{0}.master",
                                              "~/Content/{3}/Views/Shared/{0}.master",
                                              // Default locations
                                              "~/Views/{1}/{0}.master",
                                              "~/Views/Shared/{0}.master"
                                          };

            AreaMasterLocationFormats = new[] {
                                                  // Theme-specific locations (opt-in)
                                                  "~/Areas/{2}/Content/{3}/{0}.master",
                                                  "~/Areas/{2}/Content/{3}/Views/{1}/{0}.master",
                                                  "~/Areas/{2}/Content/{3}/Views/Shared/{0}.master",
                                                  "~/Content/{3}/Areas/{2}/{0}.master",
                                                  "~/Content/{3}/Areas/{2}/Views/{1}/{0}.master",
                                                  "~/Content/{3}/Areas/{2}/Views/Shared/{0}.master",
                                                  // Default locations
                                                  "~/Areas/{2}/Views/{1}/{0}.master",
                                                  "~/Areas/{2}/Views/Shared/{0}.master",
                                                  "~/Views/Areas/{2}/{1}/{0}.master",
                                                  "~/Views/Areas/{2}/Shared/{0}.master"
                                              };

            ViewLocationFormats = new[] {
                                            // Theme-specific locations (opt-in)
                                            "~/Content/{3}/Views/{1}/{0}.aspx",
                                            "~/Content/{3}/Views/{1}/{0}.ascx",
                                            "~/Content/{3}/Views/Shared/{0}.aspx",
                                            "~/Content/{3}/Views/Shared/{0}.ascx",
                                            // Default locations
                                            "~/Views/{1}/{0}.aspx",
                                            "~/Views/{1}/{0}.ascx",
                                            "~/Views/Shared/{0}.aspx",
                                            "~/Views/Shared/{0}.ascx"
                                        };

            AreaViewLocationFormats = new[] {
                                                // Theme-specific locations (opt-in)
                                                "~/Areas/{2}/Content/{3}/Views/{1}/{0}.aspx",
                                                "~/Areas/{2}/Content/{3}/Views/{1}/{0}.ascx",
                                                "~/Areas/{2}/Content/{3}/Views/Shared/{0}.aspx",
                                                "~/Areas/{2}/Content/{3}/Views/Shared/{0}.ascx",
                                                "~/Content/{3}/Views/Areas/{2}/{1}/{0}.aspx",
                                                "~/Content/{3}/Views/Areas/{2}/{1}/{0}.ascx",
                                                "~/Content/{3}/Views/Areas/{2}/Shared/{0}.aspx",
                                                "~/Content/{3}/Views/Areas/{2}/Shared/{0}.ascx",
                                                // Default locations
                                                "~/Areas/{2}/Views/{1}/{0}.aspx",
                                                "~/Areas/{2}/Views/{1}/{0}.ascx",
                                                "~/Areas/{2}/Views/Shared/{0}.aspx",
                                                "~/Areas/{2}/Views/Shared/{0}.ascx",
                                                "~/Views/Areas/{2}/{1}/{0}.aspx",
                                                "~/Views/Areas/{2}/{1}/{0}.ascx",
                                                "~/Views/Areas/{2}/Shared/{0}.aspx",
                                                "~/Views/Areas/{2}/Shared/{0}.ascx"
                                            };

            PartialViewLocationFormats = ViewLocationFormats;
            AreaPartialViewLocationFormats = AreaViewLocationFormats;
        }

        // Details elided. I like the word "elided". Thanks Bill Wagner.

        private void SetTheme(ControllerContext controllerContext)
        {
            var context = controllerContext.RequestContext.HttpContext;
            var request = context.Request;
            var session = context.Session;
            var queryString = request.QueryString;

            if (queryString.AllKeys.Contains("theme"))
            {
                var theme = queryString["theme"];
                session.Add("Theme", theme);
            }

            if (session["Theme"] == null)
            {
                return;
            }

            Theme = session["Theme"].ToString();
        }

        // Details elided

        private class ViewLocation
        {
            protected readonly string _virtualPathFormatString;

            public ViewLocation(string virtualPathFormatString)
            {
                _virtualPathFormatString = virtualPathFormatString;
            }

            public virtual string Format(string viewName, string controllerName, string areaName, string themeName)
            {
                var result = String.Format(CultureInfo.InvariantCulture, _virtualPathFormatString, viewName, controllerName, areaName, themeName);
                return result;
            }
        }

        private class AreaAwareViewLocation : ViewLocation
        {
            public AreaAwareViewLocation(string virtualPathFormatString)
                : base(virtualPathFormatString)
            {

            }

            public override string Format(string viewName, string controllerName, string areaName, string themeName)
            {
                var result = String.Format(CultureInfo.InvariantCulture, _virtualPathFormatString, viewName, controllerName, areaName, themeName);
                return result;
            }
        }
    }
}

Once you have a theme engine capable of looking for the right files in the right places, a set of extension methods is used to provide a way to inject “soft references” into your master pages and views, so that your default site design can flex when the theme changes, rather than point to the default content regardless of a new theme.

using System;
using System.Collections.Generic;
using System.IO;
using System.Web;
using System.Web.Caching;
using System.Web.Mvc;

namespace MvcTheming
{
    public static class ThemeExtensions
    {
        // format is ":ThemeCacheEntry:{areaName}:{themeName}:{resourceType}:{resourceName}"
        private const string CacheKeyFormat = ":ThemeCacheEntry:{0}:{1}:{2}:{3}";

        // {0}areaName:{1}themeName:{2}resourceType:{3}resourceName
        private static readonly string[] _contentLocationFormats = new[]
           {
               // Area and type-specific locations
               "~/Areas/{0}/Content/{1}/{3}",
               "~/Areas/{0}/Content/{2}/{3}",
               "~/Areas/{0}/Content/{1}/{2}/{3}",
               "~/Areas/{0}/Content/{1}/{2}/{3}",
               // Area-specific default locations
               "~/Areas/{0}/Content/{3}",
               "~/Areas/{0}/Scripts/{3}",
               // Area default locations
               "~/Areas/Content/{3}",
               "~/Areas/Scripts/{3}",
               // Theme and type-specific locations
               "~/Content/{1}/{3}",
               "~/Content/{2}/{3}",
               "~/Content/{1}/{2}/{3}",
               "~/Scripts/{1}/{3}",
               // Default locations
               "~/Content/{3}",
               "~/Scripts/{3}",
           };

        public static string JavaScript(this UrlHelper helper, string scriptName)
        {
            return StaticResource(helper, "JavaScript", scriptName);
        }

        public static string Css(this UrlHelper helper, string styleName)
        {
            return StaticResource(helper, "Css", styleName);
        }

        public static string Image(this UrlHelper helper, string imageName)
        {
            return StaticResource(helper, "Images", imageName);
        }

        public static string StaticResource(this UrlHelper helper,
                                            string resourceType,
                                            string resourceName)
        {
            return helper.StaticResource(resourceType, resourceName, true);
        }

        public static string StaticResource(this UrlHelper helper,
                                            string resourceType,
                                            string resourceName,
                                            bool useCache)
        {
            var areaName = helper.RequestContext.RouteData.GetAreaName();
            var themeName = FindThemeName();

            var cacheKey = String.Format(CacheKeyFormat, areaName, themeName, resourceType, resourceName);
            if(useCache)
            {
                var value = HttpRuntime.Cache[cacheKey];
                if (value != null)
                {
                    return value.ToString();
                }
            }

            var searchedLocations = new List();
            foreach (var mask in _contentLocationFormats)
            {
                var relativePath = String.Format(mask, areaName, themeName ?? "", resourceType, resourceName);
                var absolutePath = VirtualPathUtility.ToAbsolute(relativePath);
                var serverPath = helper.RequestContext.HttpContext.Server.MapPath(absolutePath);

                searchedLocations.Add(absolutePath);
                if (!File.Exists(serverPath))
                {
                    continue;
                }

                HttpRuntime.Cache.Insert(cacheKey, absolutePath, new CacheDependency(serverPath));
                return absolutePath;
            }

            throw new FileNotFoundException();
        }

        private static string FindThemeName()
        {
            string themeName = null;
            foreach(var engine in ViewEngines.Engines)
            {
                var themeViewEngine = engine as ThemeViewEngine;
                if (themeViewEngine == null)
                {
                    continue;
                }

                themeName = themeViewEngine.Theme;
            }
            return themeName;
        }
    }
}
Kick It on DotNetKicks.com
  • Line 72-74 of ThemeExtension is broken. You ALWAYS must store the return value from a Cache lookup in a local variable and then check for null and use the cached value if non-null (NOT reacquire the value from Cache) because it can be pruned out of the Cache asynchronously between the lines leading to a null reference exception.

    In other words do this:

    var cachedValue = HttpRuntime.Cache[cacheKey];

    if (cachedValue != null)
    {
    return cachedValue.ToString();
    }
  • What you describe is my normal pattern for cache access, but it looks like I got sloppy there. Your explanation makes good sense and I've updated the source listing on the blog and the download. Thanks!
  • Excellent! You've got the same issue in the static file controller article. I love both of these, BTW :)
  • Great Code! I was able to implement this in MvcCms 2.0 in just an hour or so. I was getting several people wanting to be able to use different master pages for different parts of the CMS and using the older themeviewengine there wasn't an easy way to do this.

    I have it in the source code at codeplex http://mvccms.codeplex.com/SourceControl/change... in the MvcCms 2.0 project.

    The only thing I found was there was a extra } at the end of the AreaMasterLocationFormats strings in a few places.
  • Hi Jonathan,

    Great to hear that you're using this theme, and thanks for finding those typos, I've fixed them up. Geoffrey Braaf and I are collaborating on a new edition of this theming approach that doesn't force you to use UrlHelpers or WebFormsViewEngine, hopefully you'll see that up soon.
  • erichexter
    Very nice.. you should consider contributing this to MvcContrib.
  • Thanks! I want to take this to its final conclusion first, but I've already forked it up to drop in when it's done.
  • Cool, but it needs some fine-tuning still, right? How about using a VirtualPathProvider to squeeze in your themed master page, rather than introducing this, confusing (could be view data as well) method?

    Great work!
  • You're way ahead of me, but that's the endgame; a VirtualPathProvider would be the layer that lets you use this theming approach anywhere and without using the extension methods.
  • I'd love to contribute, so let me know if you'd like some help!
  • 0men
    Daniel - this is genius man! It just helped me crack a problem ive been wrestling with for a week or more but I have utilised it not for themes but wilcard domains (IIS doesnt support it) and multiple site segragation (ease of maintenance and clear seperation between sites)!

    I used it in conjunction with a controller factory and host lookups (instead of the theme and session).

    I'll put a blog post together about it when ive finished it off so you can see how i utilised it.

    Thanks for posting this! Great work.
  • Glad you found it useful! It sounds like an interesting problem in multi-tenancy you're tackling, so definitely send me the link when you're through with your article.
blog comments powered by Disqus