Unique Sites Using Theming

Heads Up!

This article is several years old now, and much has happened since then, so please keep that in mind while reading it.

Why would you want to do that? Well, in cases where it makes sense, the advantage of maintaining a single codebase is huge - along with the efficiency provided by not having to duplicate 90% of website files (the Umbraco core components) and have multiple databases in order to host different websites.

Last year I had the opportunity to explore the possibilities of a themed installation when Scandia decided to move the uWestFest website to Umbraco Cloud hosting.

Previously, each year a new website installation was created to host that year's conference website, and between conferences a static "splash page" was put up at the uWestFest.com domain. What if we could consolidate every year’s website into a single installation? Then we could continue to have the old sites online, for archival purposes, as well as facilitate faster development of the new site each year. Theming was the answer.

Is Your Project a Good Fit for Theming?

If you are considering using theming in your Umbraco installation, I suggest you think about whether the project requirements are a good fit. To arbitrarily stuff 10 different websites into a single install just because you can might cause more problems than it will solve.

The best application is one where you have visual variations on essentially the same sort of content. In the case of uWestFest, the conference has similar content each year: A schedule of sessions, biographies of speakers, info about ticket sales, venue, sponsorship, etc. Each year we have different visual branding, though, with sites looking and behaving in completely different ways.

Other good candidates for a themed installation:

  • Sites managed by the same company, but with independent markets - like separate country sites for a brand
  • Multiple unique "microsites" for products managed by the same team (Individual books for a publisher, for instance)
  • Highly “designed” and customized landing pages

Architecture & Design Considerations

The reason your themed sites should share some similar content is that your Umbraco installation will contain all the Document Types needed to maintain each of the sites. Being able to use the same Document Types in various sites will keep the entire installation from becoming unwieldly.

Since the designs of each site will likely vary a great deal, you should design your Document Types according to content and data rather than page layout. Having structured content will give you a lot of precision when building out the site variations. For more free-form pages, use the Umbraco Grid control to handle design-specific content with maximum flexibility.

When creating your various site designs, here are two tips which will make the subsequent site implementations simpler:

  1. Design with the content properties and grid in mind – Consider how the data you are already storing can be used in the design elements for various sites, and for less structured content, think about how the grid control can be used to provide specific design implementations – either by using multiple grid widgets to construct the design, or via custom macros.
  2. Using front-end frameworks can be helpful – If you can standardize on using a certain front-end framework (such as Bootstrap, Responsive Bp, etc.) across the different site designs, you will be able to re-use portions of template or macro code, and the grid markup will be consistent.

The Theme Logic

The way that theming works in this arrangement is that Document Types have associated Templates as usual – with matching View files in the standard Umbraco “Views” folder.

When you create a new theme to use for a site, all the associated files are kept together in a single theme-named folder which contains two subfolders: “Assets” and “Views”.

  • The “Assets” folder can contain whatever items are needed for the theme design – css, js, images, etc.
  • The “Views” folder contains any View files that should override the default ones with the same name for the theme.

In practice, you can allow multiple Template choices for a Document Type, and whichever Template is selected for a given node will be used to render that specific node. Also, if you do not provide a themed version of a Template, the default View (from the main ‘Views” folder) will be used. (This is most useful for non-designed pages such as XML sitemaps.)

The routing to the themed View files is handled in a Default Controller which is simple to set up.

Setup & Components Needed

To set up your themed sites, there are a few standard components you will need. (Some of these items have been adapted from Shannon’s "Articulate" code.)

1. ThemeHelper.cs & ThemePropertyEditorApiController.cs

These can be added to App_Code or your compiled project code.

ThemeHelper.cs

namespace MySite.Theming
{
    using System;
    using System.IO;
    using System.Web;
    using System.Web.Mvc;
    using System.Web.Mvc.Html;

    using ClientDependency.Core.Mvc;

    using Umbraco.Core;
    using Umbraco.Core.IO;
    using Umbraco.Core.Logging;

