Configuring Razor view support for Episerver modules

I have not created an Episerver module before. When I started on one last week, I found that there are no examples with Razor views. After looking and trying to create a module, concluded that it even doesn't support Razor views by default. But I figured out one way which works.

At first, I have created one view for my controller, layout and required configuration for Razor views as in usual ASP.NET MVC application. Such structure looks like in the image below.

Episerver module structure in the Visual Studio's Solution Explorer

And here is the controller I used.

public class CustomerController : Controller
{
    public ActionResult Index()
    {
        return View();
    }
}

In the beginning, I could not understand why it can't resolve the view. I looked into the configured view locations and found that Episerver looks for the view in the correct module but by virtual path.

/cms/Customer/Views/Customer/

I missed that the paths supported only .aspx and .ascx files. After I had noticed it, I tried to set the view path explicitly in the controller.

return View("~/cms/Customer/Views/Customer/Index.cshtml");

Now I got a different exception which said that my view doesn't inherit from WebViewPage or WebViewPage<TModel>.

Then I tried to use an explicit path to the module, and it did work.

return View("~/modules/_protected/Customer/Views/Customer/Index.cshtml");

While it works, I didn't want to write "hardcoded" paths in the controller.

Solution

The solution is registering additional view template paths for Episerver shell modules. Episerver has ShellWebFormViewEngine which does view resolving. So we have to create one for Razor views. It should inherit from RazorViewEngine instead of WebFormViewEngine. The rest of the code is same as for the ShellWebFormViewEngine.

public class ShellRazorViewEngine : RazorViewEngine
{
    private readonly ConcurrentDictionary<string, bool> _cache = new ConcurrentDictionary<string, bool>();

    public ShellRazorViewEngine()
    {
        ViewLocationCache = new DefaultViewLocationCache();
    }

    protected override bool FileExists(
        ControllerContext controllerContext, string virtualPath)
    {
        if (controllerContext.HttpContext != null 
            && !controllerContext.HttpContext.IsDebuggingEnabled)
            return _cache.GetOrAdd(
                virtualPath, 
                p => HostingEnvironment.VirtualPathProvider.FileExists(virtualPath));
        return HostingEnvironment.VirtualPathProvider.FileExists(virtualPath);
    }
}

The view engine doesn't register paths for modules. A module initializer does it. There is a particular module initializer for Web Forms, so we have to create one also for Razor. The module initializer looks for a registered module view engine collection in the MVC view engine collection. As there could be only one type of such module engine collection registered, we have to create another one by inheriting from the original.

public class RazorModuleViewEngineCollection : ModuleViewEngineCollection
{
}

Now, we are ready to implement our module initializer.

public class RazorModuleInitializer
{
    private readonly ViewEngineCollection _viewEngines;

    public RazorModuleInitializer(ViewEngineCollection viewEngines)
    {
        _viewEngines = viewEngines;
    }

    public void RegisterModules(IEnumerable<ShellModule> modules)
    {
        var aggregatingViewEngine = GetOrCreateAggregatingViewEngine();
        foreach (var module in modules)
        {
            var viewEngine = GetViewEngine(module);
            aggregatingViewEngine.Add(module.Name, viewEngine);
        }
    }

    private ModuleViewEngineCollection GetOrCreateAggregatingViewEngine()
    {
        var engineCollection = _viewEngines
            .OfType<RazorModuleViewEngineCollection>()
            .FirstOrDefault();
        if (engineCollection != null) return engineCollection;

        var moduleEngineCollection = 
            _viewEngines.OfType<ModuleViewEngineCollection>().First();
        var index =
            _viewEngines.IndexOf(moduleEngineCollection);
        engineCollection = new RazorModuleViewEngineCollection();
        _viewEngines.Insert(index + 1, engineCollection);
        return engineCollection;
    }

    private static IViewEngine GetViewEngine(ShellModule module)
    {
        var webFormViewEngine = new ShellRazorViewEngine
        {
            MasterLocationFormats = new[]
            {
                $"~/modules/_protected/{module.Name}/Views/{{1}}/{{0}}.cshtml",
                $"~/modules/_protected/{module.Name}/Views/Shared/{{0}}.cshtml",
            },
            ViewLocationFormats = new[]
            {
                $"~/modules/_protected/{module.Name}/Views/{{1}}/{{0}}.cshtml",
                $"~/modules/_protected/{module.Name}/Views/Shared/{{0}}.cshtml"
            }
        };
        webFormViewEngine.PartialViewLocationFormats = 
            webFormViewEngine.ViewLocationFormats;
        webFormViewEngine.ViewLocationCache =
            new ModulesViewLocationCache(module.Name);
        return webFormViewEngine;
    }
}

At first, this module initializer tries to get our Razor-specific module view engine collection from the MVC registered view engines. If it is not yet registered, it creates new one and registers next to the WebForms module view engine collection. Once it retrieves the module view engine collection, it registers view engine for each module. Here we use our ShellRazorViewEngine and set it's view location paths.

The last step is initialization. We should create an initialization module which has a dependency on ShellInitialization that we should be sure that the main module initialization already happened. Then retrieve all modules and register Razor view locations for those using RazorModuleInitializer.

[InitializableModule]
[ModuleDependency(typeof(ShellInitialization))]
public class ShellRazorSupportInitialization : IInitializableModule
{
    private IServiceLocator _locator;

    public void Initialize(InitializationEngine context)
    {
        _locator = context.Locate.Advanced;
        var list = GetConfiguredModules(_locator).ToList();
        var initializer = _locator.GetInstance<RazorModuleInitializer>();
        initializer.RegisterModules(list);
    }

    public void Uninitialize(InitializationEngine context)
    {
    }

    private static IEnumerable<ShellModule> GetConfiguredModules(
        IServiceLocator locator)
    {
        return ShellModule
            .MergeDuplicateModules(
                locator.GetAllInstances<IModuleProvider>()
                .SelectMany(p => p.GetModules()));
    }
}

Now we are ready to implement our Episerver modules with Razor views.