Open msschl opened 2 years ago
Thank you for contacting us. Due to a lack of activity on this discussion issue we're closing it in an effort to keep our backlog clean. If you believe there is a concern related to the ASP.NET Core framework, which hasn't been addressed yet, please file a new issue.
This issue will be locked after 30 more days of inactivity. If you still wish to discuss this subject after then, please create a new issue!
@javiercn is this issue still considered in the backlog even though it is closed?
I've re-opened this and will try to take a look at this scenario in the next few weeks.
Thank you for contacting us. Due to a lack of activity on this discussion issue we're closing it in an effort to keep our backlog clean. If you believe there is a concern related to the ASP.NET Core framework, which hasn't been addressed yet, please file a new issue.
This issue will be locked after 30 more days of inactivity. If you still wish to discuss this subject after then, please create a new issue!
@DamianEdwards is there any news on this?
@msschl no, but I'll try and take a look soon.
Thanks a lot @DamianEdwards
Thank you for contacting us. Due to a lack of activity on this discussion issue we're closing it in an effort to keep our backlog clean. If you believe there is a concern related to the ASP.NET Core framework, which hasn't been addressed yet, please file a new issue.
This issue will be locked after 30 more days of inactivity. If you still wish to discuss this subject after then, please create a new issue!
maybe this is something for .NET 8 planning?
@msschl you're right in that there is no support for view location expanders (and thus localized versions of Razor Pages .cshtml files) in Razor Pages today. This is due to the fact that Razor Pages registers a route for every .cshtml file with a @page
directive based on the file name or value passed to the @page
directive and having multiple pages on the same route pattern is not supported.
Happy to re-open this as part of .NET 8 planning.
The simplest workaround I found to achieve the desired result is to factor localized content into separate Razor partial view files as the rendering of Razor partials goes through the regular view lookup logic and thus supports location expanders correctly, e.g.:
/Program.cs
var builder = WebApplication.CreateBuilder(args);
builder.Services
.AddRazorPages()
.AddViewLocalization();
var app = builder.Build();
app.UseHttpsRedirection();
app.UseStaticFiles();
app.UseRequestLocalization("en-US", "en-AU");
app.UseRouting();
app.MapRazorPages();
app.Run();
/Pages/Index.cshtml
@page
@using System.Globalization
@model IndexModel
@{
ViewData["Title"] = "Home page";
}
<partial name="_Welcome" />
<ul>
<li>@CultureInfo.CurrentCulture</li>
<li>@CultureInfo.CurrentUICulture</li>
</ul>
/Pages/Shared/_Welcome.cshtml
<div class="text-center">
<h1 class="display-4">Welcome</h1>
</div>
/Pages/Shared/_Welcome.en-AU.cshtml
<div class="text-center">
<h1 class="display-4">G'day</h1>
</div>
Result of navigating to /?culture=en-AU
:
Alternatively, for a routing-based approach that doesn't require any refactoring of content into separate partial views, you can use a combination of a custom IPageRouteModelConvention
and custom routing IEndpointSelectorPolicy
. The custom page convention updates the routing information of culture-suffixed pages to effectively remove the culture name from the route, and then the custom MatcherPolicy
disambiguates the conflicting routes based on the request culture when routing runs. A barebones example follows:
Program.cs
using System.Globalization;
using Microsoft.AspNetCore.Mvc.ActionConstraints;
using Microsoft.AspNetCore.Mvc.ApplicationModels;
using Microsoft.AspNetCore.Routing.Matching;
var builder = WebApplication.CreateBuilder(args);
builder.Services
.AddRazorPages(c =>
{
c.Conventions.Add(new PageLocalizationConvention());
})
.AddViewLocalization();
builder.Services.AddSingleton<MatcherPolicy, RazorPageLocMatcherPolicy>();
var app = builder.Build();
app.UseHttpsRedirection();
app.UseStaticFiles();
app.UseRequestLocalization("en-US", "en-AU");
app.UseRouting();
app.MapRazorPages();
app.Run();
class PageLocalizationConvention : IPageRouteModelConvention
{
public void Apply(PageRouteModel model)
{
var page = model.ViewEnginePath;
var dotIndex = page.IndexOf('.');
if (dotIndex > 0)
{
var culture = page.Substring(dotIndex + 1);
var newPageValue = page.Substring(0, page.Length - dotIndex);
var newRouteTemplate = newPageValue.TrimStart('/');
var selector = model.Selectors[0];
if (selector.AttributeRouteModel is { } routeModel)
{
// Update attribute route details
routeModel.SuppressLinkGeneration = true;
routeModel.Template = newRouteTemplate;
}
// Update metadata used to build the page route
var pageRouteMetadata = selector.EndpointMetadata.OfType<PageRouteMetadata>().Single();
selector.EndpointMetadata.Remove(pageRouteMetadata);
selector.EndpointMetadata.Add(new PageRouteMetadata(pageRouteMetadata.PageRoute, newRouteTemplate));
if (newPageValue.EndsWith("Index"))
{
// Add additional selector for root
var defaultPathTemplate = newPageValue.Substring(0, newPageValue.LastIndexOf("Index")).TrimStart('/');
var defaultPathSelector = new SelectorModel
{
AttributeRouteModel = new AttributeRouteModel { Template = defaultPathTemplate }
};
defaultPathSelector.EndpointMetadata.Add(new PageRouteMetadata(pageRouteMetadata.PageRoute, defaultPathTemplate));
model.Selectors.Add(defaultPathSelector);
}
}
}
}
class RazorPageLocMatcherPolicy : MatcherPolicy, IEndpointSelectorPolicy
{
public override int Order { get; }
public bool AppliesToEndpoints(IReadOnlyList<Endpoint> endpoints)
{
return endpoints.Any(e => e.Metadata.GetMetadata<PageRouteMetadata>() is not null);
}
public Task ApplyAsync(HttpContext httpContext, CandidateSet candidates)
{
var count = candidates.Count;
if (count > 1)
{
var currentCulture = CultureInfo.CurrentUICulture;
var exactMatchIndex = -1;
var invariantMatchIndex = -1;
// Update candidates based on current culture
for (var i = 0; i < candidates.Count; i++)
{
var candidate = candidates[i];
var pageRouteMetadata = candidate.Endpoint.Metadata.GetMetadata<PageRouteMetadata>();
if (pageRouteMetadata is null) continue;
var pageCulture = GetCulture(pageRouteMetadata.PageRoute);
if (pageCulture.Name == currentCulture.Name)
{
exactMatchIndex = i;
}
else if (pageCulture == CultureInfo.InvariantCulture)
{
invariantMatchIndex = i;
}
candidates.SetValidity(i, false);
// TODO: Support detecting fallback cultures, e.g. use candidate for 'en' if current culture is 'en-AU' and there is no candidate for 'en-AU'
}
if (exactMatchIndex >= 0)
{
candidates.SetValidity(exactMatchIndex, true);
}
else if (invariantMatchIndex >= 0)
{
candidates.SetValidity(invariantMatchIndex, true);
}
}
return Task.CompletedTask;
}
private static CultureInfo GetCulture(string pageRoute)
{
// PageRoute is like '/Index.en-AU'
var dotIndex = pageRoute.LastIndexOf('.');
if (dotIndex < 0)
{
return CultureInfo.InvariantCulture;
}
var cultureName = pageRoute.Substring(dotIndex + 1);
return CultureInfo.GetCultureInfo(cultureName);
}
}
Result of navigating to /?culture=en-AU
:
We've moved this issue to the Backlog milestone. This means that it is not going to be worked on for the coming release. We will reassess the backlog following the current release and consider this item at that time. To learn more about our issue management process and to have better expectation regarding different types of issues you can read our Triage Process.
@msschl I'm encountering a similar requirement (was surprised this behaviour didn't exist already for Razor pages!)
The second workaround by @DamianEdwards looks interesting - but I couldn't get it working. I only ever get one candidate match despite having several localised pages processed by the PageLocalizationConvention.
Did this solution work for you?
Haven't tested it yet :(
No worries, I figured out the problems so will post here for anyone else.
In PageLocalizationConvention it was incorrectly determining the newPageValue.
var newPageValue = page.Substring(0, dotIndex);
I also need to include the Area in the newRouteTemplate:
var newRouteTemplate = StringExtensions.JoinNonEmpty("/", model.AreaName, newPageValue.TrimStart('/'));
and build the new PageRouteMetadata based upon the newRouteTemplate:
selector.EndpointMetadata.Add(new PageRouteMetadata(newRouteTemplate, page));
I updated the policy to determine the pageCulture from the endpoint instead of the metadata:
var pageCulture = GetCulture(candidate.Endpoint.ToString() ?? string.Empty);
I added detection for parent cultures:
if (pageCulture.Name == currentCulture.Name)
exactMatchIndex = i;
else if (pageCulture.Name == currentCulture.Parent.Name)
parentMatchIndex = i;
else if (pageCulture == CultureInfo.InvariantCulture)
invariantMatchIndex = i;
And then picked the best candidate based upon that:
if (exactMatchIndex >= 0)
candidates.SetValidity(exactMatchIndex, true);
else if (parentMatchIndex >= 0)
candidates.SetValidity(parentMatchIndex, true);
else if (invariantMatchIndex >= 0)
candidates.SetValidity(invariantMatchIndex, true);
Hope its helpful. Huge thanks to @DamianEdwards - I wouldn't have known where to start without the example.
@msschl is this still a bug? if yes please a minimal repo to steps to reproduce the issue
Is there an existing issue for this?
Is your feature request related to a problem? Please describe the problem.
Bringing #4873
Localized views with Razor Pages
back to discussion for .net 7.0Describe the solution you'd like
View location expanders should apply to Razor pages too.
Additional context
@DamianEdwards @rynowak @pranavkm