    public static class ThemeHelper
    {
        public enum PathType
        {
            ThemeRoot,
            View,
            PartialView,
            GridEditor
        };

        /// <summary>
        /// Returns the final path to the requested type, based on the theme and file existence.
        /// </summary>
        /// <param name="SiteThemeName">Theme Name</param>
        /// <param name="PathType">Type of Path to return</param>
        /// <param name="ViewName">Name of the View (without extension) (optional)</param>
        /// <param name="AlternateStandardPath">If the non-themed path is not standard, provide the full path here (optional)</param>
        /// <returns></returns>
        public static string GetFinalThemePath(string SiteThemeName, PathType PathType, string ViewName = "", string AlternateStandardPath = "")
        {
            if (SiteThemeName.IsNullOrWhiteSpace())
            {
                throw new InvalidOperationException("No theme has been set for this website root, republish the root with a selected theme.");
            }

            var finalPath = "";
            var standardPath = "";
            var themePath = "";

            var baseThemePath = string.Format("~/App_Plugins/Theming/Themes/{0}", SiteThemeName);

            switch (PathType)
            {
                case PathType.ThemeRoot:
                    themePath = string.Format("{0}/", baseThemePath);
                    standardPath = themePath;
                    break;

                case PathType.View:
                    standardPath = AlternateStandardPath !="" ? AlternateStandardPath : string.Format("~/Views/{0}.cshtml", ViewName);
                    themePath = string.Format("{0}/Views/{1}.cshtml", baseThemePath, ViewName);
                    break;

                case PathType.PartialView:
                    standardPath = AlternateStandardPath != "" ? AlternateStandardPath : string.Format("~/Views/Partials/{0}.cshtml", ViewName);
                    themePath = string.Format("{0}/Views/Partials/{1}.cshtml", baseThemePath, ViewName);
                    break;

                case PathType.GridEditor:
                    standardPath = AlternateStandardPath != "" ? AlternateStandardPath : string.Format("~/Views/Partials/Grid/Editors/{0}.cshtml", ViewName);
                    themePath = string.Format("{0}/Views/Partials/Grid/Editors/{1}.cshtml", baseThemePath, ViewName);
                    break;

                default:
                    break;
            }

            if (System.IO.File.Exists(IOHelper.MapPath(themePath)))
            {
                finalPath = themePath;
            }
            else
            {
                finalPath = standardPath;
            }

            return finalPath;
        }

        /// <summary>
        /// Shortcut for 'GetFinalThemePath()' with PathType.ThemeRoot
        /// </summary>
        /// <param name="SiteThemeName"></param>
        /// <returns></returns>
        public static string GetThemePath(string SiteThemeName)
        {
            var path = GetFinalThemePath(SiteThemeName, PathType.ThemeRoot);
            return path;
            }

        /// <summary>
        /// Shortcut for 'GetFinalThemePath()' with PathType.View
        /// </summary>
        /// <param name="SiteThemeName"></param>
        /// <param name="viewName"></param>
        /// <returns></returns>
        public static string GetThemeViewPath(string SiteThemeName, string ViewName)
        {
            var path = GetFinalThemePath(SiteThemeName, PathType.View, ViewName);
            return path;
            }

        /// <summary>
        /// Shortcut for 'GetFinalThemePath()' with PathType.PartialView
        /// </summary>
        /// <param name="SiteThemeName"></param>
        /// <param name="ViewName"></param>
        /// <returns></returns>
        public static string GetThemePartialViewPath(string SiteThemeName, string ViewName)
        {

            var path = GetFinalThemePath(SiteThemeName, PathType.PartialView, ViewName);
            return path;
            }

        public static string GetCssOverridePath(string CssOverrideFileName)
        {
            if (CssOverrideFileName.IsNullOrWhiteSpace())
            {
                return "";
            }
            else
            {
                var path = "/App_Plugins/Theming/CssOverrides/{0}";
                return string.Format(path, CssOverrideFileName);
            }
            
        }

