Themed Based Views - Using a view engine in Umbraco

In our website applications at TRES, we have an option for MultiSite. This means that you can have multiple sites in one Umbraco CMS with a different look and feel. 

But we don't want to have duplicate content types or datatypes. We develop our applications in a way that we reuse a lot of element types for editing experience.

But on some occasions, we need to override the cshtml in such a way that it's completely different from the original. 

You can do this within the view but Asp.NET MVC also has an option for view engines.

What are view engines?

A view engine translates a server-side template into HTML markup and renders it in the web browser when triggered by a controller’s action method.

Umbraco and its views.

So in Umbraco, you only have a default one views folder. This contains the views you define using Umbraco or from Visual Studio. That is easy for single sites. For multiple sites, you could create multiple views or different ones for every multi-site.

but we like to reuse a lot of elements. And only change the HTML output.

In Umbraco 8 we already introduced the ViewEngine for our applications but in Umbraco 10+ we also wanted this awesome option so we can do minor changes in views where needed.

So on 24Days, I found this article.
https://24days.in/umbraco-cms/2022/one-project-to-rule-them-all/


And that got me triggered again. But here everything is done using app settings and not using Umbraco Nodes as a source.

So using this implementation and some minor tweaks we can create a viewengine. The original article was missing some logic but here I will show you the minimum code for our ViewEngine.

 

The code explained

First, we need some basic models to bind our configuration onto. 

We want to have a mapping of multiple collection support for a view folder bound to a node in Umbraco.

We call these themes in my example

 

public class ViewEngineConfiguration
{
    public const string ConfigurationName = "ViewEngine";
    public ViewEngine ViewEngine { get; set; } = new ViewEngine();
}

public class ViewEngine
{
    public Theme[] Themes { get; set; } = Array.Empty<Theme>();
}

public class Theme
{
    public long RootNodeId { get; set; }

    public string FolderName { get; set; } = string.Empty;   
}

Now we are gonna implement a VIewEngine Options Setup. This is responsible for configuring options for the Razor view engine.

Within this options class, we have the inner class that implements IViewLocationExpander, which is used by the Razor view engine to locate views.

With the ExpandViewLocations method we can find the correct views, and with the populateValues method, we are gonna retrieve the current Umbraco Context for getting the current Node ID. This we are gonna store in its values so we can use it in the ExpandViewLocations method.

public class ThemedViewEngineOptionsSetup(ViewEngineConfiguration viewEngineConfiguration)
    : IConfigureOptions<RazorViewEngineOptions>
{
    public void Configure(RazorViewEngineOptions options)
    {
        if (options == null)
        {
            throw new ArgumentNullException(nameof(options));
        }

        options.ViewLocationExpanders.Add(new ThemedViewLocationExpander(viewEngineConfiguration));

    }

    private class ThemedViewLocationExpander(ViewEngineConfiguration viewEngineConfiguration)
        : IViewLocationExpander
    {
        private const string KeyName = "themeRootId";

        public IEnumerable<string> ExpandViewLocations(ViewLocationExpanderContext context,
            IEnumerable<string> viewLocations)
        {
            // list of additional view locations to search when resolving a view


            if (!context.Values.TryGetValue(KeyName, out var value)) return viewLocations;
            
            if (value == null) return viewLocations;
            
            var theme = FindTheme(value);
            if (theme == null) return viewLocations;
            
            var folderName = theme.FolderName;
            var themedLocations = new string[]
            {
                "/Views/Themes/" + folderName + "/{0}.cshtml",
                "/Views/Themes/" + folderName + "/Shared/{0}.cshtml",
                "/Views/Themes/" + folderName + "/Partials/{0}.cshtml",
                "/Views/Themes/" + folderName + "/MacroPartials/{0}.cshtml",
            };
            viewLocations = themedLocations.Concat(viewLocations);

            return viewLocations;
        }

        public void PopulateValues(ViewLocationExpanderContext context)
        {
            var ctx =
                context.ActionContext.HttpContext.RequestServices.GetRequiredService<IUmbracoContextAccessor>();
            if (ctx.TryGetUmbracoContext(out IUmbracoContext? umbracoContext))
            {
                var currentContent = umbracoContext?.PublishedRequest?.PublishedContent;
                if (currentContent != null)
                {
                    context.Values[KeyName] = currentContent.Root().Id.ToString();
                }
            }
        }

        private Theme? FindTheme(string themeName)
        {
            return viewEngineConfiguration?.ViewEngine?.Themes?.FirstOrDefault(x => x.RootNodeId.ToString() == themeName);
        }
    }
}

