Search code examples
asp.net-corerazorlocalizationblazor

Using URL path for localization in Razor + Blazor components


I want to build an ASP.NET Razor app with razor pages and some Blazor components, with site content being localized based on the language in the URL.

For example, /en/home and /fr/home would have one backing page that renders content based on the language.

What's a method to accomplish this?


Solution

  • AspNetCore.Mvc.Localization has what we need.

    Inside _ViewImports.cshtml, we can inject an IViewLocalizer which will grab .resx files for the corresponding pages.

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

    Now the Localizer is available inside all our pages.

    For example, Index.cshtml

    @page
    @model IndexModel
    
    @{
        ViewData["Title"] = @Localizer["Title"];
    }
    
    <h1>@Localizer["Header"]</h1>
    <section>
        <p>@Localizer["Welcome", User.Identity.Name]</p>
        @Localizer["Learn"]
        <a asp-page="Page1">@Localizer["SomePage"]</a>
        <a asp-page="Dogs/Index">@Localizer["LinkDogs"]</a>
    </section>
    

    Now the page title, header, and content is localized once the resx files are created.

    Resources/Pages/Index.resx and Resources/Pages/Index.fr.resx needs to be created. There is a VSCode extension available for this since these files are just ugly XML.

    Strings can be parameterized. In the Index.cshtml example, "Welcome"="Howdy {0}" gets referenced by @Localizer["Welcome", User.Identity.Name] and the username will be substituted in for {0}.

    Inside Startup.cs, we also need to add some setup.

                services.AddLocalization(options =>
                {
                    options.ResourcesPath = "Resources";
                }); // new
                services.AddRazorPages()
                    .AddRazorRuntimeCompilation()
                    .AddViewLocalization(); // new
                services.AddServerSideBlazor();
    

    But this only gives access to the Localizer inside our .cshtml files. Our pages still look like /home instead of /en/home.

    To fix this, we will add an IPageRouteModelConvention to modify our page templates, prepending {culture} to all our pages.

    Inside Startup.cs, we need to add the convention during razor config.

                services.AddRazorPages(options =>
                {
                    options.Conventions.Add(new CultureTemplatePageRouteModelConvention());
                })
    

    I created the CultureTemplatePageRouteModelConvention.cs under a Middleware/ folder, but you can put it wherever (not sure if it's "technically" middleware?).

    using System;
    using Microsoft.AspNetCore.Mvc.ApplicationModels;
    using Microsoft.Extensions.Logging;
    
    namespace app.Middleware
    {
        public class CultureTemplatePageRouteModelConvention : IPageRouteModelConvention
        {
            public void Apply(PageRouteModel model)
            {
                // For each page Razor has detected
                foreach (var selector in model.Selectors)
                {
                    // Grab the template string
                    var template = selector.AttributeRouteModel.Template;
    
                    // Skip the MicrosoftIdentity pages
                    if (template.StartsWith("MicrosoftIdentity")) continue;
    
                    // Prepend the /{culture?}/ route value to allow for route-based localization
                    selector.AttributeRouteModel.Template = AttributeRouteModel.CombineTemplates("{culture?}", template);
                }
            }
        }
    }
    

    Now going to /en/home should resolve, and /home should not. But if you go to /fr/home you will notice that it's still using the English resx file. This is because the culture is not being updated based on the URL.

    To fix this, more modifications to Startup.cs are necessary.

    In the Configure method, we will add

                app.UseRequestLocalization();
    

    Under ConfigureServices, we will configure the request localization options. This will include adding a RequestCultureProvider which is used to determine the Culture for each request.

                services.Configure<RequestLocalizationOptions>(options =>
                {
                    options.SetDefaultCulture("en");
                    options.AddSupportedCultures("en", "fr");
                    options.AddSupportedUICultures("en", "fr");
                    options.FallBackToParentCultures = true;
                    options.RequestCultureProviders.Remove(typeof(AcceptLanguageHeaderRequestCultureProvider));
                    options.RequestCultureProviders.Insert(0, new Middleware.RouteDataRequestCultureProvider() { Options = options });
                });
    

    This uses an extension method to remove the default accept-language header culture provider

    using System;
    using System.Collections.Generic;
    using System.Linq;
    
    namespace app.Extensions
    {
        public static class ListExtensions {
            public static void Remove<T>(this IList<T> list, Type type)
            {
                var items = list.Where(x => x.GetType() == type).ToList();
                items.ForEach(x => list.Remove(x));
            }
        }
    }
    

    More importantly, we need to create the RouteDataRequestCultureProvider we just added to the list.

    Middleware/RouteDataRequestCultureProvider.cs

    using System;
    using System.Linq;
    using System.Threading.Tasks;
    using Microsoft.AspNetCore.Http;
    using Microsoft.AspNetCore.Localization;
    
    namespace app.Middleware
    {
        public class RouteDataRequestCultureProvider : RequestCultureProvider
        {
            public override Task<ProviderCultureResult> DetermineProviderCultureResult(HttpContext httpContext)
            {
                string routeCulture = (string)httpContext.Request.RouteValues["culture"];
                string urlCulture = httpContext.Request.Path.Value.Split('/')[1];
    
                // Culture provided in route values
                if (IsSupportedCulture(routeCulture))
                {
                    return Task.FromResult(new ProviderCultureResult(routeCulture));
                }
                // Culture provided in URL
                else if (IsSupportedCulture(urlCulture))
                {
                    return Task.FromResult(new ProviderCultureResult(urlCulture));
                }
                else
                // Use default culture
                {
                    return Task.FromResult(new ProviderCultureResult(DefaultCulture));
                }
            }
    
            /**
             * Culture must be in the list of supported cultures
             */
            private bool IsSupportedCulture(string lang) =>
                !string.IsNullOrEmpty(lang)
                && Options.SupportedCultures.Any(x =>
                    x.TwoLetterISOLanguageName.Equals(
                        lang,
                        StringComparison.InvariantCultureIgnoreCase
                    )
                );
    
            private string DefaultCulture => Options.DefaultRequestCulture.Culture.TwoLetterISOLanguageName;
        }
    }
    

    Note we check for RouteValues["culture"] in this provider, when that value isn't actually present yet. This is because we need another piece of middleware for Blazor to work properly. But for now, at least our pages will have the correct culture applied from the URL, which will allow /fr/ to use the correct Index.fr.resx instead of Index.resx.

    Another issue is that the asp-page tag helper doesn't work unless you also specify asp-route-culture with the user's current culture. This sucks, so we will override the tag helper with one that just copies the culture every time.

    Inside _ViewImports.cshtml

    @* Override anchor tag helpers with our own to ensure URL culture is persisted *@
    @addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers
    @removeTagHelper Microsoft.AspNetCore.Mvc.TagHelpers.AnchorTagHelper, Microsoft.AspNetCore.Mvc.TagHelpers
    @addTagHelper *, app
    

    and under TagHelpders/CultureAnchorTagHelper.cs we will add

    using System;
    using app.Extensions;
    using Microsoft.AspNetCore.Http;
    using Microsoft.AspNetCore.Mvc.TagHelpers;
    using Microsoft.AspNetCore.Mvc.ViewFeatures;
    using Microsoft.AspNetCore.Razor.TagHelpers;
    
    
    // https://stackoverflow.com/a/59283426/11141271
    // https://stackoverflow.com/questions/60397920/razorpages-anchortaghelper-does-not-remove-index-from-href
    // https://talagozis.com/en/asp-net-core/razor-pages-localisation-seo-friendly-urls
    namespace app.TagHelpers
    {
        [HtmlTargetElement("a", Attributes = ActionAttributeName)]
        [HtmlTargetElement("a", Attributes = ControllerAttributeName)]
        [HtmlTargetElement("a", Attributes = AreaAttributeName)]
        [HtmlTargetElement("a", Attributes = PageAttributeName)]
        [HtmlTargetElement("a", Attributes = PageHandlerAttributeName)]
        [HtmlTargetElement("a", Attributes = FragmentAttributeName)]
        [HtmlTargetElement("a", Attributes = HostAttributeName)]
        [HtmlTargetElement("a", Attributes = ProtocolAttributeName)]
        [HtmlTargetElement("a", Attributes = RouteAttributeName)]
        [HtmlTargetElement("a", Attributes = RouteValuesDictionaryName)]
        [HtmlTargetElement("a", Attributes = RouteValuesPrefix + "*")]
        public class CultureAnchorTagHelper : AnchorTagHelper
        {
            private const string ActionAttributeName = "asp-action";
            private const string ControllerAttributeName = "asp-controller";
            private const string AreaAttributeName = "asp-area";
            private const string PageAttributeName = "asp-page";
            private const string PageHandlerAttributeName = "asp-page-handler";
            private const string FragmentAttributeName = "asp-fragment";
            private const string HostAttributeName = "asp-host";
            private const string ProtocolAttributeName = "asp-protocol";
            private const string RouteAttributeName = "asp-route";
            private const string RouteValuesDictionaryName = "asp-all-route-data";
            private const string RouteValuesPrefix = "asp-route-";
            private readonly IHttpContextAccessor _contextAccessor;
    
            public CultureAnchorTagHelper(IHttpContextAccessor contextAccessor, IHtmlGenerator generator) :
                base(generator)
            {
                this._contextAccessor = contextAccessor;
            }
    
            public override void Process(TagHelperContext context, TagHelperOutput output)
            {
                var culture = _contextAccessor.HttpContext.Request.GetCulture();
                RouteValues["culture"] = culture;
                base.Process(context, output);
            }
        }
    }
    

    This uses an extension method to get the current culture from an HttpRequest

    using Microsoft.AspNetCore.Http;
    using Microsoft.AspNetCore.Localization;
    
    namespace app.Extensions
    {
        public static class HttpRequestExtensions
        {
            public static string GetCulture(this HttpRequest request)
            {
                return request.HttpContext.Features.Get<IRequestCultureFeature>()
                .RequestCulture.Culture.TwoLetterISOLanguageName;
            }
    
        }
    }
    

    To make sure the dependency injection for the current context works, we need to modify Startup.cs

                // Used by the culture anchor tag helper
                services.AddHttpContextAccessor();
    

    Now we can use the tag helper without things breaking.

    Example:

        <a asp-page="Page1">@Localizer["SomePage"]</a>
    

    With normal pages working, now we can work on getting Blazor components translated.

    Inside _Imports.razor, we will add

    @using Microsoft.Extensions.Localization
    

    Inside our myComponent.razor, we will add

    @inject IStringLocalizer<myComponent> Localizer
    

    Now we can use <h1>@Localizer["Header"]</h1> just like in our normal pages. But now there's another issue: our Blazor components aren't getting their Culture set correctly. The components see /_blazor as their URL instead of the page's URL. Comment out the <base href="~/"> in your <head> element in _Layout.cshtml to make Blazor try hitting /en/_blazor instead of /_blazor. This will get a 404, but we will fix that.

    Inside Startup.cs, we will register another middleware.

                app.Use(new BlazorCultureExtractor().Handle);
    

    This call should be before the app.UseEndpoints and app.UseRequestLocalization() call.

    Middleware/BlazorCultureExtractor.cs

    using System;
    using System.Text.RegularExpressions;
    using System.Threading.Tasks;
    using app.Extensions;
    using Microsoft.AspNetCore.Http;
    using Microsoft.AspNetCore.Localization;
    
    namespace app.Middleware
    {
        public class BlazorCultureExtractor
        {
            private readonly Regex BlazorRequestPattern = new Regex("^/(.*?)(/_blazor.*)$");
            public async Task Handle(HttpContext context, Func<Task> next)
            {
                var match = BlazorRequestPattern.Match(context.Request.Path.Value);
    
                // If it's a request for a blazor endpoint
                if (match.Success)
                {
                    // Grab the culture from the URL and store it in RouteValues
                    // This allows IStringLocalizers to use the correct culture in Blazor components
                    context.Request.RouteValues["culture"] = match.Groups[1].Value;
                    // Remove the /culture/ from the URL so that Blazor works properly
                    context.Request.Path = match.Groups[2].Value;
                }
    
                await next();
            }
        }
    }
    

    The middleware will check if the route is trying to hit /en/_blazor, will set the RouteValues["culture"] value to en, and will rewrite the path to /_blazor before further processing. This puts the lang in the route values for our RequestCultureProvider to use, while also fixing the 404 from blazor trying to hit our localized routes.

    Inside _Layout.cshtml I also use

        <script src="~/_framework/blazor.server.js"></script>"
    

    to ensure that the request for the blazor script hits the proper path instead of /en/_framework/.... Note the preceeding ~/ on the src attribute.

    Closing remarks

    If you want pure URL-based localization instead of the weird cookie stuff MS promotes, then it's a lot of work.

    I haven't bothered looking into doing this with Blazor pages, I'm just sticking with components for now.

    e.g.,

    <component>
        @(await Html.RenderComponentAsync<MyCounterComponent>(RenderMode.Server))
    </component>