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;
}
}
}



Socialized