Next, we add a class called ThemedViewEngine class which is a custom implementation of the IRazorViewEngine interface.

We are using a standard Razor view engine which is used to perform the actual view-finding and rendering tasks. Only with our implementation, we are changing the order to use our view engine first. 

For finding the views we are calling the original viewEngine functions. So it's only used for ordering the view engine in our case.

public class ThemedViewEngine : IRazorViewEngine
    {
        private readonly RazorViewEngine _razorViewEngine;

        public ThemedViewEngine
        (IRazorPageFactoryProvider pageFactory,
            IRazorPageActivator pageActivator,
            HtmlEncoder htmlEncoder,
            IOptions<RazorViewEngineOptions> optionsAccessor,
            ILoggerFactory loggerFactory,
            DiagnosticListener diagnosticListener)
        {
            var razorViewEngineOptions = optionsAccessor.Value;

            // Our view location expanders will be the first item, this needs to be at the end to override the searched locations.
            var ownViewLocationExpander = razorViewEngineOptions.ViewLocationExpanders[0];
            razorViewEngineOptions.ViewLocationExpanders.RemoveAt(0);
            razorViewEngineOptions.ViewLocationExpanders.Add(ownViewLocationExpander);

            _razorViewEngine = new RazorViewEngine(pageFactory, pageActivator, htmlEncoder, optionsAccessor, loggerFactory, diagnosticListener);
        }

        public ViewEngineResult FindView(ActionContext context, string viewName, bool isMainPage)
        {
            return _razorViewEngine.FindView(context, viewName, isMainPage);
        }

        public ViewEngineResult GetView(string? executingFilePath, string viewPath, bool isMainPage)
        {
            return _razorViewEngine.GetView(executingFilePath, viewPath, isMainPage);
        }

        public RazorPageResult FindPage(ActionContext context, string pageName)
        {
            return _razorViewEngine.FindPage(context, pageName);
        }

        public RazorPageResult GetPage(string executingFilePath, string pagePath)
        {
            return _razorViewEngine.GetPage(executingFilePath, pagePath);
        }

        public string? GetAbsolutePath(string? executingFilePath, string? pagePath)
        {
            return _razorViewEngine.GetAbsolutePath(executingFilePath, pagePath);
        }
    }

We need to configure it for the startup so we added an extension method that extends theIUmbracoBuilder for configuring our view engine to be used. 

 

public static class ThemedUmbracoBuilderExtensions
{
    public static IUmbracoBuilder AddCustomViewLocations(this IUmbracoBuilder builder)
    {
        var viewEngineConfiguration = builder.Config.Get<ViewEngineConfiguration>();
        if (viewEngineConfiguration == null) return builder;

        var themeConfig = new ThemedViewEngineOptionsSetup(viewEngineConfiguration);
        builder.Services.ConfigureOptions(themeConfig);
        builder.Services.AddSingleton<IRazorViewEngine, ViewEngine.ThemedViewEngine>();

        return builder;
    }
}

And now in our last step, we configure the Theme engine to be added to our Umbraco application in the program.cs

so the code will be invoked. 

builder.CreateUmbracoBuilder()
    .AddCustomViewLocations()
    .AddBackOffice()
    .AddWebsite()
    .AddDeliveryApi()
    .AddComposers()
    .Build();

And now we can configure in our AppSettings to use a different view folder for a specific root node of Umbraco.

 

  "ViewEngine": {
    "Themes": [
      {
        "RootNodeId": 1392,
        "FolderName": "Theme1"
      }
    ]
  }

And so if we now have a view that needs to be specific for one site within Umbraco we can override the view. And so we can reduce logic in our views for different situations and have nice clean CShtml files within our projects.

As a last note:

I'm using code from a fellow Umbraco Developer. I read a lot of articles and love some ideas from them. My tip for this is to try if you can reuse their ideas within your projects or default solutions. Why build everything from scratch just reuse other logic or find an article you can use for inspiration. 

And don't forget, always read https://24days.in/umbraco-cms/ and https://skrift.io/ for really good blog post regarding Umbraco and sign up for https://umb.fyi/ to get more blog posts from other Umbraco developers.