Localized routing using ASP.NET Core MVC 2

This is one implementation of localized routes using ASP.NET Core 2 MVC. [Attributes] are used to control the localized route data for the controllers and actions for simplicity.
Source code: https://github.com/saaratrix/asp.net-core-mvc-localized-routing

Table of content:

Abstract

A localized routing solution using ASP.NET Core 2.1 MVC. The implementation uses attributes on the controllers and actions to determine the localization for each culture. A convention is added in Startup.cs to iterate over all the controllers and actions to set their cultural routes based on the [LocalizationRoute] attributes.

Example usage for a controller for cultures english (default), finnish and swedish:


    // Routes for each culture:
    // Default: /Home           - / for the Index action
    // Finnish: /fi/koti        - /fi for the Index action.
    // Swedish: /sv/Hem         - /sv for the Index action.
    [LocalizationRoute("fi", "koti")]
    [LocalizationRoute("sv", "Hem", "Hemma")] // The link text for <a>linktext</a> will be Hemma
    public class HomeController : LocalizationController
    {
        public IActionResult Index()
        {
            return View();
        }

        // Routes for each culture:
        // Default: /Home/About
        // Finnish: /fi/koti/meistä
        // Swedish: /sv/Hem/om
        [LocalizationRoute("fi", "meistä")]
        [LocalizationRoute("sv", "om")]
        public IActionResult About()
        {
            return View();
        }
    }

Example usage of the cms-culture tag helper in the views for the anchor element:


    // Generated html: <a href="/Home/About">Home</a>
    <a asp-controller="home" asp-action="about" cms-culture="en">Home</a>

    // Generated html: <a href="/fi/koti/meistä">Meistä</a>
    <a asp-controller="home" asp-action="about" cms-culture="fi">Home</a>

    // Generated html: <a href="/sv/Hem/om">Hemma</a>
    <a asp-controller="home" asp-action="about" cms-culture="sv">Home</a>

    // Leaving cms-culture="" empty will use the current request culture.
    // If user is at /fi/... then finnish culture is used so the generated html would be:
    // <a href="/fi/koti/meistä">Meistä</a>
    <a asp-controller="home" asp-action="about" cms-culture="">Home</a>

        

Introduction and features

In January 2016 I wrote a blog post detailing how I did localized routing for ASP.NET Core 1.0 MVC Release candidate 1. A reader wrote a comment saying things had changed and some things were deprecated so I decided to update the localized routing solution. This is the updated project meant for ASP.NET Core 2.1 MVC.

When I started out the project in 2016 I tried looking at other solutions and the main features that I wanted were:

  • Multiple cultures/languages.
  • Easy to set up cultures and to modify the routes. Which I believe was achieved because each controller is edited individually for a good overview and fine tuning.
  • The url should start with the culture but not for default culture.
  • Actions and controllers should be localized/translated. For example where Controller = Home and Action = About it has the following routing for english and finnish culture:
    • English: /home/about
    • Finnish: /fi/koti/meistä
  • Default controller and default action aren't required in the url. As an example we're using default controller and default action.
    • English: /
    • Finnish: /fi
    As second example we're only using default action and the controller's name is Example.
    • English: /example
    • Finnish: /fi/esimerkki


Similar Projects

With these features in mind I started looking around the internet for existing implementations to use. The solution closest to what I wanted I found here: www.strathweb.com/2015/11/localized-routes-with-asp-net-5-and-mvc-6/. I did not like this for 2 reasons.
1. Their way of initializing the routes was not how I wanted to do it. Editing the routes in a large dictionary seemed clunky and could get confusing for many controllers and actions.


var localizedRoutes = new Dictionary<string LocalizedRouteInformation=[]>()
{
    {
       "order", new[]
       {
           new LocalizedRouteInformation("de-CH", "auftrag"),
           new LocalizedRouteInformation("pl-PL", "zamowienie"),
       }
    },
    {
       "orderById", new[]
       {
           new LocalizedRouteInformation("de-CH", "auftrag/{id:int}"),
           new LocalizedRouteInformation("pl-PL", "zamowienie/{id:int"),
       }
     }
};

The way I came up with looks like this for example an action called About.
        
    [LocalizationRoute("fi", "meistä")]
    [LocalizationRoute("sv", "om")]
    public IActionResult About()
    {
        return View();
    }
    

2. From what I could see it did not have the cultural part at the start of the url. The culture in their solution from what I understood is stored in a property of the action.

I also looked at other examples which were for mvc 5 or earlier. The examples had things I either didn't understand due to lack of knowledge, or features that it was missing. So in the end I decided to write my own solution and thus here is how I solved localized routing!

Here is my implementation of the localized routing. In my example I am using 3 cultures, en, fi, sv where en is the default culture.

Startup.cs

The Startup.cs is a file that comes with a new ASP.NET project. This file is where initialization and configuring the ASP.NET app happens. For the localized routing to work we need to set it up in Startup.cs.

Localized Routes

The first part in Startup.cs is to tell the LocalizationRouteDataHandler what the default culture is and what cultures we're going to use. We do this in the ConfigureServices method.
Then we add the convention LocalizationRouteConvention. Which is what will iterate over all controllers and actions to generate the localized routes based on the [LocalizationRoute] attributes.
We also configure the RequestLocalizationOptions which is what sets the culture for every http request.
In the ConfigureServices() method we're also setting up using Resource files and IViewLocalizer for translating the view texts.


    public void ConfigureServices(IServiceCollection services)
    {
            // ... Other code above here ...

            // Set up what culture to use
            LocalizationRouteDataHandler.DefaultCulture = "en";
            LocalizationRouteDataHandler.SupportedCultures = new Dictionary<string, string>()
            {
                { "en", "English" },
                { "fi", "Suomeksi" },
                { "sv", "Svenska" }
            };

            // Add the LocalizationRouteConvention to MVC Conventions.
            // Without this the localized routes won't work because this is what initializes all the routes!
            IMvcBuilder mvcBuilder = services.AddMvc(options =>
            {
                options.Conventions.Add(new LocalizationRouteConvention());
            });

            // Set up the request localization options which is what sets the culture for every http request.
            // Meaning if you visit /controller the culture is LocalizationRouteDataHandler.DefaultCulture.
            // And if you visit /fi/controller the culture is fi.
            services.Configure<RequestLocalizationOptions>(options =>
            {
                options.DefaultRequestCulture = new RequestCulture(LocalizationRouteDataHandler.DefaultCulture, LocalizationRouteDataHandler.DefaultCulture);

                foreach (var kvp in LocalizationRouteDataHandler.SupportedCultures)
                {
                    CultureInfo culture = new CultureInfo(kvp.Key);
                    options.SupportedCultures.Add(culture);
                    options.SupportedUICultures.Add(culture);
                }

                options.RequestCultureProviders = new List<IRequestCultureProvider>()
                {
                    new UrlCultureProvider(options.SupportedCultures)
                };
            });

            // Set up Resource file usage and IViewLocalizer
            services.AddLocalization(options => options.ResourcesPath = "Resources");
            // Views.Shared._Layout is for the /Views/Shared/_Layout.cshtml file
            mvcBuilder.AddViewLocalization(LanguageViewLocationExpanderFormat.Suffix)
            // Add support for localizing strings in data annotations (e.g. validation messages) via the
            // IStringLocalizer abstractions.
            .AddDataAnnotationsLocalization();

            // ... Other code below here ...
    }

Request Localization

In Startup.cs in the method Configure() the RequestLocalization for each http request is enabled through this code:


    public void Configure(IApplicationBuilder app, IHostingEnvironment env)
    {
        var localizationOptions = app.ApplicationServices.GetService<IOptions<RequestLocalizationOptions>>();
        app.UseRequestLocalization(localizationOptions.Value);

        // ... Other code below here ...
    }

        

UrlCultureProvider.cs

This is the class that was added in Startup.cs for every http request to set the culture through the RequestCultureProvider. The code below is what made sure UrlCultureProvider is used for every http request.


    options.RequestCultureProviders = new List<IRequestCultureProvider>()
    {
        new UrlCultureProvider(options.SupportedCultures)
    };

        

The default RequestProviders that ASP.NET uses are header, cookie and query string provider. We're only interested in the url which is why a new list is created. You can read more about setting the culture for each request on this website. I looked at the source code of ASP.NET localization to write the UrlCultureProvider. My implementation ONLY supports 2 letters to figure out the culture. So the UrlCultureProvider supports en but not en-US.


    /// <summary>
    /// Determines the culture information for a request via the value of the start of a url.
    /// Needs to be used in Startup.ConfigureServices().
    /// </summary>
    public class UrlCultureProvider : RequestCultureProvider
    {
        /// <summary>
        /// The default culture if none is found.
        /// </summary>
        public string DefaultCulture { get; set; } = LocalizationDataHandler.DefaultCulture;
        /// <summary>
        /// The supported cultures from url.
        /// </summary>
        public IList<CultureInfo> SupportedCultures {get; set;}
        public UrlCultureProvider(IList<CultureInfo> a_supportedCultures)
        {
            SupportedCultures = a_supportedCultures;
        }
        public override Task<ProviderCultureResult> DetermineProviderCultureResult(HttpContext httpContext)
        {
            if (httpContext == null)
            {
                throw new ArgumentNullException(nameof(httpContext));
            }

            string url = httpContext.Request.Path.Value;
            // example: /fi
            if (url.Length >= 3)
            {
                if (url.Length >= 4)
                {
                    // If the 4th character isn't a /
                    // Example: /Home , then return default culture
                    if (url[3] != '/' )
                    {
                        return Task.FromResult(new ProviderCultureResult(DefaultCulture));
                    }
                }
                // Get the /value/ value
                string startPath = url.Substring(1, 2);
                foreach (CultureInfo culture in SupportedCultures)
                {
                    if (culture.Name == startPath)
                    {
                        return Task.FromResult(new ProviderCultureResult(culture.Name));
                    }
                }
            }

            return Task.FromResult(new ProviderCultureResult(DefaultCulture));
        }
    }

