Browse Source

feat: Implement route-based culture handling with new helpers and tests

pull/25174/head
maliming 2 months ago
parent
commit
c98a08bd13
No known key found for this signature in database GPG Key ID: A646B9CB645ECEA4
  1. 2
      docs/en/Community-Articles/2026-03-29-Url-Based-Localization/POST.md
  2. 25
      framework/src/Volo.Abp.AspNetCore.Components.WebAssembly.Theming/CultureAwareAuthenticationBase.cs
  3. 35
      framework/src/Volo.Abp.AspNetCore.Components.WebAssembly.Theming/CultureAwareRedirectToLoginHelper.cs
  4. 4
      framework/src/Volo.Abp.AspNetCore.Components.WebAssembly.Theming/Volo.Abp.AspNetCore.Components.WebAssembly.Theming.csproj
  5. 37
      framework/src/Volo.Abp.AspNetCore.Components.WebAssembly/Volo/Abp/AspNetCore/Components/WebAssembly/RouteBasedCultureNavigationHelper.cs
  6. 60
      framework/src/Volo.Abp.AspNetCore.Components.WebAssembly/Volo/Abp/AspNetCore/Components/WebAssembly/RouteBasedCultureUrlHelper.cs
  7. 76
      framework/src/Volo.Abp.AspNetCore.Mvc/Volo/Abp/AspNetCore/Mvc/Localization/AbpAspNetCoreMvcQueryStringCultureReplacement.cs
  8. 1
      framework/test/Volo.Abp.AspNetCore.Mvc.Tests/Volo.Abp.AspNetCore.Mvc.Tests.csproj
  9. 38
      framework/test/Volo.Abp.AspNetCore.Mvc.Tests/Volo/Abp/AspNetCore/Mvc/Localization/LanguageSwitchRouteCultureReplacement_Tests.cs
  10. 103
      framework/test/Volo.Abp.AspNetCore.Mvc.Tests/Volo/Abp/AspNetCore/Mvc/Localization/RouteBasedCultureNavigationHelper_Tests.cs
  11. 156
      framework/test/Volo.Abp.AspNetCore.Mvc.Tests/Volo/Abp/AspNetCore/Mvc/Localization/RouteBasedCultureUrlHelper_Tests.cs
  12. 21
      modules/basic-theme/src/Volo.Abp.AspNetCore.Components.WebAssembly.BasicTheme/Themes/Basic/WebAssemblyRedirectToLogin.razor

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

25
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;
/// <summary>
/// Shared base for WASM theme <c>Authentication</c> pages.
/// Provides a <see cref="GetCultureAwareHomeUrl"/> helper so the culture-aware
/// home URL construction is not duplicated across theme packages.
/// </summary>
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 + "/";
}
}

35
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;
/// <summary>
/// Provides the shared culture-aware login redirect logic for all WASM theme
/// <c>WebAssemblyRedirectToLogin</c> components. Each theme must keep
/// <c>@inherits RedirectToLogin</c> for ABP service-replacement assignability,
/// so a common component base class is not feasible; this static helper
/// centralises the logic instead.
/// </summary>
public static class CultureAwareRedirectToLoginHelper
{
public static async Task RedirectAsync(
NavigationManager navigation,
string loginUrl,
IRouteBasedCultureUrlHelper cultureUrlHelper,
IOptions<AbpAspNetCoreComponentsWebOptions> webOptions)
{
var cultureLoginUrl = await cultureUrlHelper.PrependCulturePrefixAsync(loginUrl);
if (webOptions.Value.IsBlazorWebApp)
{
navigation.NavigateTo(cultureLoginUrl, forceLoad: true);
}
else
{
navigation.NavigateToLogin(cultureLoginUrl);
}
}
}

4
framework/src/Volo.Abp.AspNetCore.Components.WebAssembly.Theming/Volo.Abp.AspNetCore.Components.WebAssembly.Theming.csproj

@ -9,6 +9,10 @@
<WarningsAsErrors>Nullable</WarningsAsErrors>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly.Authentication" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Volo.Abp.AspNetCore.Components.WebAssembly.Theming.Bundling\Volo.Abp.AspNetCore.Components.WebAssembly.Theming.Bundling.csproj" />
<ProjectReference Include="..\Volo.Abp.AspNetCore.Components.Web.Theming\Volo.Abp.AspNetCore.Components.Web.Theming.csproj" />

37
framework/src/Volo.Abp.AspNetCore.Components.WebAssembly/Volo/Abp/AspNetCore/Components/WebAssembly/RouteBasedCultureNavigationHelper.cs

@ -16,16 +16,41 @@ public class RouteBasedCultureNavigationHelper : IRouteBasedCultureNavigationHel
IEnumerable<LanguageInfo> 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;
}
/// <summary>
/// Returns the first path segment of <paramref name="baseRelativePath"/>,
/// stripping any query string or fragment before splitting on '/'.
/// For example: "zh-Hans/account?x=1" → "zh-Hans", "tr/home#top" → "tr".
/// </summary>
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;
}
}

60
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<string> 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;
}
/// <summary>
/// Returns the first path segment of <paramref name="baseRelativePath"/>,
/// stripping any query string or fragment before splitting on '/'.
/// For example: "zh-Hans/account?x=1" → "zh-Hans", "tr/home#top" → "tr".
/// </summary>
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;
}
}

76
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;
}
/// <summary>
/// Replaces <c>culture</c> and <c>ui-culture</c> query parameters in <paramref name="url"/>
/// with the values from <paramref name="context"/>. 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.
/// </summary>
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<string, string?>(kvp.Key, v))));
return rebuiltUrl + fragment;
}
}

1
framework/test/Volo.Abp.AspNetCore.Mvc.Tests/Volo.Abp.AspNetCore.Mvc.Tests.csproj

@ -24,6 +24,7 @@
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\Volo.Abp.AspNetCore.Components.WebAssembly\Volo.Abp.AspNetCore.Components.WebAssembly.csproj" />
<ProjectReference Include="..\..\src\Volo.Abp.AspNetCore.Mvc.UI\Volo.Abp.AspNetCore.Mvc.UI.csproj" />
<ProjectReference Include="..\..\src\Volo.Abp.Autofac\Volo.Abp.Autofac.csproj" />
<ProjectReference Include="..\..\src\Volo.Abp.FluentValidation\Volo.Abp.FluentValidation.csproj" />

38
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(

103
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<LanguageInfo> 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;
}
}
}

156
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<LanguageInfo>
{
new LanguageInfo("en"),
new LanguageInfo("zh-Hans"),
new LanguageInfo("tr"),
new LanguageInfo("es-419"),
}
}
};
_configClient = Substitute.For<ICachedApplicationConfigurationClient>();
_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();
}
}

21
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> AuthenticationOptions
@inject NavigationManager Navigation
@inject IOptions<AuthenticationOptions> AuthOptions
@inject IRouteBasedCultureUrlHelper CultureUrlHelper
@inject IOptions<AbpAspNetCoreComponentsWebOptions> 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);
}
}

Loading…
Cancel
Save