Razor Themed View Engine for multi-themes site

All sources code for this post can be find at here.

I have just updated the source codes and the post with the suggest from gbitdiff. Thanks for your comment.

Have you ever implemented a multi-themes site using Razor view engine? Yes it is really good to do that. Specially, you can do it and make it work well with Razor. I spent a lot of time to investigate this problem. After searched on Google, I had found some of links very useful about this topic. But all of them also implemented in ASP.NET MVC 2.0 and custom a WebformViewEngine. So I know I must work with it from scratch. But no problem, I decided to analyses some examples at here and here. They are good samples for my work. And I can re-use the ViewEngine from them, only change from WebformViewEngine to RazorViewEngine.
What components do we need to make your site using multi themes? We only need css, images and certainly views for make its working. Basic idea is customize the ViewEngine and when the ASP.NET MVC want to find a image, css, view or partial view, we will point to the valid one in theme folder. That's it. After 4 hours to implemented it, I very happy because it can work very well. And this is my result for default theme:



red theme



and green theme



this is a project structure for multi-themes

 

As you see, I use the path Content\themes\... is same with the organisation of ASP.NET MVC 3.0, and the path ~\Views\... contain all views. And now I will show you some important line of code to implemented it.
First thing is RazorThemeViewEngine

    public class RazorThemeViewEngine : RazorViewEngine
    {
        public RazorThemeViewEngine()
        {
            MasterLocationFormats = new[] {
                "~/Views/{2}/{1}/{0}.cshtml",
                "~/Views/{2}/{1}/{0}.vbhtml",
                "~/Views/{2}/Shared/{0}.cshtml",
                "~/Views/{2}/Shared/{0}.vbhtml"
            };
            ViewLocationFormats = new[] {
                "~/Views/{2}/{1}/{0}.cshtml",
                "~/Views/{2}/{1}/{0}.vbhtml",
                "~/Views/{2}/Shared/{0}.cshtml",
                "~/Views/{2}/Shared/{0}.vbhtml"
            };
            PartialViewLocationFormats = new[] {
                "~/Views/{2}/{1}/{0}.cshtml",
                "~/Views/{2}/{1}/{0}.vbhtml",
                "~/Views/{2}/Shared/{0}.cshtml",
                "~/Views/{2}/Shared/{0}.vbhtml"
            };
        }

        protected override bool FileExists(ControllerContext controllerContext, string virtualPath)
        {
            try
            {
                return File.Exists(controllerContext.HttpContext.Server.MapPath(virtualPath));
            }
            catch (HttpException exception)
            {
                if (exception.GetHttpCode() != 0x194)
                {
                    throw;
                }
                return false;
            }
            catch
            {
                return false;
            }
        }

        public override ViewEngineResult FindView(ControllerContext controllerContext, string viewName, string masterName, bool useCache)
        {
            string[] strArray;
            string[] strArray2;

            if (controllerContext == null)
            {
                throw new ArgumentNullException("controllerContext");
            }
            if (string.IsNullOrEmpty(viewName))
            {
                throw new ArgumentException("viewName must be specified.""viewName");
            }

            var themeName = GetThemeToUse(controllerContext);

            var requiredString = controllerContext.RouteData.GetRequiredString("controller");

            var viewPath = GetPath(controllerContext, ViewLocationFormats, "ViewLocationFormats", viewName, themeName, requiredString, "View", useCache, out strArray);
            var masterPath = GetPath(controllerContext, MasterLocationFormats, "MasterLocationFormats", masterName, themeName, requiredString, "Master", useCache, out strArray2);

            if (!string.IsNullOrEmpty(viewPath) && (!string.IsNullOrEmpty(masterPath) || string.IsNullOrEmpty(masterName)))
            {
                return new ViewEngineResult(CreateView(controllerContext, viewPath, masterPath), this);
            }
            return new ViewEngineResult(strArray.Union(strArray2));
        }

        public override ViewEngineResult FindPartialView(ControllerContext controllerContext, string partialViewName, bool useCache)
        {
            string[] strArray;
            if (controllerContext == null)
            {
                throw new ArgumentNullException("controllerContext");
            }
            if (string.IsNullOrEmpty(partialViewName))
            {
                throw new ArgumentException("partialViewName must be specified.""partialViewName");
            }

            var themeName = GetThemeToUse(controllerContext);

            var requiredString = controllerContext.RouteData.GetRequiredString("controller");
            var partialViewPath = GetPath(controllerContext, PartialViewLocationFormats, "PartialViewLocationFormats", partialViewName, themeName, requiredString, "Partial", useCache, out strArray);
            return string.IsNullOrEmpty(partialViewPath) ? new ViewEngineResult(strArray) : new ViewEngineResult(CreatePartialView(controllerContext, partialViewPath), this);
        }

        private static string GetThemeToUse(ControllerContext controllerContext)
        {
            var themeName = controllerContext.HttpContext.Items["themeName"as string ?? "Default";

            return themeName;
        }

        private static readonly string[] _emptyLocations;

        private string GetPath(ControllerContext controllerContext, string[] locations, string locationsPropertyName, string name, string themeName, string controllerName, string cacheKeyPrefix, bool useCache, out string[] searchedLocations)
        {
            searchedLocations = _emptyLocations;
            if (string.IsNullOrEmpty(name))
            {
                return string.Empty;
            }
            if ((locations == null) || (locations.Length == 0))
            {
                throw new InvalidOperationException("locations must not be null or emtpy.");
            }

            bool flag = IsSpecificPath(name);
            string key = CreateCacheKey(cacheKeyPrefix, name, flag ? string.Empty : controllerName, themeName);
            if (useCache)
            {
                var viewLocation = ViewLocationCache.GetViewLocation(controllerContext.HttpContext, key);
                if (viewLocation != null)
                {
                    return viewLocation;
                }
            }
            return !flag ? GetPathFromGeneralName(controllerContext, locations, name, controllerName, themeName, key, ref searchedLocations)
                        : GetPathFromSpecificName(controllerContext, name, key, ref searchedLocations);
        }

        private static bool IsSpecificPath(string name)
        {
            var ch = name[0];
            if (ch != '~')
            {
                return (ch == '/');
            }
            return true;
        }

        private string CreateCacheKey(string prefix, string name, string controllerName, string themeName)
        {
            return string.Format(
                CultureInfo.InvariantCulture,
                ":ViewCacheEntry:{0}:{1}:{2}:{3}:{4}",
                new object[] { GetType().AssemblyQualifiedName, prefix, name, controllerName, themeName });
        }

        private string GetPathFromGeneralName(ControllerContext controllerContext, string[] locations, string name, string controllerName, string themeName, string cacheKey, ref string[] searchedLocations)
        {
            var virtualPath = string.Empty;
            searchedLocations = new string[locations.Length];
            for (var i = 0; i < locations.Length; i++)
            {
                var str2 = string.Format(CultureInfo.InvariantCulture, locations[i], new object[] { name, controllerName, themeName });

                if (FileExists(controllerContext, str2))
                {
                    searchedLocations = _emptyLocations;
                    virtualPath = str2;
                    ViewLocationCache.InsertViewLocation(controllerContext.HttpContext, cacheKey, virtualPath);
                    return virtualPath;
                }
                searchedLocations[i] = str2;
            }
            return virtualPath;
        }

        private string GetPathFromSpecificName(ControllerContext controllerContext, string name, string cacheKey, ref string[] searchedLocations)
        {
            var virtualPath = name;
            if (!FileExists(controllerContext, name))
            {
                virtualPath = string.Empty;
                searchedLocations = new[] { name };
            }
            ViewLocationCache.InsertViewLocation(controllerContext.HttpContext, cacheKey, virtualPath);
            return virtualPath;
        }
    }

so lucky because RazorViewEngine is also same with WebformViewEngine, so it is not hard to convert to. :D
Second is ThemeControllerBase

    public class ThemeControllerBase : Controller
    {
        protected override void Execute(System.Web.Routing.RequestContext requestContext)
        {
            var themeName = ConfigurationManager.AppSettings["ThemeName"];
            var defaultTheme = ConfigurationManager.AppSettings["DefaultTheme"];

            if (requestContext.HttpContext.Items[themeName] == null)
            {
                //first time load
                requestContext.HttpContext.Items[themeName] = requestContext.HttpContext.Request.Cookies.Get("theme").Value;
            }
            else
            {
                requestContext.HttpContext.Items[themeName] = defaultTheme;

                var previewTheme = requestContext.RouteData.GetRequiredString("theme");

                if (!string.IsNullOrEmpty(previewTheme))
                {
                    requestContext.HttpContext.Items[themeName] = previewTheme;
                }
            }

            base.Execute(requestContext);
        }
    }

and the DropDownList partial view for choose a list of themes:

@{ var list = ThemeSample.Helpers.ThemeHelper.GetSelectedList(ViewContext.RequestContext.HttpContext.Items["themeName"].ToString());
}
@Html.DropDownList("Theme", list)
<script language="javascript" type="text/javascript">
    $(function () {
        $('#Theme').change(function () {

            $.post('@Url.Action("Index""ThemeDropdown")', { theme: $(this).val() }, function (result) {
                $('body').html('');
                $('body').html(result);
            });
        });
    });
</script>

controller for it, after user choose the theme on DropDownList, it will submit to the action in this controller.

    public class ThemeDropdownController : Controller
    {
        [HttpPost]
        public ActionResult Index(string theme)
        {
            var themeName = ConfigurationManager.AppSettings["themeName"];

            ControllerContext.RequestContext.HttpContext.Items[themeName] = theme;
            var themeCookie = new HttpCookie("theme", theme);
            HttpContext.Response.Cookies.Add(themeCookie);

            const string controller = "Home";
            const string action = "Index";

            return Redirect(string.Format("~/{0}/{1}/{2}", theme, controller, action));
        }
    }

theme helper class

    public static class ThemeHelper
    {
        public static IList<SelectListItem> GetSelectedList(string selectedTheme)
        {
            // TODO: should get from web.config or database
            var list = new List<SelectListItem>
                            {new SelectListItem {Text = "Default", Value = "Default"},
                            new SelectListItem {Text = "Red", Value = "Red"},
                            new SelectListItem {Text = "Green", Value = "Green"}};

            foreach (var selectListItem in list.Where(selectListItem => selectListItem.Value.Equals(selectedTheme)))
            {
                selectListItem.Selected = true;
            }

            return list;
        }

        public static string GetCssWithTheme(ViewContext viewContext)
        {
            var themeName = ConfigurationManager.AppSettings["themeName"];

            return string.Format("~/Content/themes/{0}/Site.css", viewContext.HttpContext.Items[themeName]);
        }
    }

some config in Global.asax

            ViewEngines.Engines.Clear();
            ViewEngines.Engines.Add(new RazorThemeViewEngine());

Notes: In this post I did not write the helper for find images, but it is really easy to implemented. How about your thinking in this topic? I really want to know about that. Leave some messages and we will get more clearly about that. Happy coding and see you next post!

kick it on DotNetKicks.com Shout it

10 Comments

  • I think you might have a typo:

    ViewLocationFormats = new[] {
    "~/Views/{2}/{1}/{0}.cshtml",
    "~/Views/{2}/{1}/{0}.cshtml",
    "~/Views/{2}/Shared/{0}.cshtml",
    "~/Views/{2}/Shared/{0}.cshtml"
    };

    Did you intentionally repeat the ~/Views/{2}/{1}/{0}.cshtml value or did you mean to make it .vbhtml for one of them?

  • Sorry for this mistake. Cause I post it very late last night. So maybe I could not see it clearly :D. I have just updated for cshtml and vbhtml extension.

  • Is it possible, multiple themes apply on only one set of view? and how?

  • if (exception.GetHttpCode() != 0x194)

    != 404 would be a lot more readable and maintainable... The Http codes are designed to be read as int: eg all the 4xx codes are "client" problem, all the 5xx codes are server problem - they are ordered in increasing level of urgency from 1xx to 5xx. Converting them to hex removes that visual mnemonic

  • This same to the post: http://kazimanzurrashid.com/posts/asp-dot-net-mvc-theme-supported-razor-view-engine

    The post from kazimanzurrashid early than your post.

  • This is kind of a copy of the Kazim version which you should have mentioned as a reference work.

    In any case this is flawed in just the same way all those "theme" engines you mentioned (and didn't mention) are flawed. A basic, inmutable requirement of a theme engine is that the user must not be forced to duplicate or multiplicate the site pages (views) for each theme as you and all of them have. Imagine the complexity and unmanageability if on top of this you add localization?

    The main purpose of the theme is to be able to change the look of the site using exactly the same views by modifying ONLY the styles (and possibly the layout) while reusing the actual content.

    So each theme has the same elements used in the master layout it uses but alters colors (the layout only alters the look).

    For instructional purposes I have fixed those problems in this solution and enhanced it.

  • This is the right blog for anyone who hopes to find out about this topic.
    You realize a whole lot its almost tough to argue with you (not
    that I actually will need to…HaHa). You definitely put a brand new spin on a subject
    which has been discussed for many years. Great stuff, just wonderful!

  • 7hcyof A round of applause for your blog article.Really looking forward to read more.

  • I am regular reader, how are you everybody? This post posted at this website is actually
    good.

  • Kyd31Q Thanks for the article post. Fantastic.

Comments have been disabled for this content.