        /// <summary>
        /// Returns the url of a themed asset
        /// ex: @Url.ThemedAsset(Model, "images/favicon.ico")
        /// NOTE: requires '@using ClientDependency.Core.Mvc' in View
        /// </summary>
        /// <param name="Url">UrlHelper</param>
        /// <param name="SiteThemeName"></param>
        /// <param name="RelativeAssetPath">Path to file inside [theme]/Assets/ folder</param>
        /// <returns></returns>
        public static string ThemedAsset(this UrlHelper url, string SiteThemeName, string RelativeAssetPath)
        {
            var themeRoot = GetFinalThemePath(SiteThemeName, PathType.ThemeRoot);
            return VirtualPathUtility.ToAbsolute(themeRoot).EnsureEndsWith('/') + "Assets/" + RelativeAssetPath;
        }

        #region HTML Helpers

        public static HtmlHelper RequiresThemedCss(this HtmlHelper html, string SiteThemeName, string FilePath)
        {
            var themeRoot = GetFinalThemePath(SiteThemeName, PathType.ThemeRoot);
            return html.RequiresCss(themeRoot + "Assets/css" + FilePath.EnsureStartsWith('/'));
        }

        public static HtmlHelper RequiresThemedJs(this HtmlHelper html, string SiteThemeName, string FilePath)
        {
            var themeRoot = GetFinalThemePath(SiteThemeName, PathType.ThemeRoot);
            return html.RequiresJs(themeRoot + "Assets/js" + FilePath.EnsureStartsWith('/'));
        }

        public static HtmlHelper RequiresThemedCssFolder(this HtmlHelper html, string SiteThemeName)
        {
            var themeRoot = GetFinalThemePath(SiteThemeName, PathType.ThemeRoot);
            return html.RequiresFolder(themeRoot + "Assets/css",
                100, "*.css", (absPath, pri) => html.RequiresCss(absPath, pri));

        }

        //TODO: This is only here as a hack until CDF 1.8.0 is released and shipped that fixes a bug
        private static HtmlHelper RequiresFolder(this HtmlHelper html, string folderPath, int priority, string fileSearch, Action<string, int> requiresAction)
        {
            var httpContext = html.ViewContext.HttpContext;
            var systemRootPath = httpContext.Server.MapPath("~/");
            var folderMappedPath = httpContext.Server.MapPath(folderPath);

            if (folderMappedPath.StartsWith(systemRootPath))
            {
                var files = Directory.GetFiles(folderMappedPath, fileSearch, SearchOption.TopDirectoryOnly);
                foreach (var file in files)
                {
                    var absoluteFilePath = "~/" + file.Substring(systemRootPath.Length).Replace("\\", "/");
                    requiresAction(absoluteFilePath, priority);
                    html.RequiresJs(absoluteFilePath, priority);
                }
            }

            return html;
        }

        public static HtmlHelper RequiresThemedJsFolder(this HtmlHelper html, string SiteThemeName)
        {
            var themeRoot = GetFinalThemePath(SiteThemeName, PathType.ThemeRoot);
            return html.RequiresJsFolder(themeRoot + "Assets/js");
        }

        /// <summary>
        /// Renders a partial view in the current theme
        /// </summary>
        /// <param name="html"></param>
        /// <param name="SiteThemeName"></param>
        /// <param name="PartialName"></param>
        /// <param name="ViewModel"></param>
        /// <param name="ViewData"></param>
        /// <returns></returns>
        public static IHtmlString ThemedPartial(this HtmlHelper html, string SiteThemeName, string PartialName, object ViewModel, ViewDataDictionary ViewData = null)
        {
            try
            {
                var path = GetFinalThemePath(SiteThemeName, PathType.PartialView, PartialName); 
                return html.Partial(path, ViewModel, ViewData);
            }
            catch (Exception ex)
            {
                var msg = string.Format("Error rendering partial view '{0}'", PartialName);
                LogHelper.Error<IHtmlString>(msg, ex);
                return new HtmlString(string.Format("<span class=\"error\">{0}</span>", msg));
            }
        }

