dotnet / aspnetcore

ASP.NET Core is a cross-platform .NET framework for building modern cloud-based web applications on Windows, Mac, or Linux.
https://asp.net
MIT License
35.57k stars 10.05k forks source link

Localized views with Razor Pages #40186

Open msschl opened 2 years ago

msschl commented 2 years ago

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

In Razor Pages, we can inject IViewLocalizer to work with localized resources - good. But sometimes, especially on content-heavy, rather static pages, it would be nice to have location specific views, i.e. MyPage.en.cshtml and MyPage.de.cshtml etc.

My expection was that once I configure AddViewLocalization(LanguageViewLocationExpanderFormat.Suffix, ...) Razor Pages also would be resolved by their base name, but I quickly realized there might be a problem with how the corresponding PageModel should be used/resolved. I couldn't get this to work at all - is this scenario even supported with Razor Pages?

In any case, I think the corresponding docs should be more explicit about what's possible with Razor Pages and potential limitations.

Describe the solution you'd like

View location expanders should apply to Razor pages too.

Additional context

@DamianEdwards @rynowak @pranavkm

ghost commented 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!

msschl commented 2 years ago

@javiercn is this issue still considered in the backlog even though it is closed?

DamianEdwards commented 2 years ago

I've re-opened this and will try to take a look at this scenario in the next few weeks.

ghost commented 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!

msschl commented 2 years ago

@DamianEdwards is there any news on this?

DamianEdwards commented 2 years ago

@msschl no, but I'll try and take a look soon.

msschl commented 2 years ago

Thanks a lot @DamianEdwards

ghost commented 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!

msschl commented 2 years ago

maybe this is something for .NET 8 planning?

DamianEdwards commented 2 years ago

@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:

image

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:

image
ghost commented 2 years ago

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.

TheObliterator commented 1 year ago

@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?

msschl commented 1 year ago

Haven't tested it yet :(

TheObliterator commented 1 year ago

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.

hishamco commented 6 months ago

@msschl is this still a bug? if yes please a minimal repo to steps to reproduce the issue