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).