As you might know, I run a web shop written in ASP.Net MVC. The move to ASP.Net MVC is fairly recent (time frame of preview 4), the shop ran for a few years on ‘classic’ ASP.Net (am I glad that’s finally over), but provided basically the same functionality as the ported version now.
There are some improvements though (besides the obvious: a far better implementation). One of the most visible changes is the support for more SEO-friendly urls. Since the shop is available in three languages I decided to finally provide decent localized urls as well. My requirements were as follows:
- Each domain has a default culture (.be defaults to ‘nl’, .fr to ‘fr’, etc.), but supports all others as well
- When browsing the site with the default culture no culture url segment is required
- If the culture url segment specifies the default culture, the request is redirected to a url without culture segment
- Urls with a culture segment should look like ‘http://domain.com/[culture-name]/[content-url]‘
There are various ways of getting this done, but the most obvious one is a custom ActionFilterAttribute (+ optional controller base class, if you don’t like putting the attribute over and over again). An action filter alone won’t cut it, however. We’ll need to setup appropriate routes first…
Routing
In order to register localized routes as convenient as possible, I’ve created a little helper class with extension methods that provides a ‘MapLocalizedRoute‘ equivalent for each standard ‘MapRoute‘ overload.
/// <summary>
/// Provides extension methods to register localized routes.
/// </summary>
public static class RouteCollectionExtensions
{
public static void MapLocalizedRoute(this RouteCollection routes, string name, string url)
{
MapLocalizedRoute(routes, name, url, null, null);
}
public static void MapLocalizedRoute(this RouteCollection routes, string name, string url, object defaults)
{
MapLocalizedRoute(routes, name, url, defaults, null);
}
public static void MapLocalizedRoute(this RouteCollection routes, string name, string url, string[] namespaces)
{
MapLocalizedRoute(routes, name, url, null, null, namespaces);
}
public static void MapLocalizedRoute(this RouteCollection routes, string name, string url, object defaults, object constraints)
{
MapLocalizedRoute(routes, name, url, defaults, constraints, null);
}
public static void MapLocalizedRoute(this RouteCollection routes, string name, string url, object defaults, string[] namespaces)
{
MapLocalizedRoute(routes, name, url, defaults, null, namespaces);
}
public static void MapLocalizedRoute(this RouteCollection routes, string name, string url, object defaults, object constraints, string[] namespaces)
{
foreach (var cultureName in Locale.SupportedCultureNames)
{
var route = routes.MapRoute(
string.Format("{0}-{1}", name, cultureName),
string.Format("{0}/{1}", cultureName, url),
defaults,
constraints,
namespaces);
route.Defaults.Add(Locale.CultureSegmentName, cultureName);
}
}
}
MapLocalizedRoute will simply map a route for every supported culture name (as set in the Locale class). Because the culture name is a literal in the url, we need to add a ‘culture’ value to the default values of the route. Doing so gives you more control over your routing and avoids the possibility of handling unsupported or malformed culture names. With these extension methods we can do the following in our Application_Start:
Locale.DefaultCultureName = "nl";
Locale.SupportedCultureNames = new[] {"nl", "en", "fr"};
// ...
routes.MapLocalizedRoute("about", "about/", new { controller = "Root", action = "About" });
routes.MapLocalizedRoute("terms", "terms/", new { controller = "Root", action = "Terms" });
routes.MapLocalizedRoute("faq", "faq/", new { controller = "Root", action = "Faq" });
// ...
routes.MapLocalizedRoute("brands", "brands/{title}-{brandId}/{pageIndex}",
new { controller = "Products", action = "Brands", pageIndex = "0" });
routes.MapLocalizedRoute("cat", "{name}-{categoryId}/{pageIndex}",
new { controller = "Products", action = "Category", pageIndex = "0" });
// map non-localized routes here
In case you’re wondering why default culture routes are not mapped in MapLocalizedRoute and why the listing above mentions that you should map non-localized route after the localized ones. This is simply because we need to add the most common route patterns first.
Localize
Now let’s have a look at the LocalizeAttribute. For every localized url the OnActionExecuting will make sure the CurrentCulture and CurrentUICulture of the current thread are set as requested. If the requested culture is the default one, the filter will redirect the user to the default culture url (without culture segment).
Note that we don’t have to verify if the requested culture name is a supported one; the MapLocalizedRoute will make sure that only the supported languages are mapped.
[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method, AllowMultiple = false, Inherited = true)]
public class LocalizeAttribute : ActionFilterAttribute
{
private static readonly Regex removeCultureSegmentRegex = new Regex(@"^/[a-zA-Z\-]+", RegexOptions.Compiled);
public override void OnActionExecuting(ActionExecutingContext filterContext)
{
if (!filterContext.RouteData.Values.ContainsKey(Locale.CultureSegmentName))
{
SetCurrentCultures(Locale.DefaultCultureName);
return;
}
var culture = filterContext.RouteData.Values[Locale.CultureSegmentName].ToString();
if (culture == Locale.DefaultCultureName)
{
RedirectToDefaultCultureUrl(filterContext);
return;
}
SetCurrentCultures(culture);
}
private static void SetCurrentCultures(string cultureName)
{
Thread.CurrentThread.CurrentCulture = CultureInfo.CreateSpecificCulture(cultureName);
Thread.CurrentThread.CurrentUICulture = CultureInfo.GetCultureInfo(cultureName);
}
private static void RedirectToDefaultCultureUrl(RequestContext filterContext)
{
filterContext.HttpContext.Response.Redirect(removeCultureSegmentRegex.Replace(filterContext.HttpContext.Request.RawUrl, string.Empty), true);
}
}
Finally, there just theLocale class that configures the default and supported cultures.
/// <summary>
/// Holds the default and supported culture names.
/// </summary>
public static class Locale
{
static Locale()
{
DefaultCultureName = "en";
SupportedCultureNames = new[] {"en", "nl", "fr"};
}
internal const string CultureSegmentName = "culture";
public static string DefaultCultureName { get; set;}
public static string[] SupportedCultureNames { get; set;}
}