Reference

Custom View Engine for Localized Views with ASP.NET MVC Razor

This article describes how to localize ASP.NET MVC page views and partial views by providing separate Razor templates for each language. Used in combination with localization through resource files and metadata, this approach leads to a cleaner project structure and allows for localizing a web page without having to recompile. However, it comes at the cost of some duplicated markup.

Tags: aspnet csharp localization mvc razor

Prerequisites

This article assumes that you already have some facility in place to set the application’s current culture based on the user’s localization choice. Depending on your specific requirements, there are different ways to implement this.

We use a localized route handler that extracts the desired locale from the page URL, and a sample implementation may look as follows:

using System.Globalization;
using System.Threading;
using System.Web;
using System.Web.Routing;
using System.Web.Mvc;

public class LocalizedMvcRouteHandler : MvcRouteHandler
{
   protected override IHttpHandler GetHttpHandler (RequestContext requestContext)
   {
       CultureInfo ci = new CultureInfo(requestContext.RouteData.Values["language"].ToString());

       Thread.CurrentThread.CurrentUICulture = ci;
       Thread.CurrentThread.CurrentCulture = CultureInfo.CreateSpecificCulture(ci.Name);

       return base.GetHttpHandler(requestContext);
   }
}

Routes can then be registered in Global.asax.cs or the area’s registration as follows:

context.MapRoute(
   "AreaName_default",
   "AreaName/{language}/{controller}/{action}",
   new
   {
       language = "en",
       controller = "ControllerName",
       action = "ActionName"
   },
   constraints
).RouteHandler = new LocalizedMvcRouteHandler();

Using the route above, web page URLs will have the following format:

http://www.yourserver/AreaName/en/ControllerName/ActionName
http://www.yourserver/AreaName/en-US/ControllerName/ActionName

View Engine Implementation

The implementation of the custom view engine is straightforward. The overridden methods FindPartialView and FindView simply append the current culture code to name of the view and let the Razor view engine in the base class try to locate the view.

using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using System.Web.Mvc;

public class LocalizedViewEngine : RazorViewEngine
{
   public override ViewEngineResult FindPartialView (ControllerContext controllerContext, string partialViewName, bool useCache)
   {
       List<string> searched = new List<string>();

       if (!string.IsNullOrEmpty(partialViewName))
       {
           ViewEngineResult result;

           result = base.FindPartialView(controllerContext, string.Format("{0}.{1}", partialViewName, CultureInfo.CurrentUICulture.Name), useCache);

           if (result.View != null)
           {
               return result;
           }

           searched.AddRange(result.SearchedLocations);

           result = base.FindPartialView(controllerContext, string.Format("{0}.{1}", partialViewName, CultureInfo.CurrentUICulture.TwoLetterISOLanguageName), useCache);

           if (result.View != null)
           {
               return result;
           }

           searched.AddRange(result.SearchedLocations);
       }

       return new ViewEngineResult(searched.Distinct().ToList());
   }

   public override ViewEngineResult FindView (ControllerContext controllerContext, string viewName, string masterName, bool useCache)
   {
       List<string> searched = new List<string>();

       if (!string.IsNullOrEmpty(viewName))
       {
           ViewEngineResult result;

           result = base.FindView(controllerContext, string.Format("{0}.{1}", viewName, CultureInfo.CurrentUICulture.Name), masterName, useCache);

           if (result.View != null)
           {
               return result;
           }

           searched.AddRange(result.SearchedLocations);

           result = base.FindView(controllerContext, string.Format("{0}.{1}", viewName, CultureInfo.CurrentUICulture.TwoLetterISOLanguageName), masterName, useCache);

           if (result.View != null)
           {
               return result;
           }

           searched.AddRange(result.SearchedLocations);
       }

       return new ViewEngineResult(searched.Distinct().ToList());
   }
}

A two-pass approach is being used here to allow for localization of languages AND regions, such as:

ViewName.en-US.cshtml
ViewName.en.cshtml
ViewName.cshtml

The searched locations are being added to the result in case a specified view cannot be found. The information is then displayed in the exception that is generated and will allow for debugging the application. An example error message may look like:

The partial view 'ViewName' was not found or no view engine supports the searched locations. The following locations were searched:
~/Areas/AreaName/Views/ControllerName/ViewName.de.cshtml
~/Areas/AreaName/Views/ControllerName/ViewName.de.vbhtml
~/Areas/AreaName/Views/Shared/ViewName.de.cshtml
~/Areas/AreaName/Views/Shared/ViewName.de.vbhtml
~/Views/ControllerName/ViewName.de.cshtml
~/Views/ControllerName/ViewName.de.vbhtml
~/Views/Shared/ViewName.de.cshtml
~/Views/Shared/ViewName.de.vbhtml
~/Areas/AreaName/Views/ControllerName/ViewName.aspx
~/Areas/AreaName/Views/ControllerName/ViewName.ascx
~/Areas/AreaName/Views/Shared/ViewName.aspx
~/Areas/AreaName/Views/Shared/ViewName.ascx
~/Views/ControllerName/ViewName.aspx
~/Views/ControllerName/ViewName.ascx
~/Views/Shared/ViewName.aspx
~/Views/Shared/ViewName.ascx
~/Areas/AreaName/Views/ControllerName/ViewName.cshtml
~/Areas/AreaName/Views/ControllerName/ViewName.vbhtml
~/Areas/AreaName/Views/Shared/ViewName.cshtml
~/Areas/AreaName/Views/Shared/ViewName.vbhtml
~/Views/ControllerName/ViewName.cshtml
~/Views/ControllerName/ViewName.vbhtml
~/Views/Shared/ViewName.cshtml
~/Views/Shared/ViewName.vbhtml

Registering the View Engine

The view engine needs to be registered with the application before it can be used. This is done by inserting the view engine into the application’s view engine collection at application startup in Global.asax:

protected void Application_Start ()
{
   // . . .
   ViewEngines.Engines.Insert(0, new LocalizedViewEngine());
}

Notice how the view engine is inserted at the beginning of the view engine collection. That way other view engines that may be registered get a chance to locate the view if the localized view engine failed to do so.

Final Notes

A downside of the approach described in this article is that markup is being duplicated in the localized views. This problem can be mitigated to a great extent by properly splitting the pages into partial views, some of which are localized, and some of which are not. The web page should be structured in such a way that the majority of markup is located in non-localized view templates to keep duplication to a minimum.

Related Resources