From c98a08bd13849df69e11de9157517556ac29cedd Mon Sep 17 00:00:00 2001 From: maliming Date: Thu, 2 Apr 2026 13:02:32 +0800 Subject: [PATCH] feat: Implement route-based culture handling with new helpers and tests --- .../2026-03-29-Url-Based-Localization/POST.md | 2 +- .../CultureAwareAuthenticationBase.cs | 25 +++ .../CultureAwareRedirectToLoginHelper.cs | 35 ++++ ...Core.Components.WebAssembly.Theming.csproj | 4 + .../RouteBasedCultureNavigationHelper.cs | 37 ++++- .../WebAssembly/RouteBasedCultureUrlHelper.cs | 60 ++++++- ...NetCoreMvcQueryStringCultureReplacement.cs | 76 ++++++--- .../Volo.Abp.AspNetCore.Mvc.Tests.csproj | 1 + ...uageSwitchRouteCultureReplacement_Tests.cs | 38 ++++- ...RouteBasedCultureNavigationHelper_Tests.cs | 103 ++++++++++++ .../RouteBasedCultureUrlHelper_Tests.cs | 156 ++++++++++++++++++ .../Basic/WebAssemblyRedirectToLogin.razor | 21 +-- 12 files changed, 510 insertions(+), 48 deletions(-) create mode 100644 framework/src/Volo.Abp.AspNetCore.Components.WebAssembly.Theming/CultureAwareAuthenticationBase.cs create mode 100644 framework/src/Volo.Abp.AspNetCore.Components.WebAssembly.Theming/CultureAwareRedirectToLoginHelper.cs create mode 100644 framework/test/Volo.Abp.AspNetCore.Mvc.Tests/Volo/Abp/AspNetCore/Mvc/Localization/RouteBasedCultureNavigationHelper_Tests.cs create mode 100644 framework/test/Volo.Abp.AspNetCore.Mvc.Tests/Volo/Abp/AspNetCore/Mvc/Localization/RouteBasedCultureUrlHelper_Tests.cs diff --git a/docs/en/Community-Articles/2026-03-29-Url-Based-Localization/POST.md b/docs/en/Community-Articles/2026-03-29-Url-Based-Localization/POST.md index f00ce9a12b..e8a5d16015 100644 --- a/docs/en/Community-Articles/2026-03-29-Url-Based-Localization/POST.md +++ b/docs/en/Community-Articles/2026-03-29-Url-Based-Localization/POST.md @@ -6,7 +6,7 @@ Consider a book-store app where users browse in their language: - A Spanish user shares a product link. The recipient opens it in English because the cookie on *their* machine says `en`. - Search engines crawl the same URL in every language, making it impossible to create separate sitemaps per locale. -- A user bookmarks `/Books/Detail?id=42&culture=es`. After a cookie reset, the `?culture=` parameter is missing from the bookmark — the page loads in the wrong language. +- A user shares a link like `/Books/Detail?id=42&culture=es`. When the server processes the request, it sets the culture cookie and then redirects to `/Books/Detail?id=42` — stripping the `?culture=` parameter. The shared link no longer carries the intended language. Embedding the culture in the URL path — `/es/books`, `/zh-Hans/about` — solves all three. Each language has its own stable URL, readable by humans and index-friendly for search engines. diff --git a/framework/src/Volo.Abp.AspNetCore.Components.WebAssembly.Theming/CultureAwareAuthenticationBase.cs b/framework/src/Volo.Abp.AspNetCore.Components.WebAssembly.Theming/CultureAwareAuthenticationBase.cs new file mode 100644 index 0000000000..26a25b7f0e --- /dev/null +++ b/framework/src/Volo.Abp.AspNetCore.Components.WebAssembly.Theming/CultureAwareAuthenticationBase.cs @@ -0,0 +1,25 @@ +using Microsoft.AspNetCore.Components; +using Volo.Abp.AspNetCore.Components; + +namespace Volo.Abp.AspNetCore.Components.WebAssembly.Theming; + +/// +/// Shared base for WASM theme Authentication pages. +/// Provides a helper so the culture-aware +/// home URL construction is not duplicated across theme packages. +/// +public abstract class CultureAwareAuthenticationBase : AbpComponentBase +{ + [Inject] + protected NavigationManager Navigation { get; set; } = default!; + + [Parameter] + public string? Culture { get; set; } + + protected virtual string GetCultureAwareHomeUrl() + { + return string.IsNullOrEmpty(Culture) + ? Navigation.BaseUri + : Navigation.BaseUri + Culture + "/"; + } +} diff --git a/framework/src/Volo.Abp.AspNetCore.Components.WebAssembly.Theming/CultureAwareRedirectToLoginHelper.cs b/framework/src/Volo.Abp.AspNetCore.Components.WebAssembly.Theming/CultureAwareRedirectToLoginHelper.cs new file mode 100644 index 0000000000..02c2b749bf --- /dev/null +++ b/framework/src/Volo.Abp.AspNetCore.Components.WebAssembly.Theming/CultureAwareRedirectToLoginHelper.cs @@ -0,0 +1,35 @@ +using System.Threading.Tasks; +using Microsoft.AspNetCore.Components; +using Microsoft.AspNetCore.Components.WebAssembly.Authentication; +using Microsoft.Extensions.Options; +using Volo.Abp.AspNetCore.Components.Web; +using Volo.Abp.AspNetCore.Components.WebAssembly; + +namespace Volo.Abp.AspNetCore.Components.WebAssembly.Theming; + +/// +/// Provides the shared culture-aware login redirect logic for all WASM theme +/// WebAssemblyRedirectToLogin components. Each theme must keep +/// @inherits RedirectToLogin for ABP service-replacement assignability, +/// so a common component base class is not feasible; this static helper +/// centralises the logic instead. +/// +public static class CultureAwareRedirectToLoginHelper +{ + public static async Task RedirectAsync( + NavigationManager navigation, + string loginUrl, + IRouteBasedCultureUrlHelper cultureUrlHelper, + IOptions webOptions) + { + var cultureLoginUrl = await cultureUrlHelper.PrependCulturePrefixAsync(loginUrl); + if (webOptions.Value.IsBlazorWebApp) + { + navigation.NavigateTo(cultureLoginUrl, forceLoad: true); + } + else + { + navigation.NavigateToLogin(cultureLoginUrl); + } + } +} diff --git a/framework/src/Volo.Abp.AspNetCore.Components.WebAssembly.Theming/Volo.Abp.AspNetCore.Components.WebAssembly.Theming.csproj b/framework/src/Volo.Abp.AspNetCore.Components.WebAssembly.Theming/Volo.Abp.AspNetCore.Components.WebAssembly.Theming.csproj index b3fc2633a7..76b63c76c7 100644 --- a/framework/src/Volo.Abp.AspNetCore.Components.WebAssembly.Theming/Volo.Abp.AspNetCore.Components.WebAssembly.Theming.csproj +++ b/framework/src/Volo.Abp.AspNetCore.Components.WebAssembly.Theming/Volo.Abp.AspNetCore.Components.WebAssembly.Theming.csproj @@ -9,6 +9,10 @@ Nullable + + + + diff --git a/framework/src/Volo.Abp.AspNetCore.Components.WebAssembly/Volo/Abp/AspNetCore/Components/WebAssembly/RouteBasedCultureNavigationHelper.cs b/framework/src/Volo.Abp.AspNetCore.Components.WebAssembly/Volo/Abp/AspNetCore/Components/WebAssembly/RouteBasedCultureNavigationHelper.cs index 012d7e5705..6a16f7430a 100644 --- a/framework/src/Volo.Abp.AspNetCore.Components.WebAssembly/Volo/Abp/AspNetCore/Components/WebAssembly/RouteBasedCultureNavigationHelper.cs +++ b/framework/src/Volo.Abp.AspNetCore.Components.WebAssembly/Volo/Abp/AspNetCore/Components/WebAssembly/RouteBasedCultureNavigationHelper.cs @@ -16,16 +16,41 @@ public class RouteBasedCultureNavigationHelper : IRouteBasedCultureNavigationHel IEnumerable allLanguages) { var relativePath = navigationManager.ToBaseRelativePath(navigationManager.Uri); - var slashIndex = relativePath.IndexOf('/'); - var firstSegment = slashIndex >= 0 ? relativePath.Substring(0, slashIndex) : relativePath; - var allCultures = allLanguages.Select(l => l.CultureName); + // Separate the path from any query string or fragment so the culture segment + // is correctly identified even for URLs like "tr?x=1" (no slash after culture). + var suffixIndex = relativePath.IndexOfAny(['?', '#']); + var pathPart = suffixIndex >= 0 ? relativePath.Substring(0, suffixIndex) : relativePath; + var suffix = suffixIndex >= 0 ? relativePath.Substring(suffixIndex) : string.Empty; - var newRelativePath = allCultures.Any(c => string.Equals(c, firstSegment, StringComparison.OrdinalIgnoreCase)) - ? newLanguage.CultureName + (slashIndex >= 0 ? relativePath.Substring(slashIndex) : string.Empty) - : newLanguage.CultureName + "/" + relativePath; + var slashIndex = pathPart.IndexOf('/'); + var firstSegment = GetFirstPathSegment(relativePath); + var pathRemainder = slashIndex >= 0 ? pathPart.Substring(slashIndex) : string.Empty; + + // No-op: the current URL already shows the target culture — no navigation needed. + if (string.Equals(firstSegment, newLanguage.CultureName, StringComparison.OrdinalIgnoreCase)) + { + return Task.CompletedTask; + } + + var newRelativePath = allLanguages.Any(l => string.Equals(l.CultureName, firstSegment, StringComparison.OrdinalIgnoreCase)) + ? newLanguage.CultureName + pathRemainder + suffix + : newLanguage.CultureName + "/" + pathPart + suffix; navigationManager.NavigateTo(navigationManager.ToAbsoluteUri(newRelativePath).ToString(), forceLoad: true); return Task.CompletedTask; } + + /// + /// Returns the first path segment of , + /// stripping any query string or fragment before splitting on '/'. + /// For example: "zh-Hans/account?x=1" → "zh-Hans", "tr/home#top" → "tr". + /// + protected virtual string GetFirstPathSegment(string baseRelativePath) + { + var suffixIndex = baseRelativePath.IndexOfAny(['?', '#']); + var pathPart = suffixIndex >= 0 ? baseRelativePath.Substring(0, suffixIndex) : baseRelativePath; + var slashIndex = pathPart.IndexOf('/'); + return slashIndex >= 0 ? pathPart.Substring(0, slashIndex) : pathPart; + } } diff --git a/framework/src/Volo.Abp.AspNetCore.Components.WebAssembly/Volo/Abp/AspNetCore/Components/WebAssembly/RouteBasedCultureUrlHelper.cs b/framework/src/Volo.Abp.AspNetCore.Components.WebAssembly/Volo/Abp/AspNetCore/Components/WebAssembly/RouteBasedCultureUrlHelper.cs index b9f6a07b64..0c03e56b5b 100644 --- a/framework/src/Volo.Abp.AspNetCore.Components.WebAssembly/Volo/Abp/AspNetCore/Components/WebAssembly/RouteBasedCultureUrlHelper.cs +++ b/framework/src/Volo.Abp.AspNetCore.Components.WebAssembly/Volo/Abp/AspNetCore/Components/WebAssembly/RouteBasedCultureUrlHelper.cs @@ -18,8 +18,23 @@ public class RouteBasedCultureUrlHelper : IRouteBasedCultureUrlHelper, ITransien public virtual async Task PrependCulturePrefixAsync(string url) { - var config = await _configurationClient.GetAsync(); + if (string.IsNullOrEmpty(url)) + { + return url; + } + + // Skip absolute URLs with a web scheme and protocol-relative URLs. + // Intentionally avoids Uri.TryCreate here: on Unix, root-relative paths such as + // "/account/login" are parsed as absolute file:// URIs, which would incorrectly + // skip them before the culture prefix could be applied. + if (url.StartsWith("//", StringComparison.Ordinal) || + url.StartsWith("http://", StringComparison.OrdinalIgnoreCase) || + url.StartsWith("https://", StringComparison.OrdinalIgnoreCase)) + { + return url; + } + var config = await _configurationClient.GetAsync(); if (config?.Localization.UseRouteBasedCulture != true) { return url; @@ -29,6 +44,47 @@ public class RouteBasedCultureUrlHelper : IRouteBasedCultureUrlHelper, ITransien var isKnownCulture = config.Localization.Languages .Any(l => string.Equals(l.CultureName, currentCulture, StringComparison.OrdinalIgnoreCase)); - return isKnownCulture ? $"{currentCulture}/{url}" : url; + if (!isKnownCulture) + { + return url; + } + + // Idempotency guard: if the URL already carries the culture prefix, return it unchanged. + // Strip the leading scheme prefix (~/ or /) before checking the first path segment. + var pathForSegmentCheck = url.StartsWith("~/", StringComparison.Ordinal) ? url.Substring(2) + : url.StartsWith("/", StringComparison.Ordinal) ? url.Substring(1) + : url; + + if (string.Equals(GetFirstPathSegment(pathForSegmentCheck), + currentCulture, StringComparison.OrdinalIgnoreCase)) + { + return url; + } + + if (url.StartsWith("~/", StringComparison.Ordinal)) + { + return "~/" + currentCulture + "/" + url.Substring(2); + } + + if (url.StartsWith("/", StringComparison.Ordinal)) + { + return "/" + currentCulture + url; + } + + // Bare relative path (e.g. "authentication/login") + return currentCulture + "/" + url; + } + + /// + /// Returns the first path segment of , + /// stripping any query string or fragment before splitting on '/'. + /// For example: "zh-Hans/account?x=1" → "zh-Hans", "tr/home#top" → "tr". + /// + protected virtual string GetFirstPathSegment(string baseRelativePath) + { + var suffixIndex = baseRelativePath.IndexOfAny(['?', '#']); + var pathPart = suffixIndex >= 0 ? baseRelativePath.Substring(0, suffixIndex) : baseRelativePath; + var slashIndex = pathPart.IndexOf('/'); + return slashIndex >= 0 ? pathPart.Substring(0, slashIndex) : pathPart; } } diff --git a/framework/src/Volo.Abp.AspNetCore.Mvc/Volo/Abp/AspNetCore/Mvc/Localization/AbpAspNetCoreMvcQueryStringCultureReplacement.cs b/framework/src/Volo.Abp.AspNetCore.Mvc/Volo/Abp/AspNetCore/Mvc/Localization/AbpAspNetCoreMvcQueryStringCultureReplacement.cs index da48686c03..14a9ba5c51 100644 --- a/framework/src/Volo.Abp.AspNetCore.Mvc/Volo/Abp/AspNetCore/Mvc/Localization/AbpAspNetCoreMvcQueryStringCultureReplacement.cs +++ b/framework/src/Volo.Abp.AspNetCore.Mvc/Volo/Abp/AspNetCore/Mvc/Localization/AbpAspNetCoreMvcQueryStringCultureReplacement.cs @@ -1,22 +1,16 @@ using System; +using System.Collections.Generic; +using System.Linq; using System.Text.RegularExpressions; using System.Threading.Tasks; using Microsoft.AspNetCore.Routing; +using Microsoft.AspNetCore.WebUtilities; using Volo.Abp.DependencyInjection; namespace Volo.Abp.AspNetCore.Mvc.Localization; -public partial class AbpAspNetCoreMvcQueryStringCultureReplacement : IQueryStringCultureReplacement, ITransientDependency +public class AbpAspNetCoreMvcQueryStringCultureReplacement : IQueryStringCultureReplacement, ITransientDependency { - private static readonly Regex CultureQueryStringRegex = GetCultureQueryStringRegex(); - private static readonly Regex UiCultureQueryStringRegex = GetUiCultureQueryStringRegex(); - - [GeneratedRegex("culture=[A-Za-z-]+", RegexOptions.IgnoreCase)] - private static partial Regex GetCultureQueryStringRegex(); - - [GeneratedRegex("ui-culture=[A-Za-z-]+", RegexOptions.IgnoreCase)] - private static partial Regex GetUiCultureQueryStringRegex(); - public virtual Task ReplaceAsync(QueryStringCultureReplacementContext context) { if (string.IsNullOrWhiteSpace(context.ReturnUrl)) @@ -30,26 +24,60 @@ public partial class AbpAspNetCoreMvcQueryStringCultureReplacement : IQueryStrin if (!string.IsNullOrEmpty(currentCulture)) { var escapedCulture = Regex.Escape(currentCulture); + // Replace only the first occurrence so that paths like /en/products/en/details + // only have the leading culture segment replaced, while tenant-prefixed paths + // like /tenant-a/en/... are also handled correctly. var pattern = $"/{escapedCulture}(?=/|$|\\?|#)"; - context.ReturnUrl = Regex.Replace( - context.ReturnUrl, - pattern, - "/" + context.RequestCulture.Culture.Name, - RegexOptions.IgnoreCase); + context.ReturnUrl = new Regex(pattern, RegexOptions.IgnoreCase) + .Replace(context.ReturnUrl, "/" + context.RequestCulture.Culture.Name, 1); + } + + context.ReturnUrl = ReplaceQueryStringCulture(context.ReturnUrl, context); + + return Task.CompletedTask; + } + + /// + /// Replaces culture and ui-culture query parameters in + /// with the values from . Each parameter is handled independently — + /// the presence of one does not require the other. Uses a proper query parser instead of + /// regex to avoid false-positive matches inside other parameter values. + /// + protected virtual string ReplaceQueryStringCulture(string url, QueryStringCultureReplacementContext context) + { + var fragmentIndex = url.IndexOf('#'); + var fragment = fragmentIndex >= 0 ? url.Substring(fragmentIndex) : string.Empty; + var urlWithoutFragment = fragmentIndex >= 0 ? url.Substring(0, fragmentIndex) : url; + + var queryIndex = urlWithoutFragment.IndexOf('?'); + if (queryIndex < 0) + { + return url; + } + + var path = urlWithoutFragment.Substring(0, queryIndex); + var queryString = urlWithoutFragment.Substring(queryIndex); + var query = QueryHelpers.ParseQuery(queryString); + + if (!query.ContainsKey("culture") && !query.ContainsKey("ui-culture")) + { + return url; } - if (context.ReturnUrl.Contains("culture=", StringComparison.OrdinalIgnoreCase) && - context.ReturnUrl.Contains("ui-Culture=", StringComparison.OrdinalIgnoreCase)) + if (query.ContainsKey("culture")) { - context.ReturnUrl = CultureQueryStringRegex.Replace( - context.ReturnUrl, - $"culture={context.RequestCulture.Culture}"); + query["culture"] = context.RequestCulture.Culture.Name; + } - context.ReturnUrl = UiCultureQueryStringRegex.Replace( - context.ReturnUrl, - $"ui-culture={context.RequestCulture.UICulture}"); + if (query.ContainsKey("ui-culture")) + { + query["ui-culture"] = context.RequestCulture.UICulture.Name; } - return Task.CompletedTask; + var rebuiltUrl = QueryHelpers.AddQueryString( + path, + query.SelectMany(kvp => kvp.Value.Select(v => KeyValuePair.Create(kvp.Key, v)))); + + return rebuiltUrl + fragment; } } diff --git a/framework/test/Volo.Abp.AspNetCore.Mvc.Tests/Volo.Abp.AspNetCore.Mvc.Tests.csproj b/framework/test/Volo.Abp.AspNetCore.Mvc.Tests/Volo.Abp.AspNetCore.Mvc.Tests.csproj index 8a929dd38a..496dba12ed 100644 --- a/framework/test/Volo.Abp.AspNetCore.Mvc.Tests/Volo.Abp.AspNetCore.Mvc.Tests.csproj +++ b/framework/test/Volo.Abp.AspNetCore.Mvc.Tests/Volo.Abp.AspNetCore.Mvc.Tests.csproj @@ -24,6 +24,7 @@ + diff --git a/framework/test/Volo.Abp.AspNetCore.Mvc.Tests/Volo/Abp/AspNetCore/Mvc/Localization/LanguageSwitchRouteCultureReplacement_Tests.cs b/framework/test/Volo.Abp.AspNetCore.Mvc.Tests/Volo/Abp/AspNetCore/Mvc/Localization/LanguageSwitchRouteCultureReplacement_Tests.cs index 5ee339e6ee..1c5fd4cb22 100644 --- a/framework/test/Volo.Abp.AspNetCore.Mvc.Tests/Volo/Abp/AspNetCore/Mvc/Localization/LanguageSwitchRouteCultureReplacement_Tests.cs +++ b/framework/test/Volo.Abp.AspNetCore.Mvc.Tests/Volo/Abp/AspNetCore/Mvc/Localization/LanguageSwitchRouteCultureReplacement_Tests.cs @@ -206,13 +206,45 @@ public class LanguageSwitchRouteCultureReplacement_Tests } [Fact] - public async Task Should_Not_Replace_Query_String_When_Only_One_Param() + public async Task Should_Replace_Culture_When_Only_Culture_Param_Present() { - // Only culture= without ui-culture= — should not replace + // culture= and ui-culture= are now handled independently var context = new QueryStringCultureReplacementContext( new DefaultHttpContext(), new RequestCulture("en"), "/?culture=tr"); await _replacement.ReplaceAsync(context); - context.ReturnUrl.ShouldBe("/?culture=tr"); + context.ReturnUrl.ShouldBe("/?culture=en"); + } + + [Fact] + public async Task Should_Replace_UiCulture_When_Only_UiCulture_Param_Present() + { + var context = new QueryStringCultureReplacementContext( + new DefaultHttpContext(), new RequestCulture("en"), "/?ui-culture=tr"); + await _replacement.ReplaceAsync(context); + context.ReturnUrl.ShouldBe("/?ui-culture=en"); + } + + [Fact] + public async Task Should_Support_Numeric_Region_Culture_Tag() + { + // es-419 (Latin America Spanish) contains a digit — previously the regex + // [A-Za-z-]+ would not match it, leaving the query string unreplaced. + var context = new QueryStringCultureReplacementContext( + new DefaultHttpContext(), + new RequestCulture("es-419", "es-419"), + "/home?culture=tr&ui-culture=tr"); + await _replacement.ReplaceAsync(context); + context.ReturnUrl.ShouldBe("/home?culture=es-419&ui-culture=es-419"); + } + + [Fact] + public async Task Should_Replace_Only_First_Culture_Occurrence_In_Path() + { + // /en/products/en/details — the second "/en" is part of the path content, + // not a culture prefix, and must not be replaced. + var context = CreateContext("en", "tr", "/en/products/en/details"); + await _replacement.ReplaceAsync(context); + context.ReturnUrl.ShouldBe("/tr/products/en/details"); } private static QueryStringCultureReplacementContext CreateContext( diff --git a/framework/test/Volo.Abp.AspNetCore.Mvc.Tests/Volo/Abp/AspNetCore/Mvc/Localization/RouteBasedCultureNavigationHelper_Tests.cs b/framework/test/Volo.Abp.AspNetCore.Mvc.Tests/Volo/Abp/AspNetCore/Mvc/Localization/RouteBasedCultureNavigationHelper_Tests.cs new file mode 100644 index 0000000000..08706b3179 --- /dev/null +++ b/framework/test/Volo.Abp.AspNetCore.Mvc.Tests/Volo/Abp/AspNetCore/Mvc/Localization/RouteBasedCultureNavigationHelper_Tests.cs @@ -0,0 +1,103 @@ +using System.Collections.Generic; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Components; +using Shouldly; +using Volo.Abp.AspNetCore.Components.WebAssembly; +using Volo.Abp.Localization; +using Xunit; + +namespace Volo.Abp.AspNetCore.Mvc.Localization; + +public class RouteBasedCultureNavigationHelper_Tests +{ + private static readonly IEnumerable AllLanguages = new[] + { + new LanguageInfo("en"), + new LanguageInfo("tr"), + new LanguageInfo("zh-Hans"), + }; + + private readonly RouteBasedCultureNavigationHelper _helper = new(); + + [Fact] + public async Task Should_Replace_Culture_In_Simple_Path() + { + var nav = new TestNavigationManager("https://example.com/", "https://example.com/tr/home"); + await _helper.NavigateToNewCultureAsync(nav, new LanguageInfo("en"), AllLanguages); + nav.LastNavigatedUri.ShouldBe("https://example.com/en/home"); + } + + [Fact] + public async Task Should_Replace_Culture_When_No_Path_After_Culture() + { + var nav = new TestNavigationManager("https://example.com/", "https://example.com/tr"); + await _helper.NavigateToNewCultureAsync(nav, new LanguageInfo("en"), AllLanguages); + nav.LastNavigatedUri.ShouldBe("https://example.com/en"); + } + + [Fact] + public async Task Should_Replace_Culture_When_Query_String_Follows_Culture_Directly() + { + // Regression: "tr?x=1" was being treated as a single segment "tr?x=1" + // instead of culture="tr" + suffix="?x=1". + var nav = new TestNavigationManager("https://example.com/", "https://example.com/tr?x=1"); + await _helper.NavigateToNewCultureAsync(nav, new LanguageInfo("en"), AllLanguages); + nav.LastNavigatedUri.ShouldBe("https://example.com/en?x=1"); + } + + [Fact] + public async Task Should_Replace_Culture_When_Fragment_Follows_Culture_Directly() + { + var nav = new TestNavigationManager("https://example.com/", "https://example.com/tr#section"); + await _helper.NavigateToNewCultureAsync(nav, new LanguageInfo("en"), AllLanguages); + nav.LastNavigatedUri.ShouldBe("https://example.com/en#section"); + } + + [Fact] + public async Task Should_Replace_Culture_Preserving_Path_Query_And_Fragment() + { + var nav = new TestNavigationManager("https://example.com/", "https://example.com/tr/about?ref=main#top"); + await _helper.NavigateToNewCultureAsync(nav, new LanguageInfo("zh-Hans"), AllLanguages); + nav.LastNavigatedUri.ShouldBe("https://example.com/zh-Hans/about?ref=main#top"); + } + + [Fact] + public async Task Should_Prepend_Culture_When_No_Existing_Culture_Prefix() + { + var nav = new TestNavigationManager("https://example.com/", "https://example.com/identity/users"); + await _helper.NavigateToNewCultureAsync(nav, new LanguageInfo("zh-Hans"), AllLanguages); + nav.LastNavigatedUri.ShouldBe("https://example.com/zh-Hans/identity/users"); + } + + [Fact] + public async Task Should_Prepend_Culture_When_At_Root() + { + var nav = new TestNavigationManager("https://example.com/", "https://example.com/"); + await _helper.NavigateToNewCultureAsync(nav, new LanguageInfo("tr"), AllLanguages); + nav.LastNavigatedUri.ShouldBe("https://example.com/tr/"); + } + + [Fact] + public async Task Should_Not_Navigate_When_Target_Culture_Matches_Current() + { + var nav = new TestNavigationManager("https://example.com/", "https://example.com/tr/home"); + await _helper.NavigateToNewCultureAsync(nav, new LanguageInfo("tr"), AllLanguages); + // Already on /tr/home — no navigation should occur + nav.LastNavigatedUri.ShouldBeNull(); + } + + private sealed class TestNavigationManager : NavigationManager + { + public string? LastNavigatedUri { get; private set; } + + public TestNavigationManager(string baseUri, string uri) + { + Initialize(baseUri, uri); + } + + protected override void NavigateToCore(string uri, bool forceLoad) + { + LastNavigatedUri = uri; + } + } +} diff --git a/framework/test/Volo.Abp.AspNetCore.Mvc.Tests/Volo/Abp/AspNetCore/Mvc/Localization/RouteBasedCultureUrlHelper_Tests.cs b/framework/test/Volo.Abp.AspNetCore.Mvc.Tests/Volo/Abp/AspNetCore/Mvc/Localization/RouteBasedCultureUrlHelper_Tests.cs new file mode 100644 index 0000000000..b2e8ca129a --- /dev/null +++ b/framework/test/Volo.Abp.AspNetCore.Mvc.Tests/Volo/Abp/AspNetCore/Mvc/Localization/RouteBasedCultureUrlHelper_Tests.cs @@ -0,0 +1,156 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Threading.Tasks; +using NSubstitute; +using Shouldly; +using Volo.Abp.AspNetCore.Components.WebAssembly; +using Volo.Abp.AspNetCore.Mvc.ApplicationConfigurations; +using Volo.Abp.AspNetCore.Mvc.Client; +using Volo.Abp.Localization; +using Xunit; + +namespace Volo.Abp.AspNetCore.Mvc.Localization; + +public class RouteBasedCultureUrlHelper_Tests +{ + private readonly ICachedApplicationConfigurationClient _configClient; + private readonly RouteBasedCultureUrlHelper _helper; + private readonly ApplicationConfigurationDto _config; + + public RouteBasedCultureUrlHelper_Tests() + { + _config = new ApplicationConfigurationDto + { + Localization = new ApplicationLocalizationConfigurationDto + { + UseRouteBasedCulture = true, + Languages = new List + { + new LanguageInfo("en"), + new LanguageInfo("zh-Hans"), + new LanguageInfo("tr"), + new LanguageInfo("es-419"), + } + } + }; + + _configClient = Substitute.For(); + _configClient.GetAsync().Returns(_config); + + _helper = new RouteBasedCultureUrlHelper(_configClient); + } + + [Theory] + [InlineData("https://auth-server.example.com/connect/authorize")] + [InlineData("http://example.com/login")] + public async Task Should_Not_Modify_Absolute_Urls(string url) + { + using var _ = CultureScope("zh-Hans"); + var result = await _helper.PrependCulturePrefixAsync(url); + result.ShouldBe(url); + } + + [Fact] + public async Task Should_Not_Modify_Protocol_Relative_Url() + { + using var _ = CultureScope("zh-Hans"); + var result = await _helper.PrependCulturePrefixAsync("//cdn.example.com/asset.js"); + result.ShouldBe("//cdn.example.com/asset.js"); + } + + [Fact] + public async Task Should_Prepend_Culture_To_Root_Relative_Url() + { + using var _ = CultureScope("zh-Hans"); + var result = await _helper.PrependCulturePrefixAsync("/account/manage-profile"); + result.ShouldBe("/zh-Hans/account/manage-profile"); + } + + [Fact] + public async Task Should_Prepend_Culture_To_Tilde_Slash_Url() + { + using var _ = CultureScope("tr"); + var result = await _helper.PrependCulturePrefixAsync("~/account/manage-profile"); + result.ShouldBe("~/tr/account/manage-profile"); + } + + [Fact] + public async Task Should_Prepend_Culture_To_Bare_Relative_Url() + { + // Default auth URLs like "authentication/login" have no leading slash. + using var _ = CultureScope("zh-Hans"); + var result = await _helper.PrependCulturePrefixAsync("authentication/login"); + result.ShouldBe("zh-Hans/authentication/login"); + } + + [Fact] + public async Task Should_Not_Modify_Url_When_Feature_Disabled() + { + _config.Localization.UseRouteBasedCulture = false; + using var _ = CultureScope("zh-Hans"); + var result = await _helper.PrependCulturePrefixAsync("/home"); + result.ShouldBe("/home"); + } + + [Fact] + public async Task Should_Not_Modify_Url_When_Culture_Not_In_Language_List() + { + using var _ = CultureScope("fr"); + var result = await _helper.PrependCulturePrefixAsync("/home"); + result.ShouldBe("/home"); + } + + [Fact] + public async Task Should_Return_Empty_String_Unchanged() + { + var result = await _helper.PrependCulturePrefixAsync(string.Empty); + result.ShouldBe(string.Empty); + } + + [Fact] + public async Task Should_Support_Numeric_Region_Culture_Tag() + { + using var _ = CultureScope("es-419"); + var result = await _helper.PrependCulturePrefixAsync("/home"); + result.ShouldBe("/es-419/home"); + } + + [Fact] + public async Task Should_Be_Idempotent_On_Root_Relative_Url() + { + using var _ = CultureScope("zh-Hans"); + var result = await _helper.PrependCulturePrefixAsync("/zh-Hans/account/manage-profile"); + result.ShouldBe("/zh-Hans/account/manage-profile"); + } + + [Fact] + public async Task Should_Be_Idempotent_On_Tilde_Slash_Url() + { + using var _ = CultureScope("tr"); + var result = await _helper.PrependCulturePrefixAsync("~/tr/account/manage-profile"); + result.ShouldBe("~/tr/account/manage-profile"); + } + + [Fact] + public async Task Should_Be_Idempotent_On_Bare_Relative_Url() + { + using var _ = CultureScope("zh-Hans"); + var result = await _helper.PrependCulturePrefixAsync("zh-Hans/authentication/login"); + result.ShouldBe("zh-Hans/authentication/login"); + } + + private static IDisposable CultureScope(string cultureName) + { + var previous = CultureInfo.CurrentCulture; + CultureInfo.CurrentCulture = new CultureInfo(cultureName); + return new DelegateDisposable(() => CultureInfo.CurrentCulture = previous); + } + + private sealed class DelegateDisposable : IDisposable + { + private readonly System.Action _onDispose; + public DelegateDisposable(System.Action onDispose) => _onDispose = onDispose; + public void Dispose() => _onDispose(); + } +} diff --git a/modules/basic-theme/src/Volo.Abp.AspNetCore.Components.WebAssembly.BasicTheme/Themes/Basic/WebAssemblyRedirectToLogin.razor b/modules/basic-theme/src/Volo.Abp.AspNetCore.Components.WebAssembly.BasicTheme/Themes/Basic/WebAssemblyRedirectToLogin.razor index f4f3e152ce..246682edf8 100644 --- a/modules/basic-theme/src/Volo.Abp.AspNetCore.Components.WebAssembly.BasicTheme/Themes/Basic/WebAssemblyRedirectToLogin.razor +++ b/modules/basic-theme/src/Volo.Abp.AspNetCore.Components.WebAssembly.BasicTheme/Themes/Basic/WebAssemblyRedirectToLogin.razor @@ -1,25 +1,22 @@ -@inject NavigationManager Navigation @using Volo.Abp.DependencyInjection @using Volo.Abp.AspNetCore.Components.Web.BasicTheme.Themes.Basic -@using Microsoft.AspNetCore.Components.WebAssembly.Authentication +@using Volo.Abp.AspNetCore.Components.WebAssembly.Theming @using Microsoft.Extensions.Options +@using Volo.Abp.AspNetCore.Components.WebAssembly @using Volo.Abp.AspNetCore.Components.Web @inherits RedirectToLogin @attribute [ExposeServices(typeof(RedirectToLogin))] @attribute [Dependency(ReplaceServices = true)] -@inject IOptions AuthenticationOptions +@inject NavigationManager Navigation +@inject IOptions AuthOptions +@inject IRouteBasedCultureUrlHelper CultureUrlHelper @inject IOptions AbpAspNetCoreComponentsWebOptions @code { - protected override void OnInitialized() + protected override void OnInitialized() { } + + protected override Task OnInitializedAsync() { - if (AbpAspNetCoreComponentsWebOptions.Value.IsBlazorWebApp) - { - Navigation.NavigateTo(AuthenticationOptions.Value.LoginUrl, forceLoad: true); - } - else - { - Navigation.NavigateToLogin(AuthenticationOptions.Value.LoginUrl); - } + return CultureAwareRedirectToLoginHelper.RedirectAsync(Navigation, AuthOptions.Value.LoginUrl, CultureUrlHelper, AbpAspNetCoreComponentsWebOptions); } }