        /// <summary>
        /// Renders a partial view in the current theme
        /// </summary>
        /// <param name="html"></param>
        /// <param name="SiteThemeName"></param>
        /// <param name="PartialName"></param>
        /// <param name="ViewData"></param>
        /// <returns></returns>
        public static IHtmlString ThemedPartial(this HtmlHelper html, string SiteThemeName, string PartialName, ViewDataDictionary ViewData = null)
        {
            if (ViewData == null)
            {
                ViewData = html.ViewData;
            }
            try
            {
                var path = GetFinalThemePath(SiteThemeName,  PathType.PartialView ,PartialName);
                return html.Partial(path, ViewData);
            }
            catch (Exception ex)
            {
                var msg = string.Format("Error rendering partial view '{0}'", PartialName);
                LogHelper.Error<IHtmlString>(msg, ex);
                return new HtmlString(string.Format("<span class=\"error\">{0}</span>", msg));
            }

        }
        #endregion
    }

}

ThemePropertyEditorApiController.cs

namespace MySite.Theming
{
    using System.Collections.Generic;
    using System.IO;
    using System.Linq;
    using System.Web.Http;

    using Umbraco.Core.IO;
    using Umbraco.Web.WebApi;

    public class ThemePropertyEditorApiController : UmbracoApiController
    {
        //  /Umbraco/Api/ThemePropertyEditorApi/GetThemes
        [HttpGet]
        public IEnumerable<string> GetThemes()
        {
            var dir = IOHelper.MapPath("~/App_Plugins/Theming/Themes");
            var allDirs =  Directory.GetDirectories(dir).Select(x => new DirectoryInfo(x).Name);
            return allDirs;
        }
    }
  
}

2. App_Plugins Theming

Includes the back-office bits for ThemePropertyEditor and the “Themes” folder where all individual theme sets reside.

Download "Theming" files and folders in a handy ZIP file

Technically, you could also move the “Themes” folder somewhere else in the site tree – as long as you update the path in the ThemeHelper.cs and ThemePropertyEditorApiController.cs files.

3. “Site Theme” Datatype and Property

Using the “ThemePropertyEditor”, create a datatype and add it to your “level 1” Document Type (something like “Site” or “Homepage”) with the property alias “SiteTheme”. This will allow you to select an available Theme folder at the site root for each site.

4. Update the Default Controller to use theming

This default controller code will replace the current template view with the themed version, if available, on page render. Use the “ApplicationStarting” event to set the Default Controller for the entire application.

DefaultController.cs

namespace MySite.Controllers
{
    using System.Web.Mvc;

    using MySite.Theming;

    using Umbraco.Web;
    using Umbraco.Web.Models;

    /// <summary>
    /// The default controller.
    /// </summary>
    /// <remarks>
    /// If route hijacking doesn't find a controller it will use this controller.
    /// </remarks>
    public class DefaultController : Umbraco.Web.Mvc.SurfaceController
    {
        /// <summary>
        /// The default controller method
        /// </summary>
        /// <param name="model">
        /// The model.
        /// </param>
        /// <returns>
        /// The <see cref="ActionResult"/> to render the basic view model
        /// </returns>
        public override ActionResult Index(RenderModel model)
        {
            var currentTemplateName = model.Content.GetTemplateAlias();
            var siteTheme = model.Content.AncestorOrSelf(1).GetPropertyValue<string>("SiteTheme");
            var templatePath = ThemeHelper.GetFinalThemePath(siteTheme, ThemeHelper.PathType.View, currentTemplateName);

            return View(templatePath, model);
        }

    }
}

UmbracoEvents.cs