LocalizationRouteAttribute.cs

The attribute is attached to each controller or their actions to create the routes for each culture. It does not inherit from the ASP.NET RouteAttribute. The LocalizationRoute attribute is used by the LocalizationRouteConvention to create the routes for each culture for the controllers and actions.

        
    [AttributeUsage(AttributeTargets.Class | AttributeTargets.Method, AllowMultiple = true)]
    public class LocalizationRouteAttribute : Attribute
    {
        /// <summary>
        /// The character to replace whitespace with in the input route, like
        /// "batman rocks" => "batman_rocks" as route
        /// </summary>
        private const char WhiteSpaceReplacement = '_';

        /// <summary>
        /// The characters to split a route on to generate a more link friendly url.
        /// For example some_route => Some Route
        /// </summary>
        private static char[] RouteToLinkSplitCharacters = new char[] { '_', '-' };

        /// <summary>
        /// The culture string representation, en, fi, sv e.t.c.!
        /// </summary>
        public string Culture { get; set; }
        /// <summary>
        /// The route, no need for /.
        /// It is case sensitive.
        /// Meaning "roUTe" would create the route "/roUTe"
        /// </summary>
        public string Route { get; set; }
        /// <summary>
        /// What the link text is for a Html.ActionLink
        /// </summary>
        public string Link { get; set; }

        public LocalizationRouteAttribute()
        {
        }

        public LocalizationRouteAttribute(string culture)
            :this(culture, "", null)
        {

        }

        /// <summary>
        /// Attribute used by LocalizationConvention to create all the routes.
        /// Defaults Link to null
        /// </summary>
        /// <param name="culture"></param>
        /// <param name="route"></param>
        public LocalizationRouteAttribute(string culture, string route)
           : this(culture, route, null)
        {

        }

        /// <summary>
        /// Attribute used by LocalizationConvention to create all the routes.
        /// </summary>
        /// <param name="culture"></param>
        /// <param name="route"></param>
        /// <param name="link">If not defined the value is route with first letter capitalized</param>
        public LocalizationRouteAttribute(string culture, string route, string link)
        {
            Culture = culture;
            Route = route;
            // Replace all the spaces with the whitespace replacement character
            Route = Route.Replace(' ', WhiteSpaceReplacement);

            // If the link is null then set it to the route
            if (String.IsNullOrEmpty(link))
            {
                Link = ConvertRouteToLink(Culture, Route);
            }
            else
            {
                Link = link;
            }
        }

        /// <summary>
        /// Convert a route value to a link friendly value.
        /// Example of "some_route" converts to "Some Route"
        /// </summary>
        /// <param name="route"></param>
        /// <param name="culture"></param>
        /// <returns></returns>
        public static string ConvertRouteToLink(string culture, string route)
        {
            CultureInfo cultureInfo = new CultureInfo(culture, false);

            string[] routeParts = route.Split(RouteToLinkSplitCharacters);
            List<string> parsedParts = new List<string>();

            for (int i = 0; i < routeParts.Length; i++)
            {
                string routePart = routeParts[i];
                if (routePart.Length == 0)
                {
                    continue;
                }
                // The reason for doing this instead of TextInfo.ToTitleCase()
                // Is because ToTitleCase would convert batMAN to Batman instead of BatMAN.

                // Uppercase first letter
                char letter = Char.ToUpper(routePart[0], cultureInfo);
                // Then add the rest!
                routePart = routePart.Length > 1 ? routePart.Substring(1) : "";

                parsedParts.Add(letter + routePart);
            }

            return String.Join(" ", parsedParts);
        }
    }

LocalizationController.cs

The LocalizationController is a base controller that sets ViewData["Culture"] = CurrentCulture for every action so you don't have to do that for every single action. It also sets the ViewData["controller"] = ControllerName; and ViewData["action"] = ActionName;. Which is used in the partial view _CultureSelector.cshtml when changing culture so a user stays on the same page. A controller does not need to inherit from LocalizationController. The LocalizationRouteConvention will still generate the localized routes for every controller. The code for the LocalizationController:

        
    public class LocalizationController : Controller
    {
        public override void OnActionExecuting(ActionExecutingContext context)
        {
            base.OnActionExecuting(context);

            ViewData["culture"] = CultureInfo.CurrentCulture.Name;
            ViewData["controller"] = ControllerContext.ActionDescriptor.ControllerName;
            ViewData["action"] = ControllerContext.ActionDescriptor.ActionName;
        }
    }
    

CultureActionLinkTagHelper.cs

The <a> tag helper class. The CultureActionLinkTagHelper will based on the controller, action and culture generate the localized url. It also overwrites the link text which is the innerText for the anchor tag. <a>linkText</a>
TagHelper is a new cool feature for ASP.NET Core which allows you to modify the HTMLElements. Here's more information about them: https://docs.microsoft.com/en-us/aspnet/core/mvc/views/tag-helpers/intro?view=aspnetcore-2.1

How to use

The CultureActionLinkTagHelper is meant to be easy to use. The code is the same as you'd normally write when generating a link except you also add the cms-culture attribute. There are two ways to use it. cms-culture="culture" or leave the value empty and it'll automatically select current culture. cms-culture=""
Here are two examples where [LocalizationRoute("fi", "koti")] was used and the current culture is finnish.


    <a asp-controller="home" asp-action="index" cms-culture="fi">Home</a>
    <a asp-controller="home" asp-action="index" cms-culture="">Home</a>

The snippet above would output the following html output: <a href="/fi/koti">Koti</a>
The code for the CultureActionLinkTagHelper:

cms-keep-link

If you don't want the link text to be overwritten there is an extra attribute cms-keep-link. If that attribute is true cms-keep-link="true" or by changing the default behavior CultureActionLinkTagHelper.KeepLinkDefault = true. If the default behavior is changed you need to write cms-keep-link="false" to make the TagHelper overwrite the link text instead.

        
    [HtmlTargetElement("a", Attributes = CultureAttributeName)]
    [HtmlTargetElement("a", Attributes = KeepLinkAttributeName)]
    public class CultureActionLinkTagHelper : TagHelper
    {
        private const string CultureAttributeName = "cms-culture";
        private const string KeepLinkAttributeName = "cms-keep-link";
        /// <summary>
        /// The default value to use if no cms-keep-link attribute.
        /// </summary>
        public static bool KeepLinkDefault = false;
        /// <summary>
        /// The culture to use attribute.
        /// </summary>
        [HtmlAttributeName(CultureAttributeName)]
        public string Culture { get; set; }

        /// <summary>
        /// If the anchor tags innerText should kept or not.
        /// If true the explicit value between the <a>value</a>.
        /// If false then the value is the result from LocalizationUrlResult.LinkName.
        /// </summary>
        [HtmlAttributeName(KeepLinkAttributeName)]
        public bool KeepLink { get; set; } = KeepLinkDefault;

        public override Task ProcessAsync(TagHelperContext context, TagHelperOutput output)
        {
            // This happens for example if cms-culture="" is left empty
            // Which means the current culture set by the RequestProvider is used.
            if (string.IsNullOrEmpty(Culture))
            {
                Culture = CultureInfo.CurrentCulture.Name;
            }

            var urlResult = LocalizationTagHelperUtility.GetUrlResult(context, Culture);

            output.Attributes.SetAttribute("href", urlResult.Url);

            if (!KeepLink && urlResult.LinkName != "")
            {
                output.Content.SetContent(urlResult.LinkName);
            }

            return Task.FromResult(0);
        }
    }

LocalizationTagHelperUtility

LocalizationTagHelperUtility is a class that is used by CultureActionLinkTagHelper and CultureFormLinkTagHelper to get the LocalizationUrlResult data. The LocalizationUrlResult is a struct that contains the localized url and localized link name.

CultureFormLinkTagHelper.cs

