Multi-targeting Razor Views in Umbraco v8 and v9

4 min read

As we at Vendr recently released our multi-targeted Release Candidate for Vendr v2 we’ve started to look at getting ready some of our add-on packages that help complement the core product.

One of the most used add-ons is our Vendr.Checkout package which provides an instant, no-code checkout flow solution, so this was one of the first we wanted to tackle.

The Challenge

The interesting challenge with Vendr.Checkout is that it, for the most part, is a front-end extension. On install it creates a series of Umbraco nodes and then provides pre-implemented templates for them containing the relevant checkout flow logic.

The real challenge here is given the core Vendr product is multi-targeted, could we also multi-target the Vendr.Checkout package so that we wouldn’t need to manage 2 code bases for the different frameworks.

We knew back-end code was manageable using techniques we had already used in Vendr, but the biggest difficulty was the front-end and the big differences between Razor in .NET Framework and .NET Core.

The Solution

Whilst looking through the Razor templates in Vendr.Checkout, we noticed that whilst there are a lot of changes in Razor for .NET Core, we didn’t actually make use of a lot of them. All our views inherited from UmbracoViewPage which was present in v8 and v9 and most of our other logic worked with the Vendr API’s.

Thanks to this, it largely came down to namespace changes and to occasional small API differences.

Handling API Differences

Where there were API changes, the approach we took was to try to introduce extension methods to standardize the APIs. Ideally we would try and reproduce the .NET Core API in .NET Framework

internal static class HttpContextExtensions
{
#if NETFRAMEWORK
    public static string GetServerVariable(this HttpContextBase ctx, string variableName)
        => ctx.Request.ServerVariables[variableName];
#endif
}

But where this wasn’t possible, we’d introduce a new API to wrap both implementations.

#if NETFRAMEWORK
using Umbraco.Core.Composing;
using RazorPage = System.Web.Mvc.WebViewPage;
#else
using Microsoft.AspNetCore.Mvc.Razor;
#endif

public static class RazorPageExtensions
{
    public static TService GetService<TService>(this RazorPage view)
    {
#if NETFRAMEWORK
        return (TService)Current.Factory.GetInstance(typeof(TService));
#else
        return (TService)view.Context.RequestServices.GetService(typeof(TService));
#endif
    }
    }
}

Additionally, there were a few minor changes to Umbraco’s API that would require us to potentially render something in one Umbraco version that we didn’t need to in the other so we also introduced a simple IsUmbraco8 check that we could easily wrap in an if() statement.

public static class RazorPageExtensions
{
    public static bool IsUmbraco8(this RazorPage view)
    {
#if NETFRAMEWORK
        return true;
#else
        return false;
#endif
    }
}

Handling Namespace Changes

The harder problem to solve though was that of namespace changes.

For this I took inspiration from how we handled it in the back-end C# files where in, we would import the different namespaces for each framework depending on which framework was being used and just rely on the fact they shared the same name in both versions so it would just magically work.

But how do you do this in Razor views? The answer is web.config and _ViewImport.cshtml files.

The key thing here is that both of these can be used to import namespaces into a global context such that in your views you don’t need to explicitly import those namespaces. They are just magically available. The beauty here is that namespaces imported in the web.config will only be picked up by .NET Framework where as namespaces in the _ViewImport.cshtml file will only get picked up by .NET Core. So we can ship both files along with our view files and tada, framework conditional namespace imports in views.

So how would this look? Well for Vendr.Checkout we ultimately ended up with the following files located in our custom views folder.

web.config