namespace MySite.Events
{
    using System;
    using Controllers;
    using Examine;
    using Umbraco.Core;
    using Umbraco.Core.Events;
    using Umbraco.Core.Logging;
    using Umbraco.Core.Models;
    using Umbraco.Core.Publishing;
    using Umbraco.Core.Services;
    using Umbraco.Web.Mvc;

    /// <summary>
    /// Registers site specific Umbraco application event handlers
    /// </summary>
    public class UmbracoEvents : ApplicationEventHandler
    {

        protected override void ApplicationStarting(UmbracoApplicationBase umbracoApplication, ApplicationContext applicationContext)
        {
           
            try
            {
                //// By registering this here we can make sure that if route hijacking doesn't find a controller it will use this controller.
                //// That way each page will always be routed through one of our controllers.
                DefaultRenderMvcControllerResolver.Current.SetDefaultControllerType(typeof(DefaultController));
                base.ApplicationStarting(umbracoApplication, applicationContext);

                LogHelper.Info<UmbracoEvents>("Default render mvc controller successfully reassigned");
            }
            catch (Exception ex)
            {
                LogHelper.Error<UmbracoEvents>("Failed to reassign default render mvc controller", ex);
            }

        }

    }
}

5. Base Templates

As you create your Document Types, go ahead and create the Templates along with them in the standard fashion. Even if the related View files are essentially empty, having them present in the “~/Views” folder will prevent YSOD errors. When creating a theme, just create themed View files with the same names, but in your Theme’s “Views” folder.

6. Base Macros

You can create themed versions of macros as well. First, create the Macro via the back-office as usual, and update the code in the MacroPartial file to use the current site’s theme to render the associated theme file.

Example:

General Macro File (~/Views/MacroPartials/ArticlesListing.cshtml)

@using MySite.Theming
@using Umbraco.Core.Logging

@inherits Umbraco.Web.Macros.PartialViewMacroPage

@{
     var siteTheme = model.Content.AncestorOrSelf(1).GetPropertyValue<string>("SiteTheme");
}

@Html.ThemedPartial(siteTheme, "Macro_ArticlesListing")

Then add a customized macro file in your “~/App_Plugins/Theming/Themes/MyTheme/Views/Partials/” folder with the name "Macro_ArticlesListing.cshtml"

7. Base Grid Editors + Updated Grid Rendering file

Similar to the other types of view files, you can create basic grid editor files (located at “~/Views/Partials/Grid/Editors/”), which will be used by default. With a small update to your grid rendering file, you can also create grid widget overrides in an equivalent Theme folder (“~/App_Plugins/Theming/Themes/MyTheme/Views/Partials/Grid/Editors/”).

grid-renderer-example.cshtml

@inherits UmbracoViewPage<dynamic>
@using Umbraco.Web.Templates
@using Newtonsoft.Json.Linq
@using MySite.Theming

@{
	//******* Support Theming of Widgets *******
	var themeName = "";
    var site = Umbraco.TypedContent(Umbraco.AssignedContentItem).AncestorOrSelf(1) ;
    if (site != null)
    {
        themeName = site.GetPropertyValue<string>("SiteTheme");
    }

    var currentThemePath = ThemeHelper.GetThemePath(themeName);

    ViewBag.CurrentThemeName = themeName;
    ViewBag.CurrentThemePath = currentThemePath;
}

@if (Model != null && Model.sections != null)
{
    var oneColumn = ((System.Collections.ICollection)Model.sections).Count == 1;

    <div class="umb-grid">
         @if (oneColumn)
        {
            foreach (var section in Model.sections) {
                <div class="grid-section">
                    @foreach (var row in section.rows) {
                        @renderRow(row, true);
                    }
                </div>
            }
        }else { 
            <div class="container">
                <div class="row clearfix">
                    @foreach (var s in Model.sections) {
                        <div class="grid-section">
                            <div class="@("span" + s.grid) column">
                                @foreach (var row in s.rows) {
                                    @renderRow(row, false);
                                }
                            </div>
                        </div>
                    }
                </div>
            </div>
        }
    </div>
}

