The
first article looked
at how to work with cultures, and discussed the default RequestCultureProvider
components that are used to determine the culture for the current request
- components that work with the query string, cookie and request headers. The approach used in that article to demonstrate setting the culture for the request relies on setting query string values to generate locale-specific URLs. While it works, it is not the recommended approach.
Google recommend a number of other approaches, one of which involves making use of sub-directories for
locale-specific versions of the site content. Sub-directories should be named for the culture code that each version represents, so you end up with
URL segments named after the specific culture:
mydomain.com/en/about
and mydomain.com/it/about
etc.
This is all well and good, but it will result in an application with a LOT of duplication, effectively a completely new version for each supported culture
in each sub-directory. ASP.NET Core is a lot more clever than that.
It supports the notion of
route data - application data passed as a segment in the URL. ASP.NET
Core also provides a request culture provider that works with route data -
the RouteDataRequestCultureProvider
. This article builds on the
application introduced in the previous articles to demonstrate how to make
best use of this component.
The application in this article is the same one that has featured in the previous articles. It's built using the standard Razor Pages 3.1 project template with no authentication. Many of the concepts in this article were originally introduced in the previous articles, so you should read those first if you haven't already.
You need to add a route data item representing the culture to all routes. You could apply this as a route template to each page individually, but that approach is not really scalable. A better approach is to use a
PageRouteModelConvention
to add additional route templates to those that are generated by default for all pages in the
application. The
following PageRouteModelConvention
creates a new attribute route for each existing route
by combining a placeholder for a route data item named
culture
with the existing attribute route. The Order
property of the AttributeRouteModel
is set to -1
to
ensure that it is processed first:
using Microsoft.AspNetCore.Mvc.ApplicationModels; namespace Localisation.RouteModelConventions { public class CultureTemplatePageRouteModelConvention : IPageRouteModelConvention { public void Apply(PageRouteModel model) { var selectorCount = model.Selectors.Count; for (var i = 0; i < selectorCount; i++) { var selector = model.Selectors[i]; model.Selectors.Add(new SelectorModel { AttributeRouteModel = new AttributeRouteModel { Order = -1, Template = AttributeRouteModel.CombineTemplates("{culture?}", selector.AttributeRouteModel.Template), } }); } } } }
By convention, all RequestCultureProviders look for items with
a key of culture
and/or ui-culture
by default to
establish the culture of the current request. You can change this through
configuration.
The custom PageRouteModelConvention
is registered in ConfigureServices
as
part of RazorPagesOptions:
public void ConfigureServices(IServiceCollection services) { services.AddRazorPages(options => { options.Conventions.Add(new CultureTemplatePageRouteModelConvention()); }); }
If you run the application at this stage, you can test that the new attribute routes are working by manually inserting a culture code into the
Routes are resolved correctly, but the culture for the request is not
affected by the presence of the culture
route data value. The
RouteDataRequestCultureProvider
needs to be registered as part of the
RequestLocalizationOptions
in ConfigureServices
:
services.Configure<RequestLocalizationOptions>(options => { var supportedCultures = new[] { new CultureInfo("en"), new CultureInfo("de"), new CultureInfo("fr"), new CultureInfo("es"), new CultureInfo("ru"), new CultureInfo("ja"), new CultureInfo("ar"), new CultureInfo("zh"), new CultureInfo("en-GB") }; options.DefaultRequestCulture = new RequestCulture("en-GB"); options.SupportedCultures = supportedCultures; options.SupportedUICultures = supportedCultures; options.RequestCultureProviders.Insert(0, new RouteDataRequestCultureProvider { Options = options }); });
The code above is from the first article in the series, with the addition of the final line which registers the route data request culture provider, and inserts it as the first request provider to be used for culture matching. Now if you run the application again, you should see that request provider working:
Generating Links With Tag Helpers
The links at the top of the page are generated by
anchor tag helpers in the layout page. Prior to Razor Pages 2.2, anchor tag
helpers would make use of
ambient route values to generate URLs. If the target page and the
current request share the same route value within their attribute route, values
from the current request are automatically reused within URL generation.
This is very useful when every page shares the culture
route value
as in this example. However, this behaviour also has some unwanted side effects,
and was largely removed
with the introduction of endpoint routing in ASP.NET Core 2.2. Ambient route
values are now only used if the target page specified by the asp-page
attribute in the anchor tag is the same as the current request.
You can see this by navigating to the Contact page, selecting a culture (French,
in my case) and looking at the generated HTML for the links:
Notice that only the contact page's anchor tag includes the ambient route value for culture. It is not included in the links generated for the home page or the privacy page. The culture route value needs to be added explicitly:
<ul class="navbar-nav flex-grow-1"> <li class="nav-item"> <a class="nav-link text-dark" asp-route-culture="@Context.Request.RouteValues["culture"]" asp-page="/Index">@localizer.Get("Home")</a> </li> <li class="nav-item"> <a class="nav-link text-dark" asp-route-culture="@Context.Request.RouteValues["culture"]" asp-page="/Contact">@localizer.Get("Contact")</a> </li> <li class="nav-item"> <a class="nav-link text-dark" asp-route-culture="@Context.Request.RouteValues["culture"]" asp-page="/Privacy">@localizer.Get("Privacy")</a> </li> </ul>
Now all links include the culture:
However, just like the prospect of manually generating route templates for each page, this is equally unscalable for large applications. What you can do instead is to implement your own anchor tag helper which derives from the framework version and is responsible for adding the culture route value as demonstrated in this Github issue.
The code for the tag helper follows:
[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 { public CultureAnchorTagHelper(IHttpContextAccessor contextAccessor, IHtmlGenerator generator) : base(generator) { this.contextAccessor = contextAccessor; } 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 const string Href = "href"; private readonly IHttpContextAccessor contextAccessor; private readonly string defaultRequestCulture = "en"; public override void Process(TagHelperContext context, TagHelperOutput output) { var culture = (string)contextAccessor.HttpContext.Request.RouteValues["culture"]; if (culture != null && culture != defaultRequestCulture) { RouteValues["culture"] = culture; } base.Process(context, output); } }
Then you need to remove the existing anchor tag helper from the application, which is achieved by applying the removeTagHelper directive in _ViewImports
@removeTagHelper Microsoft.AspNetCore.Mvc.TagHelpers.AnchorTagHelper, Microsoft.AspNetCore.Mvc.TagHelpers
Finally, if you have been following the entire series of articles that look at localisation in Razor Pages, you may want to alter the client side code in the culture switcher ViewComponent introduced in the first article. It currently works with the query string request culture provider, so it requires amending to work with a URL segment rather than a query string value:
<script> var segments = location.pathname.split('/'); var el = document.getElementById("culture-options"); el.addEventListener("change", () => { var culture = el.options[el.selectedIndex].value; if (segments.length > 2) { segments[1] = culture; location.href = segments.join('/'); } else { location.href = '/' + culture + location.pathname; } }); </script>
Summary
The RouteDataRequestCultureProvider
is an essential component
if you want to follow the recommendations for working with optimal
locale-specific URLs in a Razor Pages application. It is not registered as one
of the default request culture providers so it needs to be configured
separately. This article has also shown how to use a PageRouteModelConvention to
centrally manage locale-specific routing across an application, and how to
circumvent the removal of ambient route values in ASP.NET Core 2.2 and still use
an anchor tag helper to generate routes that incorporate the current culture for all pages
in the application.