<?xml version="1.0" encoding="utf-8"?>
<configuration>
    <configSections>
        <sectionGroup name="system.web.webPages.razor" type="System.Web.WebPages.Razor.Configuration.RazorWebSectionGroup, System.Web.WebPages.Razor, Version=3.0.0.0, Culture=neutral, PublicKeyToken=31BF3856AD364E35">
            <section name="host" type="System.Web.WebPages.Razor.Configuration.HostSection, System.Web.WebPages.Razor, Version=3.0.0.0, Culture=neutral, PublicKeyToken=31BF3856AD364E35" requirePermission="false" />
            <section name="pages" type="System.Web.WebPages.Razor.Configuration.RazorPagesSection, System.Web.WebPages.Razor, Version=3.0.0.0, Culture=neutral, PublicKeyToken=31BF3856AD364E35" requirePermission="false" />
        </sectionGroup>
    </configSections>
    <system.web.webPages.razor>
        <host factoryType="System.Web.Mvc.MvcWebRazorHostFactory, System.Web.Mvc, Version=5.2.7.0, Culture=neutral, PublicKeyToken=31BF3856AD364E35" />
        <pages pageBaseType="System.Web.Mvc.WebViewPage">
            <namespaces>
                <add namespace="System.Web.Mvc" />
                <add namespace="System.Web.Mvc.Ajax" />
                <add namespace="System.Web.Mvc.Html" />
                <add namespace="System.Web.Routing" />
                <add namespace="Umbraco.Web" />
                <add namespace="Umbraco.Core" />
                <add namespace="Umbraco.Core.Models" />
                <add namespace="Umbraco.Core.Models.PublishedContent" />
                <add namespace="Umbraco.Web.Mvc" />
                <add namespace="Umbraco.Web.Models" />
                <add namespace="Umbraco.Web.PublishedCache" />
                <add namespace="Examine" />
                <add namespace="Umbraco.Web.PublishedModels" />
                <add namespace="Vendr.Core" />
                <add namespace="Vendr.Core.Api" />
                <add namespace="Vendr.Core.Models" />
                <add namespace="Vendr.Web" />
                <add namespace="Vendr.Web.ViewEngines" />
                <add namespace="Vendr.Extensions" />
                <add namespace="Vendr.Checkout" />
                <add namespace="Vendr.Checkout.Web" />
            </namespaces>
        </pages>
    </system.web.webPages.razor>
    <appSettings>
        <add key="webpages:Enabled" value="false" />
    </appSettings>
    <system.webServer>
        <handlers>
            <remove name="BlockViewHandler" />
            <add name="BlockViewHandler" path="*" verb="*" preCondition="integratedMode" type="System.Web.HttpNotFoundHandler" />
        </handlers>
    </system.webServer>
    <system.web>
        <compilation targetFramework="4.7.2">
            <assemblies>
                <add assembly="System.Web.Mvc, Version=5.2.7.0, Culture=neutral, PublicKeyToken=31BF3856AD364E35" />
                <add assembly="System.Runtime, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a" />
                <add assembly="netstandard, Version=2.0.0.0, Culture=neutral, PublicKeyToken=cc7b13ffcd2ddd51" />
            </assemblies>
        </compilation>
    </system.web>
</configuration>

The important bit here is the namespaces being imported in the configuration > system.web.webPages.razor > pages > namespaces section.

_ViewImports.cshtml

@using Vendr.Checkout;
@using Vendr.Checkout.Web;

@using Vendr.Core;
@using Vendr.Core.Api;
@using Vendr.Core.Models;
@using Vendr.Web;
@using Vendr.Web.ViewEngines;
@using Vendr.Extensions;

@using Umbraco.Cms.Core.PublishedCache;
@using Umbraco.Cms.Core.Models;
@using Umbraco.Cms.Core.Models.PublishedContent;
@using Umbraco.Cms.Core.Web;
@using Umbraco.Cms.Web.Common.Views;
@using Umbraco.Extensions;

For .NET Core it’s much cleaner and the _ViewImports.cshtml file simply contains the list of @using statements which should be applied to all your views.

Summary

I wasn’t really sure when I started the multi-targeted approach for Vendr.Checkout whether it would actually work as I had never seen anyone else attempting to share Razor views between the 2 frameworks so I didn’t really know if it was possible.

I’m not sure how reliable this will be as a solution for everyone as it will ultimately depend on what features of Razor you actually need, but if your needs are relatively simple like ours, this approach might just help.