The CultureFormLinkTagHelper is for <form> tags and works the same as the CultureActionLinkTagHelper except it doesn't have any link text logic.


    [HtmlTargetElement("form", Attributes = CultureAttributeName)]
    public class CultureFormLinkTagHelper : TagHelper
    {
        private const string CultureAttributeName = "cms-culture";
        /// <summary>
        /// The culture to use attribute.
        /// </summary>
        [HtmlAttributeName(CultureAttributeName)]
        public string Culture { get; set; }

        public override Task ProcessAsync(TagHelperContext context, TagHelperOutput output)
        {
            if (string.IsNullOrEmpty(Culture))
            {
                Culture = CultureInfo.CurrentCulture.Name;
            }

            LocalizationUrlResult urlResult = LocalizationTagHelperUtility.GetUrlResult(context, Culture);

            output.Attributes.SetAttribute("action", urlResult.Url);

            return Task.FromResult(0);
        }
    }

LocalizationRouteConvention.cs

This is the heart in setting up all the routing. It iterates over all controllers and all the actions for each controller. It checks if a controller or an action has the [LocalizationRoute] attribute. The convention also checks if the action already has a Selector with an AttributeRouteModel. An example where an action has a selector is for HttpPost or HttpGet with parameters.

Adding route data to the LocalizationRouteDataHandler

For all the controllers & actions that the convention iterates over it will add the route data to the LocalizationRouteDataHandler. If the route data wasn't added to the LocalizationRouteDataHandler then we couldn't generate the localized urls based on a controller name, action name and culture.

The data added to the LocalizationRouteDataHandler is stored in a static Dictionary so it's easy to query based on controller name or action name. The reason for choosing a static Dictionary is because it needs to be shared between all ASP.NET requests. However keys are only added during the LocalizedRouteConvention logic so it should be thread safe and offer better performance over a ConcurrentDictionary.

Code for LocalizedRouteConvention.cs

