MVC Portable Area Modules *Without* MasterPages

Portable Areas from MvcContrib provide a great way to build modular and composite applications on top of MVC. In short, portable areas provide a way to distribute MVC binary components as simple .NET assemblies where the aspx/ascx files are actually compiled into the assembly as embedded resources. I've blogged about Portable Areas in the past including this post here which talks about embedding resources and you can read more of an intro to Portable Areas here.

As great as Portable Areas are, the question that seems to come up the most is: what about MasterPages? MasterPages seems to be the one thing that doesn't work elegantly with portable areas because you specify the MasterPage in the @Page directive and it won't use the same mechanism of the view engine so you can't just embed them as resources. This means that you end up referencing a MasterPage that exists in the host application but not in your portable area. If you name the ContentPlaceHolderId's correctly, it will work – but it all seems a little fragile.

Ultimately, what I want is to be able to build a portable area as a module which has no knowledge of the host application. I want to be able to invoke the module by a full route on the user's browser and it gets invoked and "automatically appears" inside the application's visual chrome just like a MasterPage. So how could we accomplish this with portable areas? With this question in mind, I looked around at what other people are doing to address similar problems. Specifically, I immediately looked at how the Orchard team is handling this and I found it very compelling. Basically Orchard has its own custom layout/theme framework (utilizing a custom view engine) that allows you to build your module without any regard to the host. You simply decorate your controller with the [Themed] attribute and it will render with the outer chrome around it:

[Themed]
public class HomeController : Controller

Here is the slide from the Orchard talk at this year MIX conference which shows how it conceptually works:

It's pretty cool stuff.  So I figure, it must not be too difficult to incorporate this into the portable areas view engine as an optional piece of functionality. In fact, I'll even simplify it a little – rather than have 1) Document.aspx, 2) Layout.ascx, and 3) .ascx (as shown in the picture above); I'll just have the outer page be "Chrome.aspx" and then the specific view in question. The Chrome.aspx not only takes the place of the MasterPage, but now since we're no longer constrained by the MasterPage infrastructure, we have the choice of the Chrome.aspx living in the host or inside the portable areas as another embedded resource!

Disclaimer: credit where credit is due – much of the code from this post is me re-purposing the Orchard code to suit my needs.

To avoid confusion with Orchard, I'm going to refer to my implementation (which will be based on theirs) as a Chrome rather than a Theme. The first step I'll take is to create a ChromedAttribute which adds a flag to the current HttpContext to indicate that the controller designated Chromed like this:

[Chromed]
public class HomeController : Controller

The attribute itself is an MVC ActionFilter attribute:

public class ChromedAttribute : ActionFilterAttribute
{
    public override void OnActionExecuting(ActionExecutingContext filterContext)
    {
        var chromedAttribute = GetChromedAttribute(filterContext.ActionDescriptor);
        if (chromedAttribute != null)
        {
            filterContext.HttpContext.Items[typeof(ChromedAttribute)] = null;
        }
    }
 
    public static bool IsApplied(RequestContext context)
    {
        return context.HttpContext.Items.Contains(typeof(ChromedAttribute));
    }
 
    private static ChromedAttribute GetChromedAttribute(ActionDescriptor descriptor)
    {
        return descriptor.GetCustomAttributes(typeof(ChromedAttribute), true)
            .Concat(descriptor.ControllerDescriptor.GetCustomAttributes(typeof(ChromedAttribute), true))
            .OfType<ChromedAttribute>()
            .FirstOrDefault();
    }
}

With that in place, we only have to override the FindView() method of the custom view engine with these 6 lines of code:

public override ViewEngineResult FindView(ControllerContext controllerContext, string viewName, string masterName, bool useCache)
{
    if (ChromedAttribute.IsApplied(controllerContext.RequestContext))
    {
        var bodyView = ViewEngines.Engines.FindPartialView(controllerContext, viewName);
        var documentView = ViewEngines.Engines.FindPartialView(controllerContext, "Chrome");
        var chromeView = new ChromeView(bodyView, documentView);
        return new ViewEngineResult(chromeView, this);
    }
 
    // Just execute normally without applying Chromed View Engine
    return base.FindView(controllerContext, viewName, masterName, useCache);
}

If the view engine finds the [Chromed] attribute, it will invoke it's own process – otherwise, it'll just defer to the normal web forms view engine (with masterpages). The ChromeView's primary job is to independently set the BodyContent on the view context so that it can be rendered at the appropriate place:

public class ChromeView : IView
{
    private ViewEngineResult bodyView;
    private ViewEngineResult documentView;
 
    public ChromeView(ViewEngineResult bodyView, ViewEngineResult documentView)
    {
        this.bodyView = bodyView;
        this.documentView = documentView;
    }
 
    public void Render(ViewContext viewContext, System.IO.TextWriter writer)
    {
        ChromeViewContext chromeViewContext = ChromeViewContext.From(viewContext);
 
        // First render the Body view to the BodyContent
        using (var bodyViewWriter = new StringWriter())
        {
            var bodyViewContext = new ViewContext(viewContext, bodyView.View, viewContext.ViewData, viewContext.TempData, bodyViewWriter);
            this.bodyView.View.Render(bodyViewContext, bodyViewWriter);
            chromeViewContext.BodyContent = bodyViewWriter.ToString();
        }
        // Now render the Document view
        this.documentView.View.Render(viewContext, writer);
    }
}

The ChromeViewContext (code excluded here) mainly just has a string property for the "BodyContent" – but it also makes sure to put itself in the HttpContext so it's available. Finally, we created a little extension method so the module's view can be rendered in the appropriate place:

public static void RenderBody(this HtmlHelper htmlHelper)
{
    ChromeViewContext chromeViewContext = ChromeViewContext.From(htmlHelper.ViewContext);
    htmlHelper.ViewContext.Writer.Write(chromeViewContext.BodyContent);
}

At this point, the other thing left is to decide how we want to implement the Chrome.aspx page. One approach is the copy/paste the HTML from the typical Site.Master and change the main content placeholder to use the HTML helper above – this way, there are no MasterPages anywhere. Alternatively, we could even have Chrome.aspx utilize the MasterPage if we wanted (e.g., in the case where some pages are Chromed and some pages want to use traditional MasterPage):

<%@ Page Title="" Language="C#" MasterPageFile="~/Views/Shared/Site.Master" Inherits="System.Web.Mvc.ViewPage" %>
<asp:Content ID="Content2" ContentPlaceHolderID="MainContent" runat="server">
    <% Html.RenderBody(); %>
</asp:Content>

At this point, it's all academic. I can create a controller like this:

[Chromed]
public class WidgetController : Controller
{
    public ActionResult Index()
    {
        return View();
    }
}

Then I'll just create Index.ascx (a partial view) and put in the text "Inside my widget". Now when I run the app, I can request the full route (notice the controller name of "widget" in the address bar below) and the HTML from my Index.ascx will just appear where it is supposed to.

This means no more warnings for missing MasterPages and no more need for your module to have knowledge of the host's MasterPage placeholders. You have the option of using the Chrome.aspx in the host or providing your own while embedding it as an embedded resource itself.

I'm curious to know what people think of this approach. The code above was done with my own local copy of MvcContrib so it's not currently something you can download. At this point, these are just my initial thoughts – just incorporating some ideas for Orchard into non-Orchard apps to enable building modular/composite apps more easily. Additionally, on the flip side, I still believe that Portable Areas have potential as the module packaging story for Orchard itself.

What do you think?