@helper renderRow(dynamic row, bool singleColumn){
    <div @RenderElementAttributes(row)>
        @Umbraco.If(singleColumn, "<div class='container'>")
        <div class="row clearfix">
            @foreach ( var area in row.areas ) {
            <div class="@("span" + area.grid) column">
                <div @RenderElementAttributes(area)>
                    @foreach (var control in area.controls) {
                            if (control != null && control.editor != null && control.editor.view != null)
                            {
								//******* Support Theming of Widgets *******
								var finalViewPath = "grid/editors/base";
								var themeName = ViewBag.CurrentThemeName;
								if (themeName != "")
								{
									var viewFileName = "Base";

									finalViewPath = ThemeHelper.GetFinalThemePath(themeName, ThemeHelper.PathType.GridEditor, viewFileName);
								}

								<text>@Html.Partial(finalViewPath, (object)control)</text>

                            }
                        }
                    </div>
            </div>}
        </div>
        @Umbraco.If(singleColumn, "</div>")
    </div>
}

@functions {

    public static MvcHtmlString RenderElementAttributes(dynamic contentItem)
    {
        var attrs = new List<string>();
        JObject cfg = contentItem.config;

        if(cfg != null)
            foreach (JProperty property in cfg.Properties()) {
                attrs.Add(property.Name + "=\"" + property.Value.ToString() + "\"");
        }

        JObject style = contentItem.styles;

        if (style != null) { 
            var cssVals = new List<string>();
            foreach (JProperty property in style.Properties())
                cssVals.Add(property.Name + ":" + property.Value.ToString() + ";");

            if (cssVals.Any())
                attrs.Add("style=\"" + string.Join(" ", cssVals) + "\"");
            }

        return new MvcHtmlString(string.Join(" ", attrs));
    }
}

Adding New Themed Sites to Your Installation

Once you have the basic components in place, you can begin adding themed sites. Create a folder for the new Theme and select it on the root site/home node for the new site, add you page nodes and customize the theme View files as needed.

Some HtmlHelpers which will assist with your theme development:



@{ var thisTheme = Model.Content.AncestorOrSelf(1).GetPropertyValue<string>("SiteTheme"); }

<!-- Include a specific file-->
<script type="text/javascript" src="@Url.ThemedAsset(thisTheme,"js/site.min.js")"></script>
<link rel="stylesheet" type="text/css" href="@Url.ThemedAsset(thisTheme, "css/main.min.css")" />

<!-- Include all files in theme's "js" folder -->
@Html.RequiresThemedJsFolder(thisTheme)

<!-- Include all files in theme's "css" folder -->
@Html.RequiresThemedCssFolder(thisTheme)

<!-- Example of image insertion -->
<img class="logo" src="@Url.ThemedAsset(thisTheme, "img/logo.png")" alt="..." />

<!-- Insert a themed partial (automatically falls back to the default Views/Partials/ folder if not found)-->
@Html.ThemedPartial(thisTheme, "Nav_Main", Model)

<!-- Insert a Cached themed partial -->
@Html.CachedPartial(ThemeHelper.GetThemePartialViewPath(thisTheme, "Footer"), Model, 10, true, false)

For one-off design elements which don’t neatly fit your existing grid widgets or macros, you can use a “Code Snippet” widget to pass in straight HTML/JS or the path to a very customized partial view for rendering inside your grid. A bit of coding creativity will ensure all your needs are met.

You’ll find that individual site development can go pretty quickly once you have the basic Document Types and theming setup in your application. You can spend your time working on more elaborate front-end designs and interactions, or just putting together solid content.


Heather Floyd is a Senior Umbraco Developer at Scandia where she enjoys creating architecturally elegant Umbraco websites. She blogs occasionally at HeatherFloyd.com.

Scandia is a boutique web and app development agency that specializes in Umbraco for financial industries. Learn more at MyScandia.com.

Heather Floyd

Heather is on Twitter as