Here is the entire code for the LocalizedRouteConvention.cs file:

        
    /// <summary>
    /// Sets up all routes based on the [LocalizationRoute] attributes for all the controllers and controllers' actions.
    /// </summary>
    public class LocalizationRouteConvention : IApplicationModelConvention
    {
        public LocalizationRouteConvention()
        {
        }

        public void Apply(ApplicationModel applicationModel)
        {
            foreach (ControllerModel controller in applicationModel.Controllers)
            {
                // If the controllerName is the same as the base controller for localization go next since it doesn't have any actions
                // Since it's a controller it ends up in applicationModel.Controllers which is why we just continue;
                if (controller.ControllerName == "Localization")
                {
                    continue;
                }

                // Do the controller
                AddControllerRoutes(controller);
                // Do the actions!
                AddActionRoutes(controller);
            }
        }

        /// <summary>
        /// Add an AttributeRouteModel to a SelectorModel list.
        /// It also tries to set the first entry of the list if the AttributeRouteModel is null there.
        /// </summary>
        /// <param name="selectorModels"></param>
        /// <param name="attributeRouteModel"></param>
        public void AddAttributeRouteModel(IList<SelectorModel> selectorModels, AttributeRouteModel attributeRouteModel)
        {
            // Override what seems to be default SelectorModel
            if (selectorModels.Count == 1 && selectorModels[0].AttributeRouteModel == null)
            {
                selectorModels[0].AttributeRouteModel = attributeRouteModel;
            }
            else
            {
                selectorModels.Add(new SelectorModel
                {
                    AttributeRouteModel = attributeRouteModel
                });
            }
        }

        /// <summary>
        /// Set up the localized routes for the controller model.
        /// It uses the [LocalizationRoute] attributes and if no attributes are found it uses culture/controllerName.
        /// </summary>
        /// <param name="controllerModel"></param>
        public void AddControllerRoutes(ControllerModel controllerModel)
        {
            string controllerName = controllerModel.ControllerName;

            // If the controller is the default controller then add the "/", "/culture" routes.
            // If we don't do this then "/" or "/culture" would throw 404.
            // Instead /Default would be the only way to access the default controller.
            if (controllerName.Equals(LocalizationRouteDataHandler.DefaultController, StringComparison.Ordinal))
            {
                // Set up the "/", "/culture1", "/culture2" route templates for all supported cultures.
                foreach(var kvp in LocalizationRouteDataHandler.SupportedCultures)
                {
                    string template = LocalizationRouteDataHandler.DefaultCulture == kvp.Key ? "" : kvp.Key;

                    AttributeRouteModel defaultRoute = new AttributeRouteModel();
                    defaultRoute.Template = template;
                    AddAttributeRouteModel(controllerModel.Selectors, defaultRoute);
                }
            }

            LocalizationRouteDataHandler.AddControllerRouteData(controllerName, LocalizationRouteDataHandler.DefaultCulture, controllerName);

            // Create the route for the controller to /Default.
            // Since DefaultController also should be reachable by /Default.
            // This adds the /Default route template to controllerModel.Selectors so it is reachable by both / and /Default.
            AttributeRouteModel controllerRoute = new AttributeRouteModel();
            controllerRoute.Template = controllerModel.ControllerName;
            AddAttributeRouteModel(controllerModel.Selectors, controllerRoute);

            AddControllerLocalizedRoutes(controllerModel);
        }

        /// <summary>
        /// Add the localized routes for the controller model
        /// </summary>
        /// <param name="controllerModel"></param>
        public void AddControllerLocalizedRoutes(ControllerModel controllerModel)
        {
            // Get all the [LocalizationRoute] Attributes from the controller
            var controllerLocalizations = controllerModel.Attributes.OfType<LocalizationRouteAttribute>().ToList();
            string controllerName = controllerModel.ControllerName;

            // Keep track of which cultures did not have a [LocalizationRoute] attribute so they can have one added programmatically.
            HashSet<string> notFoundCultures = LocalizationRouteDataHandler.SupportedCultures.Select(kvp => kvp.Key).ToHashSet();
            notFoundCultures.Remove(LocalizationRouteDataHandler.DefaultCulture);

            // Loop over all [LocalizationRoute] attributes
            foreach (LocalizationRouteAttribute attribute in controllerLocalizations)
            {
                string template = attribute.Culture;
                // If the attributeRoute isn't empty then we use the route name
                if (!String.IsNullOrEmpty(attribute.Route))
                {
                    // Add / if the route doesn't start with /
                    // Otherwise the template would be {culture}{route}.
                    // Instead of {culture}/{route}
                    if (!attribute.Route.StartsWith("/"))
                    {
                        template += "/";
                    }
                    template += attribute.Route;
                }
                // If attribute.Route is empty then we use the controller name so it's not an empty name.
                else
                {
                    template += "/" + controllerName;
                }

                AttributeRouteModel localRoute = new AttributeRouteModel();
                localRoute.Template = template;
                AddAttributeRouteModel(controllerModel.Selectors, localRoute);

                // Add the route to the localizations dictionary
                LocalizationRouteDataHandler.AddControllerRouteData(controllerName, attribute.Culture, template);
                // Remove it from the not Found Cultures list since the culture had a [LocalizationRoute] attribute.
                notFoundCultures.Remove(attribute.Culture);
            }

            // Add the remaining cultures that didn't have [LocalizationRoute] attributes.
            foreach (string culture in notFoundCultures)
            {
                string template = culture;
                if (!controllerName.Equals(LocalizationRouteDataHandler.DefaultController, StringComparison.CurrentCultureIgnoreCase))
                {
                    template += "/" + controllerName;
                }

                AttributeRouteModel localRoute = new AttributeRouteModel();
                localRoute.Template = template;
                AddAttributeRouteModel(controllerModel.Selectors, localRoute);

                LocalizationRouteDataHandler.AddControllerRouteData(controllerName, culture, template);
            }
        }

        /// <summary>
        /// Adds the localized routes for a controller
        /// </summary>
        /// <param name="controllerModel"></param>
        public void AddActionRoutes(ControllerModel controllerModel)
        {
            string controllerName = controllerModel.ControllerName;
            // All the new localized actions to add to the controllerModel after all calculations.
            List<ActionModel> newActions = new List<ActionModel>();
            // Loop through all the actions to add their routes and also get the localized actions
            foreach (ActionModel action in controllerModel.Actions)
            {
                string actionName = action.ActionName;
                // If any parameters are needed such as /{index}
                string parameterTemplate = "";

                SelectorModel defaultSelectionModel = action.Selectors.FirstOrDefault(x => x.AttributeRouteModel != null);

                List<string> sortedRouteParameters = new List<string>();

                // If there is no [Route()] Attribute then create one for the route.
                if (defaultSelectionModel == null || defaultSelectionModel.AttributeRouteModel == null)
                {
                    AttributeRouteModel attributeRouteModel = new AttributeRouteModel();

                    if (action.Parameters.Count > 0)
                    {
                        foreach (ParameterModel parameter in action.Parameters)
                        {
                            sortedRouteParameters.Add(parameter.ParameterName.ToLower());
                            // TODO: ParseParameterTemplate? I think you can't skip the [Route] attribute if you want parameters.
                        }
                    }

                    if (!action.ActionName.Equals(LocalizationRouteDataHandler.DefaultAction, StringComparison.Ordinal))
                    {
                        attributeRouteModel.Template = actionName;
                        // Add the action name as it is eg: about will be about!
                        LocalizationRouteDataHandler.AddActionRouteData(controllerName, actionName, LocalizationRouteDataHandler.DefaultCulture, actionName, actionName, sortedRouteParameters);
                    }
                    else
                    {
                        // For DefaultAction we don't want to have a template.
                        // Because the default action is reachable with /Controller as the url.
                        attributeRouteModel.Template = "";
                        LocalizationRouteDataHandler.AddActionRouteData(controllerName, actionName, LocalizationRouteDataHandler.DefaultCulture, "", controllerName, sortedRouteParameters);
                    }

                    AddAttributeRouteModel(action.Selectors, attributeRouteModel);
                }
                // If a route already existed then check for parameter arguments to add to the cultural routes
                else
                {
                    string template = defaultSelectionModel.AttributeRouteModel.Template;

                    parameterTemplate = ParseParameterTemplate(template, sortedRouteParameters);

                    LocalizationRouteDataHandler.AddActionRouteData(controllerName, actionName, LocalizationRouteDataHandler.DefaultCulture, actionName, actionName, sortedRouteParameters);
                }

                var localizedActions = CreateLocalizedActionRoutes(controllerModel, action, parameterTemplate, sortedRouteParameters);
                newActions.AddRange(localizedActions);
            } // End foreach controllerModel.Actions

            // Now add all the new actions to the controller
            foreach (ActionModel action in newActions)
            {
                controllerModel.Actions.Add(action);
            }
        }

        /// <summary>
        /// Create the new list of action models for each localized route.
        /// </summary>
        /// <param name="controllerModel"></param>
        /// <param name="actionModel"></param>
        /// <param name="parameterTemplate"></param>
        /// <param name="sortedRouteParameters"></param>
        /// <returns></returns>
        public List<ActionModel> CreateLocalizedActionRoutes(ControllerModel controllerModel, ActionModel actionModel, string parameterTemplate, List<string> sortedRouteParameters)
        {
            string controllerName = controllerModel.ControllerName;
            string actionName = actionModel.ActionName;
            var actionLocalizationsAttributes = actionModel.Attributes.OfType<LocalizationRouteAttribute>().ToList();

            List<ActionModel> localizedActions = new List<ActionModel>();

            // For default actions we need to check if the [LocalizationRoute] Attribute exists or not.
            // This is so we can name the default action after the controller
            // For example otherwise HomeController for finnish Culture would be Home instead of Koti.
            if (actionName.Equals(LocalizationRouteDataHandler.DefaultAction, StringComparison.OrdinalIgnoreCase))
            {
                HashSet<string> cultures = LocalizationRouteDataHandler.SupportedCultures.Select(kvp => kvp.Key).ToHashSet();

                cultures.Remove(LocalizationRouteDataHandler.DefaultCulture);
                cultures.RemoveWhere(x => actionLocalizationsAttributes.FirstOrDefault(attr => attr.Culture == x) != null);

                // Iterate over all controllers that had a [LocalizationRoute]
                foreach (string culture in cultures)
                {
                    // The localized controller name is the link for the index action
                    string localizedControllerName = GetLocalizedControllerName(controllerModel, culture);
                    if (!localizedControllerName.Equals(controllerName, StringComparison.OrdinalIgnoreCase))
                    {
                        LocalizationRouteDataHandler.AddActionRouteData(controllerName, actionName, culture, "", localizedControllerName, sortedRouteParameters);
                    }
                }
            }

            foreach (LocalizationRouteAttribute attribute in actionLocalizationsAttributes)
            {
                string route = attribute.Route + parameterTemplate;
                // This copies all existing Attributes on the ActionModel,  [Route] [HttpGet] e.t.c.
                // Source file: https://github.com/aspnet/Mvc/blob/dev/src/Microsoft.AspNetCore.Mvc.Core/ApplicationModels/ActionModel.cs
                ActionModel newLocalizedActionModel = new ActionModel(actionModel);

                // Clear the Selectors or it will have shared selector data from default route.
                // This however clears the ActionConstraints like [HttpGet] and [HttpPost]
                newLocalizedActionModel.Selectors.Clear();
                AttributeRouteModel newLocalizedAttributeRouteModel = new AttributeRouteModel();
                newLocalizedAttributeRouteModel.Template = route;
                // Add the new actionModel for adding to controller later
                localizedActions.Add(newLocalizedActionModel);

                AddAttributeRouteModel(newLocalizedActionModel.Selectors, newLocalizedAttributeRouteModel);
                // Bug mentioned by anonymous through a comment on blog.
                // This is where the [HttpGet], [HttpPost] constraints are added back after being cleared earlier.
                foreach (var actionConstraint in actionModel.Selectors.Where(x => x.ActionConstraints.Count > 0).SelectMany(x => x.ActionConstraints))
                {
                    newLocalizedActionModel.Selectors[0].ActionConstraints.Add(actionConstraint);
                }

                string linkName = attribute.Link;
                // If the action is default (Index) and there is no LinkName set
                // Then the linkName should be the controllers name
                if (actionName.Equals(LocalizationRouteDataHandler.DefaultAction, StringComparison.OrdinalIgnoreCase) && !String.IsNullOrEmpty(linkName))
                {
                    linkName = GetLocalizedControllerName(controllerModel, attribute.Culture);
                }

                // Add the localized route for the action
                // Example of final route:  "fi/koti" + "/" + "ota_yhteyttä"
                LocalizationRouteDataHandler.AddActionRouteData(controllerName, actionName, attribute.Culture, attribute.Route, linkName, sortedRouteParameters);
            }

            return localizedActions;
        }

        /// <summary>
        /// Get the localized controller name for a specific culture.
        /// If there is no [LocalizationRoute] attribute the controllerMode.ControllerName is returned.
        /// </summary>
        /// <param name="controllerModel"></param>
        /// <param name="culture"></param>
        /// <returns></returns>
        public string GetLocalizedControllerName(ControllerModel controllerModel, string culture)
        {
            var localizationRouteAttributes = controllerModel.Attributes.OfType<LocalizationRouteAttribute>().ToList();

            string name = controllerModel.ControllerName;

            foreach (LocalizationRouteAttribute attribute in localizationRouteAttributes)
            {
                if (attribute.Culture == culture)
                {
                    if (!String.IsNullOrWhiteSpace(attribute.Link))
                    {
                        name = attribute.Link;
                    }
                    // If attribute.Link isn't set
                    // Then we extract the Link name from the Route
                    else if (!String.IsNullOrWhiteSpace(attribute.Route))
                    {
                        name = LocalizationRouteAttribute.ConvertRouteToLink(culture, attribute.Route);
                    }

                    break;
                }
            }

            return name;
        }

        /// <summary>
        /// Parses the input template and returns a parsed template that only contains the {parameter} values.
        /// It also adds parameters it encounters to the sortedRouteParameters list.
        /// </summary>
        /// <param name="template"></param>
        /// <param name="sortedRouteParameters"></param>
        /// <returns></returns>
        public string ParseParameterTemplate(string template, List<string> sortedRouteParameters)
        {
            string parameterTemplate = "";
            // Check if the route has parameters
            string[] actionComponents = template.Split('/');

            for (int i = 0; i < actionComponents.Length; i++)
            {
                string actionComponent = actionComponents[i];
                // In case of "/action/"
                if (actionComponent.Length == 0)
                {
                    continue;
                }

                // Check if first character starts with {
                if (actionComponent[0] == '{')
                {
                    // Extract the name
                    // Example of an part: { moo = 5 }
                    string name = GetParameterName(actionComponent);

                    // TODO: Evaluate if continue should be used for action or controller
                    if (name != "action" && name != "controller")
                    {
                        sortedRouteParameters.Add(name);
                    }

                    parameterTemplate += "/" + actionComponents[i];
                }
            }

            return parameterTemplate;
        }

        // More information: https://docs.microsoft.com/en-us/aspnet/core/fundamentals/routing#route-constraint-reference
        /// <summary>
        /// Gets the parameter name from a {parameter}
        /// </summary>
        /// <param name="actionComponent"></param>
        /// <returns></returns>
        public string GetParameterName(string actionComponent)
        {
            // Example: { param:int = 1 }
            string name = actionComponent.Split(new char[] { '=', ':' }).First();
            // Remove whitespace since that's invalid!
            // Also remove the { character with SubString(1)
            name = Regex.Replace(name, @"\s+", "").Substring(1);
            // If last character is } which it can be if it wasn't split on '=' or ':'
            if (name[name.Length - 1] == '}')
            {
                // Then remove that!
                name = name.Remove(name.Length - 1);
            }

            return name.ToLower();
        }
    }

LocalizationRouteDataHandler.cs

This is the class where everything is stored so the TagHelpers can find out what url and link text to generate. The localized url comes from the method GetUrl() GetUrl will return a LocalizationUrlResult struct which has the localized data.

LocalizationUrlResult


    public struct LocalizationUrlResult
    {
        /// <summary>
        /// The actual url meant for an anchor tag <a href="Url">
        /// </summary>
        public string Url;
        /// <summary>
        /// The link name for anchor tags. <a>LinkName</a>
        /// </summary>
        public string LinkName;
    }

How GetUrl works

