I ended up solving the problem by modifying Phil Haack's AreaRouteHelper extensions somewhat:
public static class AreaRouteHelper
{
public static void MapAreas(this RouteCollection routes, string url, string rootNamespace, string[] areas)
{
Array.ForEach(areas, area =>
{
Route route = new Route("{area}/" + url, new MvcRouteHandler());
route.Constraints = new RouteValueDictionary(new { area });
string areaNamespace = rootNamespace + ".Areas." + area + ".Controllers";
route.DataTokens = new RouteValueDictionary(new { namespaces = new string[] { areaNamespace } });
route.Defaults = new RouteValueDictionary(new { action = "Index", controller = "Landing", id = "" });
routes.Add(route);
});
}
public static void MapRootArea(this RouteCollection routes, string url, string rootNamespace, object defaults)
{
Route route = new Route(url, new MvcRouteHandler());
route.DataTokens = new RouteValueDictionary(new { namespaces = new string[] { rootNamespace + ".Controllers" } });
route.Defaults = new RouteValueDictionary(new { area = "", action = "Index", controller = "Lobby", id = "" });
routes.Add(route);
}
}
(basically I had to remove the word "root" from the default RootArea default and change the defaults to 'Lobby' and 'Landing' which are our standards rather than Home. When I left his default "root" in there, it added "root" to the folder structure which did not work)
Then I removed the second two routes from my RegisterRoutes and replaced with:
routes.MapAreas("{controller}/{action}/{id}", "WebUI", new[] { "Admin", "Analysis" });
routes.MapRootArea("{controller}/{action}/{id}",
"WebUI",
new { controller = "Lobby", action = "Index", id = "" });
This fix requires an area-aware view engine. The one in Haack's project is a 1.0 webform view engine, I already had an area-aware razor view engine I'd scraped from another post somewhere and this one worked. I honestly don't remember what post I found it in or I'd link directly to it to credit the author. But this is what it looks like (if anyone here knows who wrote it I'll happily attribute it to that person). Please note that the mappings here match my project and probably will not match anyone else's without some adjustment!
public class RazorAreaAwareViewEngine :RazorViewEngine
{
private static readonly string[] EmptyLocations = { };
public RazorAreaAwareViewEngine()
{
MasterLocationFormats = new string[]
{
"~/Views/{1}/{0}.master",
"~/Views/Shared/{0}.master"
};
ViewLocationFormats = new string[]
{
"~/Areas/{2}/Views/{1}/{0}.cshtml",
"~/Areas/{2}/Views/Shared/{0}.cshtml",
"~/{2}/{0}.cshtml",
"~/Views/{1}/{0}.cshtml",
"~/Views/Shared/{0}.cshtml",
};
PartialViewLocationFormats = ViewLocationFormats;
}
public override ViewEngineResult FindView(ControllerContext controllerContext, string viewName,string masterName, bool useCache)
{
if (controllerContext == null)
{
throw new ArgumentNullException("controllerContext");
}
if (string.IsNullOrEmpty(viewName))
{
throw new ArgumentNullException(viewName,
"Value cannot be null or empty.");
}
string area = GetArea(controllerContext);
return FindAreaView(controllerContext, area, viewName,
masterName, useCache);
}
public override ViewEngineResult FindPartialView(ControllerContext controllerContext, string partialViewName,bool useCache)
{
if (controllerContext == null)
{
throw new ArgumentNullException("controllerContext");
}
if (string.IsNullOrEmpty(partialViewName))
{
throw new ArgumentNullException(partialViewName,
"Value cannot be null or empty.");
}
string area = GetArea(controllerContext);
return FindAreaPartialView(controllerContext, area,
partialViewName, useCache);
}
protected string GetArea(ControllerContext controllerContext)
{
object area = null;
if (controllerContext.RouteData.DataTokens.ContainsKey("area"))
{
area = controllerContext.RouteData.DataTokens["area"];
}
if (area == null)
{
controllerContext.RouteData.Values.TryGetValue("area", out area);
}
if(area !=null)
{
return area.ToString();
}
return null;
}
protected virtual ViewEngineResult FindAreaView(ControllerContext controllerContext, string areaName, string viewName,string masterName, bool useCache)
{
string controllerName =
controllerContext.RouteData.GetRequiredString("controller");
string[] searchedViewPaths;
string viewPath = GetPath(controllerContext, ViewLocationFormats,
"ViewLocationFormats", viewName, controllerName, areaName, "View",
useCache, out searchedViewPaths);
string[] searchedMasterPaths;
string masterPath = GetPath(controllerContext, MasterLocationFormats,
"MasterLocationFormats", masterName, controllerName, areaName,
"Master", useCache, out searchedMasterPaths);
if (!string.IsNullOrEmpty(viewPath) &&
(!string.IsNullOrEmpty(masterPath) ||
string.IsNullOrEmpty(masterName)))
{
return new ViewEngineResult(CreateView(controllerContext, viewPath,
masterPath), this);
}
return new ViewEngineResult(
searchedViewPaths.Union<string>(searchedMasterPaths));
}
protected virtual ViewEngineResult FindAreaPartialView(ControllerContext controllerContext, string areaName,string viewName, bool useCache)
{
string controllerName =
controllerContext.RouteData.GetRequiredString("controller");
string[] searchedViewPaths;
string partialViewPath = GetPath(controllerContext,
ViewLocationFormats, "PartialViewLocationFormats", viewName,
controllerName, areaName, "Partial", useCache,
out searchedViewPaths);
if (!string.IsNullOrEmpty(partialViewPath))
{
return new ViewEngineResult(CreatePartialView(controllerContext,
partialViewPath), this);
}
return new ViewEngineResult(searchedViewPaths);
}
protected string CreateCacheKey(string prefix, string name,
string controller, string area)
{
return string.Format(CultureInfo.InvariantCulture,
":ViewCacheEntry:{0}:{1}:{2}:{3}:{4}:",
base.GetType().AssemblyQualifiedName,
prefix, name, controller, area);
}
protected string GetPath(ControllerContext controllerContext,string[] locations, string locationsPropertyName, string name,
string controllerName, string areaName, string cacheKeyPrefix,bool useCache, out string[] searchedLocations)
{
searchedLocations = EmptyLocations;
if (string.IsNullOrEmpty(name))
{
return string.Empty;
}
if ((locations == null) || (locations.Length == 0))
{
throw new InvalidOperationException(string.Format("The property " +
"'{0}' cannot be null or empty.", locationsPropertyName));
}
bool isSpecificPath = IsSpecificPath(name);
string key = CreateCacheKey(cacheKeyPrefix, name,
isSpecificPath ? string.Empty : controllerName,
isSpecificPath ? string.Empty : areaName);
if (useCache)
{
string viewLocation = ViewLocationCache.GetViewLocation(
controllerContext.HttpContext, key);
if (viewLocation != null)
{
return viewLocation;
}
}
if (!isSpecificPath)
{
return GetPathFromGeneralName(controllerContext, locations, name,
controllerName, areaName, key, ref searchedLocations);
}
return GetPathFromSpecificName(controllerContext, name, key,
ref searchedLocations);
}
protected string GetPathFromGeneralName(ControllerContext controllerContext,string[] locations, string name, string controllerName,
string areaName, string cacheKey, ref string[] searchedLocations)
{
string virtualPath = string.Empty;
searchedLocations = new string[locations.Length];
for (int i = 0; i < locations.Length; i++)
{
if (string.IsNullOrEmpty(areaName) && locations[i].Contains("{2}"))
{
continue;
}
string testPath = string.Format(CultureInfo.InvariantCulture,
locations[i], name, controllerName, areaName);
if (FileExists(controllerContext, testPath))
{
searchedLocations = EmptyLocations;
virtualPath = testPath;
ViewLocationCache.InsertViewLocation(
controllerContext.HttpContext, cacheKey, virtualPath);
return virtualPath;
}
searchedLocations[i] = testPath;
}
return virtualPath;
}
protected string GetPathFromSpecificName(ControllerContext controllerContext, string name, string cacheKey,ref string[] searchedLocations)
{
string virtualPath = name;
if (!FileExists(controllerContext, name))
{
virtualPath = string.Empty;
searchedLocations = new string[] { name };
}
ViewLocationCache.InsertViewLocation(controllerContext.HttpContext,
cacheKey, virtualPath);
return virtualPath;
}
protected static bool IsSpecificPath(string name)
{
char ch = name[0];
if (ch != '~')
{
return (ch == '/');
}
return true;
}
}
After making these changes, action links both inside and outside areas were generated properly and I could route both inside & outside areas.