Home > Mobile >  Using URL path for localization in Razor Blazor components
Using URL path for localization in Razor Blazor components

Time:10-15

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?

CodePudding user response:

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>

CodePudding user response:

For a Blazor WASM application you could use local storage to store/rertieve the user's selected culture and update the UI accordingly when the user changes culture. The following solution is implemented for Greek/American cultures but you could use any combination you want by changing the culture codes.

To accomplice this you will need a bit of JavaScript to read write to the local storage as follows. The following solution is implemented using bootstrap 4 and flag icons css

Within the wasm section of your application in your wwwroot folder create a folder called scipts and add a file inside called utils.js and place the following code:

function getFromLocalStorage(key) {
    return localStorage[key];
}

function setInLocalStorage(key, value) {
    localStorage[key] = value;
}

Also in your wasm project place the following code in the program.cs file:

public static async Task Main(string[] args)
{
   ....

    //get the jsinterop service
    var js = host.Services.GetRequiredService<IJSRuntime>();
    //read the culture key from the localstorage
    var culture = await js.InvokeAsync<string>("getFromLocalStorage", "culture");

    //Initializes a new variable of type System.Globalization.CultureInfo 
    CultureInfo selectedCulture;

    //if culture is null
    if (culture == null)
    {
        //set the default culture to be greek
        selectedCulture = new CultureInfo("el-GR");
    }
    else
    {
        //else set the cuture based on the culture setting retrieved from the local storage
        selectedCulture = new CultureInfo(culture);
    }
    //set the default culture for the application ui and thread
    CultureInfo.DefaultThreadCurrentCulture = selectedCulture;
    CultureInfo.DefaultThreadCurrentUICulture = selectedCulture;

    await host.RunAsync();

}

In the Shared project or wherever your resource files are place the following key/value pairs within Resource.resx

Greek   Greek   
English English 

And within Resource.el.resx

Greek   Ελληνικά    
English Αγγλικά 

Now lets create a new CultureSelectorComponent.razor component and place it within the wasm project and add the following code in it as follows:

@inject NavigationManager  navigationManager
@inject IJSRuntime js
@using System.Globalization
@*
    The culture selector component that allows the user to change their display language and system locale
    this forces all areas of the UI to render using the different available locales currently only Greek and American-US are supported
    This affects the rendering of both Text values on the UI as well as all Metric systems including Decimals as well as Date formats and all other relevant localized variables
*@
<div class="btn-group nav-item dropdown">
    <button type="button" class="btn btn-link dropdown-toggle" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
        @*currently we are showing only the selected flag with its corresponding parameters and values *@
        <span class='flag-icon flag-icon-@(languages.Where(m => m.Code.Equals(CultureInfo.CurrentCulture.Name)).FirstOrDefault().TwoCharCode)'
              data-toggle='tooltip' data-placement='top' data-original-title='@(languages.Where(m => m.Code.Equals(CultureInfo.CurrentCulture.Name)).FirstOrDefault().Name)' title='@(languages.Where(m => m.Code.Equals(CultureInfo.CurrentCulture.Name)).FirstOrDefault().Name)'>
        </span>
        @*currently we are hidding the value shown in the dropdown selected text *@
        @*@(languages.Where(m => m.Code.Equals(CultureInfo.CurrentCulture.Name)).FirstOrDefault().TwoCharCode.ToUpper())*@
    </button>
    <div class="dropdown-menu dropdown-menu-right">
        @foreach (var language in languages)
        {
            //the available flag options shown in the dropdown with their corresponding click event functions and class switcher selector
            <button class="btn dropdown-item shadow-none blue-hover @(CultureInfo.CurrentCulture.Name.Equals(language.Code) ? "" : "")"
                    @onclick="(() => SetLanguage(language.Code))">
                @((MarkupString)(language.IconTooltip)) @language.Name
            </button>
        }
    </div>
</div>

@code {
    public class Language
    {
        //the Name property that derives from the Resource files showing the language display name 
        public string Name { get; set; }
        //the ISO Language Code property, check this link for more codes/information http://www.lingoes.net/en/translator/langcode.htm
        public string Code { get; set; }
        //the two char code property
        public string TwoCharCode { get; set; }
        //the tooltip property is used for showing a tooltip on top of each of the flags when the user hovers over the flag icons
        public string IconTooltip { get; set; }
    }
    //the list of available languages together with the required properties defined above
    List<Language> languages = new List<Language>
    {
        new Language{ Name = Resource.Greek, Code = "el-GR", TwoCharCode="gr", IconTooltip = $"<span class='flag-icon flag-icon-gr' data-toggle='tooltip' data-placement='top' title='{Resource.Greek}'></span>"},
        new Language{ Name = Resource.English, Code = "en-US", TwoCharCode="us", IconTooltip = $"<span class='flag-icon flag-icon-us' data-toggle='tooltip' data-placement='top' title='{Resource.English}'></span>"},
    };
    //function that is associated with the click event when the user clicks one of the dropdown options from the language selector component. 
    void SetLanguage(string langcode)
    {
        var jsInProcessRuntime = (IJSInProcessRuntime)js;
        jsInProcessRuntime.InvokeVoid("setInLocalStorage", "culture", langcode);
        navigationManager.NavigateTo(navigationManager.Uri, forceLoad: true);
    }
}

To use the above component simply reference it as follows, in our case this reference is put in our MainLayout.razor component: <CultureSelectorComponent />

If you need to display or save the currently selected culture anywhere in your application you can simply use the .net property for retrieving the cutlure info i.e. CultureInfo.CurrentCulture.Name or retrieve it from local storage with the .js helper methods created earlier inside of the utils.js file above. Finally you should reference all css/js resources mentioned above within wwwroot/index.html (wasm client project).

  • Related