The declaration for GetUrl is: public static LocalizationUrlResult GetUrl(string controller, string action, string culture).
So it requires a controller, action and culture as input. It then converts controller and action to lowercase so it's case insensitive. Then it checks if route data exists for the controller and action. It also checks if the controller or action are default controller or action. If they are defaults then the url is just the culture: /culture/. Or if action is default then the url is culture + localized controller only /culture/controller

Code for LocalizationDataHandler.cs

        
    /// <summary>
    /// LocalizationRouteDataHandler handles storing all the localized route data and generating the localized urls.
    /// It also has the supported cultures and default culture.
    /// Set the supported cultures and default culture either in the class itself or in Startup.cs
    /// </summary>
    public static class LocalizationRouteDataHandler
    {
        /// <summary>
        /// The default culture.
        /// </summary>
        public static string DefaultCulture { get; set; }
        /// <summary>
        /// The dictionary of all supported cultures.
        /// Key = Culture Name
        /// Value = Display Name
        /// Example: en, English
        /// </summary>
        public static Dictionary<string, string> SupportedCultures { get; set; }

        public static string DefaultController { get; set; } = "Home";
        public static string DefaultAction { get; set; } = "Index";

        // Example for controller Home:
        // Home {
        //      Names = [ home, fi/koti ],
        //      Actions = {
        //          About = {
        //              UrlData = {
        //                  en = { Url = about, Link = About },
        //                  fi = { Url = meistä, Link = Meistä }
        //              },
        //              ParametersData = []
        //          }
        //      }
        // }
        /// <summary>
        /// All the routes and their cultural representation.
        /// </summary>
        // Will never get modified after initialization is done so Dictionary should be thread safe.
        public static Dictionary<string, CultureControllerRouteData> ControllerRoutes { get; } = new Dictionary<string, CultureControllerRouteData>();

        /// <summary>
        /// Add Controller Route data
        /// </summary>
        /// <param name="controller"></param>
        /// <param name="culture"></param>
        /// <param name="route"></param>
        public static void AddControllerRouteData(string controller, string culture, string route)
        {
            string controllerKey = controller.ToLower();

            // If the controller doesn't exist, create it!
            if (!ControllerRoutes.ContainsKey(controllerKey))
            {
                ControllerRoutes.TryAdd(controllerKey, new CultureControllerRouteData());
            }
            ControllerRoutes[controllerKey].Names.TryAdd(culture, route);
        }

        /// <summary>
        /// Add the action data.
        /// Will throw exception if the controller doesn't exist
        /// </summary>
        /// <param name="controller"></param>
        /// <param name="action"></param>
        /// <param name="culture"></param>
        /// <param name="route"></param>
        /// <param name="linkName"></param>
        public static void AddActionRouteData(string controller, string action, string culture, string route, string linkName, List<string> routeParameters)
        {
            string actionKey = action.ToLower();

            CultureControllerRouteData controllerData = ControllerRoutes[controller.ToLower()];
            if (!controllerData.Actions.ContainsKey(actionKey))
            {
                controllerData.Actions.TryAdd(actionKey, new CultureActionRouteData(routeParameters));
            }

            controllerData.Actions[actionKey].UrlData.TryAdd(culture, new CultureUrlRouteData(route, linkName));
        }

        /// <summary>
        /// Get the url for a controller & action based on culture
        /// </summary>
        /// <param name="controller"></param>
        /// <param name="action"></param>
        /// <param name="culture"></param>
        /// <returns></returns>
        public static LocalizationUrlResult GetUrl(string controller, string action, string culture)
        {
            LocalizationUrlResult result = new LocalizationUrlResult();
            string controllerKey = controller.ToLower();
            string actionKey = action.ToLower();

            if (ControllerRoutes.ContainsKey(controllerKey))
            {
                CultureControllerRouteData controllerData = ControllerRoutes[controllerKey];

                if (controllerData.Actions.ContainsKey(actionKey))
                {
                    bool isDefaultController = controller.Equals(DefaultController, StringComparison.OrdinalIgnoreCase);
                    bool isDefaultAction = action.Equals(DefaultAction, StringComparison.OrdinalIgnoreCase);
                    bool isDefaultCulture = culture == DefaultCulture;

                    // Ok now we have the controller name and action data name!
                    CultureActionRouteData actionData = controllerData.Actions[actionKey];
                    CultureUrlRouteData linkData = actionData.UrlData.ContainsKey(culture) ? actionData.UrlData[culture] : actionData.UrlData[DefaultCulture];

                    string controllerUrl = controllerData.Names.ContainsKey(culture) ? controllerData.Names[culture] : "";
                    // The actionUrl is "" for default action
                    string actionUrl = linkData.Route;
                    // TODO: Evaluate if default culture also should use the linkData
                    // The cms-keep-link attribute would be used otherwise.
                    string linkName = isDefaultCulture ? "" : linkData.Link;

                    // If default controller & action then the url should be
                    // Default: /
                    // Others:  /culture
                    if (isDefaultController && isDefaultAction)
                    {
                        controllerUrl = isDefaultCulture ? "" : culture;
                    }
                    else
                    {
                        // Check if culture is default culture
                        if (!isDefaultCulture)
                        {
                            // If the controller doesn't exist add the culture as prefix to the controller name
                            if (!controllerData.Names.ContainsKey(culture))
                            {
                                controllerUrl = culture + "/" + controller;
                            }
                        }
                    }

                    // So that the url is {controller}/{action} instead of {controller}{action}
                    if (!isDefaultAction)
                    {
                        controllerUrl += "/";
                    }

                    result.Url = "/" + controllerUrl + actionUrl;
                    result.LinkName = linkName;
                }
                // A controller was found with an incorrect action.
                else
                {
                    // Return just the controller url?
                    // For now explicitly throw an exception!
                    throw new ArgumentException("A controller was found without a valid action. Check that the action key is correct.");
                }
            }
            // No controller was found
            else
            {
                // As for the invalid argument more gracefully throw the error?
                throw new ArgumentException("No controller was found with that name. Check that the controller key is correct.");
            }

            return result;
        }

        /// <summary>
        /// For example: /{controller}/{action}/{param1}/{param2}
        /// Then it will return values of {param1}/{param2} in the right order based off routeValues
        /// </summary>
        /// <param name="controllerName"></param>
        /// <param name="actionName"></param>
        /// <param name="routeValues"></param>
        /// <returns></returns>
        public static string GetOrderedParameters(string controller, string action, Dictionary<string, string> routeValues)
        {
            string controllerKey = controller.ToLower();
            string actionKey = action.ToLower();

            string result = "";

            if (ControllerRoutes.ContainsKey(controllerKey))
            {
                CultureControllerRouteData controllerData = ControllerRoutes[controllerKey];

                if (controllerData.Actions.ContainsKey(actionKey))
                {
                    CultureActionRouteData actionData = controllerData.Actions[actionKey];
                    if (actionData.ParametersData != null)
                    {
                        foreach (string parameter in actionData.ParametersData)
                        {
                            if (routeValues.ContainsKey(parameter))
                            {
                                result += "/" + routeValues[parameter];
                            }
                            // Otherwise we found parameter data that isn't accounted for.
                            else
                            {
                                break;
                            }
                        }
                    }
                }
            }

            return result;
        }

        /// <summary>
        /// Get the culture from an url by checking if the href starts with /culture/
        /// So there is possibility of a collision if a controller is called a culture!
        /// So don't name them cultures!!
        /// Note: CultureInfo.CurrentCulture.Name is a good way of getting the culture for the current request.
        /// </summary>
        /// <param name="url"></param>
        /// <returns></returns>
        public static string GetCultureFromUrl(string url)
        {
            foreach(var kvp in SupportedCultures)
            {
                if (url.StartsWith("/" + kvp.Key + "/"))
                {
                    return kvp.Key;
                }
            }
            return DefaultCulture;
        }
    }

Other files

Some other small files mainly used for data structures.

CultureControllerRouteData.cs

This class is for the static dictionary ControllerRoutes in the LocalizationRouteDataHandler. The CultureActionRouteData Actions dictionary keys are the action method names for the controller in lowercase.

        
    /// <summary>
    /// The controller data for cultures stored in LocalizationRouteDataHandler.
    /// </summary>
    public class CultureControllerRouteData
    {
        /// <summary>
        /// Different names of the controller in different cultures.
        /// The name is used when constructing the localized Url.
        /// Example:
        /// en: Home
        /// fi: fi/koti
        /// </summary>
        public Dictionary<string, string> Names { get; }

        /// <summary>
        /// The actions for the controller.
        /// </summary>
        public Dictionary<string, CultureActionRouteData> Actions { get; }

        public CultureControllerRouteData()
        {
            Names = new Dictionary<string, string>();
            Actions = new Dictionary<string, CultureActionRouteData>();
        }
    }

CultureActionRouteData.cs

For the dictionary Actions in the CultureControllerRouteData. The UrlData dictionary keys are the cultures. So en, fi or sv in my implementation.

The ParametersData is a sorted string array of all parameters for the action. If the action has no parameters then ParametersData is null.

        
    /// <summary>
    /// The action data for the cultures stored in a CultureControllerData object.
    /// </summary>
    public class CultureActionRouteData
    {
        /// <summary>
        /// Different action names in different cultures.
        /// The keys are the cultures.
        /// </summary>
        public Dictionary<string, CultureUrlRouteData> UrlData { get; }

        /// <summary>
        /// The parameters by name sorted in order.
        /// Example Controller/Action/{first}/{second}
        /// [0]: first
        /// [1]: second
        /// </summary>
        public readonly string[] ParametersData;

        public CultureActionRouteData(List<string> parametersData)
        {
            UrlData = new Dictionary<string, CultureUrlRouteData>();
            // If the parameters data has any entries then convert it to a read only array.
            if (parametersData.Count > 0)
            {
                ParametersData = parametersData.ToArray();
            }
            else
            {
                ParametersData = null;
            }
        }
    }

CultureUrlRouteData.cs

The class used in the CultureActionRouteData UrlData dictionary value. It has the localized action route and link text. The controller route part is from CultureControllerRouteData.Names


    /// <summary>
    /// The route data for each culture for an action in CultureActionData.
    /// </summary>
    public class CultureUrlRouteData
    {
        public readonly string Route;
        /// <summary>
        /// The innerText for an anchor tag when you use the anchor tag helper.
        /// <a>Link</a>
        /// </summary>
        public readonly string Link;

        public CultureUrlRouteData(string route, string link)
        {
            Route = route;
            Link = link;
        }
    }

Examples

In the example project on github the cultures are en, fi and sv. Where en is the default culture. Here are some examples for two controllers, global error handling, a culture change partial view and usage of IViewLocalizer for the view.

HomeController

This is the default controller with 3 localized routes in total. Note that the finnish route koti is lowercase and the swedish Hem isn't. Both are reachable in any upper/lower case combination but the route that is written in the [LocalizedRoute] attribute is what is used when generating the url.


    // Routes for each culture:
    // Default: /Home           - / for the Index action
    // Finnish: /fi/koti        - /fi for the Index action.
    // Swedish: /sv/Hem         - /sv for the Index action.
    [LocalizationRoute("fi", "koti")]
    [LocalizationRoute("sv", "Hem", "Hemma")] // The link text for <a>linktext</a> will be Hemma
    public class HomeController : LocalizationController
    {
        public IActionResult Index()
        {
            return View();
        }

        // Routes for each culture:
        // Default: /Home/About
        // Finnish: /fi/koti/meistä
        // Swedish: /sv/Hem/om
        [LocalizationRoute("fi", "meistä")]
        [LocalizationRoute("sv", "om")]
        public IActionResult About()
        {
            ViewData["Message"] = "Your application description page.";

            return View();
        }

        // Routes for each culture:
        // Default: /Home/Contact
        // Finnish: /fi/koti/ota_yhteyttä
        // Swedish: /sv/Hem/kontakta-oss
        [LocalizationRoute("fi", "ota_yhteyttä")]                  // Automatically converts ota_yhteyttä to Ota Yhteyttä for the link text
        [LocalizationRoute("sv", "kontakta-oss", "Kontakta Oss")]  // Explicitly tell the link text to be Kontakta Oss
        public IActionResult Contact()
        {
            ViewData["Message"] = "Your contact page.";

            return View();
        }
    }

ExampleController

A controller using parameterised HttpGet and HttpPost for a form.


    // Routes for each culture:
    // Default: /Example
    // Finnish: /fi/exampleFi
    // Swedish: /sv/Example     - Takes the name of controller since no [LocalizationRoute] for swedish culture
    // The link text for <a> tags will be ExampleFi
    [LocalizationRoute("fi", "exampleFi")]
    public class ExampleController : LocalizationController
    {
        public ExampleController()
        {
        }

        public IActionResult Index()
        {
            return View();
        }

        // Routes for each culture:
        // Default: /Example/Parameter/{index}/{test}
        // Finnish: /fi/exampleFi/param/{index}/{test}
        // Swedish: /sv/Example/Parameter/{index}/{test}        - Gets the Action name automatically because no [LocalizationRoute] attribute
        // [HttpGet("parameter/{index}/{test}")]                - [HttpGet] can be used instead of [Route]
        [Route("parameter/{index}/{test}")]
        [LocalizationRoute("fi", "param")]
        public IActionResult Parameter(int index, string test)
        {
            ViewData["index"] = index;
            ViewData["test"] = test;
            ViewData["post"] = false;
            return View();
        }

        // Routes for each culture:
        // Default: /Example/Parameter
        // Finnish: /fi/exampleFi/param
        // Swedish: /sv/Example/Parameter
        [HttpPost()]
        [LocalizationRoute("fi", "param")]
        public IActionResult Parameter(ParameterViewModel model)
        {
            ViewData["index"] = model.Index;
            ViewData["test"] = model.Test;
            ViewData["post"] = true;
            return View(model);
        }
    }

IViewLocalizer

The project uses IViewLocalizer to do translations for the view texts. It requires some setup to set the IViewLocalizer up. To use the IViewLocalizer I had to install the NuGet package Microsoft.AspNetCore.Mvc.Localization.
There's more information about localizing texts here: docs.microsoft.com/en-us/aspnet/core/fundamentals/localization?view=aspnetcore-2.1

Startup.cs

The code required in Startup.cs for IViewLocalizer


    public void ConfigureServices(IServiceCollection services)
    {
        // ... Other code above or below

        // Setup resource file usage and IViewLocalizer
        services.AddLocalization(options => options.ResourcesPath = "Resources");
        // Views.Shared._Layout is for the /Views/Shared/_Layout.cshtml file
        mvcBuilder.AddViewLocalization(LanguageViewLocationExpanderFormat.Suffix)
        // Add support for localizing strings in data annotations (e.g. validation messages) via the
        // IStringLocalizer abstractions.
        .AddDataAnnotationsLocalization();
    }

        

_ViewImports.cshtml

I chose to inject an IViewLocalizer for all the views in the _ViewImports.cshtml So that an instance called Localizer is always available.


    @using Microsoft.AspNetCore.Mvc.Localization
    @inject IViewLocalizer Localizer

        

_Layout.cshtml

A short snippet from the default layout edited to use the localized routing. This is the default navbar in a new ASP.NET project edited.


    <div class="navbar-collapse collapse">
        <ul class="nav navbar-nav">
            <li><a asp-controller="Home" asp-action="Index" cms-culture="@ViewData["culture"]">Home</a></li>
            <li><a asp-controller="Home" asp-action="About" cms-culture="@ViewData["culture"]">About</a></li>
            <li><a asp-controller="Home" asp-action="Contact" cms-culture="@ViewData["culture"]">Contact</a></li>
            <li><a asp-controller="Example" asp-action="Index" cms-culture="@ViewData["culture"]">Example test</a></li>
            <li><a asp-controller="Example" asp-action="Parameter" asp-route-index="5" asp-ROUTE-test="@ViewData["culture"]" cms-culture="">example param</a></li>
            <li><a asp-controller="Example" asp-action="Parameter" asp-all-route-data="routeData" cms-culture="" cms-keep-link="true">example param 2</a></li>
        </ul>
    </div>

Global Errorhandling

Added a global error handler so the 404 errors e.t.c. are localized. It's done by using a controller and set the error handling in Startup.cs It doesn't have any [LocalizationRoute] attributes because in Startup.cs any errors are re-executed to the ErrorController. So that the url stays the same that made error happen.


    public class ErrorController : Controller
    {
        [Route("Error")]
        public IActionResult Index()
        {
            return View();
        }

        // Need the [Route] attribute or it doesn't work. It'd just be a blank screen.
        [Route("/Error/{0}")]
        public IActionResult Index(int error)
        {
            return View();
        }
    }

        

Error setup in Startup.cs

The code used in Startup.cs to enable the error handling.


    public void Configure(IApplicationBuilder app, IHostingEnvironment env)
    {
        // ... Other code above or below

        app.UseExceptionHandler("/Error");
        app.UseStatusCodePagesWithReExecute("/Error/{0}");
    }

        

Error view

The razor layout for the error view. The view uses the IViewLocalizer Localizer instance that's injected in the _ViewImports.cshtml


    @{
        ViewData["Title"] = Localizer["Error"];
    }

    <h2>@Localizer["An error has occured"]</h2>

        

Culture changing partial

This is an example of changing the culture and keep the controller and action that you're currently on as a user. It also tries to maintain the route parameters.


    @using System.Globalization
    @using localization.Localization
    @{

        string culture = ViewData["culture"] as string;
        if (culture == null)
        {
            culture = CultureInfo.CurrentCulture.Name;
        }

        string controller = ViewData.ContainsKey("controller") ? ViewData["controller"] as string : LocalizationRouteDataHandler.DefaultController;
        string action = ViewData.ContainsKey("action") ? ViewData["action"] as string : LocalizationRouteDataHandler.DefaultAction;

        // Get the route data so parameter data is maintained when changing cultures
        // ViewContext.RouteData.Values seems to always be set, so the `?` is probably unnecessary.
        Dictionary<string, string> routeData = ViewContext.RouteData?.Values.ToDictionary(kvp => kvp.Key, kvp => kvp.Value as string);
    }

    <ul class="nav navbar-nav navbar-right">
        @foreach (var kvp in LocalizationRouteDataHandler.SupportedCultures)
        {
            // Skip the current culture
            if (kvp.Key == culture) {
                continue;
            }
            <li>
                <a asp-controller="@controller" asp-action="@action" cms-culture="@kvp.Key" cms-keep-link="true" asp-all-route-data="routeData" >@kvp.Value</a>
            </li>
        }
    </ul>

        

Changes upgrading from ASP.NET Core release candidate 1 to 2.1.2

Here's a brief list of changes done to upgrade from the old blog post. A lot changed with ASP.NET Core from beta to 2.0! :) This is a link to the github pull request changes. Some of the changes are from upgrading from Visual Studio 2015 to 2017. Many changes also come from upgrading the default ASP.NET authentication template.

  • Updated namespaces from using Microsoft.AspNet.Mvc; to using Microsoft.AspNetCore.Mvc;
  • LocalizedRouteConvention.cs had the most breaking changes. AttributeRoutes property removed from ControllerModel. AttributeRouteModel property removed from ActionModel. Instead a property called Selectors which is an IList<SelectorModel> has been added to both controller and action models. The ActionModel is being cloned for each culture to set up the routing for each action. But since it is cloned it also copies the Selectors list which meant it needs to be cleared or it would have duplicate routes.
  • Startup.cs had some compilation errors changes as well. The constructor for RequestLocalizationOptions had changed. And how the Convention was added, instead of calling Insert() you now call Add()!

Conclusion

It works! I haven't tested this against other cultures like russian, japanese characters if it works as nice there. But for me this works and I hope it does for you too if you use it.

Also thank you readers that have contacted me about issues with the localized routing so that I can improve it for everyone.

Changes made after initial version

When updating from ASP.NET Core 1 to 2 it caused some regressions and loss of feature but they should be resolved since version 2.0.2.

Here is a list of major changes made to fix bugs or other improvements after this was posted on 2017.12.27.
The full changelog can be found here: github.com/saaratrix/asp.net-mvc-core-1.0-localized-routing/blob/master/changelog.md.

2.0.2

  • Fixed bugs and regressions from upgrading ASP.NET Core 1 to 2. A reader called Jesus reported some of the bugs that were solved.
  • Added examples for localized error handling, IViewLocalizer, culture selector.

2.0.1

  • Fixed a bug from upgrading to 2.0 reported by a comment written by Ruedi with [HttpGet] and [HttpPost] The bug was with the new Selectors and they were cleared in the convention when copying the ActionModel which as a side effect removed the [HttpGet] and [HttpPost] constraints. So the solution that Ruedi in the comment gave fixed the bug by adding the action constraints back to the selectors.
  • Improved routing generated from anchor TagHelper when using asp-route-parameter. This was done through updating the LocalizedRouteConvention.cs so that it used the variable string route = attribute.Route + parameterTemplate;. Instead of just using the attribute.Route

References

The references used in this article.

  1. https://github.com/saaratrix/asp.net-core-mvc-localized-routing
  2. https://www.strathweb.com/2015/11/localized-routes-with-asp-net-5-and-mvc-6/
  3. https://www.jerriepelser.com/blog/how-aspnet5-determines-culture-info-for-localization
  4. https://docs.microsoft.com/en-us/aspnet/core/mvc/views/tag-helpers/intro?view=aspnetcore-2.1
  5. https://docs.microsoft.com/en-us/aspnet/core/fundamentals/localization?view=aspnetcore-2.1

19 comments:

  1. Great work! Used version 1.0 before sucessfully.
    Had to ammend the new version as I don't have english and also don't want to show home which was great in V1.
    There is also a bug if there is a [HttpGet] and a [HttpPost] method whith the same name.
    Works fine with the default language but not for the other languages.

    The reason is, that the ActionConstraint is missing.

    So, I modified the method AddActionRoutes in LocalizedRouteConvention as follows.

    ......
    foreach ( LocalizedRouteAttribute attribute in actionLocalizationsAttributes ) {
    string route = attribute.Route + parameterTemplate;
    ActionModel newLocalizedActionModel = new ActionModel( action );

    // Clear the Selectors or it will have shared selector data
    newLocalizedActionModel.Selectors.Clear();
    AttributeRouteModel newLocalizedAttributeRouteModel = new AttributeRouteModel();
    newLocalizedAttributeRouteModel.Template = attribute.Route;
    // Add the new actionModel for adding to controller later
    newActions.Add( newLocalizedActionModel );

    AddAttributeRouteModel( newLocalizedActionModel.Selectors, newLocalizedAttributeRouteModel );
    defaultSelectionModel = action.Selectors.FirstOrDefault( x => x.ActionConstraints.Count > 0 );

    if ( defaultSelectionModel != null )
    newLocalizedActionModel.Selectors[0].ActionConstraints.Add( defaultSelectionModel.ActionConstraints[0] );

    // Add the localized route for the action
    // Example of final route: "fi/koti" + "/" + "ota_yhteyttä"
    LocalizationDataHandler.AddActionData( controllerName, actionName, attribute.Culture, attribute.Route, attribute.Link );
    }

    ReplyDelete
  2. Not longer the anonymous guy ;-). Have anonther suggestion and also a question.

    I changed type of SupportedCultures to Dictonary instead of List.
    So, I can show the named cultures in the menu for changing the language.

    LocalizationDataHandler.cs
    ---------------------------
    public static Dictionary SupportedCultures { get; set; }

    LocalizedRouteConvention.cs
    ---------------------------
    Dictionary foundCultures = new Dictionary( LocalizationDataHandler.SupportedCultures );


    Startup.cs (e.g.)
    ------------------
    LocalizationDataHandler.SupportedCultures = new Dictionary {
    {"de", "Deutsch"},
    {"fr", "Français"},
    {"it", "Italiano"}
    };

    _Layout.cshtml
    ---------------

    foreach ( KeyValuePair supportedCulture in LocalizationDataHandler.SupportedCultures ) {
    if ( ViewData["culture"] != null && ViewData["culture"].ToString() == supportedCulture.Key ) {
    continue;
    }


    }

    => (change i_d to id)
    ------------------------------------------------------
    Question:

    How can I force the application using the default culture 'de'
    e.g. instead of test.com => test.com/de ?

    ReplyDelete
    Replies
    1. Hi Ruedi, thanks for the feedback!

      In regards to your question, do you want test.com to always reroute to test.com/de ?

      If it should always reroute then I'd either add a middleware for the IApplicationBuilder app in startup.cs
      Or extend the functionality in the LocalizationController.cs OnActionExecuting

      I tried the LocalizationController route by adding these lines right after the base.OnActionExecuting(context) line.
      LocalizationController.cs
      ----------
      base.OnActionExecuting(context);

      // This checks that the incoming request path starts with /{culture}/
      bool hasCulture = LocalizationDataHandler.HasCultureInUrl(HttpContext.Request.Path);
      if (!hasCulture)
      {
      // Make sure permanent redirect is false
      // Otherwise you need to clear browser history because it'll have saved the 301 redirect for you.
      // Also it's probably a bad idea to permanently redirect / to something else if you in the future want to change the routing!
      context.Result = new RedirectResult("de/", false);
      // Might not need the return
      return;
      }

      And the function HasCultureInUrl()
      LocalizationDataHandler.cs
      -----------
      public static bool HasCultureInUrl(string a_url)
      {
      foreach (string culture in SupportedCultures)
      {
      if (a_url.StartsWith("/" + culture + "/"))
      {
      return true;
      }
      }
      return false;
      }

      Delete
    2. Hi Saaratrix,
      thanks for reply and your example which works fine!

      Also tried the middleware (s. below).

      --------------------------------
      Startup.cs
      app.UseMiddleware<RedirectUnsupportedCulturesMiddleware>( requestLocalizationOptions );

      Middleware:
      public class RedirectUnsupportedCulturesMiddleware {

      private readonly RequestDelegate next;
      private readonly UrlCultureProvider provider;

      public RedirectUnsupportedCulturesMiddleware( RequestDelegate next, RequestLocalizationOptions options ) {
      this.next = next;
      this.provider = options.RequestCultureProviders.OfType().FirstOrDefault();
      }

      public async Task Invoke( HttpContext context ) {
      if ( !this.HasCultureInUrl( context.Request.Path ) ) {
      // Make sure permanent redirect is false
      // Otherwise you need to clear browser history because it'll have saved the 301 redirect for you.
      // Also it's probably a bad idea to permanently redirect / to something else if you in the future want to change the routing!
      context.Response.Redirect( $"{this.provider.DefaultCulture}/", false );
      return;
      }

      // Call the next delegate/middleware in the pipeline
      await this.next.Invoke( context );
      }

      ///
      /// Checks that the incoming request path starts with /{culture} or /{culture}/
      /// and is also supported by the application.
      ///
      private bool HasCultureInUrl( string url ) {
      string requestedCulture = string.Empty;

      Regex regex = new Regex( "^/(?.{2})/?" );

      Match match = regex.Match( url );

      if ( match.Success )
      requestedCulture = match.Groups["culture"].Value;

      return this.provider.SupportedCultures.Contains( new CultureInfo( requestedCulture ) );
      }
      }

      Delete
  3. Excellent Post!!

    Do you have any solution for translate the controller name?

    ReplyDelete
    Replies
    1. Hi Jesus,

      It follows the same pattern as for each action.
      For example for the AccountController that comes with a new asp.net project you would do the following:

      // culture, translation
      [LocalizedRoute("sv", "konto")]
      public class AccountController : LocalizationController

      Which would create the route for AccountController
      /sv/konto

      And then for action login you do the same:
      [LocalizedRoute("sv", "logga-in", "Logga in")]
      public async Task Login(string returnUrl = null)

      Which gives you the final route of:
      /sv/konto/logga-in

      Which is the same as
      /Account/Login

      Delete
    2. Hi again,

      I will try to find any solution for the issue I have. Let me explain the problem I have:

      I try to add the [LocalizedRoute("sv", "konto")] in the Home controller, also I add it for the fi language.

      I add an option for change the language, when I select ‘sv’ works almost fine.. the translation for the controller is added correctly, but the translation for the actions never change, continue the default translation for English, this is: /sv/konto/about

      That’s a problem with the languages that are not the default; when you change to the default language (en), the pages are in blank because the actions are repeated by the method. For example: /Home/about/about

      Do you have a email account where I can send you the project with the changes I did?, in that you will see what I’m talking about

      Delete
    3. Hi Jesus, the repetition of the actions sounds like this issue:
      https://github.com/haestflod/asp.net-mvc-core-1.0-localized-routing/issues/3

      The workaround is to remove [Route("[controller]/[action]")] at the controller declaration if you have that exact issue.

      Otherwise my email is saaratrix@gmail.com and I'll have a look.

      Delete
  4. Hi Saara , great post and editing!
    I want to localize by the domain's TLD, so the routes look like
    domain.es/ofertas/coches
    doamin.en/offers/cars
    and so on.
    Do you know any refference about this?
    Thanks in advance, Ernesto

    ReplyDelete
  5. Hi I want to use the UrlCultureProvider combined with QueryString, Cookies,AcceptLanguage Header Culture providers,
    If I add UrlCulture at the end the functionality is not working , If i add it to the start the QueryString is not working .Do you have any suggestions on this?
    options.RequestCultureProviders = new List()
    {

    new QueryStringRequestCultureProvider(),
    new CookieRequestCultureProvider(),
    new AcceptLanguageHeaderRequestCultureProvider(),
    new UrlCultureProvider(options.SupportedCultures)

    };

    ReplyDelete
  6. This comment has been removed by the author.

    ReplyDelete
  7. Магазин питания для спорта, официальный портал которого доступен по адресу: SportsNutrition-24.Com, реализует огромный ассортимент товаров, которые принесут пользу и достижения как проф спортсменам, так и любителям. Интернет-магазин осуществляет свою деятельность многие годы, предоставляя клиентам со всей России качественное спортивное питание, а кроме этого витамины и специальные препараты - Предтрен. Спортпит представляет собой категорию товаров, которая призвана не только лишь улучшить спортивные достижения, но и положительно влияет на здоровье организма. Схожее питание вводится в ежедневный рацион с целью получения микро- и макроэлементов, витаминов, аминокислот и белков, а помимо этого многих других недостающих веществ. Не секрет, что организм спортсмена в процессе наращивания мышечной массы и адаптации к повышенным нагрузкам, остро нуждается в должном количестве полезных веществ. При этом, даже правильное питание и употребление растительной, а кроме этого животной пищи - не гарантирует того, что организм получил необходимые аминокислоты или белки. Чего нельзя сказать о качественном питании для спорта. Об наборе товаров Интернет-магазин "SportsNutrition-24.Com" продает качественную продукцию, которая прошла ряд проверок и получила сертификаты качества. Посетив магазин, клиенты смогут найти для себя товары из следующих категорий: - L-карнитинг (Л-карнитин) представляет собой вещество, родственное витамину B, синтез которого осуществляется в организме; - гейнеры, представляющие из себя, белково-углеводные смеси; - BCAA - средства, содержащие в собственном составе три важные аминокислоты, стимулирующие рост мышечной массы; - протеин - чистый белок, употреблять который вы можете в виде коктейлей; - разнообразные аминокислоты; - а кроме этого ряд прочих товаров (нитробустеры, жиросжигатели, специальные препараты, хондропротекторы, бустеры гормона роста, тестобустеры и все остальное). Об оплате и доставке Интернет-магазин "SportsNutrition-24.Com" предлагает большое разнообразие товаров, которое в полной мере способно удовлетворить проф и начинающих любителей спорта, включая любителей. Большой опыт позволил фирмы сделать связь с крупнейшими поставщиками и производителями питания для спорта, что позволило сделать политику цен гибкой, а цены - демократичными! Например, аминокислоты либо гейнер приобрести можно по цене, которая на 10-20% ниже, чем у конкурентов. Оплата возможна как наличным, так и безналичным расчетом. Магазин предлагает огромный выбор способов оплаты, включая оплату разными электронными платежными системами, а помимо этого дебетовыми и кредитными картами. Главный офис компании размещен в Санкт-Петербурге, но доставка товаров осуществляется во все населенные пункты РФ. Помимо самовывоза, получить товар можно при помощи любой транспортной компании, подобрать которую каждый клиент может в личном порядке.

    ReplyDelete
  8. Многие пользователи, накручивающие Инстаграм на платных сервисах, сталкиваются с тем, что со временем часть подписчиков пропадает. Они либо списываются социальной сетью, или сами отписываются. Сайт Krutiminst.ru гарантируют качество - все подписчики реальные люди, и если подписчики ушли, то Krutiminst вернет потраченные деньги - накрутка просмотров инстаграм бесплатно

    ReplyDelete
  9. Инстраграмм являться самой популярной площадкой для продвижения своего бизнеса. Но, как показывает практика, люди еще чаще подписываются на профили в которых уже достаточное количество подписчиков. В случае если заниматься продвижение своими силами, потратить на это можно очень множество времени, потому еще лучше обратиться к специалистам из Krutiminst.ru тут https://lorenzovpia00987.verybigblog.com/16023993/ways-to-get-instagram-followers-rapid

    ReplyDelete
  10. Your car might be stolen if you don't keep this in mind!

    Imagine that your vehicle was taken! When you approach the police, they inquire about a specific "VIN search"

    A VIN decoder is what?

    Similar to a passport, the "VIN decoder" allows you to find out when the car was born and who its "parent"( manufacturing plant) is. Additionally, you can find:

    1.Type of engine

    2.Automobile model

    3.The DMV and the limitations it imposes

    4.Number of drivers in this vehicle

    You'll be able to locate the car, and keeping in mind the code ensures your safety. The code can be viewed in the online database. The VIN is situated on various parts of the car to make it harder for thieves to steal, such as the first person seated on the floor, the frame (often in trucks and SUVs), the spar, and other areas.

    What if the VIN is intentionally harmed?

    There are numerous circumstances that can result in VIN damage, but failing to have one will have unpleasant repercussions because it is illegal to intentionally harm a VIN in order to avoid going to jail or being arrested by the police. You could receive a fine of up to 80,000 rubles and spend two years in jail. You might be stopped by an instructor on the road.

    Conclusion.

    The VIN decoder may help to save your car from theft. But where can you check the car reality? This is why we exist– VIN decoders!

    ReplyDelete
  11. Your car might be stolen if you don't keep this in mind!

    Imagine that your car was taken! When you visit the police, they inquire about a specific "VIN decoder"

    A VIN decoder is what?

    Similar to a passport, the "VIN decoder" allows you to find out the date of the car's birth and the identity of its "parent" (manufacturing facility). Additionally, you can find:

    1.Type of engine

    2.Model of a car

    3.The DMV's limitations

    4.The number of drivers in this vehicle

    You'll be able to locate the car, and keeping in mind the code ensures your safety. The code can be viewed in the online database. The VIN is situated on various parts of the car to make it harder for thieves to steal, such as the first person's seat on the floor, the frame (often in trucks and SUVs), the spar, and other areas.

    What happens if the VIN is harmed on purpose?

    There are numerous circumstances that can result in VIN damage, but failing to have one will have unpleasant repercussions because it is illegal to intentionally harm a VIN in order to avoid going to jail or the police. You could receive a fine of up to 80,000 rubles and spend two years in jail. You might be held up on the road by a teacher.

    Conclusion.

    The VIN decoder may help to save your car from theft. But where can you check the car reality? This is why we exist– VIN decoders!

    ReplyDelete
  12. Система бонусов и поощрений в БК 1хбет значительно увеличивает привлекательность компании в глазах игроков. Выгодные предложения доступны и новеньким, и гостям, уже имеющим опыт работы на платформе. В числе внушительного ассортимента бонусной программы очень легко потеряться. Каждый промокод 1хбет обеспечивает право на определенные преференции - 1xbet промокод.

    ReplyDelete